true ) ), array( 'post', 'page', 'product', 'product_variation', 'wp_block', 'wp_template', 'wp_template_part', 'wp_navigation', 'wp_global_styles', 'custom_css' ) ); return array_values( array_diff( array_unique( $types ), array( 'attachment', 'revision', 'nav_menu_item' ) ) ); } /** The path part of the uploads address, such as /wp-content/uploads. Any host counts: a CDN or another address of the site. */ private static function uploads_path(): string { $u = wp_get_upload_dir(); $path = wp_parse_url( (string) ( $u['baseurl'] ?? '' ), PHP_URL_PATH ); return is_string( $path ) ? untrailingslashit( $path ) : ''; } /** * Count an attachment as used (up to the limit; beyond it the check notes it overflowed). * * @param array $s The check, changed in place. * @param mixed $id The attachment. */ private static function add_id( array &$s, $id ): void { $id = absint( $id ); if ( $id && count( $s['ids'] ) < self::MAX_REFERENCES ) { $s['ids'][ $id ] = true; } elseif ( $id ) { $s['overflow'] = true; } } /** * A folder an Image Manager folder gallery names; with its sub-folders when the gallery includes them. * * @param array $s The check, changed in place. * @param mixed $value The folder's ID. * @param bool $subfolders Whether the gallery includes its sub-folders. */ private static function add_folder( array &$s, $value, bool $subfolders ): void { $id = absint( is_scalar( $value ) ? $value : 0 ); if ( ! $id ) { return; } $s['folders'][ $id ] = ! empty( $s['folders'][ $id ] ) || $subfolders; } /** * Count every attachment of a list (a comma or space separated text or an array) as used. * * @param array $s The check, changed in place. * @param mixed $value The IDs. */ private static function add_ids( array &$s, $value ): void { if ( is_string( $value ) ) { $value = preg_split( '/[\s,]+/', trim( $value ) ); } if ( is_array( $value ) ) { foreach ( $value as $v ) { if ( is_int( $v ) || ( is_string( $v ) && ctype_digit( $v ) ) ) { self::add_id( $s, $v ); } } } } /** * Every uploads address in a text, in any attribute, block setting, style or JSON. * * @param array $s The check, changed in place. * @param string $text The text. */ private static function add_paths( array &$s, string $text ): void { $base = self::uploads_path(); if ( '' === $base || ! str_contains( $text, $base ) && ! str_contains( $text, str_replace( '/', '\\/', $base ) ) ) { return; } $text = str_replace( '\\/', '/', $text ); if ( ! preg_match_all( '~' . preg_quote( $base, '~' ) . '/([^\s"\'<>()\\\\?#,;]+)~i', $text, $m ) ) { return; } foreach ( $m[1] as $path ) { $path = rawurldecode( html_entity_decode( $path, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ); if ( str_contains( $path, '..' ) || strlen( $path ) > 512 ) { continue; } if ( count( $s['paths'] ) < self::MAX_REFERENCES ) { $s['paths'][ strtolower( $path ) ] = true; } else { $s['overflow'] = true; } } } /** * Saved markup only: no shortcode, block, theme code or page is run. Same reading as Check usage, for every file at * once. * * @param array $s The check, changed in place. * @param string $content The saved markup. * @param int $owner The post it belongs to, or 0. */ private static function content( array &$s, string $content, int $owner ): void { if ( '' === $content ) { return; } $tags = new \WP_HTML_Tag_Processor( $content ); while ( $tags->next_tag( 'IMG' ) ) { $classes = $tags->get_attribute( 'class' ); if ( is_string( $classes ) && preg_match_all( '/(?:^|\s)wp-image-([0-9]+)(?:\s|$)/', $classes, $m ) ) { foreach ( $m[1] as $id ) { self::add_id( $s, $id ); } } } preg_match_all( '//s', $content, $blocks, PREG_SET_ORDER ); foreach ( $blocks as $block ) { $attrs = json_decode( $block[2], true, 16 ); if ( ! is_array( $attrs ) ) { continue; } $key = 'media-text' === $block[1] ? 'mediaId' : ( 'gallery' === $block[1] ? 'ids' : 'id' ); $value = $attrs[ $key ] ?? array(); self::add_ids( $s, is_array( $value ) ? $value : array( (string) $value ) ); } preg_match_all( '/' . get_shortcode_regex( array( 'gallery' ) ) . '/s', $content, $shortcodes, PREG_SET_ORDER ); foreach ( $shortcodes as $shortcode ) { if ( '[' === $shortcode[1] && ']' === $shortcode[6] ) { continue; } $attrs = shortcode_parse_atts( $shortcode[3] ); $attrs = is_array( $attrs ) ? $attrs : array(); $ids = $attrs['ids'] ?? ( $attrs['include'] ?? '' ); if ( $ids ) { self::add_ids( $s, $ids ); } elseif ( $owner || isset( $attrs['id'] ) ) { $parent = isset( $attrs['id'] ) ? absint( $attrs['id'] ) : $owner; if ( $parent ) { $s['parents'][ $parent ] = true; } } } // Image Manager's own folder galleries show the pictures of the folder they name. preg_match_all( '/' . get_shortcode_regex( array( Folder_Gallery::SHORTCODE ) ) . '/s', $content, $folder_codes, PREG_SET_ORDER ); foreach ( $folder_codes as $code ) { if ( '[' === $code[1] && ']' === $code[6] ) { continue; } $attrs = shortcode_parse_atts( $code[3] ); $attrs = is_array( $attrs ) ? $attrs : array(); self::add_folder( $s, $attrs['folder'] ?? 0, in_array( strtolower( (string) ( $attrs['subfolders'] ?? '' ) ), array( '1', 'yes', 'true' ), true ) ); } preg_match_all( '//s', $content, $folder_blocks, PREG_SET_ORDER ); foreach ( $folder_blocks as $block ) { $attrs = json_decode( $block[1], true, 16 ); if ( is_array( $attrs ) ) { self::add_folder( $s, $attrs['folder'] ?? 0, ! empty( $attrs['subfolders'] ) ); } } self::add_paths( $s, $content ); } /** * Walk a saved setting (widgets and the like): attachment_id / ids keys and any text. * * @param array $s The check, changed in place. * @param mixed $value The setting's value. * @param int $depth How deep the walk is (it stops past 8). */ private static function walk( array &$s, $value, int $depth = 0 ): void { if ( $depth > 8 ) { return; } if ( is_string( $value ) ) { if ( strlen( $value ) <= self::CONTENT_BYTES ) { self::content( $s, $value, 0 ); } else { ++$s['skipped']; } return; } if ( ! is_array( $value ) ) { return; } foreach ( $value as $k => $v ) { if ( in_array( $k, array( 'attachment_id', 'image_id', 'thumbnail_id' ), true ) ) { self::add_id( $s, is_scalar( $v ) ? $v : 0 ); } elseif ( 'ids' === $k ) { self::add_ids( $s, $v ); } else { self::walk( $s, $v, $depth + 1 ); } } } /** * Site-wide places: icon, logo, header and background, WooCommerce, product categories, widgets. * * @param array $s The check, changed in place. */ private static function site( array &$s ): void { global $wpdb; foreach ( array( 'site_icon', 'site_logo', 'woocommerce_placeholder_image' ) as $option ) { self::add_id( $s, get_option( $option, 0 ) ); } $email = get_option( 'woocommerce_email_header_image', '' ); if ( is_string( $email ) ) { self::add_paths( $s, $email ); } $mods = get_theme_mods(); $mods = is_array( $mods ) ? $mods : array(); self::add_id( $s, $mods['custom_logo'] ?? 0 ); if ( is_object( $mods['header_image_data'] ?? null ) ) { self::add_id( $s, $mods['header_image_data']->attachment_id ?? 0 ); } elseif ( is_array( $mods['header_image_data'] ?? null ) ) { self::add_id( $s, $mods['header_image_data']['attachment_id'] ?? 0 ); } foreach ( array( 'header_image', 'background_image' ) as $mod ) { if ( is_string( $mods[ $mod ] ?? null ) ) { self::add_paths( $s, $mods[ $mod ] ); } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- One bounded read of category pictures (term meta thumbnail_id) for the report. $thumbs = $wpdb->get_col( $wpdb->prepare( 'SELECT meta_value FROM %i WHERE meta_key = %s LIMIT %d', $wpdb->termmeta, 'thumbnail_id', 20000 ) ); foreach ( (array) $thumbs as $id ) { self::add_id( $s, $id ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- One bounded read of the saved widget settings for the report. $widgets = $wpdb->get_results( $wpdb->prepare( 'SELECT option_name, LENGTH(option_value) AS bytes, option_value FROM %i WHERE option_name LIKE %s LIMIT 200', $wpdb->options, $wpdb->esc_like( 'widget_' ) . '%' ), ARRAY_A ); foreach ( (array) $widgets as $w ) { if ( (int) $w['bytes'] > 1048576 ) { ++$s['skipped']; continue; } self::walk( $s, maybe_unserialize( $w['option_value'] ) ); } // Every file in a folder a folder gallery names counts as used, whether the folder is switched on now or not. foreach ( (array) ( $s['folders'] ?? array() ) as $folder => $subfolders ) { $terms = array( (int) $folder ); if ( $subfolders ) { $children = get_term_children( (int) $folder, Library::FOLDER ); if ( is_array( $children ) ) { $terms = array_merge( $terms, array_map( 'intval', $children ) ); } } $objects = get_objects_in_term( $terms, Library::FOLDER ); if ( is_array( $objects ) ) { foreach ( $objects as $id ) { self::add_id( $s, $id ); } } } } /** * Start (or restart) a check, or continue it. Each request works for a few seconds and saves its position. * * @param bool $start True to start a new check. * @return array The check. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function step( bool $start ): array { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { if ( ! current_user_can( 'manage_options' ) || ! current_user_can( 'upload_files' ) ) { throw new \RuntimeException( 'unused_access' ); } $token = Repository::acquire( 'unused_media', 300 ); if ( ! $token ) { throw new \RuntimeException( 'unused_busy' ); } global $wpdb; try { $s = self::state(); if ( $start ) { $types = self::types(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Fixes the content range of this check at its start. $max = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT MAX(ID) FROM %i WHERE post_type IN (' . implode( ',', array_fill( 0, count( $types ), '%s' ) ) . ')', array_merge( array( $wpdb->posts ), $types ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Fixes the file range of this check at its start. $files = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT MAX(ID) FROM %i WHERE post_type = %s', $wpdb->posts, 'attachment' ) ); $s = array( 'v' => 1, 'actor' => get_current_user_id(), 'started' => time(), 'updated' => time(), 'phase' => 'content', 'cursor' => 0, 'max' => $max, 'files_max' => $files, 'types' => $types, 'checked' => 0, 'skipped' => 0, 'ids' => array(), 'paths' => array(), 'parents' => array(), 'folders' => array(), 'files_checked' => 0, 'listed' => array(), 'more_listed' => 0, 'overflow' => false, 'status' => 'more', 'collection' => 0, 'collected' => 0, ); } elseif ( ! $s || 'more' !== $s['status'] ) { throw new \RuntimeException( 'unused_stale' ); } $until = microtime( true ) + 8; while ( 'more' === $s['status'] && microtime( true ) < $until ) { if ( 'content' === $s['phase'] ) { self::content_batch( $s ); } elseif ( 'comments' === $s['phase'] ) { self::comment_batch( $s ); } elseif ( 'site' === $s['phase'] ) { self::site( $s ); $s['phase'] = 'files'; $s['cursor'] = 0; } else { self::file_batch( $s ); } // Uses that could not all be remembered would make used files look unused: such a check lists nothing. if ( $s['overflow'] ) { $s['status'] = 'limit'; $s['listed'] = array(); $s['more_listed'] = 0; } if ( ! Repository::owns( $token, 'unused_media' ) ) { throw new \RuntimeException( 'unused_busy' ); } } $s['updated'] = time(); if ( ! Reviewed_Store::update_option( self::OPTION, $s, false ) && self::state() !== $s ) { throw new \RuntimeException( 'unused_storage' ); } return $s; } finally { Repository::release( $token, 'unused_media' ); } } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Read the next posts' saved content and excerpts for uses. * * @param array $s The check, changed in place. * @throws \RuntimeException With unused_database when the content cannot be read. */ private static function content_batch( array &$s ): void { global $wpdb; $types = $s['types']; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Bounded keyset scan of saved content; SQL caps the bytes read per item and must see current edits. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT ID, LENGTH(post_content)+LENGTH(post_excerpt) AS bytes, CASE WHEN LENGTH(post_content)+LENGTH(post_excerpt)<=%d THEN post_content ELSE \'\' END AS body, CASE WHEN LENGTH(post_content)+LENGTH(post_excerpt)<=%d THEN post_excerpt ELSE \'\' END AS excerpt FROM %i WHERE ID>%d AND ID<=%d AND post_type IN (' . implode( ',', array_fill( 0, count( $types ), '%s' ) ) . ') AND post_status NOT IN (\'trash\',\'auto-draft\') ORDER BY ID ASC LIMIT %d', array_merge( array( self::CONTENT_BYTES, self::CONTENT_BYTES, $wpdb->posts, (int) $s['cursor'], (int) $s['max'] ), $types, array( self::CONTENT_BATCH ) ) ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching if ( $wpdb->last_error || ! is_array( $rows ) ) { throw new \RuntimeException( 'unused_database' ); } if ( ! $rows ) { $s['phase'] = 'comments'; $s['cursor'] = 0; return; } $ids = array_map( 'intval', array_column( $rows, 'ID' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Featured images and product galleries of this batch. $meta = $wpdb->get_results( $wpdb->prepare( 'SELECT meta_key, LEFT(meta_value,16385) AS value FROM %i WHERE post_id IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ') AND meta_key IN (%s,%s) LIMIT 2000', array_merge( array( $wpdb->postmeta ), $ids, array( '_thumbnail_id', '_product_image_gallery' ) ) ), ARRAY_A ); foreach ( (array) $meta as $m ) { if ( '_thumbnail_id' === $m['meta_key'] ) { self::add_id( $s, $m['value'] ); } else { self::add_ids( $s, $m['value'] ); } } $base = self::uploads_path(); if ( '' !== $base ) { // Custom fields and page-builder data: read for picture addresses (not for bare numbers, which could mean anything). // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Bounded read of this batch's custom fields that hold an uploads address. $fields = $wpdb->get_results( $wpdb->prepare( 'SELECT LENGTH(meta_value) AS bytes, LEFT(meta_value,%d) AS value FROM %i WHERE post_id IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ') AND meta_key NOT IN (%s,%s) AND (meta_value LIKE %s OR meta_value LIKE %s) LIMIT 2000', array_merge( array( self::META_BYTES, $wpdb->postmeta ), $ids, array( '_wp_attachment_metadata', '_wp_attached_file' ), array( '%' . $wpdb->esc_like( $base ) . '%', '%' . $wpdb->esc_like( str_replace( '/', '\\/', $base ) ) . '%' ) ) ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching foreach ( (array) $fields as $f ) { if ( (int) $f['bytes'] > self::META_BYTES ) { ++$s['skipped']; } self::add_paths( $s, (string) $f['value'] ); } } foreach ( $rows as $row ) { $s['cursor'] = (int) $row['ID']; if ( (int) $row['bytes'] > self::CONTENT_BYTES ) { ++$s['skipped']; continue; } ++$s['checked']; self::content( $s, $row['body'] . "\n" . $row['excerpt'], (int) $row['ID'] ); } } /** * Comments and reviews: their saved text, for pictures in it. * * @param array $s The check, changed in place. * @throws \RuntimeException With unused_database when the comments cannot be read. */ private static function comment_batch( array &$s ): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Bounded keyset scan of saved comment text. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT comment_ID, LENGTH(comment_content) AS bytes, LEFT(comment_content,%d) AS body FROM %i WHERE comment_ID > %d AND comment_approved NOT IN (%s,%s) ORDER BY comment_ID ASC LIMIT %d', self::META_BYTES, $wpdb->comments, (int) $s['cursor'], 'trash', 'spam', self::COMMENT_BATCH ), ARRAY_A ); if ( $wpdb->last_error || ! is_array( $rows ) ) { throw new \RuntimeException( 'unused_database' ); } if ( ! $rows ) { $s['phase'] = 'site'; $s['cursor'] = 0; return; } foreach ( $rows as $row ) { $s['cursor'] = (int) $row['comment_ID']; if ( (int) $row['bytes'] > self::META_BYTES ) { ++$s['skipped']; continue; } if ( '' !== (string) $row['body'] ) { self::content( $s, (string) $row['body'], 0 ); } } } /** * All names one attachment has under uploads: its file, every size and the kept original. * * @param int $id The attachment. * @return array Its names under uploads, in lower case. */ private static function names( int $id ): array { $file = (string) get_post_meta( $id, '_wp_attached_file', true ); if ( '' === $file ) { return array(); } $dir = str_contains( $file, '/' ) ? substr( $file, 0, strrpos( $file, '/' ) + 1 ) : ''; $out = array( strtolower( $file ) ); $meta = wp_get_attachment_metadata( $id ); $meta = is_array( $meta ) ? $meta : array(); foreach ( array_slice( (array) ( $meta['sizes'] ?? array() ), 0, 100 ) as $size ) { if ( is_array( $size ) && is_string( $size['file'] ?? null ) && '' !== $size['file'] ) { $out[] = strtolower( $dir . wp_basename( $size['file'] ) ); } } if ( is_string( $meta['original_image'] ?? null ) && '' !== $meta['original_image'] ) { $out[] = strtolower( $dir . wp_basename( $meta['original_image'] ) ); } return array_values( array_unique( $out ) ); } /** * Check the next library files against the uses found, and list those with none. * * @param array $s The check, changed in place. * @throws \RuntimeException With unused_database when the library cannot be read. */ private static function file_batch( array &$s ): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Bounded keyset scan of the Media Library. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT ID, post_parent, post_mime_type FROM %i WHERE post_type = %s AND post_status <> %s AND ID > %d AND ID <= %d ORDER BY ID ASC LIMIT %d', $wpdb->posts, 'attachment', 'trash', (int) $s['cursor'], (int) $s['files_max'], self::FILE_BATCH ), ARRAY_A ); if ( $wpdb->last_error || ! is_array( $rows ) ) { throw new \RuntimeException( 'unused_database' ); } if ( ! $rows ) { $s['status'] = 'done'; $s['phase'] = 'done'; return; } foreach ( $rows as $row ) { $id = (int) $row['ID']; $s['cursor'] = $id; ++$s['files_checked']; if ( isset( $s['ids'][ $id ] ) || ( str_starts_with( (string) $row['post_mime_type'], 'image/' ) && isset( $s['parents'][ (int) $row['post_parent'] ] ) ) ) { continue; } $used = false; foreach ( self::names( $id ) as $name ) { if ( isset( $s['paths'][ $name ] ) ) { $used = true; break; } } if ( $used ) { continue; } if ( count( $s['listed'] ) < self::MAX_LISTED ) { $s['listed'][] = $id; } else { ++$s['more_listed']; } } } /** * Put the listed files into a new collection, in steps, so they can be reviewed in the library. Nothing is deleted. * * @return array The check, with the collection's progress. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function collect(): array { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { if ( ! current_user_can( 'manage_options' ) || ! current_user_can( 'upload_files' ) ) { throw new \RuntimeException( 'unused_access' ); } $s = self::state(); if ( ! $s || 'done' !== $s['status'] || ! $s['listed'] ) { throw new \RuntimeException( 'unused_stale' ); } if ( ! $s['collection'] || ! get_term( (int) $s['collection'], Library::COLLECTION ) ) { /* translators: %s: date and time of the check, in UTC. */ $made = Library::create_term( Library::COLLECTION, sprintf( __( 'No use found %s', 'affstackdev-image-manager' ), gmdate( 'Y-m-d H:i', (int) $s['started'] ) ) ); // A collection of the same name (a check started in the same minute) is reused. $same = is_wp_error( $made ) && 'term_exists' === $made->get_error_code() ? (int) $made->get_error_data( 'term_exists' ) : 0; if ( is_wp_error( $made ) && ! $same ) { throw new \RuntimeException( 'unused_collection' ); } $s['collection'] = $same ?: (int) $made['term_id']; $s['collected'] = 0; } $until = microtime( true ) + 8; while ( $s['collected'] < count( $s['listed'] ) && microtime( true ) < $until ) { $chunk = array_slice( $s['listed'], (int) $s['collected'], 100 ); Library::assign( $chunk, Library::COLLECTION, array( (int) $s['collection'] ) ); $s['collected'] += count( $chunk ); } Reviewed_Store::update_option( self::OPTION, $s, false ); return $s; } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * The words for the check's reason code. * * @param string $code The reason code. * @return string The translated words. */ public static function human_error( string $code ): string { $m = array( 'unused_access' => __( 'You cannot check site media.', 'affstackdev-image-manager' ), 'unused_busy' => __( 'A check is still working. Reload the library in a moment.', 'affstackdev-image-manager' ), 'unused_stale' => __( 'This check finished or changed in another window. Reload the library.', 'affstackdev-image-manager' ), 'unused_database' => __( 'The check could not read the site’s content. Reload the library and continue.', 'affstackdev-image-manager' ), 'unused_storage' => __( 'The check could not save its progress. Reload the library and continue.', 'affstackdev-image-manager' ), 'unused_collection' => __( 'The collection could not be made. Reload the library and try again.', 'affstackdev-image-manager' ), ); return $m[ $code ] ?? __( 'The check could not finish. Reload the library and continue.', 'affstackdev-image-manager' ); } }