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 : '' );
}
}