PluginProbe ʕ •ᴥ•ʔ
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback / trunk
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback vtrunk
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 1 week ago class-avcf-abilities-core.php 3 days ago class-avcf-abilities-execute-php.php 2 weeks ago class-avcf-abilities-global-styles.php 3 weeks ago class-avcf-abilities-gutenberg.php 2 weeks ago class-avcf-abilities-media.php 1 week ago class-avcf-abilities-metadata.php 1 week ago class-avcf-abilities-navigation.php 3 weeks ago class-avcf-abilities-patterns.php 3 weeks ago class-avcf-abilities-plugins.php 3 days ago class-avcf-abilities-readonly.php 2 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 2 weeks ago class-avcf-abilities-themes.php 3 days ago class-avcf-abilities-users.php 3 weeks ago class-avcf-abilities-wp-cli.php 3 days ago
class-avcf-abilities-media.php
1843 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_mime = (string) $att->post_mime_type;
1139
1140 if ( $existing_path === '' || ! file_exists( $existing_path ) ) {
1141 return [ 'success' => false, 'message' => sprintf( 'Attachment %d has no file on disk to replace.', $id ) ];
1142 }
1143
1144 // Security: get_attached_file() resolves the caller-writable
1145 // _wp_attached_file meta. Require the resolved path to be inside the
1146 // uploads directory before any unlink/copy below, otherwise a poisoned
1147 // meta value (traversal or absolute path) would delete/overwrite an
1148 // arbitrary file on the server. See avcf_resolve_within_uploads().
1149 $existing_path = $this->avcf_resolve_within_uploads( $existing_path );
1150 if ( false === $existing_path ) {
1151 return [ 'success' => false, 'message' => sprintf( 'Attachment %d resolves to a file outside the uploads directory; refusing to modify it.', $id ) ];
1152 }
1153 $existing_filename = basename( $existing_path );
1154
1155 $fetched = $this->avcf_fetch_media_source( $source, $input );
1156 if ( isset( $fetched['error'] ) ) {
1157 return [ 'success' => false, 'message' => $fetched['error'] ];
1158 }
1159
1160 $tmp_file = $fetched['tmp_file'];
1161 $new_mime = $fetched['mime'];
1162 $file_size = $fetched['size'];
1163
1164 // MIME must be in WP's allowed list.
1165 $allowed = get_allowed_mime_types();
1166 if ( ! in_array( $new_mime, $allowed, true ) ) {
1167 @unlink( $tmp_file );
1168 return [
1169 'success' => false,
1170 'message' => sprintf( 'MIME type "%s" is not allowed on this site for your user role.', $new_mime ),
1171 ];
1172 }
1173
1174 // Size limit.
1175 $max = wp_max_upload_size();
1176 if ( $max > 0 && $file_size > $max ) {
1177 @unlink( $tmp_file );
1178 return [
1179 'success' => false,
1180 'message' => sprintf( 'File size %d bytes exceeds the upload limit of %d bytes.', $file_size, $max ),
1181 ];
1182 }
1183
1184 // MIME mismatch guard.
1185 $confirm_mime = ! empty( $input['confirm_mime_change'] );
1186 if ( $new_mime !== $existing_mime && ! $confirm_mime ) {
1187 @unlink( $tmp_file );
1188 return [
1189 'success' => false,
1190 'id' => $id,
1191 'old_mime_type' => $existing_mime,
1192 'new_mime_type' => $new_mime,
1193 'message' => sprintf(
1194 'New file MIME type "%s" differs from existing "%s". Pass confirm_mime_change: true to proceed.',
1195 $new_mime,
1196 $existing_mime
1197 ),
1198 ];
1199 }
1200
1201 // Determine destination path. Default: preserve filename (so existing URLs / embeds still work).
1202 $dir = dirname( $existing_path );
1203 $rename_to = isset( $input['rename_to'] ) ? sanitize_file_name( (string) $input['rename_to'] ) : '';
1204 $new_filename = ( $rename_to !== '' ) ? $rename_to : $existing_filename;
1205 $new_path = trailingslashit( $dir ) . $new_filename;
1206
1207 // Delete existing intermediate-size files before regenerating, so stale sizes don't linger.
1208 $old_meta = wp_get_attachment_metadata( $id );
1209 if ( is_array( $old_meta ) && ! empty( $old_meta['sizes'] ) ) {
1210 foreach ( $old_meta['sizes'] as $size_meta ) {
1211 if ( empty( $size_meta['file'] ) ) {
1212 continue;
1213 }
1214 // Intermediate-size files are always plain filenames in the same
1215 // directory as the original. Strip any path component so a poisoned
1216 // metadata 'file' value can't traverse out of $dir, and re-confirm
1217 // containment before unlinking.
1218 $size_file = basename( (string) $size_meta['file'] );
1219 if ( '' === $size_file ) {
1220 continue;
1221 }
1222 $size_path = trailingslashit( $dir ) . $size_file;
1223 if ( file_exists( $size_path ) && false !== $this->avcf_resolve_within_uploads( $size_path ) ) {
1224 @unlink( $size_path );
1225 }
1226 }
1227 }
1228
1229 // Replace the file on disk. Delete old if renaming, otherwise overwrite.
1230 if ( $new_path !== $existing_path && file_exists( $existing_path ) ) {
1231 @unlink( $existing_path );
1232 }
1233 if ( ! @copy( $tmp_file, $new_path ) ) {
1234 @unlink( $tmp_file );
1235 return [ 'success' => false, 'message' => sprintf( 'Could not write replacement file to %s.', $new_path ) ];
1236 }
1237 @unlink( $tmp_file );
1238
1239 // Update attachment record: new MIME, new _wp_attached_file if path changed.
1240 $update_arr = [
1241 'ID' => $id,
1242 'post_mime_type' => $new_mime,
1243 ];
1244 wp_update_post( $update_arr );
1245
1246 // Update _wp_attached_file meta (relative path from uploads dir).
1247 $uploads = wp_upload_dir();
1248 $relative = ltrim( str_replace( trailingslashit( $uploads['basedir'] ), '', $new_path ), '/' );
1249 update_post_meta( $id, '_wp_attached_file', $relative );
1250
1251 // Regenerate intermediate sizes.
1252 if ( ! function_exists( 'wp_generate_attachment_metadata' ) ) {
1253 require_once ABSPATH . 'wp-admin/includes/image.php';
1254 }
1255 $new_metadata = wp_generate_attachment_metadata( $id, $new_path );
1256 wp_update_attachment_metadata( $id, $new_metadata );
1257
1258 $width = isset( $new_metadata['width'] ) ? (int) $new_metadata['width'] : 0;
1259 $height = isset( $new_metadata['height'] ) ? (int) $new_metadata['height'] : 0;
1260
1261 return [
1262 'success' => true,
1263 'id' => $id,
1264 'url' => (string) wp_get_attachment_url( $id ),
1265 'old_filename' => $existing_filename,
1266 'new_filename' => $new_filename,
1267 'old_mime_type' => $existing_mime,
1268 'new_mime_type' => $new_mime,
1269 'new_file_size' => $file_size,
1270 'width' => $width,
1271 'height' => $height,
1272 'message' => $new_filename === $existing_filename
1273 ? 'File replaced; filename preserved. External CDN caches may serve the old file until purged — run atarim/purge-all-caches.'
1274 : sprintf( 'File replaced and renamed from "%s" to "%s". Existing URLs referencing the old filename will break.', $existing_filename, $new_filename ),
1275 ];
1276 },
1277 'permission_callback' => function() {
1278 return current_user_can( 'upload_files' );
1279 },
1280 'meta' => [
1281 'mcp' => [ 'public' => true, 'type' => 'tool' ],
1282 'annotations' => [ 'readonly' => false, 'destructive' => true, 'idempotent' => false ],
1283 ],
1284 ] );
1285
1286 // ---- replace-media-in-content ----
1287 wp_register_ability( 'atarim/replace-media-in-content', [
1288 'label' => 'Replace Media In Content',
1289 '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.',
1290 'category' => 'atarim',
1291 'input_schema' => [
1292 'type' => 'object',
1293 'properties' => [
1294 'post_id' => [
1295 'type' => 'integer',
1296 'description' => 'Post / page / CPT ID whose body will be rewritten.',
1297 'minimum' => 1,
1298 ],
1299 'old_attachment_id' => [
1300 'type' => 'integer',
1301 'description' => 'Attachment ID to find references to.',
1302 'minimum' => 1,
1303 ],
1304 'new_attachment_id' => [
1305 'type' => 'integer',
1306 'description' => 'Attachment ID to replace with.',
1307 'minimum' => 1,
1308 ],
1309 'dry_run' => [
1310 'type' => 'boolean',
1311 'description' => 'When true (default), returns the proposed new content + replacement count without writing. Pass false to actually save.',
1312 'default' => true,
1313 ],
1314 ],
1315 'required' => [ 'post_id', 'old_attachment_id', 'new_attachment_id' ],
1316 'additionalProperties' => false,
1317 ],
1318 'output_schema' => [
1319 'type' => 'object',
1320 'properties' => [
1321 'success' => [ 'type' => 'boolean' ],
1322 'dry_run' => [ 'type' => 'boolean' ],
1323 'post_id' => [ 'type' => 'integer' ],
1324 'replacements' => [ 'type' => 'integer' ],
1325 'class_rewrites' => [ 'type' => 'integer' ],
1326 'url_rewrites' => [ 'type' => 'integer' ],
1327 'new_content' => [ 'type' => 'string' ],
1328 'message' => [ 'type' => 'string' ],
1329 ],
1330 'required' => [ 'success', 'dry_run', 'message' ],
1331 ],
1332 'execute_callback' => function( $input = [] ) {
1333 $post_id = isset( $input['post_id'] ) ? (int) $input['post_id'] : 0;
1334 $old_id = isset( $input['old_attachment_id'] ) ? (int) $input['old_attachment_id'] : 0;
1335 $new_id = isset( $input['new_attachment_id'] ) ? (int) $input['new_attachment_id'] : 0;
1336 $dry_run = ! isset( $input['dry_run'] ) ? true : (bool) $input['dry_run'];
1337
1338 if ( $post_id <= 0 || $old_id <= 0 || $new_id <= 0 ) {
1339 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.' ];
1340 }
1341 if ( $old_id === $new_id ) {
1342 return [ 'success' => false, 'dry_run' => $dry_run, 'message' => 'old_attachment_id and new_attachment_id must be different.' ];
1343 }
1344
1345 $post = get_post( $post_id );
1346 if ( ! $post ) {
1347 return [ 'success' => false, 'dry_run' => $dry_run, 'message' => sprintf( 'Post %d not found.', $post_id ) ];
1348 }
1349 $pt_obj = get_post_type_object( $post->post_type );
1350 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_post, $post_id ) ) {
1351 return [ 'success' => false, 'dry_run' => $dry_run, 'message' => sprintf( 'You do not have permission to edit this %s.', $post->post_type ) ];
1352 }
1353
1354 $old_att = get_post( $old_id );
1355 $new_att = get_post( $new_id );
1356 if ( ! $old_att || $old_att->post_type !== 'attachment' ) {
1357 return [ 'success' => false, 'dry_run' => $dry_run, 'message' => sprintf( 'old_attachment_id %d is not an attachment.', $old_id ) ];
1358 }
1359 if ( ! $new_att || $new_att->post_type !== 'attachment' ) {
1360 return [ 'success' => false, 'dry_run' => $dry_run, 'message' => sprintf( 'new_attachment_id %d is not an attachment.', $new_id ) ];
1361 }
1362
1363 $content = (string) $post->post_content;
1364 $original_content = $content;
1365
1366 // 1) Rewrite wp-image-{old_id} → wp-image-{new_id}.
1367 $class_rewrites = 0;
1368 $content = preg_replace(
1369 '/wp-image-' . $old_id . '\\b/',
1370 'wp-image-' . $new_id,
1371 $content,
1372 -1,
1373 $class_rewrites
1374 );
1375
1376 // 2) Rewrite URL references. We collect all known URLs of the old attachment
1377 // (full + intermediate sizes) and replace each with the equivalent URL of the
1378 // new attachment. WordPress stores intermediate sizes in metadata.
1379 $url_rewrites = 0;
1380
1381 $old_meta = wp_get_attachment_metadata( $old_id );
1382 $old_uploads_dir = wp_get_attachment_url( $old_id );
1383 $old_base_url = $old_uploads_dir ? preg_replace( '#/[^/]+$#', '/', $old_uploads_dir ) : '';
1384
1385 $new_full_url = (string) wp_get_attachment_url( $new_id );
1386
1387 // Build URL map: old size URL → new full URL (for now; size-perfect swap is best-effort).
1388 $url_map = [];
1389
1390 // Old "full" URL.
1391 if ( $old_uploads_dir ) {
1392 $url_map[ $old_uploads_dir ] = $new_full_url;
1393 }
1394
1395 // Old intermediate sizes.
1396 if ( is_array( $old_meta ) && ! empty( $old_meta['sizes'] ) && $old_base_url !== '' ) {
1397 foreach ( $old_meta['sizes'] as $size_name => $size_meta ) {
1398 if ( empty( $size_meta['file'] ) ) {
1399 continue;
1400 }
1401 $old_size_url = $old_base_url . $size_meta['file'];
1402 // Try to map to the same-named size on the new attachment if it exists; else fall back to new full.
1403 $new_size_url = wp_get_attachment_image_url( $new_id, $size_name );
1404 $url_map[ $old_size_url ] = $new_size_url ? $new_size_url : $new_full_url;
1405 }
1406 }
1407
1408 // Apply URL replacements. Longest URL first so size-suffixed URLs don't get partially
1409 // matched by the shorter base URL.
1410 uksort( $url_map, function( $a, $b ) { return strlen( $b ) - strlen( $a ); } );
1411
1412 foreach ( $url_map as $old_url => $new_url ) {
1413 $count = 0;
1414 $content = str_replace( $old_url, $new_url, $content, $count );
1415 $url_rewrites += $count;
1416 }
1417
1418 $total_replacements = $class_rewrites + $url_rewrites;
1419
1420 if ( $total_replacements === 0 ) {
1421 return [
1422 'success' => true,
1423 'dry_run' => $dry_run,
1424 'post_id' => $post_id,
1425 'replacements' => 0,
1426 'class_rewrites' => 0,
1427 'url_rewrites' => 0,
1428 'new_content' => $original_content,
1429 'message' => sprintf( 'No references to attachment %d found in post %d.', $old_id, $post_id ),
1430 ];
1431 }
1432
1433 if ( $dry_run ) {
1434 return [
1435 'success' => true,
1436 'dry_run' => true,
1437 'post_id' => $post_id,
1438 'replacements' => $total_replacements,
1439 'class_rewrites' => $class_rewrites,
1440 'url_rewrites' => $url_rewrites,
1441 'new_content' => $content,
1442 '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 ),
1443 ];
1444 }
1445
1446 // Save.
1447 $result = wp_update_post( [ 'ID' => $post_id, 'post_content' => $content ], true );
1448 if ( is_wp_error( $result ) ) {
1449 return [ 'success' => false, 'dry_run' => false, 'message' => 'Save failed: ' . $result->get_error_message() ];
1450 }
1451
1452 return [
1453 'success' => true,
1454 'dry_run' => false,
1455 'post_id' => $post_id,
1456 'replacements' => $total_replacements,
1457 'class_rewrites' => $class_rewrites,
1458 'url_rewrites' => $url_rewrites,
1459 'new_content' => $content,
1460 'message' => sprintf( '%d replacement(s) saved in post %d (%d class, %d URL).', $total_replacements, $post_id, $class_rewrites, $url_rewrites ),
1461 ];
1462 },
1463 'permission_callback' => function() {
1464 return current_user_can( 'edit_posts' );
1465 },
1466 'meta' => [
1467 'mcp' => [ 'public' => true, 'type' => 'tool' ],
1468 'annotations' => [ 'readonly' => false, 'destructive' => false, 'idempotent' => false ],
1469 ],
1470 ] );
1471
1472 // ---- regenerate-image ----
1473 wp_register_ability( 'atarim/regenerate-image', [
1474 'label' => 'Regenerate Image Sub-sizes',
1475 '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.',
1476 'category' => 'atarim',
1477 'input_schema' => [
1478 'type' => 'object',
1479 'properties' => [
1480 'attachment_id' => [ 'type' => 'integer', 'minimum' => 1, 'description' => 'A single attachment ID.' ],
1481 'attachment_ids' => [ 'type' => 'array', 'maxItems' => 25, 'items' => [ 'type' => 'integer', 'minimum' => 1 ], 'description' => 'Several attachment IDs (max 25).' ],
1482 '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.' ],
1483 ],
1484 'additionalProperties' => false,
1485 ],
1486 'output_schema' => [
1487 'type' => 'object',
1488 'properties' => [
1489 'success' => [ 'type' => 'boolean' ],
1490 'regenerated_count' => [ 'type' => 'integer' ],
1491 'results' => [ 'type' => 'array' ],
1492 'message' => [ 'type' => 'string' ],
1493 ],
1494 'required' => [ 'success', 'message' ],
1495 ],
1496 'execute_callback' => function( $input = [] ) {
1497 // Collect target IDs from either input shape.
1498 $ids = [];
1499 if ( isset( $input['attachment_id'] ) ) {
1500 $ids[] = (int) $input['attachment_id'];
1501 }
1502 if ( isset( $input['attachment_ids'] ) && is_array( $input['attachment_ids'] ) ) {
1503 foreach ( $input['attachment_ids'] as $one ) { $ids[] = (int) $one; }
1504 }
1505 $ids = array_values( array_unique( array_filter( $ids, function( $v ) { return $v > 0; } ) ) );
1506 if ( empty( $ids ) ) {
1507 return [ 'success' => false, 'message' => 'Provide attachment_id or attachment_ids.' ];
1508 }
1509 if ( count( $ids ) > 25 ) {
1510 return [ 'success' => false, 'message' => sprintf( 'Too many IDs (%d); max 25 per call. Split into batches.', count( $ids ) ) ];
1511 }
1512
1513 if ( ! function_exists( 'wp_generate_attachment_metadata' ) ) {
1514 require_once ABSPATH . 'wp-admin/includes/image.php';
1515 }
1516 if ( ! function_exists( 'wp_generate_attachment_metadata' ) ) {
1517 return [ 'success' => false, 'message' => 'Image metadata functions are unavailable.' ];
1518 }
1519
1520 // Optional quality: applies ONLY to the sub-sizes generated below;
1521 // the original master is never re-encoded. Filters are removed after.
1522 $quality = isset( $input['quality'] ) ? (int) $input['quality'] : 0;
1523 $filter = null;
1524 if ( $quality >= 1 && $quality <= 100 ) {
1525 $filter = function() use ( $quality ) { return $quality; };
1526 add_filter( 'wp_editor_set_quality', $filter, 9999 );
1527 add_filter( 'jpeg_quality', $filter, 9999 );
1528 }
1529
1530 // Image processing is memory-heavy; raise the ceiling (best-effort).
1531 wp_raise_memory_limit( 'image' );
1532
1533 $results = [];
1534 $ok = 0;
1535 foreach ( $ids as $id ) {
1536 // Give EACH image its own time budget so a batch does not
1537 // accumulate toward max_execution_time (that accumulation is
1538 // why one heavy image could kill an entire batch). Best-effort:
1539 // the host may disable set_time_limit or enforce a hard
1540 // PHP-FPM / web-server limit, in which case a single very heavy
1541 // image still needs WP-CLI or a raised server limit.
1542 if ( function_exists( 'set_time_limit' ) ) {
1543 @set_time_limit( 0 ); // phpcs:ignore
1544 }
1545 if ( 'attachment' !== get_post_type( $id ) ) {
1546 $results[] = [ 'id' => $id, 'regenerated' => false, 'message' => 'Not an attachment.' ];
1547 continue;
1548 }
1549 if ( ! wp_attachment_is_image( $id ) ) {
1550 $results[] = [ 'id' => $id, 'regenerated' => false, 'message' => 'Not an image attachment; skipped.' ];
1551 continue;
1552 }
1553 $file = get_attached_file( $id );
1554 if ( ! $file || ! file_exists( $file ) ) {
1555 $results[] = [ 'id' => $id, 'regenerated' => false, 'message' => 'Original file missing on disk.' ];
1556 continue;
1557 }
1558 // Per-image try/catch so a catchable fatal (e.g. an image
1559 // library exception) on one image does not abort the whole
1560 // batch. A true max_execution_time timeout is NOT catchable —
1561 // it hard-kills the request — so heavy files may still need
1562 // singly-processing or WP-CLI.
1563 try {
1564 $meta = wp_generate_attachment_metadata( $id, $file );
1565 if ( is_wp_error( $meta ) ) {
1566 $results[] = [ 'id' => $id, 'regenerated' => false, 'message' => 'Regeneration failed: ' . $meta->get_error_message() ];
1567 continue;
1568 }
1569 if ( empty( $meta ) ) {
1570 $results[] = [ 'id' => $id, 'regenerated' => false, 'message' => 'Regeneration produced no metadata (unsupported or unreadable image).' ];
1571 continue;
1572 }
1573 wp_update_attachment_metadata( $id, $meta );
1574 $sizes = ( isset( $meta['sizes'] ) && is_array( $meta['sizes'] ) ) ? array_keys( $meta['sizes'] ) : [];
1575 $ok++;
1576 $results[] = [ 'id' => $id, 'regenerated' => true, 'sizes' => $sizes, 'message' => sprintf( '%d sub-size(s) generated.', count( $sizes ) ) ];
1577 } catch ( \Throwable $e ) {
1578 $results[] = [ 'id' => $id, 'regenerated' => false, 'message' => 'Failed: ' . $e->getMessage() ];
1579 }
1580 }
1581
1582 if ( $filter ) {
1583 remove_filter( 'wp_editor_set_quality', $filter, 9999 );
1584 remove_filter( 'jpeg_quality', $filter, 9999 );
1585 }
1586
1587 return [
1588 'success' => true,
1589 'regenerated_count' => $ok,
1590 'results' => $results,
1591 'message' => sprintf( 'Regenerated %d of %d image(s)%s.', $ok, count( $ids ), ( $quality >= 1 && $quality <= 100 ) ? sprintf( ' at quality %d (original untouched)', $quality ) : '' ),
1592 ];
1593 },
1594 'permission_callback' => function() {
1595 return current_user_can( 'upload_files' );
1596 },
1597 'meta' => [
1598 'mcp' => [ 'public' => true, 'type' => 'tool' ],
1599 'annotations' => [ 'readonly' => false, 'destructive' => false, 'idempotent' => true ],
1600 ],
1601 ] );
1602 }
1603
1604 /**
1605 * Fetch a media source — URL download or base64 decode — into a temp file.
1606 *
1607 * Returns one of:
1608 * [ 'tmp_file' => path, 'filename' => name, 'mime' => mime, 'size' => bytes ]
1609 * [ 'error' => message ]
1610 *
1611 * URL fetch enforces SSRF protections: only http/https schemes, blocks RFC1918
1612 * private ranges, loopback, link-local, and cloud metadata endpoints
1613 * (AWS 169.254.169.254 etc).
1614 *
1615 * @param string $source 'url' or 'base64'
1616 * @param array $input The ability call input
1617 * @return array
1618 */
1619 private function avcf_fetch_media_source( $source, $input ) {
1620 if ( $source === 'url' ) {
1621 $url = isset( $input['source_url'] ) ? esc_url_raw( (string) $input['source_url'] ) : '';
1622 if ( $url === '' ) {
1623 return [ 'error' => 'source_url is required when source is "url".' ];
1624 }
1625
1626 $ssrf_err = $this->avcf_check_url_safety( $url );
1627 if ( $ssrf_err !== null ) {
1628 return [ 'error' => $ssrf_err ];
1629 }
1630
1631 // Stream to a temp file. download_url uses WP HTTP API + handles redirects.
1632 if ( ! function_exists( 'download_url' ) ) {
1633 require_once ABSPATH . 'wp-admin/includes/file.php';
1634 }
1635 $tmp = download_url( $url, 60 );
1636 if ( is_wp_error( $tmp ) ) {
1637 return [ 'error' => 'Download failed: ' . $tmp->get_error_message() ];
1638 }
1639
1640 $filename = isset( $input['filename'] ) ? sanitize_file_name( (string) $input['filename'] ) : '';
1641 if ( $filename === '' ) {
1642 $parsed = wp_parse_url( $url );
1643 $path = isset( $parsed['path'] ) ? $parsed['path'] : '';
1644 $filename = sanitize_file_name( basename( (string) $path ) );
1645 if ( $filename === '' ) {
1646 $filename = 'upload-' . time();
1647 }
1648 }
1649
1650 $size = (int) @filesize( $tmp );
1651 $mime = $this->avcf_detect_mime( $tmp, $filename );
1652
1653 return [
1654 'tmp_file' => $tmp,
1655 'filename' => $filename,
1656 'mime' => $mime,
1657 'size' => $size,
1658 ];
1659 }
1660
1661 if ( $source === 'base64' ) {
1662 $data = isset( $input['source_data'] ) ? (string) $input['source_data'] : '';
1663 if ( $data === '' ) {
1664 return [ 'error' => 'source_data is required when source is "base64".' ];
1665 }
1666
1667 // Strip data: URL prefix defensively if caller forgot. We document that they
1668 // should send raw base64, but be lenient on input.
1669 if ( strpos( $data, 'data:' ) === 0 ) {
1670 $comma = strpos( $data, ',' );
1671 if ( $comma !== false ) {
1672 $data = substr( $data, $comma + 1 );
1673 }
1674 }
1675
1676 // strict mode false — be tolerant of whitespace / newlines in pasted base64
1677 $decoded = base64_decode( $data, true );
1678 if ( $decoded === false ) {
1679 return [ 'error' => 'source_data is not valid base64.' ];
1680 }
1681
1682 $filename = isset( $input['filename'] ) ? sanitize_file_name( (string) $input['filename'] ) : '';
1683 if ( $filename === '' ) {
1684 return [ 'error' => 'filename is required when source is "base64" (we need the extension to determine MIME).' ];
1685 }
1686
1687 if ( ! function_exists( 'wp_tempnam' ) ) {
1688 require_once ABSPATH . 'wp-admin/includes/file.php';
1689 }
1690 $tmp = wp_tempnam( $filename );
1691 if ( ! $tmp ) {
1692 return [ 'error' => 'Could not create temporary file for upload.' ];
1693 }
1694 if ( file_put_contents( $tmp, $decoded ) === false ) {
1695 @unlink( $tmp );
1696 return [ 'error' => 'Could not write decoded data to temporary file.' ];
1697 }
1698
1699 $size = strlen( $decoded );
1700 $mime = $this->avcf_detect_mime( $tmp, $filename );
1701
1702 return [
1703 'tmp_file' => $tmp,
1704 'filename' => $filename,
1705 'mime' => $mime,
1706 'size' => $size,
1707 ];
1708 }
1709
1710 return [ 'error' => sprintf( 'Unknown source "%s".', $source ) ];
1711 }
1712
1713 /**
1714 * Validate a URL for safe outbound fetching (SSRF protection).
1715 *
1716 * Returns null on safe; a string error message otherwise. Blocks:
1717 * - non-http/https schemes
1718 * - empty hosts
1719 * - private RFC1918 ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
1720 * - loopback (127.0.0.0/8, ::1)
1721 * - link-local (169.254.0.0/16) — includes AWS / GCP / Azure metadata endpoints
1722 * - localhost-named hosts
1723 *
1724 * @param string $url
1725 * @return string|null
1726 */
1727 private function avcf_check_url_safety( $url ) {
1728 $parsed = wp_parse_url( $url );
1729 if ( ! is_array( $parsed ) || empty( $parsed['scheme'] ) || empty( $parsed['host'] ) ) {
1730 return 'Invalid URL — could not parse scheme and host.';
1731 }
1732
1733 $scheme = strtolower( $parsed['scheme'] );
1734 if ( $scheme !== 'http' && $scheme !== 'https' ) {
1735 return sprintf( 'URL scheme "%s" is not allowed — only http and https are supported.', $scheme );
1736 }
1737
1738 $host = strtolower( $parsed['host'] );
1739 if ( in_array( $host, [ 'localhost', 'localhost.localdomain' ], true ) ) {
1740 return 'Hostname "localhost" is not allowed.';
1741 }
1742
1743 // Resolve to IPs and check each against private/loopback/link-local ranges.
1744 // gethostbynamel returns array of IPv4 addresses, or false on failure.
1745 $ips = @gethostbynamel( $host );
1746 // If it's already an IP literal, gethostbynamel may return false; check filter_var below.
1747 if ( ! is_array( $ips ) ) {
1748 // Maybe an IP literal directly.
1749 if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
1750 $ips = [ $host ];
1751 } else {
1752 return sprintf( 'Could not resolve host "%s".', $host );
1753 }
1754 }
1755
1756 foreach ( $ips as $ip ) {
1757 if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
1758 return sprintf( 'URL host resolves to a blocked address (%s — private, loopback, or link-local range).', $ip );
1759 }
1760 // FILTER_FLAG_NO_RES_RANGE covers link-local; just being explicit about the AWS metadata IP for clarity.
1761 if ( $ip === '169.254.169.254' ) {
1762 return 'URL host resolves to a cloud metadata endpoint (169.254.169.254) — blocked.';
1763 }
1764 }
1765
1766 return null;
1767 }
1768
1769 /**
1770 * Detect MIME type of a file. Prefers WP's wp_check_filetype_and_ext which
1771 * combines extension-based and finfo-based detection. Falls back to finfo
1772 * directly if needed.
1773 *
1774 * @param string $path Filesystem path to the file
1775 * @param string $filename Original filename (used by WP's extension check)
1776 * @return string MIME type, or empty string if undetectable
1777 */
1778 private function avcf_detect_mime( $path, $filename ) {
1779 if ( ! function_exists( 'wp_check_filetype_and_ext' ) ) {
1780 require_once ABSPATH . 'wp-admin/includes/file.php';
1781 }
1782 $check = wp_check_filetype_and_ext( $path, $filename );
1783 if ( ! empty( $check['type'] ) ) {
1784 return (string) $check['type'];
1785 }
1786 // Fallback to finfo direct.
1787 if ( function_exists( 'finfo_open' ) ) {
1788 $finfo = finfo_open( FILEINFO_MIME_TYPE );
1789 if ( $finfo ) {
1790 $mime = finfo_file( $finfo, $path );
1791 finfo_close( $finfo );
1792 if ( $mime ) {
1793 return (string) $mime;
1794 }
1795 }
1796 }
1797 return '';
1798 }
1799
1800 /**
1801 * Canonicalize a filesystem path and require it to live inside the WordPress
1802 * uploads directory.
1803 *
1804 * Several abilities locate a file to delete/overwrite via get_attached_file(),
1805 * which trusts the attachment's _wp_attached_file meta. That meta is
1806 * caller-writable (e.g. through atarim/update-post-field), so a traversal
1807 * ("../../wp-config.php") or absolute value would make those disk operations
1808 * escape the uploads directory — enabling arbitrary file deletion and, from
1809 * there, remote code execution. Resolving the real path and confirming
1810 * containment before any unlink/copy closes that hole regardless of how the
1811 * meta was poisoned.
1812 *
1813 * @param string $path Filesystem path (typically from get_attached_file()).
1814 * @return string|false Canonical path if it exists and is inside uploads, else false.
1815 */
1816 private function avcf_resolve_within_uploads( $path ) {
1817 $path = (string) $path;
1818 if ( '' === $path || strpos( $path, "\0" ) !== false ) {
1819 return false;
1820 }
1821
1822 $uploads = wp_upload_dir();
1823 if ( empty( $uploads['basedir'] ) ) {
1824 return false;
1825 }
1826
1827 $basedir_real = realpath( $uploads['basedir'] );
1828 $real = realpath( $path );
1829 if ( false === $basedir_real || false === $real ) {
1830 return false;
1831 }
1832
1833 // Compare with a trailing separator on both sides so that a sibling
1834 // directory sharing a name prefix (e.g. ".../uploads-evil/") can't pass.
1835 $basedir_cmp = rtrim( $basedir_real, '/\\' ) . DIRECTORY_SEPARATOR;
1836 $real_cmp = $real . DIRECTORY_SEPARATOR;
1837 if ( 0 !== strpos( $real_cmp, $basedir_cmp ) ) {
1838 return false;
1839 }
1840
1841 return $real;
1842 }
1843 }