'', 'password' => '', 'did' => '', ); } /** * Stored account: identifier, encrypted password and the DID it last * resolved to ('' if it has never been resolved). * * @return array{identifier: string, password: string, did: string} */ public static function get() { $saved = get_option( self::OPTION, array() ); if ( ! is_array( $saved ) ) { $saved = array(); } $account = wp_parse_args( $saved, self::defaults() ); return array( 'identifier' => is_string( $account['identifier'] ) ? $account['identifier'] : '', 'password' => is_string( $account['password'] ) ? $account['password'] : '', 'did' => is_string( $account['did'] ) && Identity::is_did( $account['did'] ) ? $account['did'] : '', ); } /** * Saves the DID the account resolved to, so the editor knows it without * network requests. The save goes through sanitize(), which keeps the * identifier and the encrypted password. * * @param string $did Resolved DID. */ public static function remember_did( $did ) { $account = self::get(); if ( ! Identity::is_did( $did ) || $did === $account['did'] || '' === $account['identifier'] ) { return; } $account['did'] = $did; update_option( self::OPTION, $account ); } /** * Whether the account has an identifier and a password that decrypts. * * @return bool */ public static function is_configured() { $account = self::get(); if ( ! self::is_valid_identifier( $account['identifier'] ) ) { return false; } $password = Crypto::decrypt( $account['password'] ); $configured = null !== $password && '' !== $password; Crypto::wipe( $password ); return $configured; } /** * Whether a password is stored but cannot be decrypted (e.g. AUTH_KEY * changed). * * @return bool */ public static function is_password_unreadable() { $account = self::get(); if ( '' === $account['password'] ) { return false; } $password = Crypto::decrypt( $account['password'] ); $unreadable = null === $password; Crypto::wipe( $password ); return $unreadable; } /** * Registers the setting in the same group as the display settings, with * its own sanitize callback. */ public static function register() { register_setting( Settings::GROUP, self::OPTION, array( 'type' => 'array', 'default' => self::defaults(), 'sanitize_callback' => array( __CLASS__, 'sanitize' ), 'show_in_rest' => false, ) ); } /** * Keeps the option out of autoload. options.php creates it with autoload * on its first save, and register_setting() cannot change that. */ public static function disable_autoload() { wp_set_option_autoload( self::OPTION, false ); } /** * Sanitizes the submitted account. * * - `forget` empties the account. * - An invalid identifier adds an error and keeps the stored account. * - An empty `app_password` keeps the stored password; a new one is * encrypted. * - `did` is kept while the identifier does not change (the submitted one * if it is a valid DID, the stored one otherwise). A DID identifier is * its own `did`; a new handle has none until it is resolved. * * On the first save WordPress runs this twice, the second time with the * already sanitized value, so a `password` that is already encrypted is * kept as it is. * * @param mixed $input Submitted value. * @return array{identifier: string, password: string, did: string} */ public static function sanitize( $input ) { $previous = self::get(); if ( ! is_array( $input ) ) { return $previous; } if ( ! empty( $input['forget'] ) ) { return self::defaults(); } $identifier = isset( $input['identifier'] ) && is_string( $input['identifier'] ) ? self::normalize_identifier( $input['identifier'] ) : $previous['identifier']; $new = isset( $input['app_password'] ) && is_string( $input['app_password'] ) ? trim( $input['app_password'] ) : ''; if ( '' === $identifier && '' === $new && '' === $previous['identifier'] ) { return $previous; } if ( ! self::is_valid_identifier( $identifier ) ) { Crypto::wipe( $new ); self::add_error( 'identifier', __( 'The AT Protocol account must be a handle (like example.bsky.social) or a DID (did:…). The saved account has not changed.', 'parolas' ) ); return $previous; } if ( '' !== $new ) { $password = Crypto::encrypt( $new ); } elseif ( isset( $input['password'] ) && is_string( $input['password'] ) && 0 === strpos( $input['password'], Crypto::PREFIX ) ) { $password = $input['password']; } else { $password = $previous['password']; } Crypto::wipe( $new ); if ( Identity::is_did( $identifier ) ) { $did = $identifier; } elseif ( $identifier !== $previous['identifier'] ) { $did = ''; } elseif ( isset( $input['did'] ) && is_string( $input['did'] ) && Identity::is_did( $input['did'] ) ) { $did = $input['did']; } else { $did = $previous['did']; } return array( 'identifier' => $identifier, 'password' => $password, 'did' => $did, ); } /** * Lowercases an identifier and removes a leading `@`. The `%3A` of a * did:web port keeps its case. * * @param string $value Identifier. * @return string */ private static function normalize_identifier( $value ) { $value = strtolower( ltrim( trim( $value ), '@' ) ); return str_replace( '%3a', '%3A', $value ); } /** * Whether an identifier is a handle or a supported DID. * * @param string $value Identifier. * @return bool */ private static function is_valid_identifier( $value ) { return Identity::is_handle( $value ) || Identity::is_did( $value ); } /** * Adds a settings error once (the sanitize callback can run twice). * * @param string $code Error code. * @param string $message Message. */ private static function add_error( $code, $message ) { foreach ( get_settings_errors( self::OPTION ) as $error ) { if ( $error['code'] === $code ) { return; } } add_settings_error( self::OPTION, $code, $message, 'error' ); } /** * Registers the account section of the settings page. */ public static function add_fields() { add_settings_section( self::SECTION, __( 'AT Protocol account', 'parolas' ), array( __CLASS__, 'render_section' ), Settings::PAGE ); add_settings_field( 'atproto_comments_account_identifier', __( 'Handle or DID', 'parolas' ), array( __CLASS__, 'field_identifier' ), Settings::PAGE, self::SECTION, array( 'label_for' => 'atproto-comments-account-identifier' ) ); add_settings_field( 'atproto_comments_account_password', __( 'App password', 'parolas' ), array( __CLASS__, 'field_password' ), Settings::PAGE, self::SECTION, array( 'label_for' => 'atproto-comments-account-password' ) ); add_settings_field( 'atproto_comments_account_test', __( 'Connection', 'parolas' ), array( __CLASS__, 'field_test' ), Settings::PAGE, self::SECTION ); } /** * Section introduction. */ public static function render_section() { ?>

atproto_comments_publish_capability' ); ?>

'POST', 'callback' => array( __CLASS__, 'rest_test' ), 'permission_callback' => static function () { return current_user_can( 'manage_options' ); }, ) ); } /** * Tests the saved account: resolves it and opens a session. * * @return \WP_REST_Response|\WP_Error `{ handle, did, pds }`. */ public static function rest_test() { if ( ! self::is_configured() ) { return self::error_not_configured(); } $deadline = new Deadline(); $account = Identity::resolve_account( self::get()['identifier'], $deadline ); if ( is_wp_error( $account ) ) { return $account; } self::remember_did( $account['did'] ); $password = Crypto::decrypt( self::get()['password'] ); $session = Xrpc::create_session( $account['pds'], $account['did'], (string) $password, $deadline ); Crypto::wipe( $password ); if ( is_wp_error( $session ) ) { return $session; } return new \WP_REST_Response( array( 'handle' => $account['handle'], 'did' => $account['did'], 'pds' => $account['pds'], ) ); } /** * Error for an account that is missing or cannot be decrypted. * * @return \WP_Error */ public static function error_not_configured() { return new \WP_Error( 'atproto_comments_account_missing', __( 'The site has no AT Protocol account configured. Save the handle and an app password in the plugin settings first.', 'parolas' ), array( 'status' => 400 ) ); } }