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-lookup.php

class-attachment-lookup.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-lookup.php

431 lines 15.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Which attachment an image URL belongs to, asked at most once.
4 *
5 * @package ThinkRank
6 * @since 2.12.0
7 */
8
9 declare(strict_types=1);
10
11 namespace ThinkRank\SEO;
12
13 if (!defined('ABSPATH')) {
14 exit;
15 }
16
17 /**
18 * Resolves image URLs to attachments without paying for it per call.
19 *
20 * attachment_url_to_postid() is one uncached query that reads every
21 * `_wp_attached_file` row, and it only recognises the ORIGINAL upload: the URL
22 * of a generated size (`photo-1024x768.jpg`) returns 0. Open Graph, Twitter,
23 * schema, oEmbed and Image SEO each called it directly, mostly with the `large`
24 * URL of a featured image whose ID they had been holding a moment earlier — so
25 * a page paid for the same query several times and never got an answer (#847).
26 *
27 * Three ways to an ID, cheapest first:
28 *
29 * - **remember()** — the caller built the URL from an attachment ID, so it
30 * says so and nothing downstream has to ask.
31 * - **a hint** — an ID read from markup (`wp-image-123`). Believed only when
32 * that attachment really owns the file the URL names.
33 * - **the database** — one query, cached, that asks for the URL as written and
34 * for the original behind it if it looks like a generated size.
35 *
36 * @since 2.12.0
37 */
38 class Attachment_Lookup {
39
40 /**
41 * Object-cache group. ThinkRank's own, so "purge caches" clears it.
42 *
43 * @since 2.12.0
44 * @var string
45 */
46 private const CACHE_GROUP = 'thinkrank';
47
48 /**
49 * What core appends to the stored file when it replaces an upload: a
50 * downscaled copy of an image over the big-image threshold, or one turned
51 * upright from its EXIF orientation. The generated sizes are still named
52 * after the file as uploaded, so `photo-1024x683.jpg` belongs to
53 * `photo-scaled.jpg` and stripping the dimensions alone misses it.
54 *
55 * @since 2.12.0
56 * @var string[]
57 */
58 private const REPLACED_ORIGINAL_SUFFIXES = ['-scaled', '-rotated'];
59
60 /**
61 * URLs whose attachment the caller already knew, for this request.
62 *
63 * @since 2.12.0
64 * @var array<string, int>
65 */
66 private static array $known = [];
67
68 /**
69 * Record the attachment a URL was built from.
70 *
71 * @since 2.12.0
72 *
73 * @param string $url Image URL, exactly as it will be asked about.
74 * @param int $attachment_id Attachment it came from.
75 * @return void
76 */
77 public static function remember(string $url, int $attachment_id): void {
78 if ('' !== $url && $attachment_id > 0) {
79 self::$known[$url] = $attachment_id;
80 }
81 }
82
83 /**
84 * Forget everything remembered for this request.
85 *
86 * @since 2.12.0
87 * @return void
88 */
89 public static function forget(): void {
90 self::$known = [];
91 }
92
93 /**
94 * Attachment ID named by a `wp-image-{ID}` class, or 0.
95 *
96 * The editor puts it on every image it inserts. It is a claim made by
97 * markup, not a fact: content copied from another site carries that site's
98 * IDs. Pass it to id_from_url() as the hint, which checks it.
99 *
100 * @since 2.12.0
101 *
102 * @param string $html An `<img>` tag, or just its class attribute value.
103 * @return int
104 */
105 public static function hint_from_markup(string $html): int {
106 if (preg_match('/(?<![\w-])wp-image-(\d+)(?![\w-])/', $html, $match)) {
107 return (int) $match[1];
108 }
109
110 return 0;
111 }
112
113 /**
114 * Attachment ID behind an image URL, or 0 when it is not in the library.
115 *
116 * @since 2.12.0
117 *
118 * @param string $url Image URL, original or a generated size.
119 * @param int $hint Optional. An ID the markup claims; verified before use.
120 * @return int
121 */
122 public static function id_from_url(string $url, int $hint = 0): int {
123 $url = trim($url);
124
125 if ('' === $url) {
126 return 0;
127 }
128
129 if (isset(self::$known[$url])) {
130 return self::$known[$url];
131 }
132
133 if ($hint > 0 && null !== self::listed_file($hint, $url)) {
134 self::$known[$url] = $hint;
135
136 return $hint;
137 }
138
139 // Salted with the posts group's last_changed, which core moves whenever
140 // a post — an attachment included — is added, edited or deleted. An
141 // upload therefore retires a cached miss, and a deletion a cached hit.
142 $key = 'attachment_id:' . md5($url) . ':' . wp_cache_get_last_changed('posts');
143 $cached = wp_cache_get($key, self::CACHE_GROUP);
144
145 if (false !== $cached) {
146 return (int) $cached;
147 }
148
149 $attachment_id = self::query($url);
150
151 // Misses are stored too: an image hosted elsewhere is the URL that
152 // would otherwise be asked about on every view.
153 wp_cache_set($key, $attachment_id, self::CACHE_GROUP, DAY_IN_SECONDS);
154
155 return $attachment_id;
156 }
157
158 /**
159 * Dimensions and type of the file a URL points at.
160 *
161 * An attachment's own metadata describes the original. The URL being
162 * published is often a generated size, and announcing the original's
163 * 2000x1500 beside a 1024x768 file gives a consumer numbers to lay a card
164 * out with that the image does not match.
165 *
166 * @since 2.12.0
167 *
168 * @param int $attachment_id Attachment the URL belongs to.
169 * @param string $url The URL being described.
170 * @return array{width:int, height:int, type:string} Zero dimensions when
171 * unknown (vectors too).
172 */
173 public static function describe(int $attachment_id, string $url): array {
174 // No attachment, nothing to describe. Without this the `-WxH` fallback
175 // below would read a size out of the file name of a URL that is not in
176 // the library at all — a remote `photo-1024x768.jpg` would be published
177 // as 1024x768 on the word of its name. Every caller checks
178 // id_from_url() first, so this only holds the contract for the next one.
179 if ($attachment_id <= 0) {
180 return ['width' => 0, 'height' => 0, 'type' => ''];
181 }
182
183 $type = (string) get_post_mime_type($attachment_id);
184 $file = self::listed_file($attachment_id, $url);
185
186 if (null !== $file) {
187 return [
188 'width' => $file['width'],
189 'height' => $file['height'],
190 'type' => '' !== $file['type'] ? $file['type'] : $type,
191 ];
192 }
193
194 // A generated size the metadata no longer lists (thumbnails were
195 // regenerated since the URL was written) still states its dimensions
196 // in its name.
197 if (preg_match('/-(\d+)x(\d+)\.[a-zA-Z0-9]+$/', self::path($url), $match)) {
198 return [
199 'width' => (int) $match[1],
200 'height' => (int) $match[2],
201 'type' => $type,
202 ];
203 }
204
205 $meta = wp_get_attachment_metadata($attachment_id);
206
207 return [
208 'width' => is_array($meta) && isset($meta['width']) ? (int) $meta['width'] : 0,
209 'height' => is_array($meta) && isset($meta['height']) ? (int) $meta['height'] : 0,
210 'type' => $type,
211 ];
212 }
213
214 /**
215 * Ask the database once for every file the URL could be.
216 *
217 * This is attachment_url_to_postid() with an IN list where core has one
218 * value: the same normalisation, the same table, the same two filters.
219 * Asking core itself once per candidate would cost a generated-size URL
220 * that is not in the library four of the scans this class exists to avoid.
221 *
222 * The URL as written has priority: an upload the author named
223 * `banner-1200x630.jpg` is an original, and must not be mistaken for a
224 * size of some other `banner.jpg`.
225 *
226 * @since 2.12.0
227 * @param string $url Image URL.
228 * @return int
229 */
230 private static function query(string $url): int {
231 /** This filter is documented in wp-includes/media.php */
232 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core's hook, applied for parity with attachment_url_to_postid().
233 $pre = apply_filters('pre_attachment_url_to_postid', null, $url);
234
235 if (null !== $pre) {
236 return (int) $pre;
237 }
238
239 $paths = self::candidate_paths($url);
240 $stored = self::stored_paths($paths);
241 $attachment_id = 0;
242
243 foreach ($paths as $path) {
244 if (isset($stored[$path])) {
245 $attachment_id = $stored[$path];
246 break;
247 }
248 }
249
250 // MySQL matched case-insensitively and nothing matched exactly, which
251 // core resolves the same way: take what the database found.
252 if (!$attachment_id && [] !== $stored) {
253 foreach ($paths as $path) {
254 foreach ($stored as $meta_value => $stored_id) {
255 if (0 === strcasecmp((string) $meta_value, $path)) {
256 $attachment_id = $stored_id;
257 break 2;
258 }
259 }
260 }
261 }
262
263 /** This filter is documented in wp-includes/media.php */
264 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core's hook, applied for parity with attachment_url_to_postid().
265 return (int) apply_filters('attachment_url_to_postid', $attachment_id, $url);
266 }
267
268 /**
269 * What `_wp_attached_file` could hold for this URL, most likely first.
270 *
271 * The first entry is what core itself would ask for: the URL relative to
272 * the uploads directory, or the URL untouched when it is not under it. A
273 * `-WxH` suffix adds the original it would have been generated from, and
274 * the `-scaled` / `-rotated` copies core stores in the original's place.
275 *
276 * @since 2.12.0
277 * @param string $url Image URL.
278 * @return array<int, string>
279 */
280 private static function candidate_paths(string $url): array {
281 $dir = wp_get_upload_dir();
282 $path = $url;
283
284 // Core forces the URL's scheme to the uploads directory's, so an http
285 // URL still finds a file the site serves over https.
286 $site_scheme = wp_parse_url((string) ($dir['url'] ?? ''), PHP_URL_SCHEME);
287 $url_scheme = wp_parse_url($path, PHP_URL_SCHEME);
288
289 if (is_string($url_scheme) && is_string($site_scheme) && $url_scheme !== $site_scheme) {
290 $path = $site_scheme . substr($path, strlen($url_scheme));
291 }
292
293 $base = (string) ($dir['baseurl'] ?? '') . '/';
294
295 if ('/' !== $base && 0 === strpos($path, $base)) {
296 $path = substr($path, strlen($base));
297 }
298
299 $paths = [$path];
300 $original = preg_replace('/-\d+x\d+(?=\.[a-zA-Z0-9]+$)/', '', $path);
301
302 if (is_string($original) && $original !== $path) {
303 $paths[] = $original;
304
305 foreach (self::REPLACED_ORIGINAL_SUFFIXES as $suffix) {
306 $replaced = preg_replace('/(?=\.[a-zA-Z0-9]+$)/', $suffix, $original, 1);
307
308 if (is_string($replaced)) {
309 $paths[] = $replaced;
310 }
311 }
312 }
313
314 return $paths;
315 }
316
317 /**
318 * Which of the paths the library stores, as meta_value => attachment ID.
319 *
320 * @since 2.12.0
321 * @param array<int, string> $paths Candidate `_wp_attached_file` values.
322 * @return array<string, int>
323 */
324 private static function stored_paths(array $paths): array {
325 global $wpdb;
326
327 $placeholders = implode(', ', array_fill(0, count($paths), '%s'));
328
329 $rows = $wpdb->get_results(
330 $wpdb->prepare(
331 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $placeholders is one %s per path, built above; the sniff cannot count through the variable.
332 "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = '_wp_attached_file' AND meta_value IN ({$placeholders})",
333 $paths
334 )
335 );
336
337 $stored = [];
338
339 foreach ((array) $rows as $row) {
340 if (!isset($row->meta_value, $row->post_id)) {
341 continue;
342 }
343
344 // Two attachments with one file: the first row, as in core.
345 if (!isset($stored[(string) $row->meta_value])) {
346 $stored[(string) $row->meta_value] = (int) $row->post_id;
347 }
348 }
349
350 return $stored;
351 }
352
353 /**
354 * The metadata entry for the file a URL names, if this attachment has one.
355 *
356 * Doubles as the ownership check for a hinted ID: an attachment owns a URL
357 * when the URL ends in a file its metadata lists, directory included.
358 *
359 * @since 2.12.0
360 *
361 * @param int $attachment_id Attachment to look in.
362 * @param string $url Image URL.
363 * @return array{width:int, height:int, type:string}|null Null when the
364 * attachment lists no such file.
365 */
366 private static function listed_file(int $attachment_id, string $url): ?array {
367 $meta = wp_get_attachment_metadata($attachment_id);
368
369 if (!is_array($meta) || empty($meta['file']) || !is_string($meta['file'])) {
370 return null;
371 }
372
373 $path = self::path($url);
374 $dir = dirname($meta['file']);
375 $dir = '.' === $dir ? '/' : '/' . $dir . '/';
376
377 if (self::ends_with($path, $dir . wp_basename($meta['file']))) {
378 return [
379 'width' => (int) ($meta['width'] ?? 0),
380 'height' => (int) ($meta['height'] ?? 0),
381 'type' => '',
382 ];
383 }
384
385 foreach ((array) ($meta['sizes'] ?? []) as $size) {
386 if (!is_array($size) || empty($size['file'])) {
387 continue;
388 }
389
390 if (self::ends_with($path, $dir . $size['file'])) {
391 return [
392 'width' => (int) ($size['width'] ?? 0),
393 'height' => (int) ($size['height'] ?? 0),
394 'type' => (string) ($size['mime-type'] ?? ''),
395 ];
396 }
397 }
398
399 // The file as uploaded, kept beside a -scaled or -rotated replacement.
400 // Its dimensions are not recorded — the metadata's are the replacement's.
401 if (!empty($meta['original_image']) && self::ends_with($path, $dir . $meta['original_image'])) {
402 return ['width' => 0, 'height' => 0, 'type' => ''];
403 }
404
405 return null;
406 }
407
408 /**
409 * The decoded path of a URL, which is what metadata file names compare to.
410 *
411 * @since 2.12.0
412 * @param string $url Image URL.
413 * @return string
414 */
415 private static function path(string $url): string {
416 $path = wp_parse_url($url, PHP_URL_PATH);
417
418 return is_string($path) ? rawurldecode($path) : '';
419 }
420
421 /**
422 * @since 2.12.0
423 * @param string $haystack String to look in.
424 * @param string $needle Ending to look for.
425 * @return bool
426 */
427 private static function ends_with(string $haystack, string $needle): bool {
428 return '' !== $needle && substr($haystack, -strlen($needle)) === $needle;
429 }
430 }
431