PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.8
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 0.8.6 All 33 releases
desktop-mode / includes / my-wordpress / media-usage.php

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

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