wpdb = $wpdb; } /** * Insert a key, stamping created_at in UTC, and return the new id. * * @since 1.0.0 * * @param ApiKey $key The key to persist. * @return int The new row id. * * @throws ApiKeyPersistenceException When the insert fails. */ public function create( ApiKey $key ): int { $inserted = $this->wpdb->insert( $this->table(), array( 'user_id' => $key->userId(), 'label' => $key->label(), 'public_key' => $key->publicKey(), 'secret_hash' => $key->secretHash(), 'permissions' => $key->permission()->value, 'last_used_at' => null, 'status' => $key->status()->value, 'created_at' => gmdate( 'Y-m-d H:i:s' ), ) ); if ( false === $inserted ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML output here; escaped where printed. throw new ApiKeyPersistenceException( 'Failed to insert API key: ' . $this->wpdb->last_error ); } return (int) $this->wpdb->insert_id; } /** * Load a key by id. * * @since 1.0.0 * * @param int $id The row id. * @return ApiKey|null The key, or null when it does not exist. */ public function find( int $id ): ?ApiKey { $wpdb = $this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; this class decides what to cache. $row = $this->getRowOrFail( fn () => $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table(), $id ) ) ); return null !== $row ? $this->fromRow( $row ) : null; } /** * Load a key by its unique public key: the authentication lookup. * * @since 1.0.0 * * @param string $publicKey The rck_-prefixed public key as presented. * @return ApiKey|null The key, or null when none carries it. */ public function findByPublicKey( string $publicKey ): ?ApiKey { $wpdb = $this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; this class decides what to cache. $row = $this->getRowOrFail( fn () => $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE public_key = %s', $this->table(), $publicKey ) ) ); return null !== $row ? $this->fromRow( $row ) : null; } /** * All keys, newest first, for the management listing. * * @since 1.0.0 * * @return array Every key, including revoked audit rows. */ public function all(): array { $wpdb = $this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; this class decides what to cache. $rows = $this->getResultsOrFail( fn () => $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i ORDER BY id DESC', $this->table() ) ) ); return array_map( array( $this, 'fromRow' ), $rows ); } /** * Flip a key's status. * * @since 1.0.0 * * @param int $id The row id. * @param ApiKeyStatus $status The new status. * @return void * * @throws ApiKeyPersistenceException When the update fails. */ public function updateStatus( int $id, ApiKeyStatus $status ): void { $updated = $this->wpdb->update( $this->table(), array( 'status' => $status->value ), array( 'id' => $id ) ); if ( false === $updated ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML output here; escaped where printed. throw new ApiKeyPersistenceException( 'Failed to update API key status: ' . $this->wpdb->last_error ); } } /** * Stamp the last authentication time without failing the request. * * @since 1.0.0 * * @param int $id The row id. * @param string $utc The instant as a UTC MySQL datetime string. * @return void */ public function stampLastUsed( int $id, string $utc ): void { $this->wpdb->update( $this->table(), array( 'last_used_at' => $utc ), array( 'id' => $id ) ); } /** * The fully prefixed table name. * * @since 1.0.0 * * @return string The table name. */ private function table(): string { return Schema::tableName( $this->wpdb, 'api_keys' ); } /** * Run a single-row query and detect database faults. * * @since 1.0.0 * * @param callable(): mixed $query Runs the prepared query. * @return object|null The row, or null on a real miss. * * @throws ApiKeyPersistenceException When the read faulted. */ private function getRowOrFail( callable $query ): ?object { $before = $this->wpdb->last_error; $row = $query(); $after = $this->wpdb->last_error; if ( null === $row && '' !== $after && $after !== $before ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML output here; escaped where printed. throw new ApiKeyPersistenceException( 'API key read failed: ' . $after ); } return $row; } /** * Run a multi-row query and detect database faults. * * @since 1.0.0 * * @param callable(): mixed $query Runs the prepared query. * @return array The rows. * * @throws ApiKeyPersistenceException When the read faulted. */ private function getResultsOrFail( callable $query ): array { $before = $this->wpdb->last_error; $rows = $query(); $after = $this->wpdb->last_error; if ( ( null === $rows || array() === $rows ) && '' !== $after && $after !== $before ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML output here; escaped where printed. throw new ApiKeyPersistenceException( 'API key read failed: ' . $after ); } return is_array( $rows ) ? $rows : array(); } /** * Hydrate an entity from a database row. * * @since 1.0.0 * * @param object $row The database row. * @return ApiKey The entity. */ private function fromRow( object $row ): ApiKey { return new ApiKey( (int) $row->id, (int) $row->user_id, (string) $row->label, (string) $row->public_key, (string) $row->secret_hash, ApiKeyPermission::from( (string) $row->permissions ), ApiKeyStatus::from( (string) $row->status ), $this->parseInstant( $row->last_used_at ?? null ), $this->parseInstant( $row->created_at ?? null ) ); } /** * Parse a stored UTC datetime string. * * @since 1.0.0 * * @param string|null $value The stored value. * @return DateTimeImmutable|null The instant, or null. */ private function parseInstant( ?string $value ): ?DateTimeImmutable { if ( null === $value || '' === $value || '0000-00-00 00:00:00' === $value ) { return null; } return new DateTimeImmutable( $value, new DateTimeZone( 'UTC' ) ); } }