| 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 |
|