cpt = $cpt; } /** * Register all REST API routes. * * @since 1.0.0 * * @return void */ public function register_routes(): void { register_rest_route( 'wlconduit/v1', '/tools', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'list_tools' ), 'permission_callback' => array( $this, 'check_tool_permission' ), ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_tool' ), 'permission_callback' => array( $this, 'check_tool_permission' ), ), ) ); register_rest_route( 'wlconduit/v1', '/tools/export', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'export_tools' ), 'permission_callback' => array( $this, 'check_tool_permission' ), 'args' => array( 'ids' => array( 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ), ), ) ); register_rest_route( 'wlconduit/v1', '/tools/import', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'import_tools' ), 'permission_callback' => array( $this, 'check_tool_permission' ), ) ); register_rest_route( 'wlconduit/v1', '/tools/(?P\d+)', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_tool' ), 'permission_callback' => array( $this, 'check_tool_permission' ), ), array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'update_tool' ), 'permission_callback' => array( $this, 'check_tool_permission' ), ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_tool' ), 'permission_callback' => array( $this, 'check_tool_permission' ), ), ) ); register_rest_route( 'wlconduit/v1', '/taxonomies', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_taxonomies' ), 'permission_callback' => array( $this, 'check_tool_permission' ), 'args' => array( 'object_type' => array( 'required' => true, 'sanitize_callback' => 'sanitize_text_field', ), ), ) ); register_rest_route( 'wlconduit/v1', '/terms', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_terms' ), 'permission_callback' => array( $this, 'check_tool_permission' ), 'args' => array( 'taxonomy' => array( 'required' => true, 'sanitize_callback' => 'sanitize_text_field', ), 'refresh' => array( 'sanitize_callback' => 'rest_sanitize_boolean', 'default' => false, ), ), ) ); register_rest_route( 'wlconduit/v1', '/options', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_options' ), 'permission_callback' => array( $this, 'check_tool_permission' ), 'args' => array( 'refresh' => array( 'sanitize_callback' => 'rest_sanitize_boolean', 'default' => false, ), ), ) ); register_rest_route( 'wlconduit/v1', '/meta-keys', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_meta_keys' ), 'permission_callback' => array( $this, 'check_tool_permission' ), 'args' => array( 'object_type' => array( 'required' => true, 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => array( $this, 'validate_object_type' ), ), 'post_type' => array( 'sanitize_callback' => 'sanitize_text_field', ), 'refresh' => array( 'sanitize_callback' => 'rest_sanitize_boolean', 'default' => false, ), ), ) ); register_rest_route( 'wlconduit/v1', '/join-columns', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_join_columns' ), 'permission_callback' => array( $this, 'check_tool_permission' ), 'args' => array( 'block_type' => array( 'required' => true, 'sanitize_callback' => 'sanitize_text_field', ), ), ) ); register_rest_route( 'wlconduit/v1', '/settings', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_settings' ), 'permission_callback' => array( $this, 'check_admin_permission' ), ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'save_settings' ), 'permission_callback' => array( $this, 'check_admin_permission' ), ), ) ); } /** * Check if current user can manage tools. * * @since 1.0.0 * * @return bool */ public function check_tool_permission(): bool { return current_user_can( 'manage_wlconduit_tools' ); } /** * Check if current user is a site administrator. * * @since 1.0.0 * * @return bool */ public function check_admin_permission(): bool { return current_user_can( 'manage_options' ); } /** * Validate the object_type parameter. * * @since 1.0.0 * * @param string $value The object type value to validate. * @return bool|WP_Error True if valid, WP_Error if not. */ public function validate_object_type( string $value ): bool|WP_Error { if ( in_array( $value, self::ALLOWED_OBJECT_TYPES, true ) ) { return true; } return new WP_Error( 'invalid_object_type', sprintf( /* translators: %s: comma-separated list of allowed types */ __( 'object_type must be one of: %s', 'ai-conduit' ), implode( ', ', self::ALLOWED_OBJECT_TYPES ) ), array( 'status' => 400 ) ); } /** * List all tools. * * @since 1.0.0 * * @return WP_REST_Response */ public function list_tools(): WP_REST_Response { return new WP_REST_Response( $this->cpt->get_all_tools(), 200 ); } /** * Get a single tool with full config. * * @since 1.0.0 * * @param WP_REST_Request $request The incoming request. * @return WP_REST_Response|WP_Error */ public function get_tool( WP_REST_Request $request ): WP_REST_Response|WP_Error { $id = absint( $request->get_param( 'id' ) ); $tool = $this->cpt->get_tool( $id ); if ( ! $tool ) { return new WP_Error( 'not_found', __( 'Tool not found.', 'ai-conduit' ), array( 'status' => 404 ) ); } return new WP_REST_Response( $tool, 200 ); } /** * Create a new tool. * * @since 1.0.0 * * @param WP_REST_Request $request The incoming request. * @return WP_REST_Response|WP_Error */ public function create_tool( WP_REST_Request $request ): WP_REST_Response|WP_Error { $config = $request->get_json_params(); if ( empty( $config['tool_name'] ) ) { return new WP_Error( 'missing_name', __( 'Tool name is required.', 'ai-conduit' ), array( 'status' => 400 ) ); } if ( ! preg_match( '/^[a-zA-Z0-9_-]{1,64}$/', $config['tool_name'] ) ) { return new WP_Error( 'invalid_name', __( 'Tool name must be 1-64 characters using only letters, numbers, underscores, and hyphens. No spaces.', 'ai-conduit' ), array( 'status' => 400 ) ); } if ( $this->cpt->tool_name_exists( $config['tool_name'] ) ) { return new WP_Error( 'duplicate_name', __( 'A tool with this name already exists.', 'ai-conduit' ), array( 'status' => 400 ) ); } $pattern_check = $this->validate_no_pattern_keys_in_joins( $config ); if ( is_wp_error( $pattern_check ) ) { return $pattern_check; } $join_target_check = $this->validate_join_targets_are_joinable( $config ); if ( is_wp_error( $join_target_check ) ) { return $join_target_check; } $comment_meta_check = $this->validate_no_meta_keys_on_comment_joins( $config ); if ( is_wp_error( $comment_meta_check ) ) { return $comment_meta_check; } $comments_block_meta_check = $this->validate_no_meta_keys_on_comments_block( $config ); if ( is_wp_error( $comments_block_meta_check ) ) { return $comments_block_meta_check; } $site_info_check = $this->validate_site_info_block_minimal_config( $config ); if ( is_wp_error( $site_info_check ) ) { return $site_info_check; } $result = $this->cpt->save_tool( $config ); if ( is_wp_error( $result ) ) { return $result; } return new WP_REST_Response( array( 'id' => $result, 'tool_name' => sanitize_text_field( $config['tool_name'] ), ), 201 ); } /** * Update an existing tool. * * @since 1.0.0 * * @param WP_REST_Request $request The incoming request. * @return WP_REST_Response|WP_Error */ public function update_tool( WP_REST_Request $request ): WP_REST_Response|WP_Error { $id = absint( $request->get_param( 'id' ) ); $config = $request->get_json_params(); if ( ! empty( $config['tool_name'] ) && ! preg_match( '/^[a-zA-Z0-9_-]{1,64}$/', $config['tool_name'] ) ) { return new WP_Error( 'invalid_name', __( 'Tool name must be 1-64 characters using only letters, numbers, underscores, and hyphens. No spaces.', 'ai-conduit' ), array( 'status' => 400 ) ); } if ( ! empty( $config['tool_name'] ) && $this->cpt->tool_name_exists( $config['tool_name'], $id ) ) { return new WP_Error( 'duplicate_name', __( 'A tool with this name already exists.', 'ai-conduit' ), array( 'status' => 400 ) ); } $pattern_check = $this->validate_no_pattern_keys_in_joins( $config ); if ( is_wp_error( $pattern_check ) ) { return $pattern_check; } $join_target_check = $this->validate_join_targets_are_joinable( $config ); if ( is_wp_error( $join_target_check ) ) { return $join_target_check; } $comment_meta_check = $this->validate_no_meta_keys_on_comment_joins( $config ); if ( is_wp_error( $comment_meta_check ) ) { return $comment_meta_check; } $comments_block_meta_check = $this->validate_no_meta_keys_on_comments_block( $config ); if ( is_wp_error( $comments_block_meta_check ) ) { return $comments_block_meta_check; } $site_info_check = $this->validate_site_info_block_minimal_config( $config ); if ( is_wp_error( $site_info_check ) ) { return $site_info_check; } $result = $this->cpt->update_tool( $id, $config ); if ( is_wp_error( $result ) ) { return $result; } return new WP_REST_Response( array( 'id' => $result, 'tool_name' => sanitize_text_field( $config['tool_name'] ?? '' ), ), 200 ); } /** * Reject configs that combine joins with pattern (dynamic) meta keys. * * Pattern meta keys (containing %) are matched via SQL LIKE in the * non-join path's batch_fetch_meta(). The query builder used for the * join path uses literal meta_key matches via LEFT JOIN, so pattern * keys silently produce no results. Reject the save with an actionable * error rather than letting the broken config persist. * * Checks both the primary block's meta_keys and each join's * target_meta_keys. * * @since 1.0.0 * * @param array $config The tool configuration from the request. * @return true|WP_Error True if valid, WP_Error otherwise. */ private function validate_no_pattern_keys_in_joins( array $config ): bool|WP_Error { foreach ( $config['blocks'] ?? array() as $index => $block ) { $block_config = $block['config'] ?? array(); if ( empty( $block_config['joins'] ) || ! is_array( $block_config['joins'] ) ) { continue; } // Primary block's meta_keys. foreach ( $block_config['meta_keys'] ?? array() as $key => $unused ) { if ( false !== strpos( (string) $key, '%' ) ) { return new WP_Error( 'pattern_keys_not_supported_with_joins', sprintf( /* translators: 1: meta key name, 2: block number (1-based) */ __( 'Pattern meta keys (containing %%) are not supported when a block has joins configured. Found "%1$s" in block %2$d. Please remove the dynamic meta key or remove the join configuration.', 'ai-conduit' ), $key, $index + 1 ), array( 'status' => 400 ) ); } } // Each join's target_meta_keys. foreach ( $block_config['joins'] as $join_index => $join ) { foreach ( $join['target_meta_keys'] ?? array() as $key => $unused ) { if ( false !== strpos( (string) $key, '%' ) ) { $join_label = ! empty( $join['label'] ) ? $join['label'] : sprintf( 'Join %d', $join_index + 1 ); return new WP_Error( 'pattern_keys_not_supported_with_joins', sprintf( /* translators: 1: meta key name, 2: join label, 3: block number (1-based) */ __( 'Pattern meta keys (containing %%) are not supported in join targets. Found "%1$s" in join "%2$s" of block %3$d. Please remove the dynamic meta key.', 'ai-conduit' ), $key, $join_label, $index + 1 ), array( 'status' => 400 ) ); } } } } return true; } /** * Reject joins whose target_type is missing or unjoinable. * * The query builder resolves the target via Wlconduit_Block_Registry::get() and * reads get_joinable_columns() for the table/id_column/meta_table. Blocks * whose joinable columns return an empty `table` (e.g., site_info, future * non-joinable blocks) cannot participate in joins — the resulting SQL * would be malformed (LEFT JOIN with empty table name) and fail at * execution time. Same outcome for unregistered block names. * * Reject such configs at save with a clear error so users learn the * supported target types up-front instead of seeing a SQL error later. * * @since 1.0.0 * * @param array $config The tool configuration from the request. * @return true|WP_Error True if valid, WP_Error otherwise. */ private function validate_join_targets_are_joinable( array $config ): bool|WP_Error { foreach ( $config['blocks'] ?? array() as $index => $block ) { $block_config = $block['config'] ?? array(); if ( empty( $block_config['joins'] ) || ! is_array( $block_config['joins'] ) ) { continue; } foreach ( $block_config['joins'] as $join_index => $join ) { $target_type = (string) ( $join['target_type'] ?? '' ); $target_block = Wlconduit_Block_Registry::get( $target_type ); $joinable = $target_block ? $target_block->get_joinable_columns() : array(); if ( ! $target_block || empty( $joinable['table'] ) ) { $join_label = ! empty( $join['label'] ) ? $join['label'] : sprintf( 'Join %d', $join_index + 1 ); return new WP_Error( 'unjoinable_target_type', sprintf( /* translators: 1: target_type value, 2: join label, 3: block number (1-based) */ __( 'Join target "%1$s" is not a joinable block type. Found in join "%2$s" of block %3$d. Supported targets are blocks that expose joinable columns (currently posts, users, comments).', 'ai-conduit' ), $target_type, $join_label, $index + 1 ), array( 'status' => 400 ) ); } } } return true; } /** * Reject target_meta_keys on comment joins. * * Comment meta is intentionally not exposed in the join builder UI — * commentmeta is rarely populated in practice and out of scope for the * supported feature set. Reject configs that bypass the UI (e.g. direct * API calls) and try to attach target_meta_keys to a comments-targeted * join with a clear error rather than letting a config persist that * resolves to empty results at execution time. * * @since 1.0.0 * * @param array $config The tool configuration from the request. * @return true|WP_Error True if valid, WP_Error otherwise. */ private function validate_no_meta_keys_on_comment_joins( array $config ): bool|WP_Error { foreach ( $config['blocks'] ?? array() as $index => $block ) { $block_config = $block['config'] ?? array(); if ( empty( $block_config['joins'] ) || ! is_array( $block_config['joins'] ) ) { continue; } foreach ( $block_config['joins'] as $join_index => $join ) { if ( 'comments' !== ( $join['target_type'] ?? '' ) ) { continue; } if ( ! empty( $join['target_meta_keys'] ) && is_array( $join['target_meta_keys'] ) ) { $join_label = ! empty( $join['label'] ) ? $join['label'] : sprintf( 'Join %d', $join_index + 1 ); return new WP_Error( 'comment_meta_keys_not_supported', sprintf( /* translators: 1: join label, 2: block number (1-based) */ __( 'Comment meta keys are not supported in joins. Remove target_meta_keys from join "%1$s" of block %2$d.', 'ai-conduit' ), $join_label, $index + 1 ), array( 'status' => 400 ) ); } } } return true; } /** * Reject meta_keys, taxonomies, or joins on a Site Info block. * * Site Info is a static block whose execute() only reads $config['fields'] * and $config['options']. It exposes no filters (get_available_filters() * returns []) and no joinable columns (get_joinable_columns() returns * empty table/id_column/meta_table). Saving any of meta_keys, taxonomies, * or joins against a site_info block would persist config that is silently * ignored at execution. Reject such configs at save with a clear error so * users learn the supported surface up-front instead of discovering a * mismatch when their LLM never sees the configured filters. * * @since 1.0.0 * * @param array $config The tool configuration from the request. * @return true|WP_Error True if valid, WP_Error otherwise. */ private function validate_site_info_block_minimal_config( array $config ): bool|WP_Error { foreach ( $config['blocks'] ?? array() as $index => $block ) { if ( 'site_info' !== ( $block['block_type'] ?? '' ) ) { continue; } $block_config = $block['config'] ?? array(); $disallowed = array(); if ( ! empty( $block_config['meta_keys'] ) && is_array( $block_config['meta_keys'] ) ) { $disallowed[] = 'meta_keys'; } if ( ! empty( $block_config['taxonomies'] ) && is_array( $block_config['taxonomies'] ) ) { $disallowed[] = 'taxonomies'; } if ( ! empty( $block_config['joins'] ) && is_array( $block_config['joins'] ) ) { $disallowed[] = 'joins'; } if ( ! empty( $disallowed ) ) { return new WP_Error( 'site_info_only_supports_fields_and_options', sprintf( /* translators: 1: comma-separated unsupported keys, 2: block number (1-based) */ __( 'The Site Info block only supports "fields" and "options". Remove %1$s from block %2$d.', 'ai-conduit' ), implode( ', ', $disallowed ), $index + 1 ), array( 'status' => 400 ) ); } } return true; } /** * Reject meta_keys on a standalone Comments block. * * The Comments block's execute() pipeline uses get_comments() and a * hardcoded native field extractor — it does not read $config['meta_keys'] * and has no meta_query support. Saving meta_keys against a comments * block would persist config that is silently ignored at execution time, * leaking unsupported fields into the inputSchema and confusing the LLM. * Reject the save with a clear error. * * @since 1.0.0 * * @param array $config The tool configuration from the request. * @return true|WP_Error True if valid, WP_Error otherwise. */ private function validate_no_meta_keys_on_comments_block( array $config ): bool|WP_Error { foreach ( $config['blocks'] ?? array() as $index => $block ) { if ( 'comments' !== ( $block['block_type'] ?? '' ) ) { continue; } $block_config = $block['config'] ?? array(); if ( ! empty( $block_config['meta_keys'] ) && is_array( $block_config['meta_keys'] ) ) { return new WP_Error( 'comment_meta_keys_not_supported', sprintf( /* translators: %d: block number (1-based) */ __( 'Comment meta keys are not supported on the Comments block. Remove meta_keys from block %d.', 'ai-conduit' ), $index + 1 ), array( 'status' => 400 ) ); } } return true; } /** * Delete a tool. * * @since 1.0.0 * * @param WP_REST_Request $request The incoming request. * @return WP_REST_Response|WP_Error */ public function delete_tool( WP_REST_Request $request ): WP_REST_Response|WP_Error { $id = absint( $request->get_param( 'id' ) ); $result = $this->cpt->delete_tool( $id ); if ( is_wp_error( $result ) ) { return $result; } return new WP_REST_Response( array( 'deleted' => true ), 200 ); } /** * Export tools as a JSON envelope. * * @since 1.0.0 * * @param WP_REST_Request $request The incoming request. * @return WP_REST_Response */ public function export_tools( WP_REST_Request $request ): WP_REST_Response { $ids_param = $request->get_param( 'ids' ); $ids = array(); if ( ! empty( $ids_param ) ) { $ids = array_map( 'absint', array_filter( explode( ',', $ids_param ) ) ); } $configs = $this->cpt->export_tools( $ids ); $envelope = array( 'format' => 'ai-conduit-tools', 'version' => 1, 'exported_at' => gmdate( 'c' ), 'site_url' => get_site_url(), 'tools' => $configs, ); return new WP_REST_Response( $envelope, 200 ); } /** * Import tools from a JSON envelope. * * Validates the envelope format, then imports each tool through * the standard save_tool() -> sanitize_config() pipeline. * * @since 1.0.0 * * @param WP_REST_Request $request The incoming request. * @return WP_REST_Response|WP_Error */ public function import_tools( WP_REST_Request $request ): WP_REST_Response|WP_Error { $body = $request->get_json_params(); if ( empty( $body['format'] ) || 'ai-conduit-tools' !== $body['format'] ) { return new WP_Error( 'invalid_format', __( 'Invalid import file. Expected AI Conduit tools export format.', 'ai-conduit' ), array( 'status' => 400 ) ); } if ( empty( $body['version'] ) || 1 !== absint( $body['version'] ) ) { return new WP_Error( 'unsupported_version', __( 'Unsupported export version.', 'ai-conduit' ), array( 'status' => 400 ) ); } if ( empty( $body['tools'] ) || ! is_array( $body['tools'] ) ) { return new WP_Error( 'no_tools', __( 'No tools found in import file.', 'ai-conduit' ), array( 'status' => 400 ) ); } $results = array(); foreach ( $body['tools'] as $tool_config ) { if ( ! is_array( $tool_config ) ) { continue; } $results[] = $this->cpt->import_tool( $tool_config ); } return new WP_REST_Response( array( 'results' => $results, 'total' => count( $results ), ), 200 ); } /** * Get taxonomies for a given object type. * * Accepts a post type slug (e.g., 'product', 'post') or 'user'. * WordPress get_object_taxonomies() handles both. * * @since 1.0.0 * * @param WP_REST_Request $request The incoming request. * @return WP_REST_Response */ public function get_taxonomies( WP_REST_Request $request ): WP_REST_Response { $object_type = $request->get_param( 'object_type' ); $taxonomies = get_object_taxonomies( $object_type, 'objects' ); $result = array(); foreach ( $taxonomies as $tax ) { $result[] = array( 'name' => $tax->name, 'label' => $tax->label, 'internal' => ! $tax->public, ); } return new WP_REST_Response( $result, 200 ); } /** * Get wp_options keys with caching. * * @since 1.0.0 * * @param WP_REST_Request $request The incoming request. * @return WP_REST_Response */ public function get_options( WP_REST_Request $request ): WP_REST_Response { global $wpdb; $refresh = $request->get_param( 'refresh' ); $cache_key = 'wlconduit_option_keys'; if ( $refresh ) { delete_transient( $cache_key ); } $cached = get_transient( $cache_key ); if ( false !== $cached ) { return new WP_REST_Response( $cached, 200 ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom query, result is cached via transient. $keys = $wpdb->get_col( $wpdb->prepare( 'SELECT option_name FROM %i ORDER BY option_name', $wpdb->options ) ); $result = array(); foreach ( ! empty( $keys ) ? $keys : array() as $key ) { $result[] = array( 'key' => $key, 'internal' => str_starts_with( $key, '_' ), ); } $expiry = absint( get_option( 'wlconduit_cache_expiry_meta_keys', 30 ) ) * DAY_IN_SECONDS; set_transient( $cache_key, $result, $expiry ); return new WP_REST_Response( $result, 200 ); } /** * Get terms for a given taxonomy with caching. * * @since 1.0.0 * * @param WP_REST_Request $request The incoming request. * @return WP_REST_Response */ public function get_terms( WP_REST_Request $request ): WP_REST_Response { $taxonomy = $request->get_param( 'taxonomy' ); $refresh = $request->get_param( 'refresh' ); if ( ! taxonomy_exists( $taxonomy ) ) { return new WP_REST_Response( array(), 200 ); } $cache_key = 'wlconduit_terms_' . $taxonomy; if ( $refresh ) { delete_transient( $cache_key ); } $cached = get_transient( $cache_key ); if ( false !== $cached ) { return new WP_REST_Response( $cached, 200 ); } $terms = get_terms( array( 'taxonomy' => $taxonomy, 'hide_empty' => false, 'orderby' => 'name', 'order' => 'ASC', ) ); if ( is_wp_error( $terms ) ) { return new WP_REST_Response( array(), 200 ); } $result = array(); foreach ( $terms as $term ) { $result[] = array( 'id' => $term->term_id, 'name' => $term->name, 'slug' => $term->slug, 'count' => $term->count, ); } $expiry = absint( get_option( 'wlconduit_cache_expiry_terms', 30 ) ) * DAY_IN_SECONDS; set_transient( $cache_key, $result, $expiry ); return new WP_REST_Response( $result, 200 ); } /** * Get distinct meta keys with caching. * * @since 1.0.0 * * @param WP_REST_Request $request The incoming request. * @return WP_REST_Response */ public function get_meta_keys( WP_REST_Request $request ): WP_REST_Response { global $wpdb; $object_type = $request->get_param( 'object_type' ); $post_type = $request->get_param( 'post_type' ); $refresh = $request->get_param( 'refresh' ); $cache_key = 'wlconduit_meta_keys_' . $object_type; if ( $post_type ) { $cache_key .= '_' . $post_type; } if ( $refresh ) { delete_transient( $cache_key ); } $cached = get_transient( $cache_key ); if ( false !== $cached ) { return new WP_REST_Response( $cached, 200 ); } if ( 'user' === $object_type ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom query, result is cached via transient. $keys = $wpdb->get_col( $wpdb->prepare( 'SELECT DISTINCT meta_key FROM %i ORDER BY meta_key', $wpdb->usermeta ) ); } elseif ( $post_type ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom query, result is cached via transient. $keys = $wpdb->get_col( $wpdb->prepare( 'SELECT DISTINCT pm.meta_key FROM %i pm INNER JOIN %i p ON pm.post_id = p.ID WHERE p.post_type = %s ORDER BY pm.meta_key', $wpdb->postmeta, $wpdb->posts, $post_type ) ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom query, result is cached via transient. $keys = $wpdb->get_col( $wpdb->prepare( 'SELECT DISTINCT meta_key FROM %i ORDER BY meta_key', $wpdb->postmeta ) ); } $meta_table = 'user' === $object_type ? $wpdb->usermeta : $wpdb->postmeta; $result = array(); foreach ( ! empty( $keys ) ? $keys : array() as $key ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-off sample per key, cached via transient. $sample = $wpdb->get_var( $wpdb->prepare( 'SELECT meta_value FROM %i WHERE meta_key = %s LIMIT 1', $meta_table, $key ) ); $result[] = array( 'key' => $key, 'internal' => str_starts_with( $key, '_' ), 'serialized' => is_serialized( $sample ), ); } $expiry = absint( get_option( 'wlconduit_cache_expiry_meta_keys', 30 ) ) * DAY_IN_SECONDS; set_transient( $cache_key, $result, $expiry ); return new WP_REST_Response( $result, 200 ); } /** * Get native joinable columns for a block type. * * @since 1.0.0 * * @param WP_REST_Request $request The incoming request. * @return WP_REST_Response */ public function get_join_columns( WP_REST_Request $request ): WP_REST_Response { $block_type = $request->get_param( 'block_type' ); $block = Wlconduit_Block_Registry::get( $block_type ); if ( ! $block ) { return new WP_REST_Response( array(), 200 ); } $joinable = $block->get_joinable_columns(); return new WP_REST_Response( $joinable['native'], 200 ); } /** * Get current plugin settings. * * @since 1.0.0 * * @return WP_REST_Response */ public function get_settings(): WP_REST_Response { return new WP_REST_Response( $this->get_settings_values(), 200 ); } /** * Save plugin settings. * * @since 1.0.0 * * @param WP_REST_Request $request The incoming request. * @return WP_REST_Response */ public function save_settings( WP_REST_Request $request ): WP_REST_Response { $params = $request->get_json_params(); if ( isset( $params['cache_expiry_terms'] ) ) { update_option( 'wlconduit_cache_expiry_terms', min( 30, max( 1, absint( $params['cache_expiry_terms'] ) ) ) ); } if ( isset( $params['cache_expiry_meta_keys'] ) ) { update_option( 'wlconduit_cache_expiry_meta_keys', min( 30, max( 1, absint( $params['cache_expiry_meta_keys'] ) ) ) ); } if ( isset( $params['log_retention_days'] ) ) { update_option( 'wlconduit_log_retention_days', absint( $params['log_retention_days'] ) ); } if ( array_key_exists( 'logging_enabled', $params ) ) { update_option( 'wlconduit_logging_enabled', ! empty( $params['logging_enabled'] ) ? '1' : '0' ); } if ( array_key_exists( 'rate_limiting_enabled', $params ) ) { update_option( 'wlconduit_rate_limiting_enabled', ! empty( $params['rate_limiting_enabled'] ) ? '1' : '0' ); } if ( isset( $params['rate_limit'] ) ) { update_option( 'wlconduit_rate_limit', max( 1, absint( $params['rate_limit'] ) ) ); } if ( isset( $params['rate_block_duration'] ) ) { update_option( 'wlconduit_rate_block_duration', max( 1, absint( $params['rate_block_duration'] ) ) ); } return new WP_REST_Response( $this->get_settings_values(), 200 ); } /** * Get current settings values. * * @since 1.0.0 * * @return array */ private function get_settings_values(): array { return array( 'cache_expiry_terms' => absint( get_option( 'wlconduit_cache_expiry_terms', 30 ) ), 'cache_expiry_meta_keys' => absint( get_option( 'wlconduit_cache_expiry_meta_keys', 30 ) ), 'log_retention_days' => absint( get_option( 'wlconduit_log_retention_days', 30 ) ), 'logging_enabled' => get_option( 'wlconduit_logging_enabled', '1' ) === '1', 'rate_limiting_enabled' => get_option( 'wlconduit_rate_limiting_enabled', '1' ) === '1', 'rate_limit' => absint( get_option( 'wlconduit_rate_limit', 20 ) ), 'rate_block_duration' => absint( get_option( 'wlconduit_rate_block_duration', 60 ) ), ); } }