PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.8.7
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.8.7
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.8.7, at includes/my-wordpress/media-usage.php

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