namespace, '/' . $this->rest_base . '/list', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_collection_params(), ), ) ); // GET /schema/{id} - Get schemas for a post. register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P[\d]+)', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_item' ), 'permission_callback' => array( $this, 'get_item_permissions_check' ), 'args' => array( 'id' => array( 'description' => __( 'Post ID to retrieve schemas for.', 'ai-schema-gen' ), 'type' => 'integer', 'required' => true, 'sanitize_callback' => 'absint', ), ), ), ) ); // POST /schema - Create a new schema. register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_item' ), 'permission_callback' => array( $this, 'create_item_permissions_check' ), 'args' => array( 'post_id' => array( 'description' => __( 'The WordPress post ID.', 'ai-schema-gen' ), 'type' => 'integer', 'required' => true, 'sanitize_callback' => 'absint', ), 'schema_type' => array( 'description' => __( 'The schema.org type.', 'ai-schema-gen' ), 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field', ), 'schema_data' => array( 'description' => __( 'The schema markup data.', 'ai-schema-gen' ), 'type' => array( 'object', 'string' ), 'required' => true, ), 'is_auto_generated' => array( 'description' => __( 'Whether the schema was auto-generated by AI.', 'ai-schema-gen' ), 'type' => 'boolean', 'default' => false, ), ), ), ) ); // PUT /schema/{id} - Update an existing schema. register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P[\d]+)', array( array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'update_item' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), 'args' => array( 'id' => array( 'description' => __( 'Schema ID to update.', 'ai-schema-gen' ), 'type' => 'integer', 'required' => true, 'sanitize_callback' => 'absint', ), 'schema_data' => array( 'description' => __( 'Updated schema markup data.', 'ai-schema-gen' ), 'type' => array( 'object', 'string' ), 'required' => true, ), ), ), ) ); // DELETE /schema/{id} - Delete a schema. register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P[\d]+)', array( array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_item' ), 'permission_callback' => array( $this, 'delete_item_permissions_check' ), 'args' => array( 'id' => array( 'description' => __( 'Schema ID or post ID to delete schemas for.', 'ai-schema-gen' ), 'type' => 'integer', 'required' => true, 'sanitize_callback' => 'absint', ), 'post_id' => array( 'description' => __( 'If provided, delete all schemas for this post.', 'ai-schema-gen' ), 'type' => 'integer', 'sanitize_callback' => 'absint', ), ), ), ) ); } /** * Get schemas for a specific post. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object or error. */ public function get_item( $request ) { global $wpdb; $post_id = absint( $request->get_param( 'id' ) ); $valid = $this->validate_post_id( $post_id ); if ( is_wp_error( $valid ) ) { return $valid; } $table = esc_sql( $wpdb->prefix . 'schema_ai_schemas' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; no WP API alternative exists. $results = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is sanitized via esc_sql() and not user input. "SELECT * FROM {$table} WHERE post_id = %d ORDER BY created_at DESC", $post_id ) ); if ( empty( $results ) ) { return $this->success_response( array( 'schemas' => array(), 'total' => 0, ) ); } $schemas = array(); foreach ( $results as $row ) { $schemas[] = $this->prepare_item_for_response( $row, $request ); } return $this->success_response( array( 'schemas' => $schemas, 'total' => count( $schemas ), ) ); } /** * Create a new schema for a post. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object or error. */ public function create_item( $request ) { global $wpdb; $post_id = absint( $request->get_param( 'post_id' ) ); $schema_type = sanitize_text_field( $request->get_param( 'schema_type' ) ); $schema_data = $request->get_param( 'schema_data' ); $is_auto_generated = (bool) $request->get_param( 'is_auto_generated' ); $valid = $this->validate_post_id( $post_id ); if ( is_wp_error( $valid ) ) { return $valid; } // Normalise to an array so string values can be sanitised before storage. if ( is_string( $schema_data ) ) { $schema_data = json_decode( $schema_data, true ); } if ( ! is_array( $schema_data ) || empty( $schema_data ) ) { return $this->error_response( 'schema_ai_invalid_schema_data', __( 'Invalid schema data. Must be valid JSON.', 'ai-schema-gen' ), 400 ); } // Defense-in-depth: strip HTML/script from string values on the way in. // The renderer also hard-escapes on output (JSON_HEX_TAG), but sanitising // here keeps stored data clean for the visual editor and API consumers. $schema_data = $this->sanitize_schema_values( $schema_data ); $schema_data = wp_json_encode( $schema_data ); $table = esc_sql( $wpdb->prefix . 'schema_ai_schemas' ); $now = current_time( 'mysql' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; write operation (INSERT), caching not applicable. $inserted = $wpdb->insert( $table, array( 'post_id' => $post_id, 'schema_type' => $schema_type, 'schema_data' => $schema_data, 'is_auto_generated' => $is_auto_generated ? 1 : 0, 'validation_status' => 'pending', 'created_at' => $now, 'updated_at' => $now, ), array( '%d', '%s', '%s', '%d', '%s', '%s', '%s' ) ); if ( false === $inserted ) { return $this->error_response( 'schema_ai_db_error', __( 'Failed to save schema to database.', 'ai-schema-gen' ), 500 ); } $schema_id = $wpdb->insert_id; // The renderer serves from this transient cache before the DB table; // without invalidating it here, a new schema wouldn't actually appear // on the front end until the cache naturally expires. if ( class_exists( 'Schema_AI_Schema_Cache' ) ) { $cache = new Schema_AI_Schema_Cache(); $cache->invalidate( $post_id ); } // Schedule async validation. wp_schedule_single_event( time() + 10, 'schema_ai_validate', array( $schema_id ) ); // Retrieve the new row for the response. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; no WP API alternative exists. $row = $wpdb->get_row( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is sanitized via esc_sql() and not user input. "SELECT * FROM {$table} WHERE id = %d", $schema_id ) ); return $this->success_response( array( 'schema' => $this->prepare_item_for_response( $row, $request ), 'message' => __( 'Schema created successfully.', 'ai-schema-gen' ), ), 201 ); } /** * Update an existing schema. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object or error. */ public function update_item( $request ) { global $wpdb; $schema_id = absint( $request->get_param( 'id' ) ); $schema_data = $request->get_param( 'schema_data' ); $table = esc_sql( $wpdb->prefix . 'schema_ai_schemas' ); // Verify the schema exists. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; no WP API alternative exists. $existing = $wpdb->get_row( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is sanitized via esc_sql() and not user input. "SELECT * FROM {$table} WHERE id = %d", $schema_id ) ); if ( ! $existing ) { return $this->error_response( 'schema_ai_not_found', __( 'Schema not found.', 'ai-schema-gen' ), 404 ); } // Normalise to an array so string values can be sanitised before storage. if ( is_string( $schema_data ) ) { $schema_data = json_decode( $schema_data, true ); } if ( ! is_array( $schema_data ) || empty( $schema_data ) ) { return $this->error_response( 'schema_ai_invalid_schema_data', __( 'Invalid schema data. Must be valid JSON.', 'ai-schema-gen' ), 400 ); } // Defense-in-depth: strip HTML/script from string values on the way in. $schema_data = $this->sanitize_schema_values( $schema_data ); $schema_data = wp_json_encode( $schema_data ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; write operation (UPDATE), caching not applicable. $updated = $wpdb->update( $table, array( 'schema_data' => $schema_data, 'validation_status' => 'pending', 'validation_errors' => null, 'last_validated' => null, 'updated_at' => current_time( 'mysql' ), ), array( 'id' => $schema_id ), array( '%s', '%s', '%s', '%s', '%s' ), array( '%d' ) ); if ( false === $updated ) { return $this->error_response( 'schema_ai_db_error', __( 'Failed to update schema.', 'ai-schema-gen' ), 500 ); } // The renderer serves from this transient cache before the DB table; // without invalidating it here, an edit wouldn't actually appear on the // front end until the cache naturally expires. if ( class_exists( 'Schema_AI_Schema_Cache' ) ) { $cache = new Schema_AI_Schema_Cache(); $cache->invalidate( (int) $existing->post_id ); } // Retrieve the updated row. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; no WP API alternative exists. $row = $wpdb->get_row( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is sanitized via esc_sql() and not user input. "SELECT * FROM {$table} WHERE id = %d", $schema_id ) ); return $this->success_response( array( 'schema' => $this->prepare_item_for_response( $row, $request ), 'message' => __( 'Schema updated successfully.', 'ai-schema-gen' ), ) ); } /** * Delete a schema by ID, or all schemas for a post. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object or error. */ public function delete_item( $request ) { global $wpdb; $id = absint( $request->get_param( 'id' ) ); $post_id = $request->get_param( 'post_id' ); $table = esc_sql( $wpdb->prefix . 'schema_ai_schemas' ); // If post_id is explicitly provided, delete all schemas for that post. if ( $post_id ) { $post_id = absint( $post_id ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; write operation (DELETE), caching not applicable. $deleted = $wpdb->delete( $table, array( 'post_id' => $post_id ), array( '%d' ) ); if ( false === $deleted ) { return $this->error_response( 'schema_ai_db_error', __( 'Failed to delete schemas.', 'ai-schema-gen' ), 500 ); } // The renderer serves from this transient cache before the DB table; // without invalidating it here, the page keeps showing the deleted // schema until the cache naturally expires. if ( class_exists( 'Schema_AI_Schema_Cache' ) ) { $cache = new Schema_AI_Schema_Cache(); $cache->invalidate( $post_id ); } return $this->success_response( array( 'deleted' => (int) $deleted, 'message' => sprintf( /* translators: %d: number of schemas deleted */ __( '%d schema(s) deleted.', 'ai-schema-gen' ), $deleted ), ) ); } // Delete single schema by ID. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; no WP API alternative exists. $existing = $wpdb->get_row( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is sanitized via esc_sql() and not user input. "SELECT * FROM {$table} WHERE id = %d", $id ) ); if ( ! $existing ) { return $this->error_response( 'schema_ai_not_found', __( 'Schema not found.', 'ai-schema-gen' ), 404 ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; write operation (DELETE), caching not applicable. $deleted = $wpdb->delete( $table, array( 'id' => $id ), array( '%d' ) ); if ( false === $deleted ) { return $this->error_response( 'schema_ai_db_error', __( 'Failed to delete schema.', 'ai-schema-gen' ), 500 ); } // The renderer serves from this transient cache before the DB table; // without invalidating it here, the page keeps showing the deleted // schema until the cache naturally expires. if ( class_exists( 'Schema_AI_Schema_Cache' ) ) { $cache = new Schema_AI_Schema_Cache(); $cache->invalidate( (int) $existing->post_id ); } return $this->success_response( array( 'deleted' => 1, 'message' => __( 'Schema deleted successfully.', 'ai-schema-gen' ), ) ); } /** * List all schemas with pagination and filters. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object or error. */ public function get_items( $request ) { global $wpdb; $per_page = absint( $request->get_param( 'per_page' ) ) ?: 20; $page = absint( $request->get_param( 'page' ) ) ?: 1; $order_by = sanitize_sql_orderby( $request->get_param( 'order_by' ) . ' DESC' ) ? sanitize_text_field( $request->get_param( 'order_by' ) ) : 'created_at'; $schema_type = sanitize_text_field( $request->get_param( 'schema_type' ) ); $validation_status = sanitize_text_field( $request->get_param( 'validation_status' ) ); $table = esc_sql( $wpdb->prefix . 'schema_ai_schemas' ); $where = array(); $values = array(); if ( $schema_type ) { $where[] = 'schema_type = %s'; $values[] = $schema_type; } if ( $validation_status ) { $where[] = 'validation_status = %s'; $values[] = $validation_status; } $where_clause = ''; if ( ! empty( $where ) ) { $where_clause = 'WHERE ' . implode( ' AND ', $where ); } // Whitelist allowed order_by columns. $allowed_order_columns = array( 'created_at', 'updated_at', 'schema_type', 'validation_status', 'post_id' ); if ( ! in_array( $order_by, $allowed_order_columns, true ) ) { $order_by = 'created_at'; } $offset = ( $page - 1 ) * $per_page; // Get total count. // $table is sanitized via esc_sql() (trusted internal value, not user input). // $where_clause contains only whitelisted column names with %s/%d placeholders. // $order_by is validated against an allowlist above. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is sanitized via esc_sql() and not user input. $count_sql = "SELECT COUNT(*) FROM {$table}" . ( $where_clause ? ' ' . $where_clause : '' ); if ( ! empty( $values ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL built from whitelisted columns and prepare() placeholders only. $count_sql = $wpdb->prepare( $count_sql, $values ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Required for custom table; SQL built from esc_sql()-sanitized table name and whitelisted column names. $total = (int) $wpdb->get_var( $count_sql ); // Get paginated results. // $order_by is validated against an allowlist of safe column names above. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is sanitized via esc_sql() and not user input. $results_sql = "SELECT * FROM {$table}" . ( $where_clause ? ' ' . $where_clause : '' ) . ' ORDER BY ' . $order_by . ' DESC LIMIT %d OFFSET %d'; $results_values = array_merge( $values, array( $per_page, $offset ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Required for custom table; SQL built from esc_sql()-sanitized table name and whitelisted column names. $results = $wpdb->get_results( $wpdb->prepare( $results_sql, $results_values ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $results_sql built from esc_sql()-sanitized table name and whitelisted column names; dynamic values passed via prepare() placeholders. ); $schemas = array(); if ( $results ) { foreach ( $results as $row ) { $schemas[] = $this->prepare_item_for_response( $row, $request ); } } $response = $this->success_response( array( 'schemas' => $schemas, 'total' => $total, 'page' => $page, 'per_page' => $per_page, 'pages' => (int) ceil( $total / $per_page ), ) ); $response->header( 'X-WP-Total', $total ); $response->header( 'X-WP-TotalPages', (int) ceil( $total / $per_page ) ); return $response; } /** * Prepare a database row for the REST response. * * @param object $item Database row from the schemas table. * @param WP_REST_Request $request The current request. * @return array Formatted schema data for the API response. */ public function prepare_item_for_response( $item, $request ) { $post_id = (int) $item->post_id; return array( 'id' => (int) $item->id, 'post_id' => $post_id, 'post_title' => get_the_title( $post_id ), 'post_url' => get_permalink( $post_id ), 'schema_type' => $item->schema_type, 'schema_data' => json_decode( $item->schema_data, true ), 'is_auto_generated' => (bool) $item->is_auto_generated, 'confidence_score' => $item->confidence_score ? (float) $item->confidence_score : null, 'validation_status' => $item->validation_status, 'validation_errors' => $item->validation_errors ? json_decode( $item->validation_errors, true ) : null, 'last_validated' => $item->last_validated, 'created_at' => $item->created_at, 'updated_at' => $item->updated_at, ); } /** * Get the query params for collections. * * @return array Collection parameters. */ public function get_collection_params() { return array( 'page' => array( 'description' => __( 'Current page of the collection.', 'ai-schema-gen' ), 'type' => 'integer', 'default' => 1, 'minimum' => 1, 'sanitize_callback' => 'absint', ), 'per_page' => array( 'description' => __( 'Maximum number of items per page.', 'ai-schema-gen' ), 'type' => 'integer', 'default' => 20, 'minimum' => 1, 'maximum' => 100, 'sanitize_callback' => 'absint', ), 'order_by' => array( 'description' => __( 'Column to order results by.', 'ai-schema-gen' ), 'type' => 'string', 'default' => 'created_at', 'sanitize_callback' => 'sanitize_text_field', ), 'schema_type' => array( 'description' => __( 'Filter by schema type.', 'ai-schema-gen' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), 'validation_status' => array( 'description' => __( 'Filter by validation status.', 'ai-schema-gen' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), ); } /** * Recursively sanitize schema string values. * * Mirrors the meta-box save path (admin/class-admin.php): URL-looking * values pass through esc_url_raw, all other strings through * sanitize_text_field (which strips HTML/script). Keys are also sanitized. * Numeric and boolean values are preserved as-is. * * @param array $data The decoded schema data. * @return array Sanitized schema data. */ private function sanitize_schema_values( $data ) { $sanitized = array(); foreach ( $data as $key => $value ) { $key = sanitize_text_field( $key ); if ( is_array( $value ) ) { $sanitized[ $key ] = $this->sanitize_schema_values( $value ); } elseif ( is_string( $value ) ) { if ( filter_var( $value, FILTER_VALIDATE_URL ) ) { $sanitized[ $key ] = esc_url_raw( $value ); } else { $sanitized[ $key ] = sanitize_text_field( $value ); } } elseif ( is_numeric( $value ) || is_bool( $value ) ) { $sanitized[ $key ] = $value; } else { $sanitized[ $key ] = sanitize_text_field( (string) $value ); } } return $sanitized; } /** * Permission check for getting a single item. * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if allowed. */ public function get_item_permissions_check( $request ) { if ( ! current_user_can( 'edit_posts' ) ) { return new WP_Error( 'schema_ai_forbidden', __( 'You do not have permission to view schemas.', 'ai-schema-gen' ), array( 'status' => 403 ) ); } return true; } /** * Permission check for listing items. * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if allowed. */ public function get_items_permissions_check( $request ) { if ( ! current_user_can( 'edit_posts' ) ) { return new WP_Error( 'schema_ai_forbidden', __( 'You do not have permission to list schemas.', 'ai-schema-gen' ), array( 'status' => 403 ) ); } return true; } /** * Permission check for creating an item. * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if allowed. */ public function create_item_permissions_check( $request ) { if ( ! current_user_can( 'edit_posts' ) ) { return new WP_Error( 'schema_ai_forbidden', __( 'You do not have permission to create schemas.', 'ai-schema-gen' ), array( 'status' => 403 ) ); } // Verify the user can edit the specific post the schema is attached to — // matches update_item/delete_item, which already check per-post. $post_id = absint( $request->get_param( 'post_id' ) ); if ( $post_id && ! current_user_can( 'edit_post', $post_id ) ) { return new WP_Error( 'schema_ai_forbidden', __( 'You do not have permission to add schema to this post.', 'ai-schema-gen' ), array( 'status' => 403 ) ); } return true; } /** * Permission check for updating an item. * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if allowed. */ public function update_item_permissions_check( $request ) { if ( ! current_user_can( 'edit_posts' ) ) { return new WP_Error( 'schema_ai_forbidden', __( 'You do not have permission to update schemas.', 'ai-schema-gen' ), array( 'status' => 403 ) ); } // Verify the user can edit the specific post this schema belongs to. $schema_id = absint( $request->get_param( 'id' ) ); if ( $schema_id ) { global $wpdb; $table = esc_sql( $wpdb->prefix . 'schema_ai_schemas' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Permission check requires fresh data. $post_id = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is sanitized via esc_sql(). "SELECT post_id FROM {$table} WHERE id = %d", $schema_id ) ); if ( $post_id && ! current_user_can( 'edit_post', (int) $post_id ) ) { return new WP_Error( 'schema_ai_forbidden', __( 'You do not have permission to edit this post\'s schema.', 'ai-schema-gen' ), array( 'status' => 403 ) ); } } return true; } /** * Permission check for deleting an item. * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if allowed. */ public function delete_item_permissions_check( $request ) { if ( ! current_user_can( 'edit_posts' ) ) { return new WP_Error( 'schema_ai_forbidden', __( 'You do not have permission to delete schemas.', 'ai-schema-gen' ), array( 'status' => 403 ) ); } // Verify the user can edit the specific post the schema(s) belong to. $post_id_param = $request->get_param( 'post_id' ); $id_param = absint( $request->get_param( 'id' ) ); if ( $post_id_param ) { if ( ! current_user_can( 'edit_post', absint( $post_id_param ) ) ) { return new WP_Error( 'schema_ai_forbidden', __( 'You do not have permission to delete schemas for this post.', 'ai-schema-gen' ), array( 'status' => 403 ) ); } } elseif ( $id_param ) { global $wpdb; $table = esc_sql( $wpdb->prefix . 'schema_ai_schemas' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Permission check requires fresh data. $post_id = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is sanitized via esc_sql(). "SELECT post_id FROM {$table} WHERE id = %d", $id_param ) ); if ( $post_id && ! current_user_can( 'edit_post', (int) $post_id ) ) { return new WP_Error( 'schema_ai_forbidden', __( 'You do not have permission to delete this schema.', 'ai-schema-gen' ), array( 'status' => 403 ) ); } } return true; } }