update( Tables::oauth()['clients'], [ 'connect_host' => $host ], [ 'client_id' => $client_id ], [ '%s' ], [ '%s' ] ); } /** * The host a client was authorised against, if one was recorded. * * @param string $client_id The OAuth client identifier. * * @return string The recorded host, or '' when none is on file. * @since 1.4.0 */ public static function recorded_host( string $client_id ): string { if ( $client_id === '' ) { return ''; } // Read through the repository rather than naming the column in a SELECT. // The repository selects the whole row and hydrates what it finds, so on // an install whose schema has not caught up yet this returns "unrecorded" // instead of erroring, and this runs on every authenticated MCP call, // which is the last place that should depend on a migration having run. $client = ( new ClientRepository() )->getClientEntity( $client_id ); if ( $client === null ) { return ''; } return strtolower( (string) $client->getConnectHost() ); } /** * Whether a client is suspended because the site moved. * * @param string $client_id The OAuth client identifier. * * @return bool True when a recorded host disagrees with the current one. * @since 1.4.0 */ public static function is_suspended( string $client_id ): bool { $recorded = self::recorded_host( $client_id ); if ( $recorded === '' ) { return false; } $current = self::current_host(); return $current !== '' && $recorded !== $current; } /** * Every client whose recorded host disagrees with the current one. * * @return array * @since 1.4.0 */ public static function suspended_clients(): array { global $wpdb; $current = self::current_host(); if ( $current === '' || ! self::column_exists() ) { return []; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, admin screen read. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT client_id, name, connect_host FROM %i WHERE connect_host IS NOT NULL AND connect_host <> %s AND connect_host <> %s ORDER BY name ASC', Tables::oauth()['clients'], $current, '' ), ARRAY_A ); if ( ! is_array( $rows ) ) { return []; } $clients = []; foreach ( $rows as $row ) { $clients[] = [ 'client_id' => (string) $row['client_id'], 'name' => (string) $row['name'], 'connect_host' => (string) $row['connect_host'], ]; } return $clients; } /** * Whether the clients table has caught up with the `connect_host` column. * * The schema is rebuilt by `dbDelta` when the plugin version advances, so * between a schema change landing and the version bump that ships it, the * column does not exist. Naming it in a write or an aggregate query would * raise a database error on every affected request; the answer is cheap to * establish once and never changes within a request. * * @return bool True when the column is present. * @since 1.4.0 */ private static function column_exists(): bool { global $wpdb; static $exists = null; if ( $exists !== null ) { return $exists; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange -- Reading the schema, not changing it. $column = $wpdb->get_var( $wpdb->prepare( 'SHOW COLUMNS FROM %i LIKE %s', Tables::oauth()['clients'], 'connect_host' ) ); $exists = $column !== null; return $exists; } /** * Lift the suspension on every affected client by adopting the current host. * * @return int The number of clients re-confirmed. * @since 1.4.0 */ public static function reconfirm_all(): int { $confirmed = 0; foreach ( self::suspended_clients() as $client ) { self::record_connection( $client['client_id'] ); ++$confirmed; } return $confirmed; } }