PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / api / class-image-seo-endpoint.php

class-image-seo-endpoint.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.7.0, at includes/api/class-image-seo-endpoint.php

304 lines 9.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Image SEO API Endpoints Class
5 *
6 * REST API endpoints for image SEO management.
7 *
8 * @package ThinkRank
9 * @subpackage API
10 * @since 1.0.0
11 */
12
13 declare(strict_types=1);
14
15 namespace ThinkRank\API;
16
17 use ThinkRank\SEO\Image_SEO_Manager;
18 use ThinkRank\API\Traits\CSRF_Protection;
19 use WP_REST_Controller;
20 use WP_REST_Request;
21 use WP_REST_Response;
22 use WP_Error;
23
24 // Prevent direct access
25 if (!defined('ABSPATH')) {
26 exit;
27 }
28
29 /**
30 * Image SEO API Endpoints Class
31 *
32 * Provides REST API endpoints for image SEO settings management.
33 *
34 * @since 1.0.0
35 */
36 class Image_SEO_Endpoint extends WP_REST_Controller {
37 use CSRF_Protection;
38
39 /**
40 * Sanitizer per schema type, for settings args built from the schema.
41 *
42 * Only scalar types appear here. An array or object type deliberately gets
43 * no `sanitize_callback` — core falls back to `rest_parse_request_arg`,
44 * which sanitizes against the declared schema, where a string sanitizer
45 * would flatten the value to "Array" or "".
46 *
47 * @since 2.0.1
48 * @var array<string, string>
49 */
50 private const SANITIZERS = [
51 'boolean' => 'rest_sanitize_boolean',
52 'string' => 'sanitize_text_field',
53 'integer' => 'absint',
54 'number' => 'floatval',
55 ];
56
57 /**
58 * Image SEO Manager instance
59 *
60 * @since 1.0.0
61 * @var Image_SEO_Manager
62 */
63 private Image_SEO_Manager $image_manager;
64
65 /**
66 * API namespace
67 *
68 * @since 1.0.0
69 * @var string
70 */
71 protected $namespace = 'thinkrank/v1';
72
73 /**
74 * API resource base
75 *
76 * @since 1.0.0
77 * @var string
78 */
79 protected $rest_base = 'image-seo';
80
81 /**
82 * Constructor
83 *
84 * @since 1.0.0
85 */
86 public function __construct() {
87 if (!class_exists('ThinkRank\\SEO\\Image_SEO_Manager')) {
88 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-image-seo-manager.php';
89 }
90
91 $this->image_manager = new Image_SEO_Manager();
92 }
93
94 /**
95 * Register API routes
96 *
97 * @since 1.0.0
98 */
99 public function register_routes(): void {
100 register_rest_route(
101 $this->namespace,
102 '/' . $this->rest_base . '/settings',
103 [
104 [
105 'methods' => 'GET',
106 'callback' => [$this, 'get_settings'],
107 'permission_callback' => [$this, 'check_permissions']
108 ],
109 [
110 'methods' => 'POST',
111 'callback' => [$this, 'update_settings'],
112 'permission_callback' => [$this, 'check_permissions'],
113 'args' => $this->get_settings_args()
114 ]
115 ]
116 );
117
118 // Media Library alt-text coverage stats
119 register_rest_route(
120 $this->namespace,
121 '/' . $this->rest_base . '/media-alt/stats',
122 [
123 [
124 'methods' => 'GET',
125 'callback' => [$this, 'get_media_alt_stats'],
126 'permission_callback' => [$this, 'check_permissions']
127 ]
128 ]
129 );
130
131 // Bulk-fill alt text into the Media Library (batched)
132 register_rest_route(
133 $this->namespace,
134 '/' . $this->rest_base . '/media-alt/run',
135 [
136 [
137 'methods' => 'POST',
138 'callback' => [$this, 'run_media_alt_fill'],
139 'permission_callback' => [$this, 'check_permissions'],
140 'args' => [
141 'offset' => [
142 'type' => 'integer',
143 'required' => false,
144 'default' => 0,
145 'sanitize_callback' => 'absint'
146 ],
147 'limit' => [
148 'type' => 'integer',
149 'required' => false,
150 'default' => 50,
151 // Bounded: ?limit=100000 walked the whole media
152 // library synchronously, and with alt_source=ai
153 // that is one AI call per image (#394).
154 'minimum' => 1,
155 'maximum' => 500,
156 'sanitize_callback' => 'absint'
157 ],
158 'overwrite' => [
159 'type' => 'boolean',
160 'required' => false,
161 'default' => false,
162 'sanitize_callback' => 'rest_sanitize_boolean'
163 ]
164 ]
165 ]
166 ]
167 );
168 }
169
170 /**
171 * Check if user has required permissions
172 *
173 * @since 1.0.0
174 * @return bool True if authorized, false otherwise
175 */
176 public function check_permissions(): bool {
177 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_image_seo');
178 }
179
180 /**
181 * Get image SEO settings
182 *
183 * @since 1.0.0
184 * @param WP_REST_Request $request API request object
185 * @return WP_REST_Response|WP_Error API response
186 */
187 public function get_settings(WP_REST_Request $request): WP_REST_Response {
188 $settings = $this->image_manager->get_settings('site');
189 return new WP_REST_Response($settings, 200);
190 }
191
192 /**
193 * Update image SEO settings
194 *
195 * @since 1.0.0
196 * @param WP_REST_Request $request API request object
197 * @return WP_REST_Response|WP_Error API response
198 */
199 public function update_settings(WP_REST_Request $request): WP_REST_Response {
200 // Whitelist to known schema keys only, so framework params (_wpnonce,
201 // _locale, …) and any arbitrary extra fields are never persisted as settings.
202 $allowed_keys = array_keys($this->image_manager->get_settings_schema('site'));
203 $settings = array_intersect_key($request->get_params(), array_flip($allowed_keys));
204
205 $success = $this->image_manager->save_settings('site', 0, $settings);
206
207 if ($success) {
208 return new WP_REST_Response([
209 'success' => true,
210 'message' => __('Settings updated successfully', 'thinkrank')
211 ], 200);
212 }
213
214 return new WP_REST_Response([
215 'success' => false,
216 'message' => __('Failed to update settings', 'thinkrank')
217 ], 500);
218 }
219
220 /**
221 * Get Media Library alt-text coverage stats
222 *
223 * @since 1.19.1
224 * @param WP_REST_Request $request API request object
225 * @return WP_REST_Response API response
226 */
227 public function get_media_alt_stats(WP_REST_Request $request): WP_REST_Response {
228 return new WP_REST_Response($this->image_manager->get_media_alt_stats(), 200);
229 }
230
231 /**
232 * Run one batch of the Media Library alt-text bulk fill
233 *
234 * @since 1.19.1
235 * @param WP_REST_Request $request API request object
236 * @return WP_REST_Response API response
237 */
238 public function run_media_alt_fill(WP_REST_Request $request): WP_REST_Response {
239 $result = $this->image_manager->bulk_fill_missing_alt([
240 'offset' => (int) $request->get_param('offset'),
241 'limit' => (int) $request->get_param('limit'),
242 'overwrite' => (bool) $request->get_param('overwrite'),
243 ]);
244
245 return new WP_REST_Response($result, 200);
246 }
247
248 /**
249 * Get settings schema arguments
250 *
251 * @since 1.0.0
252 * @return array API arguments array
253 */
254 private function get_settings_args(): array {
255 $schema = $this->image_manager->get_settings_schema('site');
256 $args = [];
257
258 foreach ($schema as $key => $config) {
259 $args[$key] = [
260 'type' => $config['type'],
261 'required' => false,
262 ];
263
264 // Pick the sanitizer from the declared type. `sanitize_text_field`
265 // for everything non-boolean was a trap for the first array- or
266 // object-typed setting added to the schema: it casts an array to
267 // the string "Array" (PHP notice) or an empty string, so the value
268 // would arrive at the handler destroyed rather than rejected.
269 // A type with no scalar sanitizer gets none — core then falls back
270 // to `rest_parse_request_arg`, which sanitizes against this very
271 // schema instead of flattening it.
272 $sanitizer = self::SANITIZERS[$config['type']] ?? null;
273
274 if (null !== $sanitizer) {
275 $args[$key]['sanitize_callback'] = $sanitizer;
276 }
277
278 // A structural type is unusable to core without its shape.
279 foreach (['items', 'properties', 'additionalProperties'] as $keyword) {
280 if (isset($config[$keyword])) {
281 $args[$key][$keyword] = $config[$keyword];
282 }
283 }
284
285 // Carry through any constraint the schema already declares. Copying
286 // only type/required/sanitize_callback silently dropped the
287 // alt_source enum, so the REST validator never enforced it.
288 //
289 // The enum needs a validate_callback to have any effect:
290 // WP_REST_Request::has_valid_params() skips an arg entirely unless
291 // one is set, so declaring the enum alone leaves it inert. Attach
292 // it only to args that actually carry a constraint — applying it to
293 // every arg would also start enforcing `type`, turning today's
294 // lenient boolean coercion into a hard 400.
295 if (isset($config['enum'])) {
296 $args[$key]['enum'] = $config['enum'];
297 $args[$key]['validate_callback'] = 'rest_validate_request_arg';
298 }
299 }
300
301 return $args;
302 }
303 }
304