PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.12.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.12.0
2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk All 53 releases
thinkrank / includes / seo / class-attachment-usage.php

class-attachment-usage.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.12.0, at includes/seo/class-attachment-usage.php

307 lines 10.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Finds the posts an attachment appears on.
5 *
6 * WordPress records where a file lives, never where it is shown. `post_parent`
7 * only says which screen the file was uploaded from, so it is wrong as often as
8 * it is right, and page builders keep their image references in their own
9 * stored trees rather than in `post_content`. Answering "which pages render
10 * this image?" therefore means looking in four different places (#763).
11 *
12 * This is deliberately a search rather than an index. The question is asked
13 * after an alt-text write, a rare event, and an index of image usage would have
14 * to be invalidated by every post save, every builder save and every media
15 * replacement to stay honest. A bounded search run a few times a day is the
16 * cheaper side of that trade.
17 *
18 * @package ThinkRank
19 * @subpackage SEO
20 * @since 2.12.0
21 */
22
23 declare(strict_types=1);
24
25 namespace ThinkRank\SEO;
26
27 // Prevent direct access
28 if (!defined('ABSPATH')) {
29 exit;
30 }
31
32 /**
33 * Resolves attachment IDs to the posts that display them.
34 *
35 * @since 2.12.0
36 */
37 class Attachment_Usage {
38
39 /**
40 * Most posts reported for one lookup.
41 *
42 * The caller uses the answer to delete post meta and purge URLs, so an
43 * unbounded result turns one alt-text write into thousands of writes. A
44 * logo used site-wide is exactly that case, and for it the right answer is
45 * a full cache clear by hand, not a slow request that half finishes.
46 *
47 * @since 2.12.0
48 * @var int
49 */
50 public const MAX_POSTS = 200;
51
52 /**
53 * Post statuses whose rendered output can be sitting in a cache.
54 *
55 * Elementor only stores its element cache on a front-end, non-preview
56 * request, and page caches only keep what a visitor could fetch, so a
57 * draft has nothing cached to drop.
58 *
59 * @since 2.12.0
60 * @var string[]
61 */
62 private const CACHEABLE_STATUSES = ['publish', 'private'];
63
64 /**
65 * Posts that reference any of the given attachments.
66 *
67 * @since 2.12.0
68 * @param int[] $attachment_ids Attachment IDs to look for.
69 * @param int $limit Maximum posts to return. Defaults to MAX_POSTS.
70 * @return int[] Post IDs, ascending, without duplicates.
71 */
72 public static function posts_using(array $attachment_ids, int $limit = self::MAX_POSTS): array {
73 global $wpdb;
74
75 $ids = array_values(array_unique(array_filter(array_map('intval', $attachment_ids))));
76
77 if ([] === $ids || !isset($wpdb)) {
78 return [];
79 }
80
81 $limit = max(1, $limit);
82 $found = self::featured_image_posts($ids, $limit);
83 $found += self::flip(self::meta_reference_posts($ids, $limit));
84 $found += self::flip(self::content_reference_posts($ids, $limit));
85
86 $posts = array_keys($found);
87 sort($posts, SORT_NUMERIC);
88
89 return array_slice($posts, 0, $limit);
90 }
91
92 /**
93 * Posts using one of the attachments as their featured image.
94 *
95 * The only exact match of the three: `_thumbnail_id` holds the bare ID.
96 *
97 * @param int[] $ids Attachment IDs.
98 * @param int $limit Row cap.
99 * @return array<int, bool> Post IDs as keys.
100 */
101 private static function featured_image_posts(array $ids, int $limit): array {
102 global $wpdb;
103
104 $placeholders = implode(',', array_fill(0, count($ids), '%d'));
105 $statuses = self::status_placeholders();
106
107 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
108 $rows = $wpdb->get_col(
109 $wpdb->prepare(
110 "SELECT DISTINCT pm.post_id
111 FROM {$wpdb->postmeta} pm
112 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
113 WHERE pm.meta_key = '_thumbnail_id'
114 AND pm.meta_value IN ({$placeholders})
115 AND p.post_status IN ({$statuses})
116 LIMIT %d",
117 array_merge($ids, self::CACHEABLE_STATUSES, [$limit])
118 )
119 );
120 // phpcs:enable
121
122 return self::flip(array_map('intval', (array) $rows));
123 }
124
125 /**
126 * Posts whose builder tree mentions one of the attachments.
127 *
128 * Every builder stores its tree as JSON or as serialised PHP, so the ID
129 * cannot be matched exactly in SQL. The query narrows on `meta_key`, which
130 * is indexed, and PHP then confirms each candidate.
131 *
132 * @param int[] $ids Attachment IDs.
133 * @param int $limit Row cap.
134 * @return int[] Post IDs.
135 */
136 private static function meta_reference_posts(array $ids, int $limit): array {
137 global $wpdb;
138
139 $keys = Builder_Content::builder_meta_keys();
140
141 if ([] === $keys) {
142 return [];
143 }
144
145 $key_placeholders = implode(',', array_fill(0, count($keys), '%s'));
146
147 $like_clauses = [];
148 $like_values = [];
149 foreach ($ids as $id) {
150 $like_clauses[] = 'pm.meta_value LIKE %s';
151 $like_values[] = '%' . $wpdb->esc_like((string) $id) . '%';
152 }
153
154 // Candidates only: the LIKE matches 531 inside 5310 and inside any
155 // unrelated number. verify() below is what decides.
156 $like_sql = implode(' OR ', $like_clauses);
157 $statuses = self::status_placeholders();
158
159 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
160 $rows = $wpdb->get_results(
161 $wpdb->prepare(
162 "SELECT pm.post_id, pm.meta_value
163 FROM {$wpdb->postmeta} pm
164 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
165 WHERE pm.meta_key IN ({$key_placeholders})
166 AND ({$like_sql})
167 AND p.post_status IN ({$statuses})
168 LIMIT %d",
169 array_merge($keys, $like_values, self::CACHEABLE_STATUSES, [$limit * 4])
170 )
171 );
172 // phpcs:enable
173
174 $posts = [];
175 foreach ((array) $rows as $row) {
176 if (self::mentions_any($ids, (string) $row->meta_value)) {
177 $posts[] = (int) $row->post_id;
178 }
179 }
180
181 return $posts;
182 }
183
184 /**
185 * Posts whose `post_content` references one of the attachments.
186 *
187 * Covers the block and classic editors, whose image markup carries the ID
188 * in a `wp-image-<id>` class, a block attribute or a gallery shortcode.
189 *
190 * @param int[] $ids Attachment IDs.
191 * @param int $limit Row cap.
192 * @return int[] Post IDs.
193 */
194 private static function content_reference_posts(array $ids, int $limit): array {
195 global $wpdb;
196
197 $like_clauses = [];
198 $like_values = [];
199 foreach ($ids as $id) {
200 $like_clauses[] = 'p.post_content LIKE %s';
201 $like_values[] = '%' . $wpdb->esc_like((string) $id) . '%';
202 }
203
204 $like_sql = implode(' OR ', $like_clauses);
205 $statuses = self::status_placeholders();
206
207 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
208 $rows = $wpdb->get_results(
209 $wpdb->prepare(
210 "SELECT p.ID, p.post_content
211 FROM {$wpdb->posts} p
212 WHERE p.post_type != 'attachment'
213 AND p.post_status IN ({$statuses})
214 AND ({$like_sql})
215 LIMIT %d",
216 array_merge(self::CACHEABLE_STATUSES, $like_values, [$limit * 4])
217 )
218 );
219 // phpcs:enable
220
221 $posts = [];
222 foreach ((array) $rows as $row) {
223 if (self::content_mentions_any($ids, (string) $row->post_content)) {
224 $posts[] = (int) $row->ID;
225 }
226 }
227
228 return $posts;
229 }
230
231 /**
232 * `%s` placeholders for CACHEABLE_STATUSES, for an IN () clause.
233 *
234 * @return string
235 */
236 private static function status_placeholders(): string {
237 return implode(',', array_fill(0, count(self::CACHEABLE_STATUSES), '%s'));
238 }
239
240 /**
241 * Whether a builder tree really refers to one of the attachments.
242 *
243 * A number surrounded by digits is a different number, which is the one
244 * false positive worth ruling out. Beyond that this stays loose on purpose:
245 * an ID that appears as some other setting's value costs one extra cache
246 * purge, while a missed reference leaves a visitor looking at stale markup,
247 * which is the bug being fixed.
248 *
249 * @param int[] $ids Attachment IDs.
250 * @param string $value Stored builder tree.
251 * @return bool
252 */
253 private static function mentions_any(array $ids, string $value): bool {
254 foreach ($ids as $id) {
255 if (1 === preg_match('/(?<!\d)' . $id . '(?!\d)/', $value)) {
256 return true;
257 }
258 }
259
260 return false;
261 }
262
263 /**
264 * Whether `post_content` really refers to one of the attachments.
265 *
266 * Stricter than the builder check, because editor markup names the ID in a
267 * small set of known shapes and a bare number in prose is a real risk.
268 *
269 * @param int[] $ids Attachment IDs.
270 * @param string $content Post content.
271 * @return bool
272 */
273 private static function content_mentions_any(array $ids, string $content): bool {
274 foreach ($ids as $id) {
275 $patterns = [
276 '/wp-image-' . $id . '(?!\d)/', // Core image markup.
277 '/"id"\s*:\s*' . $id . '(?!\d)/', // Block attributes.
278 '/\battachment[_-]?id["\']?\s*[:=]\s*["\']?' . $id . '(?!\d)/i',
279 '/ids\s*=\s*["\'][\d, ]*(?<!\d)' . $id . '(?!\d)/', // [gallery ids="…"].
280 ];
281
282 foreach ($patterns as $pattern) {
283 if (1 === preg_match($pattern, $content)) {
284 return true;
285 }
286 }
287 }
288
289 return false;
290 }
291
292 /**
293 * Turn a list of IDs into a set keyed by ID.
294 *
295 * @param int[] $ids IDs.
296 * @return array<int, bool>
297 */
298 private static function flip(array $ids): array {
299 $set = [];
300 foreach ($ids as $id) {
301 $set[(int) $id] = true;
302 }
303
304 return $set;
305 }
306 }
307