post_status || '' !== (string) $p->post_password || ! in_array( $p->post_type, array( 'post', 'page', 'product' ), true ) ) { return false; } if ( ! is_post_publicly_viewable( $p ) ) { return false; } /** * Filters whether a published, publicly viewable post, page or product counts as a public owner of its images. * Providers may veto; a grant cannot override a private or password-protected native owner, which is refused before. * * @since 1.7.0.4 Earliest release in the source history. * * @param bool $allowed True to allow. * @param int $id The post ID. * @param \WP_Post $p The post. */ return true === apply_filters( 'afsd_im_public_owner_allowed', true, $id, $p ); } /** * A file's real path inside the uploads folder. * * @param string $relative Its path inside the uploads folder. * @return string The real path. * @throws \RuntimeException With unsafe_source_path for a path that could leave the folder, or another reason code * when the file is not there. */ public static function path( string $relative ): string { if ( '' === $relative || str_contains( $relative, '..' ) || preg_match( '~[:\\\\\x00-\x1f]~', $relative ) || str_starts_with( $relative, '/' ) ) { throw new \RuntimeException( 'unsafe_source_path' ); } $u = wp_get_upload_dir(); $root = realpath( $u['basedir'] ); if ( false === $root ) { throw new \RuntimeException( 'uploads_unavailable' ); } $raw = $root . '/' . $relative; $real = realpath( $raw ); if ( false === $real || ! is_file( $real ) || ! is_readable( $real ) ) { throw new \RuntimeException( 'source_missing' ); } // Reject symlinks in any path component, including an otherwise contained target. $cursor = $root; foreach ( explode( '/', $relative ) as $piece ) { $cursor .= '/' . $piece; if ( is_link( $cursor ) ) { throw new \RuntimeException( 'source_symlink' ); } } if ( ! str_starts_with( $real, $root . '/' ) || str_starts_with( $real, $root . '/affstackdev-image-manager/' ) ) { throw new \RuntimeException( 'unsafe_source_path' ); } return $real; } /** * An image attachment's files: the original and WordPress's sizes, each with its path, width and height. * * @param int $id The attachment. * @return array Path inside uploads => the file. */ public static function files( int $id ): array { if ( isset( self::$file_cache[ $id ] ) ) { return self::$file_cache[ $id ]; } $post = get_post( $id ); if ( ! $post || 'attachment' !== $post->post_type || ! str_starts_with( (string) $post->post_mime_type, 'image/' ) ) { return array(); } $meta = get_post_meta( $id, '_wp_attachment_metadata', true ); $relative = get_post_meta( $id, '_wp_attached_file', true ); if ( ! is_array( $meta ) || ! is_string( $relative ) || empty( $meta['file'] ) || $meta['file'] !== $relative ) { return array(); } try { self::path( $relative ); } catch ( \Throwable $e ) { return array(); } $dir = dirname( $relative ); $dir = '.' === $dir ? '' : $dir . '/'; $out = array(); $order = array( 'woocommerce_thumbnail', 'thumbnail', 'medium', 'medium_large', 'large', 'woocommerce_single', 'woocommerce_gallery_thumbnail' ); $sizes = is_array( $meta['sizes'] ?? null ) ? $meta['sizes'] : array(); foreach ( array_unique( array_merge( $order, array_keys( $sizes ) ) ) as $name ) { if ( ! isset( $sizes[ $name ] ) ) { continue; } $size = $sizes[ $name ]; $file = $size['file'] ?? ''; if ( ! is_string( $file ) || basename( $file ) !== $file || str_contains( $file, '..' ) ) { continue; } $rel = $dir . $file; try { $path = self::path( $rel ); } catch ( \Throwable $e ) { continue; } $out[ $rel ] = array( 'relative' => $rel, 'path' => $path, 'width' => (int) ( $size['width'] ?? 0 ), 'height' => (int) ( $size['height'] ?? 0 ), 'name' => $name, ); if ( count( $out ) >= 63 ) { break; } } $out[ $relative ] = array( 'relative' => $relative, 'path' => self::path( $relative ), 'width' => (int) ( $meta['width'] ?? 0 ), 'height' => (int) ( $meta['height'] ?? 0 ), 'name' => 'full', ); return self::$file_cache[ $id ] = $out; } /** * Index the addresses of an attachment's files under its source (once per request). * * @param int $attachment The attachment. * @param int $source The source row. */ public static function index( int $attachment, int $source ): void { global $wpdb; if ( isset( self::$indexed[ $attachment ] ) ) { return; } self::$indexed[ $attachment ] = true; foreach ( self::files( $attachment ) as $relative => $file ) { $key = hash( 'sha256', $relative ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin URL-to-source index uses prepared unique-key writes and current mappings; stale entries can select the wrong source after a media change. Reviewed_Store::database_query( $wpdb->prepare( 'INSERT IGNORE INTO %i (url_key,attachment_id,source_id) VALUES (%s,%d,%d)', Repository::table( 'urls' ), $key, $attachment, $source ) ); // A shared filename with competing attachment owners is ambiguous, not a reason to guess. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin URL-to-source index uses prepared unique-key writes and current mappings; stale entries can select the wrong source after a media change. Reviewed_Store::database_query( $wpdb->prepare( 'UPDATE %i SET attachment_id=0 WHERE url_key=%s AND attachment_id<>%d', Repository::table( 'urls' ), $key, $attachment ) ); } } /** * The attachment and file an address shows. * * @param string $url The address. * @param int $hint The attachment the markup names, or 0. * @param bool $lookup Whether to look the address up when the hint does not match. * @return array The attachment, the file and how it was mapped (verified_attachment or verified_jetpack_attachment), * or an empty array. */ public static function map( string $url, int $hint = 0, bool $lookup = true ): array { $media = Urls::media( $url ); if ( ! $media ) { return array(); } $key = $hint . '|' . hash( 'sha256', $url ) . '|' . (int) $lookup; if ( array_key_exists( $key, self::$map ) ) { return self::$map[ $key ]; } $ids = array(); global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin URL-to-source index uses prepared unique-key writes and current mappings; stale entries can select the wrong source after a media change. $known = $wpdb->get_row( $wpdb->prepare( 'SELECT attachment_id FROM %i WHERE url_key=%s', Repository::table( 'urls' ), hash( 'sha256', $media['relative'] ) ), ARRAY_A ); if ( $known && ! (int) $known['attachment_id'] ) { return self::$map[ $key ] = array(); } if ( $known ) { $ids[] = (int) $known['attachment_id']; } if ( $hint ) { $ids[] = $hint; } if ( $lookup ) { $id = attachment_url_to_postid( $media['url'] ); if ( $id ) { $ids[] = $id; } // A filename hint only proposes an attachment: actual metadata must prove the exact file. $base = preg_replace( '~-\d+x\d+(\.[a-z0-9]+)$~i', '$1', $media['url'] ); if ( $base !== $media['url'] ) { $id = attachment_url_to_postid( $base ); if ( $id ) { $ids[] = $id; } } } foreach ( array_unique( $ids ) as $id ) { $files = self::files( (int) $id ); if ( isset( $files[ $media['relative'] ] ) ) { return self::$map[ $key ] = array_merge( $media, array( 'id' => (int) $id, 'mapping' => $media['cdn'] ? 'verified_jetpack_attachment' : 'verified_attachment', ) ); } } return self::$map[ $key ] = array(); } /** * The attachment an img names (data-attachment-id, or a wp-image class). * * @param \WP_HTML_Tag_Processor $p The HTML processor, on an img. * @return int The attachment, or 0. */ public static function hint( \WP_HTML_Tag_Processor $p ): int { $attr = $p->get_attribute( 'data-attachment-id' ); if ( is_string( $attr ) && ctype_digit( $attr ) ) { return (int) $attr; } if ( preg_match( '~(?:^|\s)wp-image-(\d+)(?:\s|$)~', (string) $p->get_attribute( 'class' ), $m ) ) { return (int) $m[1]; } return 0; } /** * The attachments a post's saved content shows (by their wp-image classes), for content up to 512 KB. * * @param string $content The saved content. * @return array Attachment IDs. */ public static function content_ids( string $content ): array { if ( strlen( $content ) > 524288 ) { return array(); } $p = new \WP_HTML_Tag_Processor( $content ); $ids = array(); $n = 0; while ( $n++ < 128 && $p->next_tag( 'IMG' ) ) { foreach ( array( 'src', 'data-src', 'data-lazy-src', 'data-original', 'data-lazy-original' ) as $attr ) { $url = $p->get_attribute( $attr ); if ( ! is_string( $url ) || '' === $url ) { continue; } $mapped = self::map( $url, self::hint( $p ) ); if ( $mapped ) { $ids[] = $mapped['id']; } } } return array_values( array_unique( $ids ) ); } /** * The attachments a post shows: its featured image, its content's pictures and a product's gallery (up to 64). * * @param int $id The post. * @return array Attachment IDs. */ public static function post_ids( int $id ): array { if ( isset( self::$post_cache[ $id ] ) ) { return self::$post_cache[ $id ]; } $post = get_post( $id ); if ( ! $post ) { return array(); } $ids = array_merge( array( (int) get_post_thumbnail_id( $id ) ), self::content_ids( (string) $post->post_content ) ); if ( 'product' === $post->post_type && function_exists( 'wc_get_product' ) ) { $product = wc_get_product( $id ); if ( $product ) { $ids[] = (int) $product->get_image_id(); $ids = array_merge( $ids, array_slice( $product->get_gallery_image_ids(), 0, 64 ) ); // Variation coverage is bounded; the initial public catalog pass does not enumerate unbounded families. if ( $product->is_type( 'variable' ) ) { foreach ( array_slice( $product->get_children(), 0, 64 ) as $child ) { $variation = wc_get_product( $child ); if ( $variation && 'publish' === $variation->get_status() && $variation->variation_is_visible() ) { $ids[] = (int) $variation->get_image_id(); } } } } } return self::$post_cache[ $id ] = array_values( array_unique( array_filter( array_map( 'intval', $ids ) ) ) ); } /** * Whether a source may get copies and be delivered: a theme picture that passes its checks, or an attachment without a * password (its parent, if any, public) that a public post shows, that an approved comment on a public post shows, or * that is the site's logo on scanned pages. * * @param array $row The source row. * @param bool $verify_relations Whether to check again where it is used. * @return bool True when it may. */ public static function eligible( array $row, bool $verify_relations = true ): bool { if ( Theme::is_source( $row ) ) { return Theme::eligible( $row ); } $p = get_post( (int) $row['attachment_id'] ); if ( ! $p || 'attachment' !== $p->post_type || ! in_array( $p->post_status, array( 'inherit', 'publish' ), true ) || '' !== (string) $p->post_password ) { return false; } // A known private/draft parent vetoes publishing even when another page contains its URL. if ( $p->post_parent ) { $parent = get_post( $p->post_parent ); $parent_id = $parent && 'product_variation' === $parent->post_type ? (int) $parent->post_parent : (int) $p->post_parent; if ( ! self::public_post( $parent_id ) ) { return false; } } foreach ( Repository::owners( (int) $row['id'] ) as $owner ) { $id = (int) $owner['owner_id']; if ( 'post' === $owner['owner_type'] && self::public_post( $id ) && ( ! $verify_relations || in_array( (int) $row['attachment_id'], self::post_ids( $id ), true ) ) ) { return true; } if ( 'comment' === $owner['owner_type'] ) { $c = get_comment( $id ); if ( $c && '1' === (string) $c->comment_approved && self::public_post( (int) $c->comment_post_ID ) && ( ! $verify_relations || in_array( (int) $row['attachment_id'], self::content_ids( (string) $c->comment_content ), true ) ) ) { return true; } } } // An actually selected site logo seen in anonymous public HTML is a distinct supported owner. // Never grant all unattached uploads public status; the private-parent veto above still applies. if ( function_exists( 'get_theme_mod' ) && (int) get_theme_mod( 'custom_logo', 0 ) === (int) $row['attachment_id'] && ! empty( $row['details']['pages'] ) && in_array( 'public_html', array_merge( (array) ( $row['details']['surfaces'] ?? array() ), array( $row['details']['surface'] ?? '' ) ), true ) ) { /** * Filters whether the site logo, seen in anonymous public page HTML, counts as a public image. * * @since 1.7.0.4 Earliest release in the source history. * * @param bool $allowed True to allow. * @param int $attachment_id The logo's attachment ID. * @param array $row Image Manager's index row of the image. */ return true === apply_filters( 'afsd_im_public_logo_allowed', true, (int) $row['attachment_id'], $row ); } return false; } /** * Record the pictures of a public post as sources. * * @param int $id The post. * @param bool $queue Whether to queue them for copies. * @param int $priority Their priority. */ public static function post( int $id, bool $queue = false, int $priority = 3 ): void { if ( ! self::public_post( $id ) ) { return; } $page = Urls::page( get_permalink( $id ) ); if ( is_wp_error( $page ) ) { return; } foreach ( array_slice( self::post_ids( $id ), 0, 128 ) as $attachment ) { if ( ! self::files( $attachment ) ) { continue; } $r = Repository::discover( $attachment, $priority, array( 'page' => $page, 'url' => wp_get_attachment_url( $attachment ), 'surface' => 'public_post_or_product', 'mapping' => 'verified_attachment', ) ); Repository::owner( (int) $r['id'], 'post', $id ); if ( $queue && ! in_array( $r['state'], array( 'ready', 'no_saving', 'unsupported', 'queued' ), true ) ) { Repository::request( (int) $r['id'], 'upload' ); } } } /** * Record the pictures of an approved comment on a public post as sources. * * @param int $id The comment. * @param bool $queue Whether to queue them for copies. */ public static function comment( int $id, bool $queue = false ): void { $c = get_comment( $id ); if ( ! $c || '1' !== (string) $c->comment_approved || ! self::public_post( (int) $c->comment_post_ID ) ) { return; } foreach ( self::content_ids( (string) $c->comment_content ) as $attachment ) { $r = Repository::discover( $attachment, 4, array( 'page' => get_permalink( $c->comment_post_ID ), 'url' => wp_get_attachment_url( $attachment ), 'surface' => 'approved_comment_html', 'mapping' => 'verified_attachment', ) ); Repository::owner( (int) $r['id'], 'comment', $id ); if ( $queue && ! in_array( $r['state'], array( 'ready', 'no_saving', 'unsupported', 'queued' ), true ) ) { Repository::request( (int) $r['id'], 'upload' ); } } } /** * A source's files: a theme picture's or an attachment's. * * @param array $row The source row. * @return array The files. */ public static function row_files( array $row ): array { return Theme::is_source( $row ) ? Theme::files( $row ) : self::files( (int) $row['attachment_id'] ); } /** * A hash of a source's file metadata, to see whether its files changed. * * @param array $row The source row. * @return string SHA-256. */ public static function row_meta_hash( array $row ): string { return hash( 'sha256', wp_json_encode( Theme::is_source( $row ) ? ( $row['details']['theme_files'] ?? array() ) : get_post_meta( (int) $row['attachment_id'], '_wp_attachment_metadata', true ) ) ); } /** * The prefix of a source's copy file names: the attachment ID, or t or d and the row ID for theme pictures. * * @param array $row The source row. * @return string The prefix. */ public static function prefix( array $row ): string { return $row['attachment_id'] ? (string) $row['attachment_id'] : ( ( $row['details']['legacy_copy_prefix'] ?? '' ) === 'd' . $row['id'] ? 'd' : 't' ) . $row['id']; } /** * Forget what this request read about addresses, files and posts. */ public static function forget_cache(): void { self::$map = array(); self::$indexed = array(); self::$file_cache = array(); self::$post_cache = array(); } /** * A record of each of a source's files (hash, size, time and dimensions; over-limit instead of a hash for files beyond * the limit), and of the kept original. * * @param int $id The source row. * @param array $files Its files. * @return array Path => the file's record. */ public static function fingerprint( int $id, array $files ): array { $result = array(); foreach ( $files as $relative => $file ) { $size = filesize( $file['path'] ); if ( false === $size || $size > Settings::get()['max_bytes'] ) { $hash = 'over-limit'; } else { $hash = hash_file( 'sha256', $file['path'] ); } $result[ $relative ] = array( 'hash' => $hash, 'bytes' => (int) $size, 'mtime' => (int) filemtime( $file['path'] ), 'width' => $file['width'], 'height' => $file['height'], ); } $original = function_exists( 'wp_get_original_image_path' ) ? wp_get_original_image_path( $id ) : false; if ( is_string( $original ) && is_file( $original ) ) { $u = wp_get_upload_dir(); $root = realpath( $u['basedir'] ); $real = realpath( $original ); if ( $root && $real && str_starts_with( $real, $root . '/' ) ) { $rel = substr( $real, strlen( $root ) + 1 ); try { self::path( $rel ); $bytes = filesize( $real ); $result['uploaded_original'] = array( 'relative' => $rel, 'bytes' => $bytes, 'hash' => $bytes <= 33554432 ? hash_file( 'sha256', $real ) : 'not_hashed_above_32MiB', ); } catch ( \Throwable $e ) { /* No original is used as an alternate crop source. */ } } } return $result; } /** * The file an address shows among a source's files. * * @param string $url The address. * @param array $files The source's files. * @return array|null The file, or null. */ public static function variant_for_url( string $url, array $files ): ?array { $m = Urls::media( $url ); if ( ! $m || ! isset( $files[ $m['relative'] ] ) ) { return null; } $file = $files[ $m['relative'] ]; if ( $m['cdn'] && isset( $m['query']['quality'] ) ) { return null; } if ( ! $m['cdn'] || ! array_intersect( array_keys( $m['query'] ), array( 'resize', 'fit', 'w', 'h' ) ) ) { return $file; } // Map only an exact native dimension pair. Custom CDN crop/fit/quality variants remain native. $q = $m['query']; if ( isset( $q['fit'] ) || isset( $q['quality'] ) || isset( $q['w'] ) || isset( $q['h'] ) || ! is_string( $q['resize'] ?? null ) || ! preg_match( '/^([1-9][0-9]*),([1-9][0-9]*)$/', $q['resize'], $dim ) ) { return null; } $matches = array_filter( $files, static fn( $v ) => $v['width'] === (int) $dim[1] && $v['height'] === (int) $dim[2] ); return 1 === count( $matches ) ? reset( $matches ) : null; } }