array( 'resource' => 'terms', 'action' => 'query', ), 'wp.terms.get' => array( 'resource' => 'terms', 'action' => 'get', ), 'wp.terms.create' => array( 'resource' => 'terms', 'action' => 'create', ), 'wp.terms.update' => array( 'resource' => 'terms', 'action' => 'update', ), 'wp.terms.delete' => array( 'resource' => 'terms', 'action' => 'delete', ), 'wp.media.query' => array( 'resource' => 'media', 'action' => 'query', ), 'wp.media.get' => array( 'resource' => 'media', 'action' => 'get', ), 'wp.media.create' => array( 'resource' => 'media', 'action' => 'create', ), 'wp.media.update' => array( 'resource' => 'media', 'action' => 'update', ), 'wp.media.delete' => array( 'resource' => 'media', 'action' => 'delete', ), 'wp.comments.query' => array( 'resource' => 'comments', 'action' => 'query', ), 'wp.comments.get' => array( 'resource' => 'comments', 'action' => 'get', ), 'wp.comments.create' => array( 'resource' => 'comments', 'action' => 'create', ), 'wp.comments.update' => array( 'resource' => 'comments', 'action' => 'update', ), 'wp.comments.delete' => array( 'resource' => 'comments', 'action' => 'delete', ), 'wp.options.query' => array( 'resource' => 'options', 'action' => 'query', ), 'wp.options.get' => array( 'resource' => 'options', 'action' => 'get', ), 'wp.options.update' => array( 'resource' => 'options', 'action' => 'update', ), 'wp.options.delete' => array( 'resource' => 'options', 'action' => 'delete', ), 'wp.users.query' => array( 'resource' => 'users', 'action' => 'query', ), 'wp.users.get' => array( 'resource' => 'users', 'action' => 'get', ), 'wp.revisions.query' => array( 'resource' => 'revisions', 'action' => 'query', ), 'wp.revisions.get' => array( 'resource' => 'revisions', 'action' => 'get', ), ); private string $resource; private string $action; private bool $dev_mode = false; private ReadAccessPolicy $read_access; public function __construct( string $capability, ?ReadAccessPolicy $read_access = null ) { $metadata = self::CAPABILITIES[ $capability ] ?? array( 'resource' => '', 'action' => '', ); $this->resource = (string) $metadata['resource']; $this->action = (string) $metadata['action']; $this->read_access = $read_access ?? new ReadAccessPolicy(); } /** * Receive the dev-mode flag from the command runtime. * * Dev mode (internal builds only) lifts read-disclosure restrictions such * as the options allowlist. Defaults to false so public builds always * enforce the allowlist. * * @param bool $enabled Whether the internal dev module is active. */ public function set_dev_mode( bool $enabled ): void { $this->dev_mode = $enabled; } /** * Run one core-family command. * * @param array $input Command input. * @param string $phase validate or execute. */ public function run( array $input, string $phase ): array { $validation = $this->validate_input( $input ); if ( isset( $validation['error'] ) ) { return $validation; } if ( 'validate' === $phase ) { return array( 'data' => array( 'valid' => true ) ); } return match ( $this->action ) { 'query' => $this->query_items( $input ), 'get' => $this->get_item( $input ), 'create' => $this->create_item( $input ), 'update' => $this->update_item( $input ), 'delete' => $this->delete_item( $input ), default => $this->error( 'unsupported_capability', 'This core content capability is not supported.' ), }; } private function validate_input( array $input ): array { if ( isset( $input['select'] ) && ! $this->is_path_list( $input['select'] ) ) { return $this->error( 'invalid_command', 'select must be a list of array-segment paths.', array( 'input', 'select' ) ); } if ( isset( $input['where'] ) ) { $where = $this->validate_where( $input['where'], array( 'input', 'where' ) ); if ( isset( $where['error'] ) ) { return $where; } } if ( isset( $input['order_by'] ) ) { if ( ! is_array( $input['order_by'] ) ) { return $this->error( 'invalid_command', 'order_by must be a list of path sort objects.', array( 'input', 'order_by' ) ); } foreach ( $input['order_by'] as $index => $order ) { if ( ! is_array( $order ) || ! isset( $order['path'] ) || ! is_array( $order['path'] ) ) { return $this->error( 'invalid_command', 'order_by entries require an array-segment path.', array( 'input', 'order_by', $index ) ); } $dir = isset( $order['dir'] ) && is_scalar( $order['dir'] ) ? strtolower( (string) $order['dir'] ) : 'asc'; if ( ! in_array( $dir, array( 'asc', 'desc' ), true ) ) { return $this->error( 'invalid_command', 'order_by dir must be asc or desc.', array( 'input', 'order_by', $index, 'dir' ) ); } if ( ! $this->is_orderable_path( $order['path'] ) ) { return $this->error( 'invalid_path', 'Requested order_by path is not orderable for this core content resource.', array( 'input', 'order_by', $index, 'path' ) ); } } } if ( isset( $input['limit'] ) ) { if ( ! is_numeric( $input['limit'] ) || (int) $input['limit'] < 1 ) { return $this->error( 'invalid_value', 'limit must be a positive integer.', array( 'input', 'limit' ) ); } if ( (int) $input['limit'] > self::MAX_LIMIT ) { return $this->error( 'limit_exceeded', 'limit exceeds the core content query cap.', array( 'input', 'limit' ), array( 'max' => self::MAX_LIMIT ) ); } } if ( isset( $input['cursor'] ) && null !== $input['cursor'] && ! is_string( $input['cursor'] ) ) { return $this->error( 'invalid_value', 'cursor must be null or an opaque string.', array( 'input', 'cursor' ) ); } if ( 'query' === $this->action ) { $cursor = CommandPagination::validate( $input['cursor'] ?? null, $this->cursor_context( $input ) ); if ( isset( $cursor['error'] ) ) { return $cursor; } } if ( in_array( $this->action, array( 'create', 'update', 'delete' ), true ) && in_array( $this->resource, array( 'users', 'revisions' ), true ) ) { return $this->error( 'unsupported_capability', 'This family is read-only in v1.' ); } if ( 'options' === $this->resource && in_array( $this->action, array( 'update', 'delete' ), true ) ) { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : array(); $name = $this->target_key( $target ); if ( '' !== $name ) { $structural_denial = $this->structural_option_write_denial( $name ); if ( isset( $structural_denial['error'] ) ) { return $structural_denial; } } } if ( 'comments' === $this->resource ) { $comment_status = $this->validate_comment_status_input( $input ); if ( isset( $comment_status['error'] ) ) { return $comment_status; } } return array( 'data' => array( 'valid' => true ) ); } private function query_items( array $input ): array { $items = $this->documents( $input ); $items = $this->filter_documents( $items, $input['where'] ?? null, $input['search'] ?? null ); $items = $this->sort_documents( $items, $input['order_by'] ?? array() ); $limit = isset( $input['limit'] ) ? (int) $input['limit'] : self::DEFAULT_LIMIT; $page = CommandPagination::page( $items, $limit, $input['cursor'] ?? null, $this->cursor_context( $input ) ); if ( isset( $page['error'] ) ) { return $page; } return array( 'data' => array( 'resource' => $this->resource, 'items' => array_map( fn ( array $document ): array => $this->item_result( $document, $input['select'] ?? null ), $page['items'] ), 'page' => array( 'next_cursor' => $page['next_cursor'], 'limit' => $limit, ), ), 'meta' => array( 'total' => count( $items ), 'returned_count' => count( $page['items'] ), ), ); } private function cursor_context( array $input ): array { return array( 'resource' => $this->resource, 'where' => $input['where'] ?? null, 'search' => $input['search'] ?? null, 'order_by' => $input['order_by'] ?? array(), ); } private function get_item( array $input ): array { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : array(); // Term identity is taxonomy-scoped: an id alone can never match, so ask // for the missing taxonomy instead of reporting a misleading not_found. if ( 'terms' === $this->resource && isset( $target['id'] ) && ! isset( $target['taxonomy'] ) ) { return $this->error( 'invalid_target', 'terms get requires target.taxonomy alongside target.id.', array( 'input', 'target', 'taxonomy' ) ); } $key = $this->target_key( $target ); if ( '' === $key ) { return $this->error( 'invalid_target', 'get requires a family identity target.', array( 'input', 'target' ) ); } foreach ( $this->documents( $input ) as $document ) { if ( $key === $this->document_key( $document ) ) { return array( 'data' => array( 'resource' => $this->resource, 'target' => $this->target_for_document( $document ), 'document' => $this->project_document( $document, $input['select'] ?? null ), 'facts' => $this->facts_for_document( $document ), ), ); } } return $this->error( 'not_found', 'No core content item matched the requested target.', array( 'input', 'target' ) ); } private function create_item( array $input ): array { $values = isset( $input['values'] ) && is_array( $input['values'] ) ? $input['values'] : null; if ( null === $values ) { return $this->error( 'invalid_command', 'create requires a values object.', array( 'input', 'values' ) ); } return match ( $this->resource ) { 'terms' => $this->create_term( $values ), 'media' => $this->create_media( $values ), 'comments' => $this->create_comment( $values ), default => $this->error( 'unsupported_capability', 'This family does not support create.' ), }; } private function update_item( array $input ): array { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : array(); $changes = isset( $input['changes'] ) && is_array( $input['changes'] ) ? array_values( $input['changes'] ) : array(); $target_was_not_found = false; if ( array() === $changes ) { return $this->error( 'invalid_command', 'update requires a non-empty changes list.', array( 'input', 'changes' ) ); } $document = null; if ( 'options' !== $this->resource ) { $get = $this->get_item( array( 'target' => $target ) ); if ( isset( $get['error'] ) ) { if ( 'not_found' !== ( $get['error']['code'] ?? '' ) ) { return $get; } $document = $this->minimal_document_for_target( $target ); $target_was_not_found = true; } else { $document = $get['data']['document']; } } else { $name = $this->target_key( $target ); if ( '' === $name ) { return $this->error( 'invalid_target', 'options update requires target.name.', array( 'input', 'target' ) ); } $document = array( 'identity' => array( 'name' => $name ), 'value' => function_exists( 'get_option' ) ? get_option( $name, null ) : null, 'autoload' => $this->option_autoload_value( $name ), ); } foreach ( $changes as $index => $change ) { $result = $this->apply_change( $document, is_array( $change ) ? $change : array(), $index ); if ( isset( $result['error'] ) ) { return $result; } $document = $result['document']; } return match ( $this->resource ) { 'terms' => $this->persist_term_update( $target, $document, $target_was_not_found ), 'media' => $this->persist_media_update( $target, $document, $target_was_not_found ), 'comments' => $this->persist_comment_update( $target, $document, $target_was_not_found ), 'options' => $this->persist_option_update( $target, $document ), default => $this->error( 'unsupported_capability', 'This family does not support update.' ), }; } private function minimal_document_for_target( array $target ): array { return match ( $this->resource ) { 'terms' => array( 'id' => (int) ( $target['id'] ?? 0 ), 'taxonomy' => (string) ( $target['taxonomy'] ?? '' ), 'meta' => array(), ), 'media' => array( 'id' => (int) ( $target['id'] ?? 0 ), 'title' => array( 'raw' => '' ), 'caption' => array( 'raw' => '' ), 'description' => array( 'raw' => '' ), 'meta' => array(), ), 'comments' => array( 'id' => (int) ( $target['id'] ?? 0 ), 'status' => '', 'content' => array( 'raw' => '' ), 'meta' => array(), ), default => array(), }; } private function delete_item( array $input ): array { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : array(); return match ( $this->resource ) { 'terms' => $this->delete_term( $target ), 'media' => $this->delete_media( $target, ( $input['force'] ?? false ) === true ), 'comments' => $this->delete_comment( $target, ( $input['force'] ?? false ) === true ), 'options' => $this->delete_option( $target ), default => $this->error( 'unsupported_capability', 'This family does not support delete.' ), }; } private function documents( array $input ): array { return match ( $this->resource ) { 'terms' => $this->term_documents( $input ), 'media' => $this->media_documents(), 'comments' => $this->comment_documents(), 'options' => $this->option_documents(), 'users' => $this->user_documents(), 'revisions' => $this->revision_documents( $input ), default => array(), }; } private function term_documents( array $input ): array { $taxonomy = $this->taxonomy_from_input( $input ); $args = array( 'hide_empty' => false ); if ( '' !== $taxonomy ) { $args['taxonomy'] = $taxonomy; } $terms = function_exists( 'get_terms' ) ? get_terms( $args ) : array(); if ( ! is_array( $terms ) ) { return array(); } return array_values( array_map( fn ( object $term ): array => array( 'id' => (int) $term->term_id, 'taxonomy' => (string) $term->taxonomy, 'name' => (string) $term->name, 'slug' => (string) $term->slug, 'parent' => (int) ( $term->parent ?? 0 ), 'count' => (int) ( $term->count ?? 0 ), 'meta' => $this->meta_document( 'term', (int) $term->term_id ), ), $terms ) ); } private function media_documents(): array { $posts = function_exists( 'get_posts' ) ? get_posts( array( 'post_type' => 'attachment', 'post_status' => 'any', 'numberposts' => -1, ) ) : array(); if ( ! is_array( $posts ) ) { return array(); } return array_values( array_map( fn ( object $post ): array => $this->media_document( $post ), $posts ) ); } private function comment_documents(): array { $args = array(); $comments = function_exists( 'get_comments' ) ? get_comments( $args ) : array(); if ( ! is_array( $comments ) ) { return array(); } return array_values( array_map( fn ( object $comment ): array => $this->comment_document( $comment ), $comments ) ); } private function option_documents(): array { global $wpdb; $documents = array(); if ( is_object( $wpdb ) && isset( $wpdb->options ) && method_exists( $wpdb, 'get_results' ) && method_exists( $wpdb, 'prepare' ) ) { $output_type = defined( 'ARRAY_A' ) ? ARRAY_A : 'ARRAY_A'; list( $query, $params ) = $this->option_documents_query(); // Core has no API that lists options together with their autoload // flags. This admin-only read serves the live options contract on // demand, so caching would hand the assistant stale data. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- fully prepared on this line: the placeholder list is dynamic (allowlisted option names/prefixes), so the SQL cannot be a literal, and every value goes through $wpdb->prepare(). $rows = $wpdb->get_results( $wpdb->prepare( $query, ...$params ), $output_type ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching if ( is_array( $rows ) ) { $documents = array_map( fn ( array $row ): array => array( 'identity' => array( 'name' => (string) ( $row['option_name'] ?? '' ) ), 'value' => $this->option_value_document( (string) ( $row['option_name'] ?? '' ), $this->maybe_unserialize( $row['option_value'] ?? null ) ), 'value_summary' => $this->option_value_summary( $this->maybe_unserialize( $row['option_value'] ?? null ) ), 'autoload' => $this->autoload_to_bool( $row['autoload'] ?? null ), ), $rows ); } } if ( array() === $documents ) { $options = function_exists( 'wp_load_alloptions' ) ? wp_load_alloptions() : array(); $options = is_array( $options ) ? $options : array(); $documents = array_map( fn ( string $name, $value ): array => array( 'identity' => array( 'name' => $name ), 'value' => $this->option_value_document( $name, $value ), 'value_summary' => $this->option_value_summary( $value ), 'autoload' => $this->option_autoload_value( $name ), ), array_keys( $options ), array_values( $options ) ); } // Single read-disclosure boundary: public builds see only allowlisted // options; recursive redaction is a final value-level backstop. return Redactor::redact_recursive( $this->read_access->filter_readable_options( $documents, $this->dev_mode ) ); } /** * Build the unprepared options query and its placeholder values. * * The placeholder list is dynamic (allowlisted exact names plus prefixes), * so the caller passes both through $wpdb->prepare() at the call site. * * @return array{0: string, 1: array} SQL with placeholders, then values. */ private function option_documents_query(): array { global $wpdb; $query = "SELECT option_name, option_value, autoload FROM {$wpdb->options}"; $params = array(); if ( ! $this->dev_mode ) { $clauses = array(); $exact = $this->read_access->readable_option_exact_names(); if ( array() !== $exact ) { $clauses[] = 'option_name IN (' . implode( ', ', array_fill( 0, count( $exact ), '%s' ) ) . ')'; $params = array_merge( $params, $exact ); } foreach ( $this->read_access->readable_option_prefixes() as $prefix ) { $clauses[] = 'option_name LIKE %s'; $params[] = $this->wpdb_like_prefix( (string) $prefix ); } $query .= ' WHERE ' . ( array() === $clauses ? '1 = 0' : implode( ' OR ', $clauses ) ); } $params[] = 250; return array( $query . ' ORDER BY option_name ASC LIMIT %d', $params ); } private function wpdb_like_prefix( string $prefix ): string { global $wpdb; $escaped = method_exists( $wpdb, 'esc_like' ) ? $wpdb->esc_like( $prefix ) : addcslashes( $prefix, '_%\\' ); return $escaped . '%'; } private function option_value_document( string $name, $value ) { if ( 'rewrite_rules' !== $name ) { return $this->normalize_option_value( $value ); } $rules = is_array( $value ) ? $value : array(); $sample = array(); foreach ( $rules as $pattern => $query ) { if ( count( $sample ) >= 25 ) { break; } $sample[] = array( 'pattern' => (string) $pattern, 'query' => is_scalar( $query ) ? (string) $query : wp_json_encode( $query ), ); } return array( 'count' => count( $rules ), 'sample' => $sample, 'truncated' => count( $rules ) > count( $sample ), ); } private function option_value_summary( $value ): array { $encoded = function_exists( 'wp_json_encode' ) ? wp_json_encode( $value ) : json_encode( $value ); return array( 'type' => $this->value_type_name( $value ), 'bytes' => is_string( $encoded ) ? strlen( $encoded ) : 0, ); } private function value_type_name( $value ): string { if ( is_array( $value ) ) { return array_values( $value ) === $value ? 'array' : 'object'; } if ( is_object( $value ) ) { return 'object'; } if ( is_bool( $value ) ) { return 'boolean'; } if ( is_int( $value ) ) { return 'integer'; } if ( is_float( $value ) ) { return 'number'; } if ( null === $value ) { return 'null'; } return 'string'; } private function normalize_option_value( $value ) { if ( ! is_array( $value ) ) { return $value; } $normalized = array(); foreach ( $value as $key => $child ) { $normalized[ $key ] = $this->normalize_option_value( $child ); } $keys = array_keys( $normalized ); if ( array() === $keys ) { return array(); } $numeric_keys = array_filter( $keys, static fn ( $key ): bool => is_int( $key ) || ( is_string( $key ) && ctype_digit( $key ) ) ); if ( count( $numeric_keys ) !== count( $keys ) ) { return $normalized; } uksort( $normalized, static fn ( $left, $right ): int => (int) $left <=> (int) $right ); return array_values( $normalized ); } private function user_documents(): array { $users = function_exists( 'get_users' ) ? get_users( array( 'fields' => 'all' ) ) : array(); if ( ! is_array( $users ) ) { return array(); } return array_values( array_map( static fn ( object $user ): array => array( 'id' => (int) $user->ID, 'login' => (string) ( $user->user_login ?? '' ), 'email' => (string) ( $user->user_email ?? '' ), 'name' => (string) ( $user->display_name ?? '' ), 'roles' => isset( $user->roles ) && is_array( $user->roles ) ? array_values( $user->roles ) : array(), 'meta' => array(), ), $users ) ); } private function revision_documents( array $input ): array { $post_id = isset( $input['target']['post_id'] ) && is_numeric( $input['target']['post_id'] ) ? (int) $input['target']['post_id'] : 0; if ( $post_id <= 0 && isset( $input['where']['all'] ) ) { foreach ( $input['where']['all'] as $predicate ) { if ( isset( $predicate['path'], $predicate['op'], $predicate['value'] ) && array( 'post_id' ) === $predicate['path'] && 'eq' === $predicate['op'] ) { $post_id = (int) $predicate['value']; } } } if ( $post_id <= 0 ) { return array(); } $revisions = function_exists( 'wp_get_post_revisions' ) ? wp_get_post_revisions( $post_id ) : array(); if ( ! is_array( $revisions ) ) { return array(); } return array_values( array_map( static fn ( object $revision ): array => array( 'id' => (int) $revision->ID, 'post_id' => (int) $post_id, 'title' => array( 'raw' => (string) $revision->post_title ), 'content' => array( 'raw' => (string) $revision->post_content ), 'excerpt' => array( 'raw' => (string) $revision->post_excerpt ), 'modified_gmt' => (string) $revision->post_modified_gmt, 'author' => array( 'id' => (int) ( $revision->post_author ?? 0 ) ), ), $revisions ) ); } private function create_term( array $values ): array { $taxonomy = isset( $values['taxonomy'] ) && is_scalar( $values['taxonomy'] ) ? (string) $values['taxonomy'] : ''; $name = isset( $values['name'] ) && is_scalar( $values['name'] ) ? (string) $values['name'] : ''; if ( '' === $taxonomy || '' === $name ) { return $this->error( 'invalid_value', 'terms create requires taxonomy and name.', array( 'input', 'values' ) ); } $result = function_exists( 'wp_insert_term' ) ? wp_insert_term( $name, $taxonomy, $values ) : array( 'term_id' => 0 ); if ( function_exists( 'is_wp_error' ) && is_wp_error( $result ) ) { if ( in_array( $result->get_error_code(), array( 'term_exists', 'duplicate_term_slug' ), true ) ) { return $this->error( 'conflict', 'A term with that name or slug already exists in this taxonomy.', array( 'input', 'values' ) ); } return $this->error( 'execution_failed', 'WordPress failed to create the term.' ); } if ( ! is_array( $result ) || ! isset( $result['term_id'] ) ) { return $this->error( 'execution_failed', 'WordPress failed to create the term.' ); } $id = (int) $result['term_id']; $this->persist_meta( 'term', $id, isset( $values['meta'] ) && is_array( $values['meta'] ) ? $values['meta'] : array() ); return array( 'data' => array( 'created' => true, 'target' => array( 'resource' => 'terms', 'taxonomy' => $taxonomy, 'id' => $id, ), ), ); } private function create_media( array $values ): array { $source_url = $this->media_source_url( $values ); if ( '' !== $source_url ) { return $this->sideload_media( $values, $source_url ); } return $this->error( 'invalid_value', 'media create requires values.source.url so the attachment has a real file.', array( 'input', 'values', 'source', 'url' ) ); } private function media_source_url( array $values ): string { if ( isset( $values['source']['url'] ) && is_scalar( $values['source']['url'] ) ) { return trim( (string) $values['source']['url'] ); } if ( isset( $values['url'] ) && is_scalar( $values['url'] ) ) { return trim( (string) $values['url'] ); } return ''; } private function sideload_media( array $values, string $source_url ): array { if ( ! filter_var( $source_url, FILTER_VALIDATE_URL ) ) { return $this->error( 'invalid_value', 'media create requires a valid source URL.', array( 'input', 'values', 'source', 'url' ) ); } $this->ensure_media_functions_loaded(); if ( function_exists( 'download_url' ) && function_exists( 'media_handle_sideload' ) ) { $tmp_name = $this->download_media_source( $source_url ); if ( function_exists( 'is_wp_error' ) && is_wp_error( $tmp_name ) ) { return $this->error( 'execution_failed', $tmp_name->get_error_message() ); } if ( ! is_string( $tmp_name ) || '' === $tmp_name ) { return $this->error( 'execution_failed', 'WordPress failed to download the media source.' ); } $file_name = $this->media_file_name( $source_url, $values ); $file_size = filesize( $tmp_name ); $file = array( 'name' => $file_name, 'type' => isset( $values['mime_type'] ) ? (string) $values['mime_type'] : $this->media_file_type( $file_name ), 'tmp_name' => $tmp_name, 'error' => 0, 'size' => false === $file_size ? 0 : $file_size, ); $post_data = array( 'post_title' => isset( $values['title']['raw'] ) ? (string) $values['title']['raw'] : (string) ( $values['title'] ?? pathinfo( $file_name, PATHINFO_FILENAME ) ), ); $post_id = isset( $values['post_id'] ) && is_numeric( $values['post_id'] ) ? (int) $values['post_id'] : 0; $id = media_handle_sideload( $file, $post_id, null, $post_data ); if ( function_exists( 'is_wp_error' ) && is_wp_error( $id ) ) { if ( file_exists( $tmp_name ) ) { wp_delete_file( $tmp_name ); } return $this->error( 'execution_failed', $id->get_error_message() ); } if ( ! is_numeric( $id ) || (int) $id <= 0 ) { if ( file_exists( $tmp_name ) ) { wp_delete_file( $tmp_name ); } return $this->error( 'execution_failed', 'WordPress failed to sideload the media item.' ); } $this->persist_meta( 'post', (int) $id, isset( $values['meta'] ) && is_array( $values['meta'] ) ? $values['meta'] : array() ); return array( 'data' => array( 'created' => true, 'target' => array( 'resource' => 'media', 'id' => (int) $id, ), 'source' => array( 'url' => $source_url ), ), ); } $postarr = array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_title' => isset( $values['title']['raw'] ) ? (string) $values['title']['raw'] : (string) ( $values['title'] ?? 'Attachment' ), 'post_mime_type' => isset( $values['mime_type'] ) ? (string) $values['mime_type'] : '', 'guid' => $source_url, ); $id = function_exists( 'wp_insert_attachment' ) ? wp_insert_attachment( $postarr ) : 0; if ( ! is_numeric( $id ) || (int) $id <= 0 ) { return $this->error( 'execution_failed', 'WordPress failed to create the media item.' ); } return array( 'data' => array( 'created' => true, 'target' => array( 'resource' => 'media', 'id' => (int) $id, ), 'source' => array( 'url' => $source_url ), ), ); } private function ensure_media_functions_loaded(): void { if ( function_exists( 'media_handle_sideload' ) && function_exists( 'download_url' ) ) { return; } if ( defined( 'ABSPATH' ) ) { foreach ( array( 'wp-admin/includes/file.php', 'wp-admin/includes/media.php', 'wp-admin/includes/image.php' ) as $relative ) { $path = ABSPATH . $relative; if ( file_exists( $path ) ) { require_once $path; } } } } private function download_media_source( string $source_url ) { $filter = null; if ( $this->is_safe_local_sideload_source( $source_url ) && function_exists( 'add_filter' ) && function_exists( 'remove_filter' ) ) { $allowed_host = strtolower( (string) wp_parse_url( $source_url, PHP_URL_HOST ) ); $filter = static function ( $external, $host ) use ( $allowed_host ) { return strtolower( (string) $host ) === $allowed_host ? true : $external; }; add_filter( 'http_request_host_is_external', $filter, 10, 2 ); } try { return download_url( $source_url ); } finally { if ( null !== $filter && function_exists( 'remove_filter' ) ) { remove_filter( 'http_request_host_is_external', $filter, 10 ); } } } private function is_safe_local_sideload_source( string $source_url ): bool { $host = strtolower( (string) wp_parse_url( $source_url, PHP_URL_HOST ) ); if ( in_array( $host, array( 'localhost', '127.0.0.1', '::1' ), true ) ) { return true; } $site_host = strtolower( (string) wp_parse_url( function_exists( 'get_site_url' ) ? get_site_url() : '', PHP_URL_HOST ) ); $home_host = strtolower( (string) wp_parse_url( function_exists( 'get_home_url' ) ? get_home_url() : '', PHP_URL_HOST ) ); return '' !== $host && in_array( $host, array_filter( array( $site_host, $home_host ) ), true ); } private function media_file_name( string $source_url, array $values ): string { $path = (string) wp_parse_url( $source_url, PHP_URL_PATH ); $name = basename( $path ); if ( '' !== $name && '.' !== $name ) { return $this->sanitize_file_name( $name ); } $title = isset( $values['title']['raw'] ) ? (string) $values['title']['raw'] : 'sideloaded-media'; $slug = function_exists( 'sanitize_title' ) ? sanitize_title( $title ) : strtolower( preg_replace( '/[^a-zA-Z0-9_-]+/', '-', $title ) ); return $this->sanitize_file_name( $slug . '.jpg' ); } private function sanitize_file_name( string $name ): string { if ( function_exists( 'sanitize_file_name' ) ) { return sanitize_file_name( $name ); } $name = preg_replace( '/[^a-zA-Z0-9._-]+/', '-', $name ); return '' === $name ? 'sideloaded-media' : (string) $name; } private function media_file_type( string $file_name ): string { if ( function_exists( 'wp_check_filetype' ) ) { $type = wp_check_filetype( $file_name ); return is_array( $type ) && isset( $type['type'] ) ? (string) $type['type'] : ''; } return ''; } private function create_comment( array $values ): array { $status = $this->comment_status_code_result( $values['status'] ?? 'hold' ); if ( isset( $status['error'] ) ) { return $status; } $commentdata = array( 'comment_post_ID' => (int) ( $values['post_id'] ?? 0 ), 'comment_author' => (string) ( $values['author_name'] ?? '' ), 'comment_author_email' => (string) ( $values['author_email'] ?? '' ), 'comment_author_url' => (string) ( $values['author_url'] ?? '' ), 'comment_content' => (string) ( $values['content']['raw'] ?? $values['content'] ?? '' ), 'comment_approved' => $status['data']['status'], ); $id = function_exists( 'wp_insert_comment' ) ? wp_insert_comment( $commentdata ) : 0; if ( ! is_numeric( $id ) || (int) $id <= 0 ) { return $this->error( 'execution_failed', 'WordPress failed to create the comment.' ); } return array( 'data' => array( 'created' => true, 'target' => array( 'resource' => 'comments', 'id' => (int) $id, ), ), ); } private function persist_term_update( array $target, array $document, bool $target_was_not_found ): array { $id = isset( $target['id'] ) ? (int) $target['id'] : 0; $taxonomy = isset( $target['taxonomy'] ) ? (string) $target['taxonomy'] : (string) ( $document['taxonomy'] ?? '' ); if ( $id <= 0 || '' === $taxonomy ) { return $this->error( 'invalid_target', 'terms update requires taxonomy and id.', array( 'input', 'target' ) ); } $args = array_intersect_key( $document, array_flip( array( 'name', 'slug', 'parent' ) ) ); $result = array( 'term_id' => $id, 'term_taxonomy_id' => $id, ); if ( function_exists( 'wp_update_term' ) ) { $result = wp_update_term( $id, $taxonomy, $args ); } $error = $this->wp_error_result( $result, 'A term with that name or slug already exists in this taxonomy.', 'WordPress failed to update the term.', array( 'input', 'changes' ), $target_was_not_found ); if ( null !== $error ) { return $error; } if ( ! is_array( $result ) || ! isset( $result['term_id'] ) || (int) $result['term_id'] <= 0 ) { if ( $target_was_not_found ) { return $this->error( 'not_found', 'No core content item matched the requested target.', array( 'input', 'target' ) ); } return $this->error( 'execution_failed', 'WordPress failed to update the term.' ); } if ( isset( $document['meta'] ) && is_array( $document['meta'] ) ) { $this->persist_meta( 'term', $id, $document['meta'] ); } return array( 'data' => array( 'updated' => true, 'target' => array( 'resource' => 'terms', 'taxonomy' => $taxonomy, 'id' => $id, ), ), ); } private function persist_media_update( array $target, array $document, bool $target_was_not_found ): array { $id = (int) ( $target['id'] ?? 0 ); if ( $id <= 0 ) { return $this->error( 'invalid_target', 'media update requires id.', array( 'input', 'target' ) ); } $result = $id; if ( function_exists( 'wp_update_post' ) ) { $result = wp_update_post( array( 'ID' => $id, 'post_title' => (string) ( $document['title']['raw'] ?? '' ), 'post_excerpt' => (string) ( $document['caption']['raw'] ?? '' ), 'post_content' => (string) ( $document['description']['raw'] ?? '' ), ), true ); } $error = $this->wp_error_result( $result, 'A media item with that slug already exists.', 'WordPress failed to update the media item.', array( 'input', 'changes' ), $target_was_not_found ); if ( null !== $error ) { return $error; } if ( ! is_numeric( $result ) || (int) $result <= 0 ) { if ( $target_was_not_found ) { return $this->error( 'not_found', 'No core content item matched the requested target.', array( 'input', 'target' ) ); } return $this->error( 'execution_failed', 'WordPress failed to update the media item.' ); } if ( isset( $document['meta'] ) && is_array( $document['meta'] ) ) { $this->persist_meta( 'post', $id, $document['meta'] ); } return array( 'data' => array( 'updated' => true, 'target' => array( 'resource' => 'media', 'id' => $id, ), ), ); } private function persist_comment_update( array $target, array $document, bool $target_was_not_found ): array { $id = (int) ( $target['id'] ?? 0 ); if ( $id <= 0 ) { return $this->error( 'invalid_target', 'comments update requires id.', array( 'input', 'target' ) ); } $status = $this->comment_status_code_result( $document['status'] ?? '' ); if ( isset( $status['error'] ) ) { return $status; } $result = true; if ( function_exists( 'wp_update_comment' ) ) { $result = wp_update_comment( array( 'comment_ID' => $id, 'comment_content' => (string) ( $document['content']['raw'] ?? '' ), 'comment_approved' => $status['data']['status'], ) ); } $error = $this->wp_error_result( $result, 'A comment update conflict occurred.', 'WordPress failed to update the comment.', array( 'input', 'changes' ), $target_was_not_found ); if ( null !== $error ) { return $error; } if ( false === $result || 0 === $result || null === $result ) { if ( $target_was_not_found ) { return $this->error( 'not_found', 'No core content item matched the requested target.', array( 'input', 'target' ) ); } return $this->error( 'execution_failed', 'WordPress failed to update the comment.' ); } if ( isset( $document['meta'] ) && is_array( $document['meta'] ) ) { $this->persist_meta( 'comment', $id, $document['meta'] ); } return array( 'data' => array( 'updated' => true, 'target' => array( 'resource' => 'comments', 'id' => $id, ), ), ); } private function persist_option_update( array $target, array $document ): array { $name = $this->target_key( $target ); if ( '' === $name ) { return $this->error( 'invalid_target', 'options update requires target.name.', array( 'input', 'target' ) ); } $structural_denial = $this->structural_option_write_denial( $name ); if ( isset( $structural_denial['error'] ) ) { return $structural_denial; } if ( function_exists( 'update_option' ) ) { if ( array_key_exists( 'autoload', $document ) && null !== $document['autoload'] && $this->function_accepts_args( 'update_option', 3 ) ) { update_option( $name, $document['value'] ?? null, (bool) $document['autoload'] ); } else { update_option( $name, $document['value'] ?? null ); } } return array( 'data' => array( 'updated' => true, 'target' => array( 'resource' => 'options', 'name' => $name, ), ), ); } private function delete_term( array $target ): array { $id = (int) ( $target['id'] ?? 0 ); $taxonomy = (string) ( $target['taxonomy'] ?? '' ); if ( $id <= 0 || '' === $taxonomy ) { return $this->error( 'invalid_target', 'terms delete requires taxonomy and id.', array( 'input', 'target' ) ); } $raw_result = function_exists( 'wp_delete_term' ) ? wp_delete_term( $id, $taxonomy ) : true; $wp_error = $this->wp_error_result( $raw_result, 'A term delete conflict occurred.', 'WordPress failed to delete the term.', array( 'input', 'target' ) ); if ( null !== $wp_error ) { return $wp_error; } // wp_delete_term returns exactly true on success; false or 0 (the // default term) both mean the term was not deleted. if ( true !== $raw_result ) { return $this->error( 'execution_failed', 'WordPress failed to delete the term.', array( 'input', 'target' ) ); } return array( 'data' => array( 'deleted' => true, 'target' => array( 'resource' => 'terms', 'taxonomy' => $taxonomy, 'id' => $id, ), ), ); } private function delete_media( array $target, bool $force ): array { $id = (int) ( $target['id'] ?? 0 ); if ( $id <= 0 ) { return $this->error( 'invalid_target', 'media delete requires id.', array( 'input', 'target' ) ); } $deleted = function_exists( 'wp_delete_attachment' ) ? (bool) wp_delete_attachment( $id, $force ) : true; if ( ! $deleted ) { return $this->error( 'execution_failed', 'WordPress failed to delete the attachment.', array( 'input', 'target' ) ); } return array( 'data' => array( 'deleted' => true, 'target' => array( 'resource' => 'media', 'id' => $id, ), ), ); } private function delete_comment( array $target, bool $force ): array { $id = (int) ( $target['id'] ?? 0 ); if ( $id <= 0 ) { return $this->error( 'invalid_target', 'comments delete requires id.', array( 'input', 'target' ) ); } $deleted = function_exists( 'wp_delete_comment' ) ? (bool) wp_delete_comment( $id, $force ) : true; if ( ! $deleted ) { return $this->error( 'execution_failed', 'WordPress failed to delete the comment.', array( 'input', 'target' ) ); } return array( 'data' => array( 'deleted' => true, 'target' => array( 'resource' => 'comments', 'id' => $id, ), ), ); } private function delete_option( array $target ): array { $name = $this->target_key( $target ); if ( '' === $name ) { return $this->error( 'invalid_target', 'options delete requires name.', array( 'input', 'target' ) ); } $structural_denial = $this->structural_option_write_denial( $name ); if ( isset( $structural_denial['error'] ) ) { return $structural_denial; } $deleted = function_exists( 'delete_option' ) ? (bool) delete_option( $name ) : true; if ( ! $deleted ) { return $this->error( 'execution_failed', 'WordPress failed to delete the option.', array( 'input', 'target' ) ); } return array( 'data' => array( 'deleted' => true, 'target' => array( 'resource' => 'options', 'name' => $name, ), ), ); } private function structural_option_write_denial( string $name ): array { if ( $this->dev_mode || ! $this->is_structural_option_name( $name ) ) { return array( 'data' => array( 'allowed' => true ) ); } return $this->error( 'permission_denied', 'This structural option cannot be changed by public builds.', array( 'input', 'target', 'name' ), array( 'layer' => 'hard_rule', 'reason' => 'structural_option_denied', ) ); } private function is_structural_option_name( string $name ): bool { if ( in_array( $name, self::STRUCTURAL_OPTION_DENYLIST, true ) ) { return true; } return $name === $this->user_roles_option_name(); } private function user_roles_option_name(): string { global $wpdb; $prefix = is_object( $wpdb ) && isset( $wpdb->prefix ) && is_scalar( $wpdb->prefix ) ? (string) $wpdb->prefix : 'wp_'; return $prefix . 'user_roles'; } private function media_document( object $post ): array { $url = function_exists( 'wp_get_attachment_url' ) ? (string) wp_get_attachment_url( (int) $post->ID ) : (string) ( $post->guid ?? '' ); return array( 'id' => (int) $post->ID, 'status' => (string) $post->post_status, 'title' => array( 'raw' => (string) $post->post_title ), 'caption' => array( 'raw' => (string) $post->post_excerpt ), 'description' => array( 'raw' => (string) $post->post_content ), 'mime_type' => (string) ( $post->post_mime_type ?? '' ), 'url' => $url, 'source' => array( 'url' => $url ), 'meta' => $this->meta_document( 'post', (int) $post->ID ), ); } private function comment_document( object $comment ): array { return array( 'id' => (int) $comment->comment_ID, 'post_id' => (int) $comment->comment_post_ID, 'status' => $this->comment_status_label( $comment->comment_approved ), 'author' => array( 'name' => (string) $comment->comment_author, 'url' => (string) ( $comment->comment_author_url ?? '' ), ), 'content' => array( 'raw' => (string) $comment->comment_content ), 'date_gmt' => (string) ( $comment->comment_date_gmt ?? '' ), 'meta' => $this->meta_document( 'comment', (int) $comment->comment_ID ), ); } private function meta_document( string $type, int $id ): array { $reader = $this->meta_reader( $type ); if ( ! function_exists( $reader ) ) { return array(); } $raw = $reader( $id ); if ( ! is_array( $raw ) ) { return array(); } $meta = array(); foreach ( $raw as $key => $values ) { $meta[ (string) $key ] = is_array( $values ) && 1 === count( $values ) ? array_values( $values )[0] : $values; } return $meta; } private function persist_meta( string $type, int $id, array $meta ): void { $updater = $this->meta_updater( $type ); if ( ! function_exists( $updater ) ) { return; } foreach ( $meta as $key => $value ) { $updater( $id, (string) $key, $value ); } } private function validate_comment_status_input( array $input ): array { if ( 'create' === $this->action && isset( $input['values'] ) && is_array( $input['values'] ) && array_key_exists( 'status', $input['values'] ) ) { return $this->comment_status_code_result( $input['values']['status'] ); } if ( 'update' === $this->action && isset( $input['changes'] ) && is_array( $input['changes'] ) ) { foreach ( $input['changes'] as $index => $change ) { $path = is_array( $change ) && isset( $change['path'] ) && is_array( $change['path'] ) ? array_values( $change['path'] ) : array(); if ( is_array( $change ) && 'set' === (string) ( $change['op'] ?? '' ) && array( 'status' ) === $path ) { $result = $this->comment_status_code_result( $change['value'] ?? null, array( 'input', 'changes', $index, 'value' ) ); if ( isset( $result['error'] ) ) { return $result; } } } } return array( 'data' => array( 'valid' => true ) ); } private function comment_status_code_result( $status, array $path = array( 'input', 'values', 'status' ) ): array { $normalized = is_scalar( $status ) ? strtolower( trim( (string) $status ) ) : ''; $code = match ( $normalized ) { 'approve', 'approved', '1' => '1', 'hold', 'pending', '0', '' => '0', 'spam' => 'spam', 'trash' => 'trash', default => null, }; if ( null === $code ) { return $this->error( 'invalid_value', 'Comment status must be one of: approve, hold, spam, trash.', $path, array( 'allowed' => array( 'approve', 'hold', 'spam', 'trash' ) ) ); } return array( 'data' => array( 'status' => $code ) ); } private function comment_status_label( $status ): string { return match ( (string) $status ) { '1' => 'approve', '0' => 'hold', 'spam' => 'spam', 'trash' => 'trash', default => (string) $status, }; } private function meta_reader( string $type ): string { return match ( $type ) { 'term' => 'get_term_meta', 'comment' => 'get_comment_meta', default => 'get_post_meta', }; } private function meta_updater( string $type ): string { return match ( $type ) { 'term' => 'update_term_meta', 'comment' => 'update_comment_meta', default => 'update_post_meta', }; } private function wp_error_result( $result, string $conflict_message, string $fallback_message, ?array $path = null, bool $target_was_not_found = false ): ?array { if ( ! function_exists( 'is_wp_error' ) || ! is_wp_error( $result ) ) { return null; } if ( $this->is_conflict_wp_error( $result ) ) { return $this->error( 'conflict', $conflict_message, $path ); } if ( $target_was_not_found && $this->is_not_found_wp_error( $result ) ) { return $this->error( 'not_found', 'No core content item matched the requested target.', array( 'input', 'target' ) ); } $message = is_callable( array( $result, 'get_error_message' ) ) ? (string) $result->get_error_message() : ''; return $this->error( 'execution_failed', '' !== $message ? $message : $fallback_message ); } private function is_conflict_wp_error( $error ): bool { $code = is_callable( array( $error, 'get_error_code' ) ) ? (string) $error->get_error_code() : ''; if ( in_array( $code, array( 'term_exists', 'duplicate_term_slug', 'post_exists', 'post_name_conflict' ), true ) ) { return true; } return str_contains( $code, 'duplicate' ) || str_contains( $code, 'already_exists' ) || str_contains( $code, 'conflict' ); } private function is_not_found_wp_error( $error ): bool { $code = is_callable( array( $error, 'get_error_code' ) ) ? (string) $error->get_error_code() : ''; return in_array( $code, array( 'invalid_term', 'invalid_post', 'invalid_comment', 'term_not_found', 'post_not_found', 'comment_not_found' ), true ); } private function filter_documents( array $documents, $where, $search ): array { $search = is_scalar( $search ) ? strtolower( (string) $search ) : ''; if ( '' !== $search ) { $documents = array_values( array_filter( $documents, static fn ( array $document ): bool => str_contains( strtolower( (string) wp_json_encode( $document ) ), $search ) ) ); } if ( is_array( $where ) ) { $documents = array_values( array_filter( $documents, fn ( array $document ): bool => $this->where_matches( $document, $where ) ) ); } return $documents; } private function validate_where( $where, array $path ): array { if ( ! is_array( $where ) ) { return $this->error( 'invalid_command', 'where must be a predicate or all/any group.', $path ); } if ( isset( $where['all'] ) || isset( $where['any'] ) ) { $keys = array_values( array_intersect( array_keys( $where ), array( 'all', 'any' ) ) ); if ( 1 !== count( $keys ) || ! is_array( $where[ $keys[0] ] ) ) { return $this->error( 'invalid_command', 'Predicate groups must contain exactly one all or any list.', $path ); } foreach ( $where[ $keys[0] ] as $index => $child ) { $result = $this->validate_where( $child, array_merge( $path, array( $keys[0], $index ) ) ); if ( isset( $result['error'] ) ) { return $result; } } return array( 'data' => array( 'valid' => true ) ); } if ( ! isset( $where['path'], $where['op'] ) || ! $this->is_path( $where['path'] ) ) { return $this->error( 'invalid_command', 'Predicates require path and op fields.', $path ); } if ( ! $this->is_queryable_path( $where['path'] ) ) { return $this->error( 'invalid_path', 'Requested predicate path is not queryable for this core content resource.', array_merge( $path, array( 'path' ) ) ); } $op = is_scalar( $where['op'] ) ? (string) $where['op'] : ''; if ( ! in_array( $op, self::QUERY_OPS, true ) ) { return $this->error( 'invalid_value', 'Predicate op is not part of the core content query grammar.', array_merge( $path, array( 'op' ) ) ); } if ( ! array_key_exists( 'value', $where ) ) { return $this->error( 'invalid_command', 'Predicates require a value field.', array_merge( $path, array( 'value' ) ) ); } return array( 'data' => array( 'valid' => true ) ); } private function sort_documents( array $documents, array $order_by ): array { if ( array() === $order_by ) { return $documents; } usort( $documents, function ( array $a, array $b ) use ( $order_by ): int { foreach ( $order_by as $order ) { $path = isset( $order['path'] ) && is_array( $order['path'] ) ? array_values( $order['path'] ) : array(); $dir = isset( $order['dir'] ) && is_scalar( $order['dir'] ) ? strtolower( (string) $order['dir'] ) : 'asc'; $left = $this->path_get( $a, $path ); $right = $this->path_get( $b, $path ); $comparison = $this->compare_values( $left, $right ); if ( 0 !== $comparison ) { return 'desc' === $dir ? -$comparison : $comparison; } } return 0; } ); return $documents; } private function compare_values( $left, $right ): int { if ( is_numeric( $left ) && is_numeric( $right ) ) { return (float) $left <=> (float) $right; } return strnatcasecmp( (string) $left, (string) $right ); } private function is_orderable_path( array $path ): bool { $key = implode( '.', array_map( 'strval', array_values( $path ) ) ); return in_array( $key, match ( $this->resource ) { 'terms' => array( 'id', 'taxonomy', 'name', 'slug', 'parent', 'count' ), 'media' => array( 'id', 'mime_type' ), 'comments' => array( 'id', 'post_id', 'status', 'date_gmt' ), 'options' => array( 'identity.name', 'autoload' ), 'users' => array( 'id', 'login', 'name' ), 'revisions' => array( 'id', 'post_id', 'modified_gmt' ), default => array(), }, true ); } private function option_autoload_value( string $name ): ?bool { global $wpdb; if ( is_object( $wpdb ) && isset( $wpdb->options ) && method_exists( $wpdb, 'get_var' ) && method_exists( $wpdb, 'prepare' ) ) { // Core has no API that reads an option's autoload flag. This // admin-only lookup serves the live contract on demand, so caching // would hand the assistant stale data. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return $this->autoload_to_bool( $wpdb->get_var( $wpdb->prepare( "SELECT autoload FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", $name ) ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching } return null; } private function autoload_to_bool( $value ): ?bool { if ( null === $value || '' === $value ) { return null; } return ! in_array( strtolower( (string) $value ), array( 'no', 'off', 'false', '0' ), true ); } private function maybe_unserialize( $value ) { if ( function_exists( 'maybe_unserialize' ) ) { return maybe_unserialize( $value ); } if ( is_string( $value ) ) { $unserialized = @unserialize( $value ); if ( false !== $unserialized || 'b:0;' === $value ) { return $unserialized; } } return $value; } private function function_accepts_args( string $function_name, int $count ): bool { if ( ! function_exists( $function_name ) ) { return false; } try { return ( new \ReflectionFunction( $function_name ) )->getNumberOfParameters() >= $count; } catch ( \ReflectionException ) { return false; } } private function predicate_matches( array $document, array $predicate ): bool { $value = $this->path_get( $document, $predicate['path'] ); $expected = $predicate['value'] ?? null; return match ( (string) $predicate['op'] ) { 'eq' => $value === $expected || (string) $value === (string) $expected, 'in' => is_array( $expected ) && in_array( $value, $expected, true ), 'exists' => ( null !== $value ) === (bool) $expected, default => false, }; } private function where_matches( array $document, array $where ): bool { if ( isset( $where['all'] ) && is_array( $where['all'] ) ) { foreach ( $where['all'] as $child ) { if ( ! is_array( $child ) || ! $this->where_matches( $document, $child ) ) { return false; } } return true; } if ( isset( $where['any'] ) && is_array( $where['any'] ) ) { foreach ( $where['any'] as $child ) { if ( is_array( $child ) && $this->where_matches( $document, $child ) ) { return true; } } return false; } return $this->predicate_matches( $document, $where ); } private function item_result( array $document, $select ): array { return array( 'target' => $this->target_for_document( $document ), 'document' => $this->project_document( $document, $select ), 'facts' => $this->facts_for_document( $document ), ); } private function project_document( array $document, $select ): array { if ( null === $select || array() === $select ) { return $this->default_projection( $document ); } $projected = array(); foreach ( $select as $path ) { if ( ! is_array( $path ) ) { continue; } $projected = $this->path_set( $projected, array_values( $path ), $this->path_get( $document, $path ) ); } return $projected; } private function default_projection( array $document ): array { foreach ( array( 'meta', 'email' ) as $sensitive ) { if ( array_key_exists( $sensitive, $document ) ) { unset( $document[ $sensitive ] ); } } return $document; } private function target_for_document( array $document ): array { $target = array( 'resource' => $this->resource ); foreach ( array( 'id', 'taxonomy', 'post_id' ) as $key ) { if ( array_key_exists( $key, $document ) ) { $target[ $key ] = $document[ $key ]; } } if ( isset( $document['identity']['name'] ) ) { $target['name'] = $document['identity']['name']; } return $target; } private function target_key( array $target ): string { if ( 'terms' === $this->resource && isset( $target['taxonomy'], $target['id'] ) ) { return (string) $target['taxonomy'] . ':' . (int) $target['id']; } if ( 'revisions' === $this->resource && isset( $target['post_id'], $target['id'] ) ) { return (int) $target['post_id'] . ':' . (int) $target['id']; } foreach ( array( 'id', 'name', 'key' ) as $key ) { if ( isset( $target[ $key ] ) && is_scalar( $target[ $key ] ) ) { return (string) $target[ $key ]; } } return ''; } private function document_key( array $document ): string { if ( 'terms' === $this->resource ) { return (string) $document['taxonomy'] . ':' . (int) $document['id']; } if ( 'revisions' === $this->resource ) { return (int) $document['post_id'] . ':' . (int) $document['id']; } if ( isset( $document['identity']['name'] ) ) { return (string) $document['identity']['name']; } return isset( $document['id'] ) ? (string) $document['id'] : ''; } private function facts_for_document( array $document ): array { $facts = array(); if ( isset( $document['modified_gmt'] ) ) { $facts['modified_gmt'] = $document['modified_gmt']; } return $facts; } private function taxonomy_from_input( array $input ): string { if ( isset( $input['target']['taxonomy'] ) && is_scalar( $input['target']['taxonomy'] ) ) { return (string) $input['target']['taxonomy']; } if ( isset( $input['where']['all'] ) && is_array( $input['where']['all'] ) ) { foreach ( $input['where']['all'] as $predicate ) { if ( isset( $predicate['path'], $predicate['op'], $predicate['value'] ) && array( 'taxonomy' ) === $predicate['path'] && 'eq' === $predicate['op'] ) { return (string) $predicate['value']; } } } return ''; } private function apply_change( array $document, array $change, int $index ): array { foreach ( array_keys( $change ) as $change_key ) { if ( ! in_array( (string) $change_key, array( 'op', 'path', 'value', 'provider' ), true ) ) { return $this->error( 'invalid_command', 'Change items may contain only op, path, value, and provider.', array( 'input', 'changes', $index, (string) $change_key ) ); } } $op = isset( $change['op'] ) && is_scalar( $change['op'] ) ? (string) $change['op'] : ''; $path = isset( $change['path'] ) && is_array( $change['path'] ) ? array_values( $change['path'] ) : array(); if ( ! in_array( $op, array( 'set', 'remove', 'insert' ), true ) || array() === $path ) { return $this->error( 'invalid_command', 'changes require op and path.', array( 'input', 'changes', $index ) ); } if ( in_array( $op, array( 'set', 'insert' ), true ) && ! array_key_exists( 'value', $change ) ) { return $this->error( 'invalid_value', 'set and insert require value.', array( 'input', 'changes', $index, 'value' ) ); } $path_error = $this->writable_change_path_error( $path, $change['provider'] ?? null, $index ); if ( null !== $path_error ) { return $path_error; } if ( 'set' === $op ) { return array( 'document' => $this->path_set( $document, $path, $change['value'] ) ); } if ( 'remove' === $op ) { return array( 'document' => $this->path_remove( $document, $path ) ); } return array( 'document' => $this->path_insert( $document, $path, $change['value'] ) ); } /** * Reject change paths the persist layer does not write. * * Keeps the effective schema honest: a path the schema does not advertise * as writable fails with `invalid_path` instead of silently mutating a * document field that persistence then discards. * * @param array $path Change path segments. * @param mixed $provider Optional provider disambiguator. * @param int $index Change index for error paths. */ private function writable_change_path_error( array $path, $provider, int $index ): ?array { if ( null !== $provider && ! is_scalar( $provider ) ) { return $this->error( 'invalid_command', 'provider must be a scalar disambiguator.', array( 'input', 'changes', $index, 'provider' ) ); } $key = implode( '.', array_map( 'strval', $path ) ); $is_meta = 'meta' === (string) $path[0] && count( $path ) >= 2; $core = match ( $this->resource ) { 'terms' => array( 'name', 'slug', 'parent' ), 'media' => array( 'title.raw', 'caption.raw', 'description.raw' ), 'comments' => array( 'status', 'content.raw' ), 'options' => array( 'autoload' ), default => array(), }; $is_core = in_array( $key, $core, true ) || ( 'options' === $this->resource && ( 'value' === $key || str_starts_with( $key, 'value.' ) ) ); if ( ! $is_core && ! ( $is_meta && 'options' !== $this->resource ) ) { return $this->error( 'invalid_path', 'Path is not writable for this resource.', array( 'input', 'changes', $index, 'path' ) ); } $expected_provider = $is_core ? 'core' : 'meta'; if ( null !== $provider && '' !== (string) $provider && (string) $provider !== $expected_provider ) { return $this->error( 'invalid_path', 'The requested provider does not serve this path.', array( 'input', 'changes', $index, 'provider' ) ); } return null; } private function path_get( array $document, array $path ) { $node = $document; foreach ( $path as $segment ) { $key = (string) $segment; if ( ! is_array( $node ) || ! array_key_exists( $key, $node ) ) { return null; } $node = $node[ $key ]; } return $node; } private function path_set( array $document, array $path, $value ): array { $key = (string) $path[0]; if ( 1 === count( $path ) ) { $document[ $key ] = $value; return $document; } $child = isset( $document[ $key ] ) && is_array( $document[ $key ] ) ? $document[ $key ] : array(); $document[ $key ] = $this->path_set( $child, array_slice( $path, 1 ), $value ); return $document; } private function path_remove( array $document, array $path ): array { $key = (string) $path[0]; if ( 1 === count( $path ) ) { unset( $document[ $key ] ); return $document; } if ( isset( $document[ $key ] ) && is_array( $document[ $key ] ) ) { $document[ $key ] = $this->path_remove( $document[ $key ], array_slice( $path, 1 ) ); } return $document; } private function path_insert( array $document, array $path, $value ): array { $key = (string) $path[0]; if ( 1 === count( $path ) ) { $document[] = $value; return $document; } if ( ! isset( $document[ $key ] ) || ! is_array( $document[ $key ] ) ) { $document[ $key ] = array(); } $document[ $key ] = $this->path_insert( $document[ $key ], array_slice( $path, 1 ), $value ); return $document; } private function is_path_list( $select ): bool { if ( ! is_array( $select ) ) { return false; } foreach ( $select as $path ) { if ( ! is_array( $path ) || array() === $path ) { return false; } } return true; } private function is_path( $path ): bool { if ( ! is_array( $path ) || array() === $path ) { return false; } foreach ( $path as $segment ) { if ( ! is_scalar( $segment ) || is_bool( $segment ) || '' === (string) $segment ) { return false; } } return true; } private function is_queryable_path( array $path ): bool { $key = implode( '.', array_map( 'strval', array_values( $path ) ) ); if ( in_array( $key, match ( $this->resource ) { 'terms' => array( 'id', 'taxonomy', 'name', 'slug', 'parent', 'count' ), 'media' => array( 'id', 'mime_type' ), 'comments' => array( 'id', 'post_id', 'status', 'date_gmt' ), 'options' => array( 'identity.name', 'autoload' ), 'users' => array( 'id', 'login', 'name' ), 'revisions' => array( 'id', 'post_id', 'modified_gmt' ), default => array(), }, true ) ) { return true; } return 2 === count( $path ) && 'meta' === (string) $path[0] && in_array( $this->resource, array( 'terms', 'media', 'comments', 'users' ), true ); } private function error( string $code, string $message, ?array $path = null, array $details = array() ): array { $error = array( 'code' => $code, 'message' => $message, ); if ( null !== $path ) { $error['path'] = $path; } if ( array() !== $details ) { $error['details'] = $details; } return array( 'error' => $error ); } }