its real path and address (in the home address's scheme). */ private static function roots(): array { if ( ! function_exists( 'get_template_directory' ) || ! function_exists( 'get_stylesheet_directory' ) ) { return array(); } $out = array(); $pairs = array( array( get_template_directory(), get_template_directory_uri() ), array( get_stylesheet_directory(), get_stylesheet_directory_uri() ) ); foreach ( self::folders() as $folder ) { $pairs[] = array( WP_CONTENT_DIR . '/' . $folder, content_url( $folder ) ); } foreach ( $pairs as $pair ) { list( $dir, $uri ) = $pair; $root = realpath( $dir ); // 1.9.0 (F-IM-28): the root keeps the home address's scheme, so a worker started from WP-CLI (no HTTPS request, // content_url() says http) finds the same roots and keys as a visitor's HTTPS request. $uri = set_url_scheme( rtrim( $uri, '/' ), (string) wp_parse_url( home_url( '/' ), PHP_URL_SCHEME ) ); if ( $root && ! is_link( $dir ) && Urls::origin( $uri ) === Urls::origin( home_url( '/' ) ) ) { $out[ hash( 'sha256', $uri ) ] = array( 'dir' => $root, 'uri' => $uri, ); } } return $out; } /** * 1.9.0 (D-500 gap 11): the folders inside wp-content the store owner named (Settings), each a real folder there that is * not the uploads folder or inside it, not a link, and not Image Manager's own. * * @param array|null $names Folder names to check instead of the saved ones. * @return array Relative folder names. */ public static function folders( ?array $names = null ): array { $content = realpath( WP_CONTENT_DIR ); $uploads = realpath( (string) wp_get_upload_dir()['basedir'] ); $own = realpath( DIR ); $out = array(); foreach ( array_slice( (array) ( $names ?? ( Settings::get()['picture_folders'] ?? array() ) ), 0, 10 ) as $name ) { $name = trim( (string) $name, " \t/" ); if ( '' === $name || strlen( $name ) > 200 || ! preg_match( '~^[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*$~D', $name ) || preg_match( '~(?:^|/)\.\.?(?:/|$)~', $name ) ) { continue; } $dir = WP_CONTENT_DIR . '/' . $name; $real = realpath( $dir ); if ( ! $content || ! $real || is_link( $dir ) || ! is_dir( $real ) || ! str_starts_with( $real, $content . '/' ) ) { continue; } if ( ( $uploads && ( $real === $uploads || str_starts_with( $real, $uploads . '/' ) || str_starts_with( $uploads, $real . '/' ) ) ) || ( $own && ( $real === $own || str_starts_with( $real, $own . '/' ) ) ) ) { continue; } $out[] = $name; } return array_values( array_unique( $out ) ); } /** * Returns a verified local image descriptor, never trusting a submitted filesystem path. * * @param string $url The picture's address on a page. * @return array|null The folder it is in, its path inside it, its address without query and its real path, or null * when it is no picture of those folders. */ public static function resolve( string $url ): ?array { if ( ! Settings::get()['theme_images'] || strlen( $url ) > 2048 ) { return null; } $p = wp_parse_url( $url ); if ( ! is_array( $p ) || ! empty( $p['user'] ) || ! empty( $p['pass'] ) || ! empty( $p['fragment'] ) || ! Urls::safe_path( $p['path'] ?? '' ) ) { return null; } if ( ! empty( $p['query'] ) ) { parse_str( $p['query'], $q ); if ( array_diff( array_keys( $q ), array( 'ver', 'version' ) ) ) { return null; } foreach ( $q as $v ) { if ( ! is_string( $v ) || ! preg_match( '/^[a-zA-Z0-9._-]{1,64}$/D', $v ) ) { return null; } } } $clean = strtok( $url, '?' ); foreach ( self::roots() as $key => $root ) { if ( ! str_starts_with( $clean, $root['uri'] . '/' ) ) { continue; } $relative = rawurldecode( substr( $clean, strlen( $root['uri'] ) + 1 ) ); if ( ! preg_match( '/\.(?:jpe?g|png|gif|webp)$/iD', $relative ) || str_contains( $relative, '..' ) || preg_match( '~[\\\\\x00-\x1f:]~', $relative ) ) { return null; } $path = $root['dir']; foreach ( explode( '/', $relative ) as $piece ) { if ( '' === $piece || '.' === $piece ) { return null; } $path .= '/' . $piece; if ( is_link( $path ) ) { return null; } } $real = realpath( $path ); if ( ! $real || ! str_starts_with( $real, $root['dir'] . '/' ) || ! is_file( $real ) || ! is_readable( $real ) ) { return null; } return array( 'root' => $key, 'relative' => $relative, 'url' => $clean, 'path' => $real, ); } return null; } /** * The theme source an address is indexed under. * * @param string $url The picture's address (its query is ignored). * @return array|null The source row, or null. */ public static function find( string $url ): ?array { global $wpdb; $clean = strtok( $url, '?' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read/update the plugin theme-source URL index and collision records; current mappings are required during rediscovery. $id = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT source_id FROM %i WHERE url_key=%s', Repository::table( 'urls' ), hash( 'sha256', 'theme-url:' . $clean ) ) ); $row = $id ? Repository::row( $id ) : null; return $row && self::is_source( $row ) ? $row : null; } /** * Group only responsive candidates encountered on the same actual HTML element. * * @param array $urls The addresses one element offers (src and srcset). * @param string $page The page they were found on. * @param int $priority The source's priority. * @return array The addresses handled. */ public static function discover( array $urls, string $page, int $priority ): array { global $wpdb; $files = array(); $row = null; $handled = array(); foreach ( array_slice( array_unique( $urls ), 0, 32 ) as $url ) { $r = self::resolve( $url ); if ( ! $r ) { continue; } $info = wp_getimagesize( $r['path'] ); if ( ! $info || ! in_array( $info['mime'] ?? '', array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp' ), true ) ) { continue; } $known = self::find( $url ); if ( $known && $row && (int) $known['id'] !== (int) $row['id'] ) { continue; } if ( $known ) { $row = $known; } $key = $r['root'] . '/' . $r['relative']; $files[ $key ] = array( 'root' => $r['root'], 'relative' => $r['relative'], 'url' => $r['url'], 'width' => (int) $info[0], 'height' => (int) $info[1], ); $handled[] = $url; } if ( ! $files ) { return array(); } $first = reset( $files ); if ( ! $row ) { // Reuse an earlier unmapped row for the main file rather than duplicating the pilot inventory. $row = Repository::discover( 0, $priority, array( 'url' => $first['url'], 'page' => $page, 'surface' => 'active_theme', 'mapping' => 'verified_theme_file', ) ); } $d = $row['details']; $before = $d['theme_files'] ?? array(); $d['kind'] = 'theme'; $d['surface'] = 'active_theme'; $d['mapping'] = 'verified_theme_file'; $d['theme_files'] = array_slice( array_merge( $before, $files ), 0, 32, true ); $d['pages'] = array_slice( array_values( array_unique( array_merge( $d['pages'] ?? array(), array( $page ) ) ) ), 0, 12 ); $d['urls'] = array_slice( array_values( array_unique( array_merge( $d['urls'] ?? array(), $handled ) ) ), 0, 32 ); $values = array( 'details' => $d, 'priority' => min( (int) $row['priority'], $priority ), ); $changed = $before !== $d['theme_files']; if ( 'unmapped' === $row['state'] || ( $changed && ! $row['excluded'] ) ) { $values['state'] = 'discovered'; $values['reason'] = ''; } if ( $changed && ! empty( $row['manifest']['schema'] ) ) { $m = $row['manifest']; $m['meta_hash'] = hash( 'sha256', wp_json_encode( $d['theme_files'] ) ); $values['manifest'] = $m; } Repository::update( (int) $row['id'], $values ); foreach ( $files as $f ) { $key = hash( 'sha256', 'theme-url:' . $f['url'] ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read/update the plugin theme-source URL index and collision records; current mappings are required during rediscovery. Reviewed_Store::database_query( $wpdb->prepare( 'INSERT IGNORE INTO %i (url_key,attachment_id,source_id) VALUES (%s,0,%d)', Repository::table( 'urls' ), $key, (int) $row['id'] ) ); // Retire only duplicate unmapped inventory entries: no files or prior optimization records are deleted. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read/update the plugin theme-source URL index and collision records; current mappings are required during rediscovery. $duplicate = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM %i WHERE source_key=%s AND state='unmapped' AND id<>%d", Repository::table( 'sources' ), hash( 'sha256', 'unmapped:' . $f['url'] ), (int) $row['id'] ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read/update the plugin theme-source URL index and collision records; current mappings are required during rediscovery. if ( $duplicate ) { Reviewed_Store::database_delete( Repository::table( 'sources' ), array( 'id' => (int) $duplicate, 'state' => 'unmapped', ) ); } } return $handled; } /** * A theme source's files that still resolve to the same file. * * @param array $row The source row. * @return array Path key => relative path, real path, width, height and name. */ public static function files( array $row ): array { if ( ! self::is_source( $row ) || ! Settings::get()['theme_images'] ) { return array(); } $out = array(); foreach ( $row['details']['theme_files'] ?? array() as $key => $f ) { $r = self::resolve( $f['url'] ?? '' ); if ( ! $r || $r['root'] !== $f['root'] || $r['relative'] !== $f['relative'] ) { continue; } $out[ $key ] = array( 'relative' => $key, 'path' => $r['path'], 'width' => (int) $f['width'], 'height' => (int) $f['height'], 'name' => basename( $f['relative'] ), ); } return $out; } /** * Whether a theme source may be delivered: found on a public page, not printed inside a picture element, its files * still there. * * @param array $row The source row. * @return bool True when it may. */ public static function eligible( array $row ): bool { if ( ! self::is_source( $row ) || ! Settings::get()['theme_images'] || empty( $row['details']['pages'] ) || ! empty( $row['details']['native_picture'] ) || ! self::files( $row ) ) { return false; } /** * Filters whether a theme image carried over from an earlier release's record (its copies keep their earlier names) may still be treated as public. * * @since 1.7.0.4 Earliest release in the source history. * * @param bool $allowed True to allow. * @param array $row Image Manager's index row of the theme image. */ if ( ! empty( $row['details']['legacy_copy_prefix'] ) && true !== apply_filters( 'afsd_im_public_theme_image_allowed', true, $row ) ) { return false; } /** * Filters whether a theme image seen on public pages is eligible for optimization and delivery. * * @since 1.7.0.4 Earliest release in the source history. * * @param bool $allowed True to allow. * @param int $source_id Image Manager's source ID of the theme image. * @param array $details The source's recorded details (its theme files, the public pages and addresses it was seen on). */ return true === apply_filters( 'afsd_im_theme_source_allowed', true, (int) $row['id'], $row['details'] ); } /** * The theme picture pass on a public page (not a product page), where delivery is allowed: a pass on the page's * whole HTML through WordPress's template enhancement output buffer (Hooks::PAGE_HTML). */ public static function start_buffer(): void { if ( ! Settings::get()['theme_images'] || ! Delivery::allowed() || ( function_exists( 'is_product' ) && is_product() ) ) { return; } // The response is bounded, read-only, and only indexed active-theme images can change. add_filter( Hooks::PAGE_HTML, array( self::class, 'render' ), Hooks::PASS_THEME ); } /** * The buffered page after the theme picture pass; the page as it was when anything goes wrong. * * @param string $html The page. * @return string The page. */ public static function render( string $html ): string { try { return self::render_images( $html ); } catch ( \Throwable $e ) { return $html; } // Never turn a delivery integration failure into a broken public response. } /** * Wrap the indexed theme pictures of a page in picture elements with their copies. * * @param string $html The page. * @return string The page. */ private static function render_images( string $html ): string { if ( strlen( $html ) > 2097152 || ! Delivery::allowed() || ( function_exists( 'is_product' ) && is_product() ) || ! Settings::get()['theme_images'] ) { return $html; } $p = new \WP_HTML_Tag_Processor( $html ); if ( ! method_exists( $p, 'set_attribute' ) || ! method_exists( $p, 'is_tag_closer' ) ) { return $html; } $blocked = 0; $serial = bin2hex( random_bytes( 12 ) ); $wraps = array(); $n = 0; while ( $n++ < 8000 && $p->next_tag( array( 'tag_closers' => 'visit' ) ) ) { $tag = $p->get_tag(); if ( in_array( $tag, array( 'PICTURE', 'NOSCRIPT', 'SVG', 'VIDEO', 'AUDIO' ), true ) ) { $blocked = $p->is_tag_closer() ? max( 0, $blocked - 1 ) : $blocked + 1; continue; } if ( $blocked || 'IMG' !== $tag || $p->is_tag_closer() || count( $wraps ) >= 128 ) { continue; } $source = self::source_markup( $p ); if ( ! $source ) { continue; } $marker = $serial . '-' . count( $wraps ); $wraps[ $marker ] = $source; $p->set_attribute( 'data-afsd-im-wrap', $marker ); } if ( ! $wraps ) { return $html; } $marked = $p->get_updated_html(); $output = preg_replace_callback( '~\x22\x27]|\x22[^\x22]*\x22|\x27[^\x27]*\x27)*>~i', static function ( $m ) use ( $wraps ) { if ( ! preg_match( '~\sdata-afsd-im-wrap="([a-f0-9]+-[0-9]+)"~', $m[0], $tag ) || ! isset( $wraps[ $tag[1] ] ) ) { return $m[0]; } $img = str_replace( $tag[0], '', $m[0] ); return '' . $wraps[ $tag[1] ] . $img . ''; }, $marked ); return is_string( $output ) ? $output : $html; } /** * The source elements for the img the processor is on, or nothing when it is scripted or lazy-loaded by another * plugin. * * @param \WP_HTML_Tag_Processor $p The HTML processor, on an img. * @return string The source elements' HTML, or an empty string. */ private static function source_markup( \WP_HTML_Tag_Processor $p ): string { foreach ( array( 'data-src', 'data-lazy-src', 'data-original', 'data-lazy-original', 'data-srcset', 'data-lazy-srcset', 'data-large_image', 'data-large-image', 'data-afsd-im', 'data-afsd-im-wrap' ) as $attr ) { if ( null !== $p->get_attribute( $attr ) ) { return ''; } } $src = $p->get_attribute( 'src' ); if ( ! is_string( $src ) ) { return ''; } $row = self::find( $src ); if ( ! $row || $row['excluded'] || ! $row['delivery'] || ! self::eligible( $row ) ) { return ''; } $files = self::files( $row ); $map = $row['details']['theme_files']; $get = static function ( string $url, int $width = 0 ) use ( $row, $map, $files ) { $r = self::resolve( $url ); if ( ! $r ) { return null; } $key = $r['root'] . '/' . $r['relative']; $v = $row['manifest']['variants'][ $key ] ?? null; if ( ( ! $v && empty( $row['manifest']['responsive'] ) ) || ! isset( $files[ $key ], $map[ $key ] ) || ( $width && $files[ $key ]['width'] !== $width ) ) { return null; } return array( 'variant' => $v ?? array(), 'file' => $files[ $key ], 'width' => $width, 'native_url' => $url, ); }; $base = $get( $src ); if ( ! $base ) { return ''; } $srcset = $p->get_attribute( 'srcset' ); $entries = array(); if ( is_string( $srcset ) && '' !== $srcset ) { $candidates = Urls::srcset( $srcset ); if ( ! $candidates ) { return ''; } foreach ( $candidates as $c ) { $item = $get( $c['url'], $c['width'] ); if ( ! $item ) { return ''; }$entries[] = $item; } } else { $entries[] = $base; } $sizes = $p->get_attribute( 'sizes' ); if ( is_string( $sizes ) ) { $sizes = preg_replace( '/^auto(?:\s*,\s*|\s*$)/i', '', $sizes ); } if ( count( $entries ) > 1 && ! $sizes ) { return ''; } return Delivery::sources_html( Responsive::extend( Delivery::complete_sets( $entries, ! empty( $row['manifest']['responsive'] ) ), $entries, $row, is_string( $sizes ) ? $sizes : '' ), is_string( $sizes ) ? $sizes : '' ); } }