PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.6
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.6
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / my-wordpress / media-usage.php

media-usage.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.6, at includes/my-wordpress/media-usage.php

576 lines 19.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — My WordPress: per-attachment "used in" endpoint.
4 *
5 * `GET /desktop-mode/v1/media-usage/<id>` returns the list of public
6 * post-type entries that reference a given attachment, either as
7 * featured image (`_thumbnail_id` meta) or as an embed inside
8 * `post_content` (block-editor `wp-image-<id>` class or a direct URL
9 * to the attachment file).
10 *
11 * Payload shape:
12 *
13 * {
14 * media: { id, title, mime, sourceUrl, filename, date, author },
15 * usedIn: [
16 * { postId, postType, postTypeLabel, title, status, link,
17 * editLink, usedAs: 'featured'|'content'|'meta',
18 * authorId, authorName, date }
19 * ]
20 * }
21 *
22 * Rows are filtered per-row with `current_user_can( 'read_post' )`,
23 * so subscribers never see drafts/private posts they can't read.
24 *
25 * Only the viewer-independent reference scan (post id => usedAs map)
26 * is cached in a transient keyed on the attachment id; the per-row
27 * `read_post` gate runs on every request, so a cached scan can never
28 * leak rows across viewers with different capabilities. Cache is
29 * busted whenever any post is saved or deleted.
30 *
31 * @package WPDesktopMode
32 * @since 0.8.6
33 */
34
35 defined( 'ABSPATH' ) || exit;
36
37 /**
38 * Register the route.
39 *
40 * @since 0.8.6
41 */
42 function desktop_mode_my_wordpress_register_media_usage_route() {
43 register_rest_route(
44 'desktop-mode/v1',
45 '/media-usage/(?P<id>\d+)',
46 array(
47 'methods' => WP_REST_Server::READABLE,
48 'callback' => 'desktop_mode_my_wordpress_media_usage_callback',
49 'permission_callback' => static function ( $request ) {
50 $id = (int) $request->get_param( 'id' );
51 if ( $id <= 0 ) {
52 return false;
53 }
54 $post = get_post( $id );
55 if ( ! $post || 'attachment' !== $post->post_type ) {
56 return false;
57 }
58 return current_user_can( 'read_post', $id );
59 },
60 'args' => array(
61 'id' => array(
62 'required' => true,
63 'type' => 'integer',
64 'sanitize_callback' => 'absint',
65 ),
66 ),
67 )
68 );
69 }
70 add_action( 'rest_api_init', 'desktop_mode_my_wordpress_register_media_usage_route' );
71
72 /**
73 * Cache TTL (seconds). Filterable so sites that bulk-import media
74 * can shorten the window, or sites with stable libraries can
75 * lengthen it.
76 *
77 * @since 0.8.6
78 *
79 * @param int $attachment_id Attachment id.
80 * @return int
81 */
82 function desktop_mode_my_wordpress_media_usage_ttl( $attachment_id ) {
83 /**
84 * Filter the media-usage transient TTL.
85 *
86 * @since 0.8.6
87 *
88 * @param int $seconds Default 300 (5 minutes).
89 * @param int $attachment_id Attachment id the cache key is for.
90 */
91 return (int) apply_filters( 'desktop_mode_my_wordpress_media_usage_cache_ttl', 300, $attachment_id );
92 }
93
94 /**
95 * Capability buckets the cache namespaces over. A second-tier
96 * change to the gating logic must update this list — the busters
97 * iterate over the same array, so the writer and the buster can
98 * never go out of sync.
99 *
100 * @since 0.8.6
101 *
102 * @return string[]
103 */
104 function desktop_mode_my_wordpress_media_usage_cache_buckets() {
105 return array( 'edit', 'read' );
106 }
107
108 /**
109 * Bucket key for the current user — the writer's view of which
110 * cache slot to read/write.
111 *
112 * @since 0.8.6
113 *
114 * @return string
115 */
116 function desktop_mode_my_wordpress_media_usage_current_bucket() {
117 return current_user_can( 'edit_others_posts' ) ? 'edit' : 'read';
118 }
119
120 /**
121 * Build the transient key — namespaces the cache by attachment id
122 * AND a coarse capability bucket. The cached value is the
123 * viewer-independent reference map (per-row capability gating runs
124 * after the cache read), so the bucket is key hygiene rather than a
125 * security boundary.
126 *
127 * @since 0.8.6
128 *
129 * @param int $attachment_id Attachment id.
130 * @param string $bucket Optional bucket override. Defaults to
131 * the current user's bucket.
132 * @return string
133 */
134 function desktop_mode_my_wordpress_media_usage_cache_key( $attachment_id, $bucket = null ) {
135 if ( null === $bucket ) {
136 $bucket = desktop_mode_my_wordpress_media_usage_current_bucket();
137 }
138 return 'dm_media_usage_' . (int) $attachment_id . '_' . $bucket . '_v1';
139 }
140
141 /**
142 * Endpoint callback. See file docblock for payload shape.
143 *
144 * @since 0.8.6
145 *
146 * @param WP_REST_Request $request REST request.
147 * @return array|WP_Error
148 */
149 function desktop_mode_my_wordpress_media_usage_callback( $request ) {
150 $attachment_id = (int) $request->get_param( 'id' );
151 $attachment = get_post( $attachment_id );
152 if ( ! $attachment || 'attachment' !== $attachment->post_type ) {
153 return new WP_Error(
154 'desktop_mode_media_not_found',
155 __( 'Attachment not found.', 'desktop-mode' ),
156 array( 'status' => 404 )
157 );
158 }
159
160 $cache_key = desktop_mode_my_wordpress_media_usage_cache_key( $attachment_id );
161 $rows_by_post = get_transient( $cache_key );
162 if ( ! is_array( $rows_by_post ) ) {
163 $rows_by_post = desktop_mode_my_wordpress_media_usage_collect( $attachment );
164 set_transient(
165 $cache_key,
166 $rows_by_post,
167 desktop_mode_my_wordpress_media_usage_ttl( $attachment_id )
168 );
169 }
170
171 /*
172 * The transient stores ONLY the viewer-independent reference map
173 * (post id => usedAs). Both the per-row `read_post` gate and the
174 * extension filter run on every request: the gate so a cache hit
175 * written during one viewer's request can never leak unreadable
176 * rows to another viewer, the filter so plugin extensions (ACF
177 * image meta, page-builder galleries, etc.) stay live while the
178 * heavy LIKE-scan portion stays cached. The base map only
179 * refreshes on the cache-bust events (save_post,
180 * before_delete_post, delete_attachment).
181 */
182 $payload = desktop_mode_my_wordpress_media_usage_build( $attachment, $rows_by_post );
183
184 /**
185 * Filter the media-usage payload before returning to the bundle.
186 * Plugins (ACF, page builders, Yoast image meta) can append rows
187 * to `usedIn` describing their own attachment references.
188 *
189 * @since 0.8.6
190 *
191 * @param array $payload Default payload.
192 * @param int $attachment_id Subject attachment id.
193 */
194 return apply_filters( 'desktop_mode_my_wordpress_media_usage', $payload, $attachment_id );
195 }
196
197 /**
198 * Collect the viewer-independent reference map for an attachment:
199 * post id => 'featured'|'content'. This is the heavy SQL portion of
200 * the payload and the ONLY part that gets transient-cached — the
201 * per-row `read_post` gate lives in
202 * `desktop_mode_my_wordpress_media_usage_build()` and runs on every
203 * request, so a cached map can never leak rows across viewers with
204 * different capabilities.
205 *
206 * @since 0.8.6
207 *
208 * @param WP_Post $attachment Attachment post.
209 * @return array<int,string> Map of post id => usedAs kind.
210 */
211 function desktop_mode_my_wordpress_media_usage_collect( $attachment ) {
212 global $wpdb;
213
214 $attachment_id = (int) $attachment->ID;
215 $file_url = (string) wp_get_attachment_url( $attachment_id );
216 $file_basename = '' !== $file_url ? wp_basename( $file_url ) : '';
217
218 $public_types = array_values( get_post_types( array( 'public' => true ), 'names' ) );
219 // Filter out `attachment` from the search — attachments don't
220 // reference other attachments in a meaningful way for this view.
221 $public_types = array_values( array_diff( $public_types, array( 'attachment' ) ) );
222 if ( empty( $public_types ) ) {
223 return array();
224 }
225
226 // `usedAs` priority: featured > content > meta. We collect every
227 // hit per post id, then collapse to the highest-priority kind for
228 // display so a row isn't double-listed.
229 $rows_by_post = array();
230
231 // --- Featured image references --------------------------------------
232 $thumb_post_ids = $wpdb->get_col(
233 $wpdb->prepare(
234 "SELECT post_id FROM {$wpdb->postmeta}
235 WHERE meta_key = '_thumbnail_id' AND meta_value = %s",
236 (string) $attachment_id
237 )
238 );
239 foreach ( (array) $thumb_post_ids as $pid ) {
240 $pid = (int) $pid;
241 if ( $pid > 0 ) {
242 $rows_by_post[ $pid ] = 'featured';
243 }
244 }
245
246 // --- Content embeds (block class + raw URL) -------------------------
247 // We need to match the file basename AND its variants — WP
248 // auto-generates `image-scaled.jpg` for big uploads and stores
249 // THAT as `_wp_attached_file`, while editors emit the original
250 // URL in `<img src>`. Without trying both, a post embedding the
251 // unscaled URL never matches a `-scaled` attachment.
252 if ( '' !== $file_basename ) {
253 $basename_variants = array( $file_basename );
254 if ( preg_match( '/^(.*)-scaled(\.[a-zA-Z0-9]+)$/', $file_basename, $m ) ) {
255 $basename_variants[] = $m[1] . $m[2];
256 }
257 $basename_variants = array_values( array_unique( $basename_variants ) );
258
259 $class_pattern = '%wp-image-' . $attachment_id . '%';
260 $url_patterns = array();
261 foreach ( $basename_variants as $variant ) {
262 $url_patterns[] = '%' . $wpdb->esc_like( $variant ) . '%';
263 }
264
265 // Build the OR-arms — one for class, N for URL variants.
266 $pattern_args = array_merge( array( $class_pattern ), $url_patterns );
267 $pattern_clause = implode( ' OR ', array_fill( 0, count( $pattern_args ), 'post_content LIKE %s' ) );
268 $type_holders = implode( ',', array_fill( 0, count( $public_types ), '%s' ) );
269 $query_args = array_merge( $pattern_args, $public_types );
270
271 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
272 $content_rows = $wpdb->get_col(
273 $wpdb->prepare(
274 "SELECT ID FROM {$wpdb->posts}
275 WHERE ( {$pattern_clause} )
276 AND post_status NOT IN ( 'auto-draft', 'inherit', 'trash' )
277 AND post_type IN ( {$type_holders} )",
278 $query_args
279 )
280 );
281
282 // Build the URL-variant list (canonical + unscaled) for the
283 // confirmation pass below. `wp_get_attachment_image_src` is
284 // unaware of these — we precompute them here.
285 $url_variants = array();
286 if ( '' !== $file_url ) {
287 $url_variants[] = $file_url;
288 if ( preg_match( '/^(.*)-scaled(\.[a-zA-Z0-9]+)$/', $file_url, $m ) ) {
289 $url_variants[] = $m[1] . $m[2];
290 } elseif ( preg_match( '/^(.*)(\.[a-zA-Z0-9]+)$/', $file_url, $m ) ) {
291 $url_variants[] = $m[1] . '-scaled' . $m[2];
292 }
293 }
294
295 // The LIKE scan over-fetches: `%wp-image-12%` matches
296 // `wp-image-123`. Re-check each candidate with a word-
297 // boundary regex (matches `wp-image-12` followed by any
298 // non-digit, or end of string), AND accept any post that
299 // contains any of the URL variants.
300 $class_re = '/wp-image-' . $attachment_id . '(?!\d)/';
301 foreach ( (array) $content_rows as $pid ) {
302 $pid = (int) $pid;
303 if ( $pid <= 0 || isset( $rows_by_post[ $pid ] ) ) {
304 continue;
305 }
306 $content_post = get_post( $pid );
307 if ( ! $content_post || ! isset( $content_post->post_content ) ) {
308 continue;
309 }
310 $haystack = (string) $content_post->post_content;
311 $has_class = (bool) preg_match( $class_re, $haystack );
312 $has_url = false;
313 foreach ( $url_variants as $variant ) {
314 if ( '' !== $variant && false !== strpos( $haystack, $variant ) ) {
315 $has_url = true;
316 break;
317 }
318 }
319 if ( ! $has_class && ! $has_url ) {
320 continue;
321 }
322 $rows_by_post[ $pid ] = 'content';
323 }
324 }
325
326 return $rows_by_post;
327 }
328
329 /**
330 * Build the payload for the CURRENT viewer. The reference map can be
331 * passed in (typically straight from the transient); when omitted
332 * it's collected fresh. Row building applies
333 * `current_user_can( 'read_post' )` per row on every call — never
334 * cache this function's output, it is viewer-specific.
335 *
336 * @since 0.8.6
337 *
338 * @param WP_Post $attachment Attachment post.
339 * @param array<int,string>|null $rows_by_post Optional precollected map of
340 * post id => usedAs kind.
341 * @return array
342 */
343 function desktop_mode_my_wordpress_media_usage_build( $attachment, $rows_by_post = null ) {
344 $attachment_id = (int) $attachment->ID;
345 $file_url = (string) wp_get_attachment_url( $attachment_id );
346 $file_basename = '' !== $file_url ? wp_basename( $file_url ) : '';
347
348 $author = get_userdata( (int) $attachment->post_author );
349 $media_info = array(
350 'id' => $attachment_id,
351 'title' => (string) get_the_title( $attachment_id ),
352 'mime' => (string) $attachment->post_mime_type,
353 'sourceUrl' => $file_url,
354 'filename' => $file_basename,
355 'date' => mysql2date( 'c', $attachment->post_date_gmt, false ),
356 'author' => array(
357 'id' => (int) $attachment->post_author,
358 'name' => $author ? (string) $author->display_name : '',
359 ),
360 );
361
362 if ( ! is_array( $rows_by_post ) ) {
363 $rows_by_post = desktop_mode_my_wordpress_media_usage_collect( $attachment );
364 }
365
366 $public_types = array_values( get_post_types( array( 'public' => true ), 'names' ) );
367 // Filter out `attachment` from the search — attachments don't
368 // reference other attachments in a meaningful way for this view.
369 $public_types = array_values( array_diff( $public_types, array( 'attachment' ) ) );
370
371 // --- Build the row payload, per-row capability gated ----------------
372 $type_objects = array();
373 foreach ( $public_types as $type ) {
374 $type_objects[ $type ] = get_post_type_object( $type );
375 }
376
377 $used_in = array();
378 foreach ( $rows_by_post as $post_id => $used_as ) {
379 $post = get_post( $post_id );
380 if ( ! $post ) {
381 continue;
382 }
383 if ( ! in_array( $post->post_type, $public_types, true ) ) {
384 continue;
385 }
386 if ( ! current_user_can( 'read_post', $post_id ) ) {
387 continue;
388 }
389 $author_obj = get_userdata( (int) $post->post_author );
390 $type_obj = isset( $type_objects[ $post->post_type ] ) ? $type_objects[ $post->post_type ] : null;
391 $used_in[] = array(
392 'postId' => (int) $post->ID,
393 'postType' => (string) $post->post_type,
394 'postTypeLabel' => $type_obj && isset( $type_obj->labels->singular_name )
395 ? (string) $type_obj->labels->singular_name
396 : (string) $post->post_type,
397 'title' => (string) get_the_title( $post ),
398 'status' => (string) $post->post_status,
399 'link' => (string) get_permalink( $post ),
400 'editLink' => (string) get_edit_post_link( $post->ID, 'raw' ),
401 'usedAs' => $used_as,
402 'authorId' => (int) $post->post_author,
403 'authorName' => $author_obj ? (string) $author_obj->display_name : '',
404 'date' => mysql2date( 'c', $post->post_date_gmt, false ),
405 );
406 }
407
408 // Stable sort: most recent first.
409 usort(
410 $used_in,
411 static function ( $a, $b ) {
412 return strcmp( (string) $b['date'], (string) $a['date'] );
413 }
414 );
415
416 return array(
417 'media' => $media_info,
418 'usedIn' => $used_in,
419 );
420 }
421
422 /**
423 * Per-post stash of attachment ids referenced BEFORE an in-progress
424 * update. Populated on `pre_post_update` (where the DB row still
425 * reflects the previous state), drained on `save_post` so the
426 * buster can union pre + post sets — otherwise removing a
427 * `wp-image-N` block from a post would leave the cache for
428 * attachment N stale until the TTL expires.
429 *
430 * @since 0.8.6
431 *
432 * @var array<int,array<int,true>>
433 */
434 $GLOBALS['desktop_mode_media_usage_pre_save_refs'] = array();
435
436 /**
437 * Extract attachment ids referenced by a post — featured image plus
438 * everything resolvable from `post_content`. Defers to the canonical
439 * resolver in `attached-media.php` so this buster catches the same
440 * cases the REST field does (block-class scan, classic `[caption]`
441 * shortcodes, `data-id` / `data-attachment-id`, and raw `<img src>`
442 * URL resolution including `-scaled.jpg` ↔ original swaps), plus
443 * any ids appended via the `desktop_mode_my_wordpress_attached_media`
444 * filter (ACF, page builders, post-meta galleries). Without this
445 * delegation, editing a post to add or remove a raw URL embed
446 * wouldn't bust the affected attachment's media-usage cache until
447 * the TTL expired.
448 *
449 * @since 0.8.6
450 *
451 * @param int|WP_Post $post Post id or object.
452 * @return array<int,true> Set of attachment ids keyed for dedup.
453 */
454 function desktop_mode_my_wordpress_media_usage_extract_refs( $post ) {
455 $ids = array();
456 $obj = is_object( $post ) ? $post : get_post( (int) $post );
457 if ( ! $obj || ! isset( $obj->ID ) ) {
458 return $ids;
459 }
460 if ( ! function_exists( 'desktop_mode_my_wordpress_post_attached_media' ) ) {
461 // Defensive: bootstrap order should always load
462 // attached-media.php before this can be called from a save
463 // hook, but fall back to the legacy minimal scan just in
464 // case so the buster never silently no-ops.
465 $thumb = (int) get_post_meta( (int) $obj->ID, '_thumbnail_id', true );
466 if ( $thumb > 0 ) {
467 $ids[ $thumb ] = true;
468 }
469 $content = isset( $obj->post_content ) ? (string) $obj->post_content : '';
470 if ( '' !== $content && preg_match_all( '/wp-image-(\d+)/', $content, $m ) ) {
471 foreach ( $m[1] as $id ) {
472 $ids[ (int) $id ] = true;
473 }
474 }
475 return $ids;
476 }
477 foreach ( desktop_mode_my_wordpress_post_attached_media( (int) $obj->ID ) as $id ) {
478 $id = (int) $id;
479 if ( $id > 0 ) {
480 $ids[ $id ] = true;
481 }
482 }
483 return $ids;
484 }
485
486 /**
487 * Snapshot the attachment refs of a post just before it's updated.
488 * Fired by `pre_post_update`, which runs before the DB row mutates,
489 * so `get_post` here returns the OLD content. We stash the ref set
490 * in a per-request global and read it back in the `save_post` hook.
491 *
492 * @since 0.8.6
493 *
494 * @param int $post_id Post id about to be updated.
495 */
496 function desktop_mode_my_wordpress_media_usage_snapshot_pre_save( $post_id ) {
497 $post_id = (int) $post_id;
498 if ( $post_id <= 0 ) {
499 return;
500 }
501 $GLOBALS['desktop_mode_media_usage_pre_save_refs'][ $post_id ] =
502 desktop_mode_my_wordpress_media_usage_extract_refs( $post_id );
503 }
504 add_action( 'pre_post_update', 'desktop_mode_my_wordpress_media_usage_snapshot_pre_save' );
505
506 /**
507 * Bust the transient when a post changes. The cache key is
508 * per-attachment, so we don't know which entries reference what —
509 * the correct move is to delete cache for every attachment
510 * referenced by the saved/deleted post. The union of:
511 *
512 * - pre-save refs (captured by `pre_post_update` above) so a
513 * reference removal still busts the dropped attachment's cache,
514 * - post-save refs (read here) so a freshly-added reference
515 * busts the cache too.
516 *
517 * Bounded by the actual count of `wp-image-N` matches in either
518 * version of the content + the post's `_thumbnail_id`.
519 *
520 * @since 0.8.6
521 *
522 * @param int $post_id Post id that was just modified.
523 */
524 function desktop_mode_my_wordpress_media_usage_bust_for_post( $post_id ) {
525 $post_id = (int) $post_id;
526 if ( $post_id <= 0 ) {
527 return;
528 }
529
530 $ids = desktop_mode_my_wordpress_media_usage_extract_refs( $post_id );
531
532 if ( isset( $GLOBALS['desktop_mode_media_usage_pre_save_refs'][ $post_id ] ) ) {
533 $ids += $GLOBALS['desktop_mode_media_usage_pre_save_refs'][ $post_id ];
534 unset( $GLOBALS['desktop_mode_media_usage_pre_save_refs'][ $post_id ] );
535 }
536
537 foreach ( array_keys( $ids ) as $attachment_id ) {
538 foreach ( desktop_mode_my_wordpress_media_usage_cache_buckets() as $bucket ) {
539 delete_transient(
540 desktop_mode_my_wordpress_media_usage_cache_key( (int) $attachment_id, $bucket )
541 );
542 }
543 }
544 }
545 add_action( 'save_post', 'desktop_mode_my_wordpress_media_usage_bust_for_post' );
546 // `before_delete_post`, NOT `deleted_post`. By the time `deleted_post`
547 // fires, `delete_all_meta_for_post` has already wiped `_thumbnail_id`
548 // and the row itself is gone — `extract_refs()` would return an empty
549 // set, so the cache for any referenced attachment would survive until
550 // its 5-minute TTL. `before_delete_post` fires while the post + meta
551 // are still readable. Signature matches (we only consume the first
552 // arg, the post id).
553 add_action( 'before_delete_post', 'desktop_mode_my_wordpress_media_usage_bust_for_post' );
554 // New posts skip `pre_post_update` but still go through `save_post`,
555 // so the buster works as-is — the pre-snapshot is just empty.
556
557 /**
558 * Bust the transient when the attachment itself is deleted.
559 *
560 * @since 0.8.6
561 *
562 * @param int $post_id Attachment id.
563 */
564 function desktop_mode_my_wordpress_media_usage_bust_for_attachment( $post_id ) {
565 $post_id = (int) $post_id;
566 if ( $post_id <= 0 ) {
567 return;
568 }
569 foreach ( desktop_mode_my_wordpress_media_usage_cache_buckets() as $bucket ) {
570 delete_transient(
571 desktop_mode_my_wordpress_media_usage_cache_key( $post_id, $bucket )
572 );
573 }
574 }
575 add_action( 'delete_attachment', 'desktop_mode_my_wordpress_media_usage_bust_for_attachment' );
576