*/ private static array $known = []; /** * Record the attachment a URL was built from. * * @since 2.12.0 * * @param string $url Image URL, exactly as it will be asked about. * @param int $attachment_id Attachment it came from. * @return void */ public static function remember(string $url, int $attachment_id): void { if ('' !== $url && $attachment_id > 0) { self::$known[$url] = $attachment_id; } } /** * Forget everything remembered for this request. * * @since 2.12.0 * @return void */ public static function forget(): void { self::$known = []; } /** * Attachment ID named by a `wp-image-{ID}` class, or 0. * * The editor puts it on every image it inserts. It is a claim made by * markup, not a fact: content copied from another site carries that site's * IDs. Pass it to id_from_url() as the hint, which checks it. * * @since 2.12.0 * * @param string $html An `` tag, or just its class attribute value. * @return int */ public static function hint_from_markup(string $html): int { if (preg_match('/(? 0 && null !== self::listed_file($hint, $url)) { self::$known[$url] = $hint; return $hint; } // Salted with the posts group's last_changed, which core moves whenever // a post — an attachment included — is added, edited or deleted. An // upload therefore retires a cached miss, and a deletion a cached hit. $key = 'attachment_id:' . md5($url) . ':' . wp_cache_get_last_changed('posts'); $cached = wp_cache_get($key, self::CACHE_GROUP); if (false !== $cached) { return (int) $cached; } $attachment_id = self::query($url); // Misses are stored too: an image hosted elsewhere is the URL that // would otherwise be asked about on every view. wp_cache_set($key, $attachment_id, self::CACHE_GROUP, DAY_IN_SECONDS); return $attachment_id; } /** * Dimensions and type of the file a URL points at. * * An attachment's own metadata describes the original. The URL being * published is often a generated size, and announcing the original's * 2000x1500 beside a 1024x768 file gives a consumer numbers to lay a card * out with that the image does not match. * * @since 2.12.0 * * @param int $attachment_id Attachment the URL belongs to. * @param string $url The URL being described. * @return array{width:int, height:int, type:string} Zero dimensions when * unknown (vectors too). */ public static function describe(int $attachment_id, string $url): array { // No attachment, nothing to describe. Without this the `-WxH` fallback // below would read a size out of the file name of a URL that is not in // the library at all — a remote `photo-1024x768.jpg` would be published // as 1024x768 on the word of its name. Every caller checks // id_from_url() first, so this only holds the contract for the next one. if ($attachment_id <= 0) { return ['width' => 0, 'height' => 0, 'type' => '']; } $type = (string) get_post_mime_type($attachment_id); $file = self::listed_file($attachment_id, $url); if (null !== $file) { return [ 'width' => $file['width'], 'height' => $file['height'], 'type' => '' !== $file['type'] ? $file['type'] : $type, ]; } // A generated size the metadata no longer lists (thumbnails were // regenerated since the URL was written) still states its dimensions // in its name. if (preg_match('/-(\d+)x(\d+)\.[a-zA-Z0-9]+$/', self::path($url), $match)) { return [ 'width' => (int) $match[1], 'height' => (int) $match[2], 'type' => $type, ]; } $meta = wp_get_attachment_metadata($attachment_id); return [ 'width' => is_array($meta) && isset($meta['width']) ? (int) $meta['width'] : 0, 'height' => is_array($meta) && isset($meta['height']) ? (int) $meta['height'] : 0, 'type' => $type, ]; } /** * Ask the database once for every file the URL could be. * * This is attachment_url_to_postid() with an IN list where core has one * value: the same normalisation, the same table, the same two filters. * Asking core itself once per candidate would cost a generated-size URL * that is not in the library four of the scans this class exists to avoid. * * The URL as written has priority: an upload the author named * `banner-1200x630.jpg` is an original, and must not be mistaken for a * size of some other `banner.jpg`. * * @since 2.12.0 * @param string $url Image URL. * @return int */ private static function query(string $url): int { /** This filter is documented in wp-includes/media.php */ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core's hook, applied for parity with attachment_url_to_postid(). $pre = apply_filters('pre_attachment_url_to_postid', null, $url); if (null !== $pre) { return (int) $pre; } $paths = self::candidate_paths($url); $stored = self::stored_paths($paths); $attachment_id = 0; foreach ($paths as $path) { if (isset($stored[$path])) { $attachment_id = $stored[$path]; break; } } // MySQL matched case-insensitively and nothing matched exactly, which // core resolves the same way: take what the database found. if (!$attachment_id && [] !== $stored) { foreach ($paths as $path) { foreach ($stored as $meta_value => $stored_id) { if (0 === strcasecmp((string) $meta_value, $path)) { $attachment_id = $stored_id; break 2; } } } } /** This filter is documented in wp-includes/media.php */ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core's hook, applied for parity with attachment_url_to_postid(). return (int) apply_filters('attachment_url_to_postid', $attachment_id, $url); } /** * What `_wp_attached_file` could hold for this URL, most likely first. * * The first entry is what core itself would ask for: the URL relative to * the uploads directory, or the URL untouched when it is not under it. A * `-WxH` suffix adds the original it would have been generated from, and * the `-scaled` / `-rotated` copies core stores in the original's place. * * @since 2.12.0 * @param string $url Image URL. * @return array */ private static function candidate_paths(string $url): array { $dir = wp_get_upload_dir(); $path = $url; // Core forces the URL's scheme to the uploads directory's, so an http // URL still finds a file the site serves over https. $site_scheme = wp_parse_url((string) ($dir['url'] ?? ''), PHP_URL_SCHEME); $url_scheme = wp_parse_url($path, PHP_URL_SCHEME); if (is_string($url_scheme) && is_string($site_scheme) && $url_scheme !== $site_scheme) { $path = $site_scheme . substr($path, strlen($url_scheme)); } $base = (string) ($dir['baseurl'] ?? '') . '/'; if ('/' !== $base && 0 === strpos($path, $base)) { $path = substr($path, strlen($base)); } $paths = [$path]; $original = preg_replace('/-\d+x\d+(?=\.[a-zA-Z0-9]+$)/', '', $path); if (is_string($original) && $original !== $path) { $paths[] = $original; foreach (self::REPLACED_ORIGINAL_SUFFIXES as $suffix) { $replaced = preg_replace('/(?=\.[a-zA-Z0-9]+$)/', $suffix, $original, 1); if (is_string($replaced)) { $paths[] = $replaced; } } } return $paths; } /** * Which of the paths the library stores, as meta_value => attachment ID. * * @since 2.12.0 * @param array $paths Candidate `_wp_attached_file` values. * @return array */ private static function stored_paths(array $paths): array { global $wpdb; $placeholders = implode(', ', array_fill(0, count($paths), '%s')); $rows = $wpdb->get_results( $wpdb->prepare( // 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. "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = '_wp_attached_file' AND meta_value IN ({$placeholders})", $paths ) ); $stored = []; foreach ((array) $rows as $row) { if (!isset($row->meta_value, $row->post_id)) { continue; } // Two attachments with one file: the first row, as in core. if (!isset($stored[(string) $row->meta_value])) { $stored[(string) $row->meta_value] = (int) $row->post_id; } } return $stored; } /** * The metadata entry for the file a URL names, if this attachment has one. * * Doubles as the ownership check for a hinted ID: an attachment owns a URL * when the URL ends in a file its metadata lists, directory included. * * @since 2.12.0 * * @param int $attachment_id Attachment to look in. * @param string $url Image URL. * @return array{width:int, height:int, type:string}|null Null when the * attachment lists no such file. */ private static function listed_file(int $attachment_id, string $url): ?array { $meta = wp_get_attachment_metadata($attachment_id); if (!is_array($meta) || empty($meta['file']) || !is_string($meta['file'])) { return null; } $path = self::path($url); $dir = dirname($meta['file']); $dir = '.' === $dir ? '/' : '/' . $dir . '/'; if (self::ends_with($path, $dir . wp_basename($meta['file']))) { return [ 'width' => (int) ($meta['width'] ?? 0), 'height' => (int) ($meta['height'] ?? 0), 'type' => '', ]; } foreach ((array) ($meta['sizes'] ?? []) as $size) { if (!is_array($size) || empty($size['file'])) { continue; } if (self::ends_with($path, $dir . $size['file'])) { return [ 'width' => (int) ($size['width'] ?? 0), 'height' => (int) ($size['height'] ?? 0), 'type' => (string) ($size['mime-type'] ?? ''), ]; } } // The file as uploaded, kept beside a -scaled or -rotated replacement. // Its dimensions are not recorded — the metadata's are the replacement's. if (!empty($meta['original_image']) && self::ends_with($path, $dir . $meta['original_image'])) { return ['width' => 0, 'height' => 0, 'type' => '']; } return null; } /** * The decoded path of a URL, which is what metadata file names compare to. * * @since 2.12.0 * @param string $url Image URL. * @return string */ private static function path(string $url): string { $path = wp_parse_url($url, PHP_URL_PATH); return is_string($path) ? rawurldecode($path) : ''; } /** * @since 2.12.0 * @param string $haystack String to look in. * @param string $needle Ending to look for. * @return bool */ private static function ends_with(string $haystack, string $needle): bool { return '' !== $needle && substr($haystack, -strlen($needle)) === $needle; } }