PluginProbe ʕ •ᴥ•ʔ
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback / 5.1.1
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback v5.1.1
5.1.3 5.1.2 5.1.1 5.1 5.0 trunk 3.10 3.11 3.12 3.13 3.14 3.15 3.16 3.17 3.18 3.19 3.2.0 3.2.1 3.22 3.22.1 3.22.2 3.22.3 3.22.4 3.22.5 3.22.6 3.3.0 3.3.1 3.3.2 3.3.2.1 3.3.2.2 3.3.3 3.30 3.31 3.32 3.4 3.4.1 3.4.3 3.4.4 3.5 3.5.1 3.6 3.6.1 3.7 3.8 3.9 3.9.1 3.9.2 3.9.3 3.9.4 3.9.6 3.9.6.1 4.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.0.9 4.1.0 4.1.1 4.1.2 4.1.3 4.2 4.2.1 4.2.2 4.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.4
atarim-visual-collaboration / doit / abilities / class-avcf-abilities-media.php
atarim-visual-collaboration / doit / abilities Last commit date
class-avcf-abilities-base.php 3 weeks ago class-avcf-abilities-block-navigation.php 3 weeks ago class-avcf-abilities-cache.php 3 weeks ago class-avcf-abilities-content.php 3 weeks ago class-avcf-abilities-core.php 3 weeks ago class-avcf-abilities-execute-php.php 3 weeks ago class-avcf-abilities-global-styles.php 3 weeks ago class-avcf-abilities-gutenberg.php 3 weeks ago class-avcf-abilities-media.php 3 weeks ago class-avcf-abilities-metadata.php 3 weeks ago class-avcf-abilities-navigation.php 3 weeks ago class-avcf-abilities-patterns.php 3 weeks ago class-avcf-abilities-plugins.php 3 weeks ago class-avcf-abilities-readonly.php 3 weeks ago class-avcf-abilities-settings.php 3 weeks ago class-avcf-abilities-taxonomies.php 3 weeks ago class-avcf-abilities-templates.php 3 weeks ago class-avcf-abilities-theme-files.php 3 weeks ago class-avcf-abilities-themes.php 3 weeks ago class-avcf-abilities-users.php 3 weeks ago
class-avcf-abilities-media.php
1780 lines
1 <?php
2 /**
3 * Media library MCP abilities.
4 *
5 * Registers Atarim/* abilities for working with media attachments —
6 * listing/searching, reading individual items with full metadata,
7 * updating attachment fields, bulk alt-text editing, safe deletion with
8 * in-use detection, uploading/replacing files, and core sub-size
9 * regeneration.
10 *
11 * Third-party image OPTIMIZER integration (ShortPixel / Smush / EWWW /
12 * Imagify, i.e. driving an installed compressor) is intentionally NOT in
13 * this cluster — that belongs in a third-party/{plugin}/ integration
14 * module. Core, plugin-free sub-size regeneration (with an optional
15 * quality re-encode of the derivatives) DOES live here, as regenerate-image.
16 *
17 * Exposed abilities:
18 * atarim/list-media Media with filters (mime, missing_alt, attached, search, date range).
19 * atarim/get-media Single attachment with dimensions, on-demand file size.
20 * atarim/update-media Update alt_text, title, caption, description.
21 * atarim/bulk-update-alt-text Array of {id, alt_text} tuples with per-id results.
22 * atarim/delete-media Single attachment delete with in-use safety guard.
23 * atarim/bulk-delete-media Bulk delete with dry_run default and confirm_in_use guard.
24 * atarim/upload-media Sideload a file into the media library.
25 * atarim/replace-media-file Replace an attachment's underlying file.
26 * atarim/replace-media-in-content Swap one attachment for another across content.
27 * atarim/regenerate-image Regenerate sub-sizes (single or batch ≤25), optional quality re-encode of derivatives; original untouched.
28 *
29 * Note: abilities are exposed automatically — class-avcf-mcp.php builds the
30 * server tool list dynamically from wp_get_abilities(), including every
31 * ability whose meta has mcp.public = true and mcp.type = 'tool'. No manual
32 * $tools entry is required. (Exposure can still be suppressed via the
33 * avcf_mcp_blocked_abilities blocklist.)
34 *
35 * @package atarim-visual-collaboration
36 */
37
38 if ( ! defined('ABSPATH') ) {
39 exit;
40 }
41
42 class AVCF_Abilities_Media extends AVCF_Abilities_Base {
43
44 public function register() {
45
46 // ---- list-media ----
47 wp_register_ability( 'atarim/list-media', [
48 'label' => 'List Media',
49 'description' => 'Returns attachment items with filters: MIME type (image, image/jpeg, video, etc.), missing_alt (audit gap), attached/unattached, free-text search, upload date range, and pagination. By default the file_size field is NOT populated (reading from disk per item is slow on large libraries) — pass include_file_size: true to opt in. Missing files are flagged with file_missing: true.',
50 'category' => 'atarim',
51 'input_schema' => [
52 'type' => 'object',
53 'properties' => [
54 'mime_type' => [
55 'type' => 'string',
56 'description' => 'Filter by MIME type or prefix. "image" matches all images; "image/jpeg" matches only JPEGs; "video" all videos; "application/pdf" specific PDFs.',
57 'minLength' => 1,
58 ],
59 'attached' => [
60 'type' => 'string',
61 'description' => '"yes" returns only attachments linked to a post (post_parent > 0). "no" returns only unattached items. Omit for both.',
62 'enum' => [ 'yes', 'no' ],
63 ],
64 'missing_alt' => [
65 'type' => 'boolean',
66 'description' => 'When true, return only image attachments whose _wp_attachment_image_alt meta is empty or unset. Useful for accessibility audits. Ignored for non-image MIME types.',
67 'default' => false,
68 ],
69 'search' => [
70 'type' => 'string',
71 'description' => 'Search across title, file name, and alt text.',
72 'minLength' => 1,
73 ],
74 'date_from' => [
75 'type' => 'string',
76 'description' => 'Items uploaded on or after this date. ISO 8601 or strtotime()-parseable.',
77 ],
78 'date_to' => [
79 'type' => 'string',
80 'description' => 'Items uploaded on or before this date.',
81 ],
82 'author' => [
83 'type' => 'integer',
84 'description' => 'Filter by uploader user ID.',
85 'minimum' => 1,
86 ],
87 'orderby' => [
88 'type' => 'string',
89 'enum' => [ 'date', 'modified', 'title', 'ID' ],
90 'default' => 'date',
91 ],
92 'order' => [
93 'type' => 'string',
94 'enum' => [ 'ASC', 'DESC' ],
95 'default' => 'DESC',
96 ],
97 'limit' => [
98 'type' => 'integer',
99 'description' => 'Max items. -1 for all (slow on big libraries). Defaults to 50.',
100 'default' => 50,
101 'minimum' => -1,
102 ],
103 'offset' => [
104 'type' => 'integer',
105 'description' => 'Skip this many items (pagination).',
106 'default' => 0,
107 'minimum' => 0,
108 ],
109 'include_file_size' => [
110 'type' => 'boolean',
111 'description' => 'Read each file size from disk. Adds one filesystem call per returned item. Defaults to false. Items whose file is missing on disk are flagged with file_missing: true.',
112 'default' => false,
113 ],
114 ],
115 'additionalProperties' => false,
116 ],
117 'output_schema' => [
118 'type' => 'object',
119 'properties' => [
120 'total' => [ 'type' => 'integer' ],
121 'returned' => [ 'type' => 'integer' ],
122 'offset' => [ 'type' => 'integer' ],
123 'items' => [
124 'type' => 'array',
125 'items' => [
126 'type' => 'object',
127 'properties' => [
128 'id' => [ 'type' => 'integer' ],
129 'title' => [ 'type' => 'string' ],
130 'slug' => [ 'type' => 'string' ],
131 'mime_type' => [ 'type' => 'string' ],
132 'url' => [ 'type' => 'string' ],
133 'alt_text' => [ 'type' => 'string' ],
134 'caption' => [ 'type' => 'string' ],
135 'description' => [ 'type' => 'string' ],
136 'attached_to' => [ 'type' => 'integer' ],
137 'author' => [ 'type' => 'integer' ],
138 'date' => [ 'type' => 'string' ],
139 'modified' => [ 'type' => 'string' ],
140 'width' => [ 'type' => 'integer' ],
141 'height' => [ 'type' => 'integer' ],
142 'file_size' => [ 'type' => [ 'integer', 'null' ] ],
143 'file_missing' => [ 'type' => 'boolean' ],
144 ],
145 ],
146 ],
147 ],
148 'required' => [ 'total', 'returned', 'items' ],
149 ],
150 'execute_callback' => function( $input = [] ) {
151 $limit = isset( $input['limit'] ) ? (int) $input['limit'] : 50;
152 $offset = isset( $input['offset'] ) ? max( 0, (int) $input['offset'] ) : 0;
153
154 $args = [
155 'post_type' => 'attachment',
156 'post_status' => 'inherit',
157 'posts_per_page' => $limit,
158 'offset' => $offset,
159 'orderby' => isset( $input['orderby'] ) ? sanitize_key( $input['orderby'] ) : 'date',
160 'order' => ( isset( $input['order'] ) && strtoupper( $input['order'] ) === 'ASC' ) ? 'ASC' : 'DESC',
161 ];
162
163 // MIME filter — WP_Query accepts a prefix like "image" or a full type.
164 if ( ! empty( $input['mime_type'] ) ) {
165 $args['post_mime_type'] = (string) $input['mime_type'];
166 }
167
168 // Attached / unattached.
169 if ( isset( $input['attached'] ) ) {
170 if ( $input['attached'] === 'no' ) {
171 $args['post_parent'] = 0;
172 } elseif ( $input['attached'] === 'yes' ) {
173 $args['post_parent__not_in'] = [ 0 ];
174 }
175 }
176
177 if ( isset( $input['author'] ) ) {
178 $args['author'] = (int) $input['author'];
179 }
180
181 if ( ! empty( $input['search'] ) ) {
182 $args['s'] = (string) $input['search'];
183 }
184
185 // Date range — WP_Query date_query.
186 if ( ! empty( $input['date_from'] ) || ! empty( $input['date_to'] ) ) {
187 $date_query = [];
188 if ( ! empty( $input['date_from'] ) ) {
189 list( $after, , $err ) = $this->avcf_normalize_post_date( (string) $input['date_from'] );
190 if ( $err !== null ) {
191 return [ 'total' => 0, 'returned' => 0, 'offset' => $offset, 'items' => [], 'message' => 'date_from: ' . $err ];
192 }
193 $date_query['after'] = $after;
194 }
195 if ( ! empty( $input['date_to'] ) ) {
196 list( $before, , $err ) = $this->avcf_normalize_post_date( (string) $input['date_to'] );
197 if ( $err !== null ) {
198 return [ 'total' => 0, 'returned' => 0, 'offset' => $offset, 'items' => [], 'message' => 'date_to: ' . $err ];
199 }
200 $date_query['before'] = $before;
201 }
202 $date_query['inclusive'] = true;
203 $args['date_query'] = [ $date_query ];
204 }
205
206 // missing_alt — meta_query for empty/unset alt text. Only meaningful for images.
207 $missing_alt = ! empty( $input['missing_alt'] );
208 if ( $missing_alt ) {
209 $args['meta_query'] = [
210 'relation' => 'OR',
211 [ 'key' => '_wp_attachment_image_alt', 'compare' => 'NOT EXISTS' ],
212 [ 'key' => '_wp_attachment_image_alt', 'value' => '', 'compare' => '=' ],
213 ];
214 // If the caller didn't already constrain to images, do it for them — alt text
215 // is meaningless on non-image attachments.
216 if ( empty( $args['post_mime_type'] ) ) {
217 $args['post_mime_type'] = 'image';
218 }
219 }
220
221 $include_file_size = ! empty( $input['include_file_size'] );
222
223 $query = new \WP_Query( $args );
224 $items = [];
225
226 foreach ( $query->posts as $att ) {
227 $alt = (string) get_post_meta( $att->ID, '_wp_attachment_image_alt', true );
228 $meta = wp_get_attachment_metadata( $att->ID );
229 $width = isset( $meta['width'] ) ? (int) $meta['width'] : 0;
230 $height = isset( $meta['height'] ) ? (int) $meta['height'] : 0;
231 $url = (string) wp_get_attachment_url( $att->ID );
232
233 $file_size = null;
234 $file_missing = false;
235 if ( $include_file_size ) {
236 $path = get_attached_file( $att->ID );
237 if ( $path && file_exists( $path ) ) {
238 $file_size = (int) filesize( $path );
239 } else {
240 $file_missing = true;
241 }
242 }
243
244 $items[] = [
245 'id' => (int) $att->ID,
246 'title' => (string) $att->post_title,
247 'slug' => (string) $att->post_name,
248 'mime_type' => (string) $att->post_mime_type,
249 'url' => $url,
250 'alt_text' => $alt,
251 'caption' => (string) $att->post_excerpt,
252 'description' => (string) $att->post_content,
253 'attached_to' => (int) $att->post_parent,
254 'author' => (int) $att->post_author,
255 'date' => (string) $att->post_date_gmt,
256 'modified' => (string) $att->post_modified_gmt,
257 'width' => $width,
258 'height' => $height,
259 'file_size' => $file_size,
260 'file_missing' => $file_missing,
261 ];
262 }
263
264 return [
265 'total' => (int) $query->found_posts,
266 'returned' => count( $items ),
267 'offset' => $offset,
268 'items' => $items,
269 ];
270 },
271 'permission_callback' => function() {
272 return current_user_can( 'upload_files' );
273 },
274 'meta' => [
275 'mcp' => [ 'public' => true, 'type' => 'tool' ],
276 'annotations' => [ 'readonly' => true, 'destructive' => false, 'idempotent' => true ],
277 ],
278 ] );
279
280 // ---- get-media ----
281 wp_register_ability( 'atarim/get-media', [
282 'label' => 'Get Media',
283 'description' => 'Returns full detail for a single attachment by ID: file URL, MIME type, alt text, caption, description, dimensions, file size (read from disk), and the post it is attached to (if any). Also reports usage signal: in_use_as_featured_media counts how many posts use this as their _thumbnail_id.',
284 'category' => 'atarim',
285 'input_schema' => [
286 'type' => 'object',
287 'properties' => [
288 'id' => [
289 'type' => 'integer',
290 'description' => 'Attachment ID.',
291 'minimum' => 1,
292 ],
293 ],
294 'required' => [ 'id' ],
295 'additionalProperties' => false,
296 ],
297 'output_schema' => [
298 'type' => 'object',
299 'properties' => [
300 'success' => [ 'type' => 'boolean' ],
301 'id' => [ 'type' => 'integer' ],
302 'title' => [ 'type' => 'string' ],
303 'slug' => [ 'type' => 'string' ],
304 'mime_type' => [ 'type' => 'string' ],
305 'url' => [ 'type' => 'string' ],
306 'file_path' => [ 'type' => 'string' ],
307 'alt_text' => [ 'type' => 'string' ],
308 'caption' => [ 'type' => 'string' ],
309 'description' => [ 'type' => 'string' ],
310 'attached_to' => [ 'type' => 'integer' ],
311 'author' => [ 'type' => 'integer' ],
312 'date' => [ 'type' => 'string' ],
313 'modified' => [ 'type' => 'string' ],
314 'width' => [ 'type' => 'integer' ],
315 'height' => [ 'type' => 'integer' ],
316 'file_size' => [ 'type' => [ 'integer', 'null' ] ],
317 'file_missing' => [ 'type' => 'boolean' ],
318 'in_use_as_featured_media' => [ 'type' => 'integer' ],
319 'sizes' => [ 'type' => 'object' ],
320 'message' => [ 'type' => 'string' ],
321 ],
322 'required' => [ 'success', 'message' ],
323 ],
324 'execute_callback' => function( $input = [] ) {
325 $id = isset( $input['id'] ) ? (int) $input['id'] : 0;
326 if ( $id <= 0 ) {
327 return [ 'success' => false, 'message' => 'id is required and must be a positive integer.' ];
328 }
329
330 $att = get_post( $id );
331 if ( ! $att || $att->post_type !== 'attachment' ) {
332 return [ 'success' => false, 'message' => sprintf( 'Attachment %d not found.', $id ) ];
333 }
334
335 $alt = (string) get_post_meta( $id, '_wp_attachment_image_alt', true );
336 $meta = wp_get_attachment_metadata( $id );
337 $width = isset( $meta['width'] ) ? (int) $meta['width'] : 0;
338 $height = isset( $meta['height'] ) ? (int) $meta['height'] : 0;
339 $url = (string) wp_get_attachment_url( $id );
340 $path = (string) get_attached_file( $id );
341
342 $file_size = null;
343 $file_missing = true;
344 if ( $path && file_exists( $path ) ) {
345 $file_size = (int) filesize( $path );
346 $file_missing = false;
347 }
348
349 // Featured-image usage count via _thumbnail_id meta lookup.
350 global $wpdb;
351 $featured_uses = (int) $wpdb->get_var( $wpdb->prepare(
352 "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_thumbnail_id' AND meta_value = %s",
353 (string) $id
354 ) );
355
356 // Intermediate sizes (thumbnail, medium, large, etc.).
357 $sizes = [];
358 if ( isset( $meta['sizes'] ) && is_array( $meta['sizes'] ) ) {
359 foreach ( $meta['sizes'] as $size_name => $size_meta ) {
360 $sizes[ $size_name ] = [
361 'width' => isset( $size_meta['width'] ) ? (int) $size_meta['width'] : 0,
362 'height' => isset( $size_meta['height'] ) ? (int) $size_meta['height'] : 0,
363 'file' => isset( $size_meta['file'] ) ? (string) $size_meta['file'] : '',
364 'mime_type' => isset( $size_meta['mime-type'] ) ? (string) $size_meta['mime-type'] : '',
365 ];
366 }
367 }
368
369 return [
370 'success' => true,
371 'id' => $id,
372 'title' => (string) $att->post_title,
373 'slug' => (string) $att->post_name,
374 'mime_type' => (string) $att->post_mime_type,
375 'url' => $url,
376 'file_path' => $path,
377 'alt_text' => $alt,
378 'caption' => (string) $att->post_excerpt,
379 'description' => (string) $att->post_content,
380 'attached_to' => (int) $att->post_parent,
381 'author' => (int) $att->post_author,
382 'date' => (string) $att->post_date_gmt,
383 'modified' => (string) $att->post_modified_gmt,
384 'width' => $width,
385 'height' => $height,
386 'file_size' => $file_size,
387 'file_missing' => $file_missing,
388 'in_use_as_featured_media' => $featured_uses,
389 'sizes' => $sizes,
390 'message' => 'OK.',
391 ];
392 },
393 'permission_callback' => function() {
394 return current_user_can( 'upload_files' );
395 },
396 'meta' => [
397 'mcp' => [ 'public' => true, 'type' => 'tool' ],
398 'annotations' => [ 'readonly' => true, 'destructive' => false, 'idempotent' => true ],
399 ],
400 ] );
401
402 // ---- update-media ----
403 wp_register_ability( 'atarim/update-media', [
404 'label' => 'Update Media',
405 'description' => 'Updates fields on a single attachment. Only id is required; pass any subset of alt_text, title, caption, description. Omitted fields are left unchanged. Pass an empty string to clear a field. Alt text is stored in _wp_attachment_image_alt meta; title/caption/description are post fields.',
406 'category' => 'atarim',
407 'input_schema' => [
408 'type' => 'object',
409 'properties' => [
410 'id' => [
411 'type' => 'integer',
412 'description' => 'Attachment ID.',
413 'minimum' => 1,
414 ],
415 'alt_text' => [
416 'type' => 'string',
417 'description' => 'Alt text for accessibility. Empty string clears it.',
418 ],
419 'title' => [
420 'type' => 'string',
421 'description' => 'Attachment title.',
422 ],
423 'caption' => [
424 'type' => 'string',
425 'description' => 'Caption (stored as post_excerpt). Empty string clears it.',
426 ],
427 'description' => [
428 'type' => 'string',
429 'description' => 'Description (stored as post_content). Empty string clears it.',
430 ],
431 ],
432 'required' => [ 'id' ],
433 'additionalProperties' => false,
434 ],
435 'output_schema' => [
436 'type' => 'object',
437 'properties' => [
438 'success' => [ 'type' => 'boolean' ],
439 'id' => [ 'type' => 'integer' ],
440 'updated' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ],
441 'message' => [ 'type' => 'string' ],
442 ],
443 'required' => [ 'success', 'message' ],
444 ],
445 'execute_callback' => function( $input = [] ) {
446 $id = isset( $input['id'] ) ? (int) $input['id'] : 0;
447 if ( $id <= 0 ) {
448 return [ 'success' => false, 'message' => 'id is required and must be a positive integer.' ];
449 }
450
451 $att = get_post( $id );
452 if ( ! $att || $att->post_type !== 'attachment' ) {
453 return [ 'success' => false, 'id' => $id, 'message' => sprintf( 'Attachment %d not found.', $id ) ];
454 }
455
456 $updated = [];
457 $post_arr = [ 'ID' => $id ];
458
459 if ( array_key_exists( 'alt_text', $input ) ) {
460 update_post_meta( $id, '_wp_attachment_image_alt', sanitize_text_field( (string) $input['alt_text'] ) );
461 $updated[] = 'alt_text';
462 }
463 if ( array_key_exists( 'title', $input ) ) {
464 $post_arr['post_title'] = sanitize_text_field( (string) $input['title'] );
465 $updated[] = 'title';
466 }
467 if ( array_key_exists( 'caption', $input ) ) {
468 $post_arr['post_excerpt'] = sanitize_textarea_field( (string) $input['caption'] );
469 $updated[] = 'caption';
470 }
471 if ( array_key_exists( 'description', $input ) ) {
472 $post_arr['post_content'] = wp_kses_post( (string) $input['description'] );
473 $updated[] = 'description';
474 }
475
476 if ( empty( $updated ) ) {
477 return [ 'success' => false, 'id' => $id, 'message' => 'No fields provided to update.' ];
478 }
479
480 if ( count( $post_arr ) > 1 ) {
481 $res = wp_update_post( $post_arr, true );
482 if ( is_wp_error( $res ) ) {
483 return [ 'success' => false, 'id' => $id, 'message' => 'Update failed: ' . $res->get_error_message() ];
484 }
485 }
486
487 return [
488 'success' => true,
489 'id' => $id,
490 'updated' => $updated,
491 'message' => sprintf( 'Updated: %s.', implode( ', ', $updated ) ),
492 ];
493 },
494 'permission_callback' => function() {
495 return current_user_can( 'upload_files' );
496 },
497 'meta' => [
498 'mcp' => [ 'public' => true, 'type' => 'tool' ],
499 'annotations' => [ 'readonly' => false, 'destructive' => false, 'idempotent' => false ],
500 ],
501 ] );
502
503 // ---- bulk-update-alt-text ----
504 wp_register_ability( 'atarim/bulk-update-alt-text', [
505 'label' => 'Bulk Update Alt Text',
506 'description' => 'Updates alt text on many attachments in a single call. Accepts an array of {id, alt_text} tuples — each tuple updates one attachment. Per-id success tracking so partial failures (missing attachments, permission issues) don\'t mask the rest. Max 500 items per call. Use list-media with missing_alt: true to find candidates that need alt text.',
507 'category' => 'atarim',
508 'input_schema' => [
509 'type' => 'object',
510 'properties' => [
511 'items' => [
512 'type' => 'array',
513 'description' => 'Array of {id, alt_text} objects. Each updates one attachment.',
514 'items' => [
515 'type' => 'object',
516 'properties' => [
517 'id' => [ 'type' => 'integer', 'minimum' => 1 ],
518 'alt_text' => [ 'type' => 'string' ],
519 ],
520 'required' => [ 'id', 'alt_text' ],
521 'additionalProperties' => false,
522 ],
523 'minItems' => 1,
524 'maxItems' => 500,
525 ],
526 ],
527 'required' => [ 'items' ],
528 'additionalProperties' => false,
529 ],
530 'output_schema' => [
531 'type' => 'object',
532 'properties' => [
533 'success' => [ 'type' => 'boolean' ],
534 'attempted' => [ 'type' => 'integer' ],
535 'updated' => [ 'type' => 'integer' ],
536 'failed' => [ 'type' => 'integer' ],
537 'results' => [
538 'type' => 'array',
539 'items' => [
540 'type' => 'object',
541 'properties' => [
542 'id' => [ 'type' => 'integer' ],
543 'success' => [ 'type' => 'boolean' ],
544 'message' => [ 'type' => 'string' ],
545 ],
546 ],
547 ],
548 'message' => [ 'type' => 'string' ],
549 ],
550 'required' => [ 'success', 'attempted', 'updated', 'failed', 'results', 'message' ],
551 ],
552 'execute_callback' => function( $input = [] ) {
553 $items = isset( $input['items'] ) && is_array( $input['items'] ) ? $input['items'] : [];
554 if ( empty( $items ) ) {
555 return [
556 'success' => false,
557 'attempted' => 0,
558 'updated' => 0,
559 'failed' => 0,
560 'results' => [],
561 'message' => 'items is required and must be a non-empty array.',
562 ];
563 }
564
565 $results = [];
566 $updated = 0;
567 $failed = 0;
568
569 foreach ( $items as $row ) {
570 if ( ! is_array( $row ) || ! isset( $row['id'] ) || ! array_key_exists( 'alt_text', $row ) ) {
571 $results[] = [ 'id' => 0, 'success' => false, 'message' => 'Invalid row — id and alt_text are required.' ];
572 $failed++;
573 continue;
574 }
575 $id = (int) $row['id'];
576 if ( $id <= 0 ) {
577 $results[] = [ 'id' => $id, 'success' => false, 'message' => 'Invalid id.' ];
578 $failed++;
579 continue;
580 }
581 $att = get_post( $id );
582 if ( ! $att || $att->post_type !== 'attachment' ) {
583 $results[] = [ 'id' => $id, 'success' => false, 'message' => 'Attachment not found.' ];
584 $failed++;
585 continue;
586 }
587 if ( ! current_user_can( 'edit_post', $id ) ) {
588 $results[] = [ 'id' => $id, 'success' => false, 'message' => 'Permission denied.' ];
589 $failed++;
590 continue;
591 }
592
593 $alt = sanitize_text_field( (string) $row['alt_text'] );
594 update_post_meta( $id, '_wp_attachment_image_alt', $alt );
595
596 $results[] = [ 'id' => $id, 'success' => true, 'message' => 'OK.' ];
597 $updated++;
598 }
599
600 $attempted = count( $items );
601
602 return [
603 'success' => ( $failed === 0 ),
604 'attempted' => $attempted,
605 'updated' => $updated,
606 'failed' => $failed,
607 'results' => $results,
608 'message' => sprintf( '%d of %d updated, %d failed.', $updated, $attempted, $failed ),
609 ];
610 },
611 'permission_callback' => function() {
612 return current_user_can( 'upload_files' );
613 },
614 'meta' => [
615 'mcp' => [ 'public' => true, 'type' => 'tool' ],
616 'annotations' => [ 'readonly' => false, 'destructive' => false, 'idempotent' => true ],
617 ],
618 ] );
619
620 // ---- delete-media ----
621 wp_register_ability( 'atarim/delete-media', [
622 'label' => 'Delete Media',
623 'description' => 'Deletes a single attachment from the library and removes the underlying file(s) from disk. By default refuses if the attachment is referenced as a featured image on any post — pass confirm_in_use: true to delete anyway (the affected posts lose their featured image). Important limitation: in-use detection ONLY checks featured-image references. Attachments embedded directly in post content via URL, used by page builders, or referenced from custom meta are NOT detected as in-use. The AI / caller should treat confirm_in_use: true as "I have audited usage independently".',
624 'category' => 'atarim',
625 'input_schema' => [
626 'type' => 'object',
627 'properties' => [
628 'id' => [
629 'type' => 'integer',
630 'description' => 'Attachment ID.',
631 'minimum' => 1,
632 ],
633 'confirm_in_use' => [
634 'type' => 'boolean',
635 'description' => 'Required to delete an attachment that is used as a featured image on at least one post.',
636 'default' => false,
637 ],
638 ],
639 'required' => [ 'id' ],
640 'additionalProperties' => false,
641 ],
642 'output_schema' => [
643 'type' => 'object',
644 'properties' => [
645 'success' => [ 'type' => 'boolean' ],
646 'id' => [ 'type' => 'integer' ],
647 'in_use_as_featured_media' => [ 'type' => 'integer' ],
648 'message' => [ 'type' => 'string' ],
649 ],
650 'required' => [ 'success', 'message' ],
651 ],
652 'execute_callback' => function( $input = [] ) {
653 $id = isset( $input['id'] ) ? (int) $input['id'] : 0;
654 if ( $id <= 0 ) {
655 return [ 'success' => false, 'message' => 'id is required and must be a positive integer.' ];
656 }
657 $confirm = ! empty( $input['confirm_in_use'] );
658
659 $att = get_post( $id );
660 if ( ! $att || $att->post_type !== 'attachment' ) {
661 return [ 'success' => false, 'id' => $id, 'message' => sprintf( 'Attachment %d not found.', $id ) ];
662 }
663 if ( ! current_user_can( 'delete_post', $id ) ) {
664 return [ 'success' => false, 'id' => $id, 'message' => 'Permission denied.' ];
665 }
666
667 global $wpdb;
668 $featured_uses = (int) $wpdb->get_var( $wpdb->prepare(
669 "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_thumbnail_id' AND meta_value = %s",
670 (string) $id
671 ) );
672
673 if ( $featured_uses > 0 && ! $confirm ) {
674 return [
675 'success' => false,
676 'id' => $id,
677 'in_use_as_featured_media' => $featured_uses,
678 'message' => sprintf(
679 'Attachment %d is the featured image on %d post(s). Pass confirm_in_use: true to delete anyway (those posts will lose their featured image). NOTE: in-use detection does NOT scan post content or custom meta — verify usage independently before confirming.',
680 $id,
681 $featured_uses
682 ),
683 ];
684 }
685
686 $result = wp_delete_attachment( $id, true );
687 if ( $result === false || $result === null ) {
688 return [ 'success' => false, 'id' => $id, 'in_use_as_featured_media' => $featured_uses, 'message' => 'Delete failed: WordPress reported the operation did not complete.' ];
689 }
690
691 return [
692 'success' => true,
693 'id' => $id,
694 'in_use_as_featured_media' => $featured_uses,
695 'message' => $featured_uses > 0
696 ? sprintf( 'Attachment deleted; %d post(s) lost their featured image.', $featured_uses )
697 : 'Attachment deleted.',
698 ];
699 },
700 'permission_callback' => function() {
701 return current_user_can( 'upload_files' );
702 },
703 'meta' => [
704 'mcp' => [ 'public' => true, 'type' => 'tool' ],
705 'annotations' => [ 'readonly' => false, 'destructive' => true, 'idempotent' => false ],
706 ],
707 ] );
708
709 // ---- bulk-delete-media ----
710 wp_register_ability( 'atarim/bulk-delete-media', [
711 'label' => 'Bulk Delete Media',
712 'description' => 'Deletes many attachments in a single call. DRY-RUN BY DEFAULT — call with dry_run: false to actually delete. In dry-run mode returns the list of attachments that WOULD be deleted (and which are in use). Same featured-image safety guard as delete-media: attachments used as featured images are skipped unless confirm_in_use: true. Same caveat: in-use detection only checks featured-image references, not post content / meta / page builders.',
713 'category' => 'atarim',
714 'input_schema' => [
715 'type' => 'object',
716 'properties' => [
717 'ids' => [
718 'type' => 'array',
719 'description' => 'Attachment IDs to delete.',
720 'items' => [ 'type' => 'integer', 'minimum' => 1 ],
721 'minItems' => 1,
722 'maxItems' => 500,
723 ],
724 'dry_run' => [
725 'type' => 'boolean',
726 'description' => 'When true (default), reports what WOULD be deleted without actually deleting. Pass false to actually delete.',
727 'default' => true,
728 ],
729 'confirm_in_use' => [
730 'type' => 'boolean',
731 'description' => 'When true, attachments that are featured images on existing posts are deleted anyway. Default false — those are skipped with a per-id message.',
732 'default' => false,
733 ],
734 ],
735 'required' => [ 'ids' ],
736 'additionalProperties' => false,
737 ],
738 'output_schema' => [
739 'type' => 'object',
740 'properties' => [
741 'success' => [ 'type' => 'boolean' ],
742 'dry_run' => [ 'type' => 'boolean' ],
743 'attempted' => [ 'type' => 'integer' ],
744 'deleted' => [ 'type' => 'integer' ],
745 'skipped' => [ 'type' => 'integer' ],
746 'failed' => [ 'type' => 'integer' ],
747 'results' => [
748 'type' => 'array',
749 'items' => [
750 'type' => 'object',
751 'properties' => [
752 'id' => [ 'type' => 'integer' ],
753 'action' => [ 'type' => 'string' ],
754 'in_use_as_featured_media' => [ 'type' => 'integer' ],
755 'message' => [ 'type' => 'string' ],
756 ],
757 ],
758 ],
759 'message' => [ 'type' => 'string' ],
760 ],
761 'required' => [ 'success', 'dry_run', 'attempted', 'deleted', 'skipped', 'failed', 'results', 'message' ],
762 ],
763 'execute_callback' => function( $input = [] ) {
764 $ids = isset( $input['ids'] ) && is_array( $input['ids'] ) ? array_values( array_unique( array_map( 'intval', $input['ids'] ) ) ) : [];
765 $dry_run = ! isset( $input['dry_run'] ) ? true : (bool) $input['dry_run'];
766 $confirm = ! empty( $input['confirm_in_use'] );
767
768 if ( empty( $ids ) ) {
769 return [
770 'success' => false,
771 'dry_run' => $dry_run,
772 'attempted' => 0,
773 'deleted' => 0,
774 'skipped' => 0,
775 'failed' => 0,
776 'results' => [],
777 'message' => 'ids is required and must be a non-empty array.',
778 ];
779 }
780
781 global $wpdb;
782 $results = [];
783 $deleted = 0;
784 $skipped = 0;
785 $failed = 0;
786
787 foreach ( $ids as $id ) {
788 if ( $id <= 0 ) {
789 $results[] = [ 'id' => $id, 'action' => 'failed', 'in_use_as_featured_media' => 0, 'message' => 'Invalid id.' ];
790 $failed++;
791 continue;
792 }
793 $att = get_post( $id );
794 if ( ! $att || $att->post_type !== 'attachment' ) {
795 $results[] = [ 'id' => $id, 'action' => 'failed', 'in_use_as_featured_media' => 0, 'message' => 'Attachment not found.' ];
796 $failed++;
797 continue;
798 }
799 if ( ! current_user_can( 'delete_post', $id ) ) {
800 $results[] = [ 'id' => $id, 'action' => 'failed', 'in_use_as_featured_media' => 0, 'message' => 'Permission denied.' ];
801 $failed++;
802 continue;
803 }
804
805 $featured_uses = (int) $wpdb->get_var( $wpdb->prepare(
806 "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_thumbnail_id' AND meta_value = %s",
807 (string) $id
808 ) );
809
810 if ( $featured_uses > 0 && ! $confirm ) {
811 $results[] = [
812 'id' => $id,
813 'action' => 'skipped',
814 'in_use_as_featured_media' => $featured_uses,
815 'message' => sprintf( 'Used as featured image on %d post(s); pass confirm_in_use to delete.', $featured_uses ),
816 ];
817 $skipped++;
818 continue;
819 }
820
821 if ( $dry_run ) {
822 $results[] = [
823 'id' => $id,
824 'action' => 'would_delete',
825 'in_use_as_featured_media' => $featured_uses,
826 'message' => $featured_uses > 0
827 ? sprintf( 'Would delete; %d post(s) would lose their featured image.', $featured_uses )
828 : 'Would delete.',
829 ];
830 $deleted++;
831 continue;
832 }
833
834 $res = wp_delete_attachment( $id, true );
835 if ( $res === false || $res === null ) {
836 $results[] = [ 'id' => $id, 'action' => 'failed', 'in_use_as_featured_media' => $featured_uses, 'message' => 'Delete failed.' ];
837 $failed++;
838 continue;
839 }
840
841 $results[] = [
842 'id' => $id,
843 'action' => 'deleted',
844 'in_use_as_featured_media' => $featured_uses,
845 'message' => $featured_uses > 0
846 ? sprintf( 'Deleted; %d post(s) lost their featured image.', $featured_uses )
847 : 'Deleted.',
848 ];
849 $deleted++;
850 }
851
852 $attempted = count( $ids );
853
854 return [
855 'success' => ( $failed === 0 ),
856 'dry_run' => $dry_run,
857 'attempted' => $attempted,
858 'deleted' => $deleted,
859 'skipped' => $skipped,
860 'failed' => $failed,
861 'results' => $results,
862 'message' => $dry_run
863 ? sprintf( 'DRY RUN: %d would be deleted, %d skipped (in use), %d failed. Pass dry_run: false to actually delete.', $deleted, $skipped, $failed )
864 : sprintf( '%d deleted, %d skipped (in use), %d failed.', $deleted, $skipped, $failed ),
865 ];
866 },
867 'permission_callback' => function() {
868 return current_user_can( 'upload_files' );
869 },
870 'meta' => [
871 'mcp' => [ 'public' => true, 'type' => 'tool' ],
872 'annotations' => [ 'readonly' => false, 'destructive' => true, 'idempotent' => false ],
873 ],
874 ] );
875
876 // ---- upload-media ----
877 wp_register_ability( 'atarim/upload-media', [
878 'label' => 'Upload Media',
879 'description' => 'Adds a new attachment to the WordPress media library. Source can be either a URL (the server fetches it; SSRF protections apply — private IPs, AWS metadata, and non-http(s) schemes are blocked) or base64-encoded file data. The file goes through WordPress\'s standard upload pipeline including MIME validation against the current user\'s allowed list, virus-scan filters that other plugins may register, and intermediate-size generation for images. Returns the new attachment ID. Optional post_id to attach to a specific post; optional alt_text to set on upload (saves a round-trip).',
880 'category' => 'atarim',
881 'input_schema' => [
882 'type' => 'object',
883 'properties' => [
884 'source' => [
885 'type' => 'string',
886 'description' => 'How to source the file. "url": fetch from the source_url field (http/https only, SSRF-protected). "base64": decode the source_data field.',
887 'enum' => [ 'url', 'base64' ],
888 ],
889 'source_url' => [
890 'type' => 'string',
891 'description' => 'When source is "url": the http or https URL to download from. Required for url source. Private IPs and cloud metadata endpoints are blocked.',
892 ],
893 'source_data' => [
894 'type' => 'string',
895 'description' => 'When source is "base64": base64-encoded file contents (the data itself, NOT a data URL prefix like "data:image/jpeg;base64,..."). Required for base64 source.',
896 ],
897 'filename' => [
898 'type' => 'string',
899 'description' => 'Filename to store the upload as (including extension). For url source, defaults to the basename of the URL if omitted. For base64 source, this is required.',
900 ],
901 'post_id' => [
902 'type' => 'integer',
903 'description' => 'Attach the new media to this post ID. Defaults to 0 (unattached).',
904 'minimum' => 0,
905 'default' => 0,
906 ],
907 'alt_text' => [
908 'type' => 'string',
909 'description' => 'Alt text to set on the new attachment after upload. Optional — saves a follow-up update-media call.',
910 ],
911 'title' => [
912 'type' => 'string',
913 'description' => 'Attachment title. Defaults to the filename minus extension.',
914 ],
915 'caption' => [
916 'type' => 'string',
917 'description' => 'Caption (stored as post_excerpt).',
918 ],
919 'description' => [
920 'type' => 'string',
921 'description' => 'Long description (stored as post_content).',
922 ],
923 ],
924 'required' => [ 'source' ],
925 'additionalProperties' => false,
926 ],
927 'output_schema' => [
928 'type' => 'object',
929 'properties' => [
930 'success' => [ 'type' => 'boolean' ],
931 'id' => [ 'type' => 'integer' ],
932 'url' => [ 'type' => 'string' ],
933 'filename' => [ 'type' => 'string' ],
934 'mime_type' => [ 'type' => 'string' ],
935 'file_size' => [ 'type' => 'integer' ],
936 'width' => [ 'type' => 'integer' ],
937 'height' => [ 'type' => 'integer' ],
938 'message' => [ 'type' => 'string' ],
939 ],
940 'required' => [ 'success', 'message' ],
941 ],
942 'execute_callback' => function( $input = [] ) {
943 $source = isset( $input['source'] ) ? sanitize_key( $input['source'] ) : '';
944 if ( ! in_array( $source, [ 'url', 'base64' ], true ) ) {
945 return [ 'success' => false, 'message' => 'source must be "url" or "base64".' ];
946 }
947
948 // Fetch / decode the bytes via the shared helper.
949 $fetched = $this->avcf_fetch_media_source( $source, $input );
950 if ( isset( $fetched['error'] ) ) {
951 return [ 'success' => false, 'message' => $fetched['error'] ];
952 }
953
954 $tmp_file = $fetched['tmp_file'];
955 $filename = $fetched['filename'];
956 $mime_detected = $fetched['mime'];
957 $file_size = $fetched['size'];
958
959 // MIME must be in WP's allowed list for the current user.
960 $allowed = get_allowed_mime_types();
961 if ( ! in_array( $mime_detected, $allowed, true ) ) {
962 @unlink( $tmp_file );
963 return [
964 'success' => false,
965 'message' => sprintf( 'MIME type "%s" is not allowed on this site for your user role.', $mime_detected ),
966 ];
967 }
968
969 // Size limit.
970 $max = wp_max_upload_size();
971 if ( $max > 0 && $file_size > $max ) {
972 @unlink( $tmp_file );
973 return [
974 'success' => false,
975 'message' => sprintf( 'File size %d bytes exceeds the upload limit of %d bytes.', $file_size, $max ),
976 ];
977 }
978
979 // Move into the uploads dir via WP's sideload pipeline.
980 if ( ! function_exists( 'wp_handle_sideload' ) ) {
981 require_once ABSPATH . 'wp-admin/includes/file.php';
982 }
983 if ( ! function_exists( 'wp_generate_attachment_metadata' ) ) {
984 require_once ABSPATH . 'wp-admin/includes/image.php';
985 }
986 if ( ! function_exists( 'wp_read_image_metadata' ) ) {
987 require_once ABSPATH . 'wp-admin/includes/media.php';
988 }
989
990 $file_array = [
991 'name' => $filename,
992 'tmp_name' => $tmp_file,
993 'size' => $file_size,
994 ];
995
996 $overrides = [ 'test_form' => false, 'test_size' => true ];
997 $sideload = wp_handle_sideload( $file_array, $overrides );
998
999 if ( ! empty( $sideload['error'] ) ) {
1000 @unlink( $tmp_file );
1001 return [ 'success' => false, 'message' => 'Upload failed: ' . $sideload['error'] ];
1002 }
1003
1004 // Insert the attachment record.
1005 $post_id_parent = isset( $input['post_id'] ) ? (int) $input['post_id'] : 0;
1006 if ( $post_id_parent > 0 && ! get_post( $post_id_parent ) ) {
1007 @unlink( $sideload['file'] );
1008 return [ 'success' => false, 'message' => sprintf( 'post_id %d does not exist.', $post_id_parent ) ];
1009 }
1010
1011 $title = isset( $input['title'] )
1012 ? sanitize_text_field( (string) $input['title'] )
1013 : preg_replace( '/\\.[^.]+$/', '', $filename );
1014
1015 $attachment = [
1016 'post_mime_type' => $sideload['type'],
1017 'post_title' => $title,
1018 'post_status' => 'inherit',
1019 'post_parent' => $post_id_parent,
1020 'post_content' => isset( $input['description'] ) ? wp_kses_post( (string) $input['description'] ) : '',
1021 'post_excerpt' => isset( $input['caption'] ) ? sanitize_textarea_field( (string) $input['caption'] ) : '',
1022 ];
1023
1024 $attach_id = wp_insert_attachment( $attachment, $sideload['file'], $post_id_parent, true );
1025 if ( is_wp_error( $attach_id ) ) {
1026 @unlink( $sideload['file'] );
1027 return [ 'success' => false, 'message' => 'Attachment insert failed: ' . $attach_id->get_error_message() ];
1028 }
1029
1030 // Generate intermediate sizes for images.
1031 $metadata = wp_generate_attachment_metadata( $attach_id, $sideload['file'] );
1032 wp_update_attachment_metadata( $attach_id, $metadata );
1033
1034 // Set alt text if provided.
1035 if ( isset( $input['alt_text'] ) ) {
1036 update_post_meta( $attach_id, '_wp_attachment_image_alt', sanitize_text_field( (string) $input['alt_text'] ) );
1037 }
1038
1039 $width = isset( $metadata['width'] ) ? (int) $metadata['width'] : 0;
1040 $height = isset( $metadata['height'] ) ? (int) $metadata['height'] : 0;
1041
1042 return [
1043 'success' => true,
1044 'id' => (int) $attach_id,
1045 'url' => (string) wp_get_attachment_url( $attach_id ),
1046 'filename' => basename( $sideload['file'] ),
1047 'mime_type' => $sideload['type'],
1048 'file_size' => $file_size,
1049 'width' => $width,
1050 'height' => $height,
1051 'message' => sprintf( 'Uploaded as attachment %d.', $attach_id ),
1052 ];
1053 },
1054 'permission_callback' => function() {
1055 return current_user_can( 'upload_files' );
1056 },
1057 'meta' => [
1058 'mcp' => [ 'public' => true, 'type' => 'tool' ],
1059 'annotations' => [ 'readonly' => false, 'destructive' => false, 'idempotent' => false ],
1060 ],
1061 ] );
1062
1063 // ---- replace-media-file ----
1064 wp_register_ability( 'atarim/replace-media-file', [
1065 'label' => 'Replace Media File',
1066 'description' => 'Replaces the underlying file of an existing attachment, keeping the SAME attachment ID. Useful when the same image needs a higher-resolution version, or a typo in a graphic needs fixing without breaking every URL/embed that references the old image. Preserves the filename by default so existing URLs continue working. MIME type changes (e.g. JPG → PNG) hard-fail unless confirm_mime_change: true. Regenerates intermediate sizes. Note: external CDN caches (Cloudflare, WP Rocket) may serve the old file until purged — run atarim/purge-all-caches (or atarim/purge-url-cache for the specific file) afterward.',
1067 'category' => 'atarim',
1068 'input_schema' => [
1069 'type' => 'object',
1070 'properties' => [
1071 'id' => [
1072 'type' => 'integer',
1073 'description' => 'Attachment ID whose file will be replaced.',
1074 'minimum' => 1,
1075 ],
1076 'source' => [
1077 'type' => 'string',
1078 'description' => 'How to source the new file. "url" or "base64". Same protections as upload-media.',
1079 'enum' => [ 'url', 'base64' ],
1080 ],
1081 'source_url' => [
1082 'type' => 'string',
1083 'description' => 'When source is "url": http or https URL of the replacement file.',
1084 ],
1085 'source_data' => [
1086 'type' => 'string',
1087 'description' => 'When source is "base64": base64-encoded file contents.',
1088 ],
1089 'rename_to' => [
1090 'type' => 'string',
1091 'description' => 'Rename the file to this name (including extension). If omitted, the original filename is preserved — recommended so existing URLs / embeds keep working.',
1092 ],
1093 'confirm_mime_change' => [
1094 'type' => 'boolean',
1095 'description' => 'Required when the new file has a different MIME type than the existing attachment. Without this flag the ability hard-fails on mismatch — replacing an image with a non-image is almost always a mistake.',
1096 'default' => false,
1097 ],
1098 ],
1099 'required' => [ 'id', 'source' ],
1100 'additionalProperties' => false,
1101 ],
1102 'output_schema' => [
1103 'type' => 'object',
1104 'properties' => [
1105 'success' => [ 'type' => 'boolean' ],
1106 'id' => [ 'type' => 'integer' ],
1107 'url' => [ 'type' => 'string' ],
1108 'old_filename' => [ 'type' => 'string' ],
1109 'new_filename' => [ 'type' => 'string' ],
1110 'old_mime_type' => [ 'type' => 'string' ],
1111 'new_mime_type' => [ 'type' => 'string' ],
1112 'new_file_size' => [ 'type' => 'integer' ],
1113 'width' => [ 'type' => 'integer' ],
1114 'height' => [ 'type' => 'integer' ],
1115 'message' => [ 'type' => 'string' ],
1116 ],
1117 'required' => [ 'success', 'message' ],
1118 ],
1119 'execute_callback' => function( $input = [] ) {
1120 $id = isset( $input['id'] ) ? (int) $input['id'] : 0;
1121 if ( $id <= 0 ) {
1122 return [ 'success' => false, 'message' => 'id is required and must be a positive integer.' ];
1123 }
1124 $att = get_post( $id );
1125 if ( ! $att || $att->post_type !== 'attachment' ) {
1126 return [ 'success' => false, 'message' => sprintf( 'Attachment %d not found.', $id ) ];
1127 }
1128 if ( ! current_user_can( 'edit_post', $id ) ) {
1129 return [ 'success' => false, 'message' => 'Permission denied.' ];
1130 }
1131
1132 $source = isset( $input['source'] ) ? sanitize_key( $input['source'] ) : '';
1133 if ( ! in_array( $source, [ 'url', 'base64' ], true ) ) {
1134 return [ 'success' => false, 'message' => 'source must be "url" or "base64".' ];
1135 }
1136
1137 $existing_path = (string) get_attached_file( $id );
1138 $existing_filename = basename( $existing_path );
1139 $existing_mime = (string) $att->post_mime_type;
1140
1141 if ( $existing_path === '' || ! file_exists( $existing_path ) ) {
1142 return [ 'success' => false, 'message' => sprintf( 'Attachment %d has no file on disk to replace.', $id ) ];
1143 }
1144
1145 $fetched = $this->avcf_fetch_media_source( $source, $input );
1146 if ( isset( $fetched['error'] ) ) {
1147 return [ 'success' => false, 'message' => $fetched['error'] ];
1148 }
1149
1150 $tmp_file = $fetched['tmp_file'];
1151 $new_mime = $fetched['mime'];
1152 $file_size = $fetched['size'];
1153
1154 // MIME must be in WP's allowed list.
1155 $allowed = get_allowed_mime_types();
1156 if ( ! in_array( $new_mime, $allowed, true ) ) {
1157 @unlink( $tmp_file );
1158 return [
1159 'success' => false,
1160 'message' => sprintf( 'MIME type "%s" is not allowed on this site for your user role.', $new_mime ),
1161 ];
1162 }
1163
1164 // Size limit.
1165 $max = wp_max_upload_size();
1166 if ( $max > 0 && $file_size > $max ) {
1167 @unlink( $tmp_file );
1168 return [
1169 'success' => false,
1170 'message' => sprintf( 'File size %d bytes exceeds the upload limit of %d bytes.', $file_size, $max ),
1171 ];
1172 }
1173
1174 // MIME mismatch guard.
1175 $confirm_mime = ! empty( $input['confirm_mime_change'] );
1176 if ( $new_mime !== $existing_mime && ! $confirm_mime ) {
1177 @unlink( $tmp_file );
1178 return [
1179 'success' => false,
1180 'id' => $id,
1181 'old_mime_type' => $existing_mime,
1182 'new_mime_type' => $new_mime,
1183 'message' => sprintf(
1184 'New file MIME type "%s" differs from existing "%s". Pass confirm_mime_change: true to proceed.',
1185 $new_mime,
1186 $existing_mime
1187 ),
1188 ];
1189 }
1190
1191 // Determine destination path. Default: preserve filename (so existing URLs / embeds still work).
1192 $dir = dirname( $existing_path );
1193 $rename_to = isset( $input['rename_to'] ) ? sanitize_file_name( (string) $input['rename_to'] ) : '';
1194 $new_filename = ( $rename_to !== '' ) ? $rename_to : $existing_filename;
1195 $new_path = trailingslashit( $dir ) . $new_filename;
1196
1197 // Delete existing intermediate-size files before regenerating, so stale sizes don't linger.
1198 $old_meta = wp_get_attachment_metadata( $id );
1199 if ( is_array( $old_meta ) && ! empty( $old_meta['sizes'] ) ) {
1200 foreach ( $old_meta['sizes'] as $size_meta ) {
1201 if ( ! empty( $size_meta['file'] ) ) {
1202 $size_path = trailingslashit( $dir ) . $size_meta['file'];
1203 if ( file_exists( $size_path ) ) {
1204 @unlink( $size_path );
1205 }
1206 }
1207 }
1208 }
1209
1210 // Replace the file on disk. Delete old if renaming, otherwise overwrite.
1211 if ( $new_path !== $existing_path && file_exists( $existing_path ) ) {
1212 @unlink( $existing_path );
1213 }
1214 if ( ! @copy( $tmp_file, $new_path ) ) {
1215 @unlink( $tmp_file );
1216 return [ 'success' => false, 'message' => sprintf( 'Could not write replacement file to %s.', $new_path ) ];
1217 }
1218 @unlink( $tmp_file );
1219
1220 // Update attachment record: new MIME, new _wp_attached_file if path changed.
1221 $update_arr = [
1222 'ID' => $id,
1223 'post_mime_type' => $new_mime,
1224 ];
1225 wp_update_post( $update_arr );
1226
1227 // Update _wp_attached_file meta (relative path from uploads dir).
1228 $uploads = wp_upload_dir();
1229 $relative = ltrim( str_replace( trailingslashit( $uploads['basedir'] ), '', $new_path ), '/' );
1230 update_post_meta( $id, '_wp_attached_file', $relative );
1231
1232 // Regenerate intermediate sizes.
1233 if ( ! function_exists( 'wp_generate_attachment_metadata' ) ) {
1234 require_once ABSPATH . 'wp-admin/includes/image.php';
1235 }
1236 $new_metadata = wp_generate_attachment_metadata( $id, $new_path );
1237 wp_update_attachment_metadata( $id, $new_metadata );
1238
1239 $width = isset( $new_metadata['width'] ) ? (int) $new_metadata['width'] : 0;
1240 $height = isset( $new_metadata['height'] ) ? (int) $new_metadata['height'] : 0;
1241
1242 return [
1243 'success' => true,
1244 'id' => $id,
1245 'url' => (string) wp_get_attachment_url( $id ),
1246 'old_filename' => $existing_filename,
1247 'new_filename' => $new_filename,
1248 'old_mime_type' => $existing_mime,
1249 'new_mime_type' => $new_mime,
1250 'new_file_size' => $file_size,
1251 'width' => $width,
1252 'height' => $height,
1253 'message' => $new_filename === $existing_filename
1254 ? 'File replaced; filename preserved. External CDN caches may serve the old file until purged — run atarim/purge-all-caches.'
1255 : sprintf( 'File replaced and renamed from "%s" to "%s". Existing URLs referencing the old filename will break.', $existing_filename, $new_filename ),
1256 ];
1257 },
1258 'permission_callback' => function() {
1259 return current_user_can( 'upload_files' );
1260 },
1261 'meta' => [
1262 'mcp' => [ 'public' => true, 'type' => 'tool' ],
1263 'annotations' => [ 'readonly' => false, 'destructive' => true, 'idempotent' => false ],
1264 ],
1265 ] );
1266
1267 // ---- replace-media-in-content ----
1268 wp_register_ability( 'atarim/replace-media-in-content', [
1269 'label' => 'Replace Media In Content',
1270 'description' => 'Swaps references to one attachment for another inside a single post\'s body. Rewrites wp-image-{id} class attributes, direct URL references to the old image (including all intermediate sizes), and srcset entries. Both attachments must exist. Defaults to dry-run mode — returns the proposed new content and a replacement count without saving. Pass dry_run: false to write. Limitation: only post_content is rewritten. References inside custom fields, page builder data, or serialized post meta are NOT touched.',
1271 'category' => 'atarim',
1272 'input_schema' => [
1273 'type' => 'object',
1274 'properties' => [
1275 'post_id' => [
1276 'type' => 'integer',
1277 'description' => 'Post / page / CPT ID whose body will be rewritten.',
1278 'minimum' => 1,
1279 ],
1280 'old_attachment_id' => [
1281 'type' => 'integer',
1282 'description' => 'Attachment ID to find references to.',
1283 'minimum' => 1,
1284 ],
1285 'new_attachment_id' => [
1286 'type' => 'integer',
1287 'description' => 'Attachment ID to replace with.',
1288 'minimum' => 1,
1289 ],
1290 'dry_run' => [
1291 'type' => 'boolean',
1292 'description' => 'When true (default), returns the proposed new content + replacement count without writing. Pass false to actually save.',
1293 'default' => true,
1294 ],
1295 ],
1296 'required' => [ 'post_id', 'old_attachment_id', 'new_attachment_id' ],
1297 'additionalProperties' => false,
1298 ],
1299 'output_schema' => [
1300 'type' => 'object',
1301 'properties' => [
1302 'success' => [ 'type' => 'boolean' ],
1303 'dry_run' => [ 'type' => 'boolean' ],
1304 'post_id' => [ 'type' => 'integer' ],
1305 'replacements' => [ 'type' => 'integer' ],
1306 'class_rewrites' => [ 'type' => 'integer' ],
1307 'url_rewrites' => [ 'type' => 'integer' ],
1308 'new_content' => [ 'type' => 'string' ],
1309 'message' => [ 'type' => 'string' ],
1310 ],
1311 'required' => [ 'success', 'dry_run', 'message' ],
1312 ],
1313 'execute_callback' => function( $input = [] ) {
1314 $post_id = isset( $input['post_id'] ) ? (int) $input['post_id'] : 0;
1315 $old_id = isset( $input['old_attachment_id'] ) ? (int) $input['old_attachment_id'] : 0;
1316 $new_id = isset( $input['new_attachment_id'] ) ? (int) $input['new_attachment_id'] : 0;
1317 $dry_run = ! isset( $input['dry_run'] ) ? true : (bool) $input['dry_run'];
1318
1319 if ( $post_id <= 0 || $old_id <= 0 || $new_id <= 0 ) {
1320 return [ 'success' => false, 'dry_run' => $dry_run, 'message' => 'post_id, old_attachment_id, and new_attachment_id are all required and must be positive integers.' ];
1321 }
1322 if ( $old_id === $new_id ) {
1323 return [ 'success' => false, 'dry_run' => $dry_run, 'message' => 'old_attachment_id and new_attachment_id must be different.' ];
1324 }
1325
1326 $post = get_post( $post_id );
1327 if ( ! $post ) {
1328 return [ 'success' => false, 'dry_run' => $dry_run, 'message' => sprintf( 'Post %d not found.', $post_id ) ];
1329 }
1330 $pt_obj = get_post_type_object( $post->post_type );
1331 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_post, $post_id ) ) {
1332 return [ 'success' => false, 'dry_run' => $dry_run, 'message' => sprintf( 'You do not have permission to edit this %s.', $post->post_type ) ];
1333 }
1334
1335 $old_att = get_post( $old_id );
1336 $new_att = get_post( $new_id );
1337 if ( ! $old_att || $old_att->post_type !== 'attachment' ) {
1338 return [ 'success' => false, 'dry_run' => $dry_run, 'message' => sprintf( 'old_attachment_id %d is not an attachment.', $old_id ) ];
1339 }
1340 if ( ! $new_att || $new_att->post_type !== 'attachment' ) {
1341 return [ 'success' => false, 'dry_run' => $dry_run, 'message' => sprintf( 'new_attachment_id %d is not an attachment.', $new_id ) ];
1342 }
1343
1344 $content = (string) $post->post_content;
1345 $original_content = $content;
1346
1347 // 1) Rewrite wp-image-{old_id} → wp-image-{new_id}.
1348 $class_rewrites = 0;
1349 $content = preg_replace(
1350 '/wp-image-' . $old_id . '\\b/',
1351 'wp-image-' . $new_id,
1352 $content,
1353 -1,
1354 $class_rewrites
1355 );
1356
1357 // 2) Rewrite URL references. We collect all known URLs of the old attachment
1358 // (full + intermediate sizes) and replace each with the equivalent URL of the
1359 // new attachment. WordPress stores intermediate sizes in metadata.
1360 $url_rewrites = 0;
1361
1362 $old_meta = wp_get_attachment_metadata( $old_id );
1363 $old_uploads_dir = wp_get_attachment_url( $old_id );
1364 $old_base_url = $old_uploads_dir ? preg_replace( '#/[^/]+$#', '/', $old_uploads_dir ) : '';
1365
1366 $new_full_url = (string) wp_get_attachment_url( $new_id );
1367
1368 // Build URL map: old size URL → new full URL (for now; size-perfect swap is best-effort).
1369 $url_map = [];
1370
1371 // Old "full" URL.
1372 if ( $old_uploads_dir ) {
1373 $url_map[ $old_uploads_dir ] = $new_full_url;
1374 }
1375
1376 // Old intermediate sizes.
1377 if ( is_array( $old_meta ) && ! empty( $old_meta['sizes'] ) && $old_base_url !== '' ) {
1378 foreach ( $old_meta['sizes'] as $size_name => $size_meta ) {
1379 if ( empty( $size_meta['file'] ) ) {
1380 continue;
1381 }
1382 $old_size_url = $old_base_url . $size_meta['file'];
1383 // Try to map to the same-named size on the new attachment if it exists; else fall back to new full.
1384 $new_size_url = wp_get_attachment_image_url( $new_id, $size_name );
1385 $url_map[ $old_size_url ] = $new_size_url ? $new_size_url : $new_full_url;
1386 }
1387 }
1388
1389 // Apply URL replacements. Longest URL first so size-suffixed URLs don't get partially
1390 // matched by the shorter base URL.
1391 uksort( $url_map, function( $a, $b ) { return strlen( $b ) - strlen( $a ); } );
1392
1393 foreach ( $url_map as $old_url => $new_url ) {
1394 $count = 0;
1395 $content = str_replace( $old_url, $new_url, $content, $count );
1396 $url_rewrites += $count;
1397 }
1398
1399 $total_replacements = $class_rewrites + $url_rewrites;
1400
1401 if ( $total_replacements === 0 ) {
1402 return [
1403 'success' => true,
1404 'dry_run' => $dry_run,
1405 'post_id' => $post_id,
1406 'replacements' => 0,
1407 'class_rewrites' => 0,
1408 'url_rewrites' => 0,
1409 'new_content' => $original_content,
1410 'message' => sprintf( 'No references to attachment %d found in post %d.', $old_id, $post_id ),
1411 ];
1412 }
1413
1414 if ( $dry_run ) {
1415 return [
1416 'success' => true,
1417 'dry_run' => true,
1418 'post_id' => $post_id,
1419 'replacements' => $total_replacements,
1420 'class_rewrites' => $class_rewrites,
1421 'url_rewrites' => $url_rewrites,
1422 'new_content' => $content,
1423 'message' => sprintf( 'DRY RUN: %d total replacement(s) (%d class, %d URL) would be made in post %d. Pass dry_run: false to save.', $total_replacements, $class_rewrites, $url_rewrites, $post_id ),
1424 ];
1425 }
1426
1427 // Save.
1428 $result = wp_update_post( [ 'ID' => $post_id, 'post_content' => $content ], true );
1429 if ( is_wp_error( $result ) ) {
1430 return [ 'success' => false, 'dry_run' => false, 'message' => 'Save failed: ' . $result->get_error_message() ];
1431 }
1432
1433 return [
1434 'success' => true,
1435 'dry_run' => false,
1436 'post_id' => $post_id,
1437 'replacements' => $total_replacements,
1438 'class_rewrites' => $class_rewrites,
1439 'url_rewrites' => $url_rewrites,
1440 'new_content' => $content,
1441 'message' => sprintf( '%d replacement(s) saved in post %d (%d class, %d URL).', $total_replacements, $post_id, $class_rewrites, $url_rewrites ),
1442 ];
1443 },
1444 'permission_callback' => function() {
1445 return current_user_can( 'edit_posts' );
1446 },
1447 'meta' => [
1448 'mcp' => [ 'public' => true, 'type' => 'tool' ],
1449 'annotations' => [ 'readonly' => false, 'destructive' => false, 'idempotent' => false ],
1450 ],
1451 ] );
1452
1453 // ---- regenerate-image ----
1454 wp_register_ability( 'atarim/regenerate-image', [
1455 'label' => 'Regenerate Image Sub-sizes',
1456 'description' => 'Regenerate the sub-sizes (thumbnails / intermediate sizes) for one or more image attachments using WordPress core. Use after registering new image sizes, switching themes, or to shrink derivative files. Pass attachment_id for one, or attachment_ids for a batch (max 25 per call). Optional quality (1-100) re-encodes the GENERATED sub-sizes at that JPEG/WebP quality for a plugin-free size win; the ORIGINAL master file is never modified. Non-image attachments are skipped with a note. Each image is processed independently (per-image time/memory limits are raised best-effort) so one failure does not abort the rest of the batch, and images already done in a batch are saved even if a later one fails. However, a single very heavy image can still exceed a host hard PHP limit (PHP-FPM / web-server max execution time), which aborts the request — for heavy libraries process singly or use WP-CLI. To optimize an entire large library, work in small batches.',
1457 'category' => 'atarim',
1458 'input_schema' => [
1459 'type' => 'object',
1460 'properties' => [
1461 'attachment_id' => [ 'type' => 'integer', 'minimum' => 1, 'description' => 'A single attachment ID.' ],
1462 'attachment_ids' => [ 'type' => 'array', 'maxItems' => 25, 'items' => [ 'type' => 'integer', 'minimum' => 1 ], 'description' => 'Several attachment IDs (max 25).' ],
1463 'quality' => [ 'type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'description' => 'Re-encode the generated sub-sizes at this quality (1-100). The original is not touched. Omit to use the site default.' ],
1464 ],
1465 'additionalProperties' => false,
1466 ],
1467 'output_schema' => [
1468 'type' => 'object',
1469 'properties' => [
1470 'success' => [ 'type' => 'boolean' ],
1471 'regenerated_count' => [ 'type' => 'integer' ],
1472 'results' => [ 'type' => 'array' ],
1473 'message' => [ 'type' => 'string' ],
1474 ],
1475 'required' => [ 'success', 'message' ],
1476 ],
1477 'execute_callback' => function( $input = [] ) {
1478 // Collect target IDs from either input shape.
1479 $ids = [];
1480 if ( isset( $input['attachment_id'] ) ) {
1481 $ids[] = (int) $input['attachment_id'];
1482 }
1483 if ( isset( $input['attachment_ids'] ) && is_array( $input['attachment_ids'] ) ) {
1484 foreach ( $input['attachment_ids'] as $one ) { $ids[] = (int) $one; }
1485 }
1486 $ids = array_values( array_unique( array_filter( $ids, function( $v ) { return $v > 0; } ) ) );
1487 if ( empty( $ids ) ) {
1488 return [ 'success' => false, 'message' => 'Provide attachment_id or attachment_ids.' ];
1489 }
1490 if ( count( $ids ) > 25 ) {
1491 return [ 'success' => false, 'message' => sprintf( 'Too many IDs (%d); max 25 per call. Split into batches.', count( $ids ) ) ];
1492 }
1493
1494 if ( ! function_exists( 'wp_generate_attachment_metadata' ) ) {
1495 require_once ABSPATH . 'wp-admin/includes/image.php';
1496 }
1497 if ( ! function_exists( 'wp_generate_attachment_metadata' ) ) {
1498 return [ 'success' => false, 'message' => 'Image metadata functions are unavailable.' ];
1499 }
1500
1501 // Optional quality: applies ONLY to the sub-sizes generated below;
1502 // the original master is never re-encoded. Filters are removed after.
1503 $quality = isset( $input['quality'] ) ? (int) $input['quality'] : 0;
1504 $filter = null;
1505 if ( $quality >= 1 && $quality <= 100 ) {
1506 $filter = function() use ( $quality ) { return $quality; };
1507 add_filter( 'wp_editor_set_quality', $filter, 9999 );
1508 add_filter( 'jpeg_quality', $filter, 9999 );
1509 }
1510
1511 // Image processing is memory-heavy; raise the ceiling (best-effort).
1512 wp_raise_memory_limit( 'image' );
1513
1514 $results = [];
1515 $ok = 0;
1516 foreach ( $ids as $id ) {
1517 // Give EACH image its own time budget so a batch does not
1518 // accumulate toward max_execution_time (that accumulation is
1519 // why one heavy image could kill an entire batch). Best-effort:
1520 // the host may disable set_time_limit or enforce a hard
1521 // PHP-FPM / web-server limit, in which case a single very heavy
1522 // image still needs WP-CLI or a raised server limit.
1523 if ( function_exists( 'set_time_limit' ) ) {
1524 @set_time_limit( 0 ); // phpcs:ignore
1525 }
1526 if ( 'attachment' !== get_post_type( $id ) ) {
1527 $results[] = [ 'id' => $id, 'regenerated' => false, 'message' => 'Not an attachment.' ];
1528 continue;
1529 }
1530 if ( ! wp_attachment_is_image( $id ) ) {
1531 $results[] = [ 'id' => $id, 'regenerated' => false, 'message' => 'Not an image attachment; skipped.' ];
1532 continue;
1533 }
1534 $file = get_attached_file( $id );
1535 if ( ! $file || ! file_exists( $file ) ) {
1536 $results[] = [ 'id' => $id, 'regenerated' => false, 'message' => 'Original file missing on disk.' ];
1537 continue;
1538 }
1539 // Per-image try/catch so a catchable fatal (e.g. an image
1540 // library exception) on one image does not abort the whole
1541 // batch. A true max_execution_time timeout is NOT catchable —
1542 // it hard-kills the request — so heavy files may still need
1543 // singly-processing or WP-CLI.
1544 try {
1545 $meta = wp_generate_attachment_metadata( $id, $file );
1546 if ( is_wp_error( $meta ) ) {
1547 $results[] = [ 'id' => $id, 'regenerated' => false, 'message' => 'Regeneration failed: ' . $meta->get_error_message() ];
1548 continue;
1549 }
1550 if ( empty( $meta ) ) {
1551 $results[] = [ 'id' => $id, 'regenerated' => false, 'message' => 'Regeneration produced no metadata (unsupported or unreadable image).' ];
1552 continue;
1553 }
1554 wp_update_attachment_metadata( $id, $meta );
1555 $sizes = ( isset( $meta['sizes'] ) && is_array( $meta['sizes'] ) ) ? array_keys( $meta['sizes'] ) : [];
1556 $ok++;
1557 $results[] = [ 'id' => $id, 'regenerated' => true, 'sizes' => $sizes, 'message' => sprintf( '%d sub-size(s) generated.', count( $sizes ) ) ];
1558 } catch ( \Throwable $e ) {
1559 $results[] = [ 'id' => $id, 'regenerated' => false, 'message' => 'Failed: ' . $e->getMessage() ];
1560 }
1561 }
1562
1563 if ( $filter ) {
1564 remove_filter( 'wp_editor_set_quality', $filter, 9999 );
1565 remove_filter( 'jpeg_quality', $filter, 9999 );
1566 }
1567
1568 return [
1569 'success' => true,
1570 'regenerated_count' => $ok,
1571 'results' => $results,
1572 'message' => sprintf( 'Regenerated %d of %d image(s)%s.', $ok, count( $ids ), ( $quality >= 1 && $quality <= 100 ) ? sprintf( ' at quality %d (original untouched)', $quality ) : '' ),
1573 ];
1574 },
1575 'permission_callback' => function() {
1576 return current_user_can( 'upload_files' );
1577 },
1578 'meta' => [
1579 'mcp' => [ 'public' => true, 'type' => 'tool' ],
1580 'annotations' => [ 'readonly' => false, 'destructive' => false, 'idempotent' => true ],
1581 ],
1582 ] );
1583 }
1584
1585 /**
1586 * Fetch a media source — URL download or base64 decode — into a temp file.
1587 *
1588 * Returns one of:
1589 * [ 'tmp_file' => path, 'filename' => name, 'mime' => mime, 'size' => bytes ]
1590 * [ 'error' => message ]
1591 *
1592 * URL fetch enforces SSRF protections: only http/https schemes, blocks RFC1918
1593 * private ranges, loopback, link-local, and cloud metadata endpoints
1594 * (AWS 169.254.169.254 etc).
1595 *
1596 * @param string $source 'url' or 'base64'
1597 * @param array $input The ability call input
1598 * @return array
1599 */
1600 private function avcf_fetch_media_source( $source, $input ) {
1601 if ( $source === 'url' ) {
1602 $url = isset( $input['source_url'] ) ? esc_url_raw( (string) $input['source_url'] ) : '';
1603 if ( $url === '' ) {
1604 return [ 'error' => 'source_url is required when source is "url".' ];
1605 }
1606
1607 $ssrf_err = $this->avcf_check_url_safety( $url );
1608 if ( $ssrf_err !== null ) {
1609 return [ 'error' => $ssrf_err ];
1610 }
1611
1612 // Stream to a temp file. download_url uses WP HTTP API + handles redirects.
1613 if ( ! function_exists( 'download_url' ) ) {
1614 require_once ABSPATH . 'wp-admin/includes/file.php';
1615 }
1616 $tmp = download_url( $url, 60 );
1617 if ( is_wp_error( $tmp ) ) {
1618 return [ 'error' => 'Download failed: ' . $tmp->get_error_message() ];
1619 }
1620
1621 $filename = isset( $input['filename'] ) ? sanitize_file_name( (string) $input['filename'] ) : '';
1622 if ( $filename === '' ) {
1623 $parsed = wp_parse_url( $url );
1624 $path = isset( $parsed['path'] ) ? $parsed['path'] : '';
1625 $filename = sanitize_file_name( basename( (string) $path ) );
1626 if ( $filename === '' ) {
1627 $filename = 'upload-' . time();
1628 }
1629 }
1630
1631 $size = (int) @filesize( $tmp );
1632 $mime = $this->avcf_detect_mime( $tmp, $filename );
1633
1634 return [
1635 'tmp_file' => $tmp,
1636 'filename' => $filename,
1637 'mime' => $mime,
1638 'size' => $size,
1639 ];
1640 }
1641
1642 if ( $source === 'base64' ) {
1643 $data = isset( $input['source_data'] ) ? (string) $input['source_data'] : '';
1644 if ( $data === '' ) {
1645 return [ 'error' => 'source_data is required when source is "base64".' ];
1646 }
1647
1648 // Strip data: URL prefix defensively if caller forgot. We document that they
1649 // should send raw base64, but be lenient on input.
1650 if ( strpos( $data, 'data:' ) === 0 ) {
1651 $comma = strpos( $data, ',' );
1652 if ( $comma !== false ) {
1653 $data = substr( $data, $comma + 1 );
1654 }
1655 }
1656
1657 // strict mode false — be tolerant of whitespace / newlines in pasted base64
1658 $decoded = base64_decode( $data, true );
1659 if ( $decoded === false ) {
1660 return [ 'error' => 'source_data is not valid base64.' ];
1661 }
1662
1663 $filename = isset( $input['filename'] ) ? sanitize_file_name( (string) $input['filename'] ) : '';
1664 if ( $filename === '' ) {
1665 return [ 'error' => 'filename is required when source is "base64" (we need the extension to determine MIME).' ];
1666 }
1667
1668 if ( ! function_exists( 'wp_tempnam' ) ) {
1669 require_once ABSPATH . 'wp-admin/includes/file.php';
1670 }
1671 $tmp = wp_tempnam( $filename );
1672 if ( ! $tmp ) {
1673 return [ 'error' => 'Could not create temporary file for upload.' ];
1674 }
1675 if ( file_put_contents( $tmp, $decoded ) === false ) {
1676 @unlink( $tmp );
1677 return [ 'error' => 'Could not write decoded data to temporary file.' ];
1678 }
1679
1680 $size = strlen( $decoded );
1681 $mime = $this->avcf_detect_mime( $tmp, $filename );
1682
1683 return [
1684 'tmp_file' => $tmp,
1685 'filename' => $filename,
1686 'mime' => $mime,
1687 'size' => $size,
1688 ];
1689 }
1690
1691 return [ 'error' => sprintf( 'Unknown source "%s".', $source ) ];
1692 }
1693
1694 /**
1695 * Validate a URL for safe outbound fetching (SSRF protection).
1696 *
1697 * Returns null on safe; a string error message otherwise. Blocks:
1698 * - non-http/https schemes
1699 * - empty hosts
1700 * - private RFC1918 ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
1701 * - loopback (127.0.0.0/8, ::1)
1702 * - link-local (169.254.0.0/16) — includes AWS / GCP / Azure metadata endpoints
1703 * - localhost-named hosts
1704 *
1705 * @param string $url
1706 * @return string|null
1707 */
1708 private function avcf_check_url_safety( $url ) {
1709 $parsed = wp_parse_url( $url );
1710 if ( ! is_array( $parsed ) || empty( $parsed['scheme'] ) || empty( $parsed['host'] ) ) {
1711 return 'Invalid URL — could not parse scheme and host.';
1712 }
1713
1714 $scheme = strtolower( $parsed['scheme'] );
1715 if ( $scheme !== 'http' && $scheme !== 'https' ) {
1716 return sprintf( 'URL scheme "%s" is not allowed — only http and https are supported.', $scheme );
1717 }
1718
1719 $host = strtolower( $parsed['host'] );
1720 if ( in_array( $host, [ 'localhost', 'localhost.localdomain' ], true ) ) {
1721 return 'Hostname "localhost" is not allowed.';
1722 }
1723
1724 // Resolve to IPs and check each against private/loopback/link-local ranges.
1725 // gethostbynamel returns array of IPv4 addresses, or false on failure.
1726 $ips = @gethostbynamel( $host );
1727 // If it's already an IP literal, gethostbynamel may return false; check filter_var below.
1728 if ( ! is_array( $ips ) ) {
1729 // Maybe an IP literal directly.
1730 if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
1731 $ips = [ $host ];
1732 } else {
1733 return sprintf( 'Could not resolve host "%s".', $host );
1734 }
1735 }
1736
1737 foreach ( $ips as $ip ) {
1738 if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
1739 return sprintf( 'URL host resolves to a blocked address (%s — private, loopback, or link-local range).', $ip );
1740 }
1741 // FILTER_FLAG_NO_RES_RANGE covers link-local; just being explicit about the AWS metadata IP for clarity.
1742 if ( $ip === '169.254.169.254' ) {
1743 return 'URL host resolves to a cloud metadata endpoint (169.254.169.254) — blocked.';
1744 }
1745 }
1746
1747 return null;
1748 }
1749
1750 /**
1751 * Detect MIME type of a file. Prefers WP's wp_check_filetype_and_ext which
1752 * combines extension-based and finfo-based detection. Falls back to finfo
1753 * directly if needed.
1754 *
1755 * @param string $path Filesystem path to the file
1756 * @param string $filename Original filename (used by WP's extension check)
1757 * @return string MIME type, or empty string if undetectable
1758 */
1759 private function avcf_detect_mime( $path, $filename ) {
1760 if ( ! function_exists( 'wp_check_filetype_and_ext' ) ) {
1761 require_once ABSPATH . 'wp-admin/includes/file.php';
1762 }
1763 $check = wp_check_filetype_and_ext( $path, $filename );
1764 if ( ! empty( $check['type'] ) ) {
1765 return (string) $check['type'];
1766 }
1767 // Fallback to finfo direct.
1768 if ( function_exists( 'finfo_open' ) ) {
1769 $finfo = finfo_open( FILEINFO_MIME_TYPE );
1770 if ( $finfo ) {
1771 $mime = finfo_file( $finfo, $path );
1772 finfo_close( $finfo );
1773 if ( $mime ) {
1774 return (string) $mime;
1775 }
1776 }
1777 }
1778 return '';
1779 }
1780 }