*/ private const SANITIZERS = [ 'boolean' => 'rest_sanitize_boolean', 'string' => 'sanitize_text_field', 'integer' => 'absint', 'number' => 'floatval', ]; /** * Image SEO Manager instance * * @since 1.0.0 * @var Image_SEO_Manager */ private Image_SEO_Manager $image_manager; /** * API namespace * * @since 1.0.0 * @var string */ protected $namespace = 'thinkrank/v1'; /** * API resource base * * @since 1.0.0 * @var string */ protected $rest_base = 'image-seo'; /** * Constructor * * @since 1.0.0 */ public function __construct() { if (!class_exists('ThinkRank\\SEO\\Image_SEO_Manager')) { require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-image-seo-manager.php'; } $this->image_manager = new Image_SEO_Manager(); } /** * Register API routes * * @since 1.0.0 */ public function register_routes(): void { register_rest_route( $this->namespace, '/' . $this->rest_base . '/settings', [ [ 'methods' => 'GET', 'callback' => [$this, 'get_settings'], 'permission_callback' => [$this, 'check_permissions'] ], [ 'methods' => 'POST', 'callback' => [$this, 'update_settings'], 'permission_callback' => [$this, 'check_permissions'], 'args' => $this->get_settings_args() ] ] ); // Media Library alt-text coverage stats register_rest_route( $this->namespace, '/' . $this->rest_base . '/media-alt/stats', [ [ 'methods' => 'GET', 'callback' => [$this, 'get_media_alt_stats'], 'permission_callback' => [$this, 'check_permissions'] ] ] ); // Bulk-fill alt text into the Media Library (batched) register_rest_route( $this->namespace, '/' . $this->rest_base . '/media-alt/run', [ [ 'methods' => 'POST', 'callback' => [$this, 'run_media_alt_fill'], 'permission_callback' => [$this, 'check_permissions'], 'args' => [ 'offset' => [ 'type' => 'integer', 'required' => false, 'default' => 0, 'sanitize_callback' => 'absint' ], 'limit' => [ 'type' => 'integer', 'required' => false, 'default' => 50, // Bounded: ?limit=100000 walked the whole media // library synchronously, and with alt_source=ai // that is one AI call per image (#394). 'minimum' => 1, 'maximum' => 500, 'sanitize_callback' => 'absint' ], 'overwrite' => [ 'type' => 'boolean', 'required' => false, 'default' => false, 'sanitize_callback' => 'rest_sanitize_boolean' ] ] ] ] ); } /** * Check if user has required permissions * * @since 1.0.0 * @return bool True if authorized, false otherwise */ public function check_permissions(): bool { return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_image_seo'); } /** * Get image SEO settings * * @since 1.0.0 * @param WP_REST_Request $request API request object * @return WP_REST_Response|WP_Error API response */ public function get_settings(WP_REST_Request $request): WP_REST_Response { $settings = $this->image_manager->get_settings('site'); return new WP_REST_Response($settings, 200); } /** * Update image SEO settings * * @since 1.0.0 * @param WP_REST_Request $request API request object * @return WP_REST_Response|WP_Error API response */ public function update_settings(WP_REST_Request $request): WP_REST_Response { // Whitelist to known schema keys only, so framework params (_wpnonce, // _locale, …) and any arbitrary extra fields are never persisted as settings. $allowed_keys = array_keys($this->image_manager->get_settings_schema('site')); $settings = array_intersect_key($request->get_params(), array_flip($allowed_keys)); $success = $this->image_manager->save_settings('site', 0, $settings); if ($success) { return new WP_REST_Response([ 'success' => true, 'message' => __('Settings updated successfully', 'thinkrank') ], 200); } return new WP_REST_Response([ 'success' => false, 'message' => __('Failed to update settings', 'thinkrank') ], 500); } /** * Get Media Library alt-text coverage stats * * @since 1.19.1 * @param WP_REST_Request $request API request object * @return WP_REST_Response API response */ public function get_media_alt_stats(WP_REST_Request $request): WP_REST_Response { return new WP_REST_Response($this->image_manager->get_media_alt_stats(), 200); } /** * Run one batch of the Media Library alt-text bulk fill * * @since 1.19.1 * @param WP_REST_Request $request API request object * @return WP_REST_Response API response */ public function run_media_alt_fill(WP_REST_Request $request): WP_REST_Response { $result = $this->image_manager->bulk_fill_missing_alt([ 'offset' => (int) $request->get_param('offset'), 'limit' => (int) $request->get_param('limit'), 'overwrite' => (bool) $request->get_param('overwrite'), ]); return new WP_REST_Response($result, 200); } /** * Get settings schema arguments * * @since 1.0.0 * @return array API arguments array */ private function get_settings_args(): array { $schema = $this->image_manager->get_settings_schema('site'); $args = []; foreach ($schema as $key => $config) { $args[$key] = [ 'type' => $config['type'], 'required' => false, ]; // Pick the sanitizer from the declared type. `sanitize_text_field` // for everything non-boolean was a trap for the first array- or // object-typed setting added to the schema: it casts an array to // the string "Array" (PHP notice) or an empty string, so the value // would arrive at the handler destroyed rather than rejected. // A type with no scalar sanitizer gets none — core then falls back // to `rest_parse_request_arg`, which sanitizes against this very // schema instead of flattening it. $sanitizer = self::SANITIZERS[$config['type']] ?? null; if (null !== $sanitizer) { $args[$key]['sanitize_callback'] = $sanitizer; } // A structural type is unusable to core without its shape. foreach (['items', 'properties', 'additionalProperties'] as $keyword) { if (isset($config[$keyword])) { $args[$key][$keyword] = $config[$keyword]; } } // Carry through any constraint the schema already declares. Copying // only type/required/sanitize_callback silently dropped the // alt_source enum, so the REST validator never enforced it. // // The enum needs a validate_callback to have any effect: // WP_REST_Request::has_valid_params() skips an arg entirely unless // one is set, so declaring the enum alone leaves it inert. Attach // it only to args that actually carry a constraint — applying it to // every arg would also start enforcing `type`, turning today's // lenient boolean coercion into a hard 400. if (isset($config['enum'])) { $args[$key]['enum'] = $config['enum']; $args[$key]['validate_callback'] = 'rest_validate_request_arg'; } } return $args; } }