$pending['confirmed_slug'], 'new_slug' => $pending['new_slug'], 'expires' => (int) $pending['expires'], ]; } /** * Whether a pending state has run out. * * @param array $pending Pending state. * @param int $now Current Unix time. * * @return bool */ public static function is_expired( array $pending, int $now ): bool { return $now >= (int) $pending['expires']; } /** * Add or drop the pending state on a new option value after a slug change. * Pure: the caller decides whether this is a settings-screen save, that the * module is enabled and that no constant is in force. * * A pending state that already exists keeps its ORIGINAL confirmed_slug, so * a slug that was never confirmed can never become the revert target. * * @param array $stored Option as stored now. * @param array $new_options Option about to be stored. * @param int $now Current Unix time. * * @return array New option value. */ public static function begin( array $stored, array $new_options, int $now ): array { $old_slug = (string) ( $stored['custom_slug'] ?? '' ); $new_slug = (string) ( $new_options['custom_slug'] ?? '' ); if ( '' === $new_slug || $new_slug === $old_slug ) { return $new_options; } $existing = self::get_pending( $stored ); $confirmed = null === $existing ? $old_slug : $existing['confirmed_slug']; // Back to the slug that is known to work: nothing to verify. if ( $new_slug === $confirmed ) { unset( $new_options[ self::PENDING_KEY ] ); return $new_options; } $new_options[ self::PENDING_KEY ] = [ 'confirmed_slug' => $confirmed, 'new_slug' => $new_slug, 'expires' => $now + self::get_timeout(), ]; return $new_options; } /** * The token that proves the new URL loads: HMAC of the slug and expiry. Never stored. * * @param string $new_slug The unconfirmed slug. * @param int $expires The pending expiry. * * @return string */ public static function get_token( string $new_slug, int $expires ): string { return hash_hmac( 'sha256', $new_slug . '|' . $expires, wp_salt( 'auth' ) ); } /** * The one-time verification link: the new login URL plus ?ao_verify=. * * @param array $pending Pending state. * * @return string */ public static function get_verify_url( array $pending ): string { return add_query_arg( [ 'ao_verify' => self::get_token( $pending['new_slug'], $pending['expires'] ) ], Custom_Login_Url::build_url( $pending['new_slug'] ) ); } /** * Whether a token proves the pending state: unexpired, for the slug in * force, compared in constant time. It grants no access, only "this URL loads". * * @param array $options Module option. * @param string $token Token from the request. * @param int $now Current Unix time. * * @return bool */ public static function is_token_valid( array $options, string $token, int $now ): bool { $pending = self::get_pending( $options ); if ( null === $pending || '' === $token || self::is_expired( $pending, $now ) ) { return false; } if ( (string) ( $options['custom_slug'] ?? '' ) !== $pending['new_slug'] ) { return false; } return hash_equals( self::get_token( $pending['new_slug'], $pending['expires'] ), $token ); } /** * Confirm from the verification link served on the new login URL. * * @param string $token Token from the request. * @param int $now Current Unix time. * * @return bool Whether the pending state was confirmed. */ public static function confirm_with_token( string $token, ?int $now = null ): bool { if ( ! self::is_token_valid( self::read_options(), $token, $now ?? time() ) ) { return false; } if ( ! self::clear() ) { return false; } self::set_status( 'confirmed' ); return true; } /** * Remove the pending state and its cron event without touching the slug. * Used to confirm ("Keep this URL"), and whenever the CLI, a disabled * module or a constant takes over. * * @return bool Whether a pending state was removed. */ public static function clear(): bool { $options = self::read_options(); self::unschedule(); if ( ! isset( $options[ self::PENDING_KEY ] ) ) { return false; } unset( $options[ self::PENDING_KEY ] ); return self::write_options( $options ); } /** * Restore the previous slug now. Idempotent: the pending state is removed * in the same write, so a second call (or a concurrent one that loses the * write) finds nothing to do and sends no email. * * Guard: the slug is restored only while the stored slug still equals the * unconfirmed one; if someone changed it meanwhile, only the pending state * is discarded. * * @param bool $email Send the revert notice (respecting the email_on_change setting). * * @return bool Whether the slug was restored by this call. */ public static function revert( bool $email ): bool { $options = self::read_options(); $pending = self::get_pending( $options ); self::unschedule(); if ( null === $pending ) { if ( isset( $options[ self::PENDING_KEY ] ) ) { unset( $options[ self::PENDING_KEY ] ); self::write_options( $options ); } return false; } unset( $options[ self::PENDING_KEY ] ); $restore = (string) ( $options['custom_slug'] ?? '' ) === $pending['new_slug']; if ( $restore ) { $options['custom_slug'] = $pending['confirmed_slug']; } // update_option() returns true only for the call that actually changed the value. if ( ! self::write_options( $options ) || ! $restore ) { return false; } $wants_email = ! isset( $options['email_on_change'] ) || ! empty( $options['email_on_change'] ); if ( $email && $wants_email ) { $send = static function () use ( $pending ) { Custom_Login_Url::send_login_url_email( $pending['confirmed_slug'], 'reverted', (int) max( 1, round( self::get_timeout() / 60 ) ) ); }; // The lazy check runs at plugins_loaded, too early to translate the message or read the module // registry's localized catalogue, so the email waits for init. The slug is already restored. if ( did_action( 'init' ) ) { $send(); } else { add_action( 'init', $send ); } } return true; } /** * Revert when the pending state has run out. Called by the lazy check at * plugins_loaded and by the cron event. A wp-config.php constant always wins: * with one in force the pending state is dropped without reverting. * * @param int|null $now Current Unix time. * * @return bool Whether the slug was restored. */ public static function maybe_expire( ?int $now = null ): bool { $options = self::read_options(); $pending = self::get_pending( $options ); if ( null === $pending ) { return false; } if ( 'db' !== ao_get_effective_login_slug( $options )['source'] ) { self::clear(); return false; } if ( ! self::is_expired( $pending, $now ?? time() ) ) { return false; } if ( ! self::revert( true ) ) { return false; } self::set_status( 'reverted' ); return true; } /** * Keep the cron event in step with the stored pending state. Runs when the * option is saved from the settings screen. * * @param mixed $old_value Previous option value. * @param mixed $new_value New option value. * * @return void */ public static function sync_schedule( $old_value, $new_value ): void { $old = self::get_pending( is_array( $old_value ) ? $old_value : [] ); $new = self::get_pending( is_array( $new_value ) ? $new_value : [] ); if ( $new === $old ) { return; } self::unschedule(); if ( null !== $new ) { // A new pending state starts clean: any stale one-shot outcome is dropped first. self::discard_status(); wp_schedule_single_event( $new['expires'], self::CRON_HOOK ); } } /** * Remove the scheduled revert. * * @return void */ public static function unschedule(): void { wp_clear_scheduled_hook( self::CRON_HOOK ); } /** * Module toggled off while a change is waiting: restore the last confirmed * slug, with no email. Turning the module back on must never bring back a * slug nobody has opened. With a wp-config.php constant in force, the slug * is not the stored one, so only the pending state is dropped. * * @param string $module_id Module that changed. * @param bool $enabled New state. * * @return void */ public static function on_module_state_changed( $module_id, $enabled ): void { if ( Custom_Login_Url::MODULE_ID !== $module_id || $enabled ) { return; } if ( 'db' === ao_get_effective_login_slug( self::read_options() )['source'] ) { self::revert( false ); } else { self::clear(); } } }