post_type || 'trash' === $post->post_status || ! current_user_can( 'edit_post', $id ) ) { throw new \RuntimeException( 'access' ); } return $post; } /** * The post types whose saved content the check reads. * * @return array Post type names. */ private static function types(): array { $types = array_merge( get_post_types( array( 'public' => true ) ), array( 'post', 'page', 'product', 'product_variation', 'wp_block', 'wp_template', 'wp_template_part', 'wp_navigation' ) ); return array_values( array_diff( array_unique( $types ), array( 'attachment', 'revision', 'nav_menu_item' ) ) ); } /** * The current administrator's saved report. * * @return array The report, or an empty array. */ private static function state(): array { $state = get_transient( self::key() ); return is_array( $state ) && 1 === ( $state['v'] ?? 0 ) ? $state : array(); } /** * Deliberately one temporary report per administrator/site, with bounded size and expiry. * * @param int $id The attachment. * @return array The report about it, or an empty array. */ public static function snapshot( int $id ): array { self::authorize( $id ); $state = self::state(); return ( $state['id'] ?? 0 ) === $id ? $state : array(); } /** * An address in the form the check compares: the host in lower case, a port other than 80 or 443, and the decoded path * (relative and protocol-relative forms made absolute; dot segments refused). * * @param string $url The address. * @return string The compared form, or an empty string. */ private static function canonical( string $url ): string { $url = trim( html_entity_decode( $url, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ); if ( str_starts_with( $url, '//' ) ) { $url = 'https:' . $url; } elseif ( str_starts_with( $url, '/' ) ) { $home = wp_parse_url( home_url( '/' ) ); $url = 'https://' . ( $home['host'] ?? '' ) . ( isset( $home['port'] ) ? ':' . $home['port'] : '' ) . $url; } $parts = wp_parse_url( $url ); if ( ! is_array( $parts ) || ! in_array( strtolower( $parts['scheme'] ?? '' ), array( 'http', 'https' ), true ) || empty( $parts['host'] ) || isset( $parts['user'] ) || isset( $parts['pass'] ) ) { return ''; } $path = rawurldecode( $parts['path'] ?? '' ); if ( ! $path || preg_match( '~(?:^|/)\.\.(?:/|$)|[\x00-\x1f]~', $path ) ) { return ''; } $port = isset( $parts['port'] ) && ! in_array( $parts['port'], array( 80, 443 ), true ) ? ':' . $parts['port'] : ''; return strtolower( $parts['host'] ) . $port . $path; } /** * The addresses of an attachment's file, its sizes (or PDF previews) and its kept original, in the compared form. * * @param int $id The attachment. * @return array Compared form => file_url, size_url or pdf_preview. */ private static function targets( int $id ): array { $url = (string) wp_get_attachment_url( $id ); $urls = array(); $add = static function ( string $candidate, string $kind ) use ( &$urls ): void { $key = self::canonical( $candidate ); if ( $key ) { $urls[ $key ] = $kind; } }; $add( $url, 'file_url' ); $meta = wp_get_attachment_metadata( $id ); $meta = is_array( $meta ) ? $meta : array(); $base = preg_replace( '~[^/]*$~', '', preg_replace( '/[?#].*$/', '', $url ) ); $pdf = 'application/pdf' === get_post_mime_type( $id ); foreach ( array_slice( (array) ( $meta['sizes'] ?? array() ), 0, 100 ) as $size ) { $file = is_array( $size ) ? ( $size['file'] ?? '' ) : ''; if ( is_string( $file ) && $file && wp_basename( $file ) === $file && ! str_contains( $file, '\\' ) ) { $add( $base . $file, $pdf ? 'pdf_preview' : 'size_url' ); } } $original = $meta['original_image'] ?? ''; if ( is_string( $original ) && $original && wp_basename( $original ) === $original && ! str_contains( $original, '\\' ) ) { $add( $base . $original, 'file_url' ); } return $urls; } /** * A fingerprint of the attachment's type, file and addresses, so a report about an older file is not shown. * * @param int $id The attachment. * @param array $urls Its addresses. * @return string SHA-256. */ private static function signature( int $id, array $urls ): string { return hash( 'sha256', wp_json_encode( array( get_post_mime_type( $id ), get_post_meta( $id, '_wp_attached_file', true ), $urls ) ) ); } /** * Whether a list of IDs (a comma or space separated text or an array) names the attachment. * * @param mixed $value The list. * @param int $id The attachment. * @return bool True when it does. */ private static function id_list( $value, int $id ): bool { if ( is_string( $value ) ) { $value = preg_split( '/[\s,]+/', trim( $value ) ); } if ( ! is_array( $value ) ) { return false; } foreach ( $value as $candidate ) { if ( ( is_int( $candidate ) || ( is_string( $candidate ) && ctype_digit( $candidate ) ) ) && (int) $candidate === $id ) { return true; } } return false; } /** * Inspect stored syntax only. No shortcodes, blocks, theme code, or remote pages are executed. * * @param string $content The saved content. * @param int $id The attachment. * @param array $urls Its addresses (targets()). * @param int $owner The post the content belongs to, or 0. * @param int $parent The attachment's parent post. * @param bool $image Whether the attachment is an image. * @return array The kinds of use found. */ private static function content( string $content, int $id, array $urls, int $owner, int $parent, bool $image ): array { $found = array(); $add_url = static function ( $value, string $kind ) use ( &$found, $urls ): void { if ( ! is_string( $value ) || strlen( $value ) > 8192 ) { return; } $key = self::canonical( $value ); if ( isset( $urls[ $key ] ) ) { $found[ 'pdf_preview' === $urls[ $key ] ? 'pdf_preview' : $kind ] = true; } }; $tags = new \WP_HTML_Tag_Processor( $content ); while ( $tags->next_tag() ) { $tag = $tags->get_tag(); if ( 'IMG' === $tag ) { $add_url( $tags->get_attribute( 'src' ), 'image_url' ); $classes = $tags->get_attribute( 'class' ); if ( is_string( $classes ) && preg_match( '/(?:^|\s)wp-image-' . $id . '(?:\s|$)/', $classes ) ) { $found['image_id'] = true; } } if ( in_array( $tag, array( 'IMG', 'SOURCE' ), true ) ) { $srcset = $tags->get_attribute( 'srcset' ); if ( is_string( $srcset ) ) { foreach ( explode( ',', $srcset ) as $candidate ) { $add_url( preg_split( '/\s+/', trim( $candidate ) )[0] ?? '', 'responsive_url' ); } } } if ( 'A' === $tag ) { $add_url( $tags->get_attribute( 'href' ), 'file_link' ); } if ( in_array( $tag, array( 'IFRAME', 'EMBED', 'VIDEO', 'AUDIO', 'SOURCE' ), true ) ) { $add_url( $tags->get_attribute( 'src' ), 'embedded_url' ); } if ( 'OBJECT' === $tag ) { $add_url( $tags->get_attribute( 'data' ), 'embedded_url' ); } if ( 'VIDEO' === $tag ) { $add_url( $tags->get_attribute( 'poster' ), 'image_url' ); } } // Core attachment attributes are interpreted by block type, never by an arbitrary numeric 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(); if ( self::id_list( is_array( $value ) ? $value : array( $value ), $id ) ) { $found[ 'gallery' === $block[1] ? 'gallery_block' : 'media_block' ] = true; } } 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::id_list( $ids, $id ) ) { $found['gallery_shortcode'] = true; } elseif ( ! $ids && $image && $parent > 0 && $parent === ( isset( $attrs['id'] ) ? absint( $attrs['id'] ) : $owner ) && ! self::id_list( $attrs['exclude'] ?? '', $id ) ) { $found['gallery_shortcode'] = true; } } return array_keys( $found ); } /** * Optimistic run/cursor checks and an owner-aware lease make retries and parallel tabs safe. * * @param int $id The attachment. * @param string $mode start or continue. * @param string $run The check's run token. * @param int $cursor Where the check is. * @return array The report. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function step( int $id, string $mode, string $run, int $cursor ): array { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { $attachment = self::authorize( $id ); if ( ! in_array( $mode, array( 'start', 'continue' ), true ) ) { throw new \RuntimeException( 'request' ); } $lock = 'media_usage_' . get_current_user_id(); $token = Repository::acquire( $lock, 120 ); if ( ! $token ) { throw new \RuntimeException( 'busy' ); } try { $state = self::state(); if ( $run !== ( $state['run'] ?? '' ) || $cursor !== (int) ( $state['cursor'] ?? 0 ) ) { throw new \RuntimeException( 'stale' ); } $urls = self::targets( $id ); $signature = self::signature( $id, $urls ); global $wpdb; if ( 'start' === $mode ) { $types = self::types(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Bounded keyset scan of current post content and image-reference metadata; SQL caps content bytes before materialization and must reflect current edits. $max = $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 ) ) ); if ( $wpdb->last_error ) { throw new \RuntimeException( 'database' ); } $state = array( 'v' => 1, 'id' => $id, 'run' => bin2hex( random_bytes( 16 ) ), 'cursor' => 0, 'max' => (int) $max, 'types' => $types, 'checked' => 0, 'skipped' => 0, 'results' => array(), 'status' => 'more', 'started' => time(), 'updated' => time(), 'signature' => $signature, ); } elseif ( ( $state['id'] ?? 0 ) !== $id || 'more' !== ( $state['status'] ?? '' ) ) { throw new \RuntimeException( 'stale' ); } if ( ! hash_equals( $state['signature'], $signature ) ) { throw new \RuntimeException( 'changed' ); } $types = $state['types']; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Bounded keyset scan of current post content and image-reference metadata; SQL caps content bytes before materialization and must reflect current edits. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT ID,post_type,post_parent,post_modified_gmt,LENGTH(post_content)+LENGTH(post_excerpt) AS content_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, $state['cursor'], $state['max'] ), $types, array( self::BATCH + 1 ) ) ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching if ( $wpdb->last_error || ! is_array( $rows ) ) { throw new \RuntimeException( 'database' ); } $more = count( $rows ) > self::BATCH; $rows = array_slice( $rows, 0, self::BATCH ); $meta = array(); $meta_limited = false; if ( $rows ) { $ids = array_map( 'intval', array_column( $rows, 'ID' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Bounded keyset scan of current post content and image-reference metadata; SQL caps content bytes before materialization and must reflect current edits. $data = $wpdb->get_results( $wpdb->prepare( 'SELECT post_id,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 (\'_thumbnail_id\',\'_product_image_gallery\') ORDER BY meta_id ASC LIMIT 201', array_merge( array( $wpdb->postmeta ), $ids ) ), ARRAY_A ); if ( $wpdb->last_error || ! is_array( $data ) ) { throw new \RuntimeException( 'database' ); } $meta_limited = count( $data ) > 200; foreach ( array_slice( $data, 0, 200 ) as $datum ) { $meta[ (int) $datum['post_id'] ][ $datum['meta_key'] ][] = $datum['value']; } } $start = microtime( true ); foreach ( $rows as $row ) { $owner = (int) $row['ID']; $state['cursor'] = $owner; // Skip oversized records before WordPress capability mapping loads their full post objects. if ( (int) $row['content_bytes'] > self::CONTENT_BYTES || strlen( $row['body'] ) + strlen( $row['excerpt'] ) > self::CONTENT_BYTES ) { ++$state['skipped']; continue; } if ( ! current_user_can( 'edit_post', $owner ) ) { continue; } ++$state['checked']; $found = array(); $skipped = $meta_limited; foreach ( $meta[ $owner ] ?? array() as $key => $values ) { foreach ( $values as $value ) { if ( strlen( $value ) > 16384 ) { $skipped = true; continue; } if ( '_thumbnail_id' === $key ? ( ! ctype_digit( $value ) || (int) $value !== $id ) : ! self::id_list( $value, $id ) ) { continue; } if ( '_thumbnail_id' === $key ) { $found[] = 'product_variation' === $row['post_type'] ? 'variation_image' : ( 'product' === $row['post_type'] ? 'product_image' : 'featured_image' ); } elseif ( 'product' === $row['post_type'] ) { $found[] = 'product_gallery'; } } } if ( ! $skipped ) { $found = array_merge( $found, self::content( $row['body'] . "\n" . $row['excerpt'], $id, $urls, $owner, (int) $attachment->post_parent, str_starts_with( (string) $attachment->post_mime_type, 'image/' ) ) ); } if ( $skipped ) { ++$state['skipped']; } if ( $found ) { $state['results'][ $owner ] = array( 'kinds' => array_values( array_unique( $found ) ), 'modified' => $row['post_modified_gmt'], ); } if ( count( $state['results'] ) >= self::RESULTS ) { $state['status'] = 'limit'; break; } if ( microtime( true ) - $start > 1.5 ) { $more = true; break; } } if ( 'limit' !== $state['status'] ) { $state['status'] = $more ? 'more' : 'done'; } $state['updated'] = time(); if ( ! Repository::owns( $token, $lock ) ) { throw new \RuntimeException( 'busy' ); } if ( ! Reviewed_Store::set_transient( self::key(), $state, self::TTL ) ) { throw new \RuntimeException( 'storage' ); } return $state; } finally { Repository::release( $token, $lock ); } } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * The words for each kind of use. * * @return array Kind => translated words. */ private static function labels(): array { return array( 'featured_image' => __( 'Featured image', 'affstackdev-image-manager' ), 'product_image' => __( 'Product image', 'affstackdev-image-manager' ), 'product_gallery' => __( 'Product gallery', 'affstackdev-image-manager' ), 'variation_image' => __( 'Variation image', 'affstackdev-image-manager' ), 'media_block' => __( 'Media block attachment', 'affstackdev-image-manager' ), 'gallery_block' => __( 'Gallery block', 'affstackdev-image-manager' ), 'gallery_shortcode' => __( 'Gallery shortcode', 'affstackdev-image-manager' ), 'image_id' => __( 'Image attachment ID in markup', 'affstackdev-image-manager' ), 'image_url' => __( 'Image URL', 'affstackdev-image-manager' ), 'responsive_url' => __( 'Responsive image URL', 'affstackdev-image-manager' ), 'file_link' => __( 'File link', 'affstackdev-image-manager' ), 'embedded_url' => __( 'Embedded media URL', 'affstackdev-image-manager' ), 'pdf_preview' => __( 'PDF preview image URL', 'affstackdev-image-manager' ), ); } /** * Print the Check usage panel: the button and the report. * * @param int $id The attachment. * @param string $back_url The library address to return to. * @param string $message A notice to show. */ public static function render( int $id, string $back_url, string $message = '' ): void { self::authorize( $id ); $saved = self::state(); $state = ( $saved['id'] ?? 0 ) === $id ? $saved : array(); echo '

' . esc_html__( 'Where is this used?', 'affstackdev-image-manager' ) . '

' . esc_html( $message ) . '
'; echo '

' . esc_html__( 'Check saved pages, posts, products, variations, shared blocks and templates for references to this file. Opening this panel does not start a check.', 'affstackdev-image-manager' ) . '

'; echo '

' . esc_html( Suite::enhanced() ? __( 'Choose Check usage once to continue through small batches automatically. You can pause and resume. Closing this panel or hiding the browser tab stops further batches; a batch already running may still save.', 'affstackdev-image-manager' ) : __( 'PHP mode checks up to 40 content items per request. Choose Continue checking while more content remains.', 'affstackdev-image-manager' ) ) . '

'; if ( $state ) { $status = 'more' === $state['status'] ? __( 'More content remains.', 'affstackdev-image-manager' ) : ( 'limit' === $state['status'] ? __( 'The 100-result limit was reached. This check is incomplete.', 'affstackdev-image-manager' ) : __( 'The supported-content pass has finished.', 'affstackdev-image-manager' ) ); /* translators: 1: Content items checked; 2: UTC date and time of the saved check. */ echo '

' . esc_html( sprintf( __( '%1$d content items checked. Last saved check: %2$s UTC.', 'affstackdev-image-manager' ), $state['checked'], gmdate( 'Y-m-d H:i:s', $state['updated'] ) ) ) . ' ' . esc_html( $status ) . '

'; if ( $state['skipped'] ) { /* translators: %d: Number of content items exceeding a scan limit. */ echo '

' . esc_html( sprintf( __( '%d content items exceeded the content or metadata size limit; their checks are incomplete.', 'affstackdev-image-manager' ), $state['skipped'] ) ) . '

'; } $labels = self::labels(); $shown = 0; echo ''; if ( ! $shown ) { echo '

' . esc_html__( 'No accessible references found in the content checked so far. This does not mean the file is unused.', 'affstackdev-image-manager' ) . '

'; } } echo '
'; foreach ( array( 'action' => 'afsd_im_media_usage', 'im_media_id' => $id, 'run' => $saved['run'] ?? '', 'cursor' => $saved['cursor'] ?? 0, 'back_url' => $back_url, ) as $key => $value ) { echo ''; } wp_nonce_field( 'afsd_im_usage_' . $id ); echo ' '; echo '
'; echo '

' . esc_html__( 'Reload report', 'affstackdev-image-manager' ) . '

'; echo '

' . esc_html__( 'Results describe saved references at the time checked, not proof of what a visitor sees. Missing references never establish that a file is unused or safe to delete. This check does not inspect theme files, custom fields/page-builder data, widgets/options, CSS, external sites or live pages, and it does not expand placements of shared blocks/templates. Content you cannot edit is excluded.', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Your temporary report expires after 24 hours. Starting a check for another file replaces it. No media or content is changed.', 'affstackdev-image-manager' ) . '

'; } /** * The words for a check's error. * * @param \Throwable $e The error. * @return string The translated words. */ private static function error( \Throwable $e ): string { $messages = array( 'access' => __( 'You do not have permission to check this attachment.', 'affstackdev-image-manager' ), 'nonce' => __( 'Your session expired. Reload the details page before checking again.', 'affstackdev-image-manager' ), 'request' => __( 'The usage request is invalid. Reload the details page.', 'affstackdev-image-manager' ), 'stale' => __( 'This report changed in another request or expired. Reload the details panel before continuing.', 'affstackdev-image-manager' ), 'changed' => __( 'The attachment changed since this check started. Start a new check.', 'affstackdev-image-manager' ), 'busy' => __( 'Another usage check is still finishing. Reload the details panel in a moment.', 'affstackdev-image-manager' ), ); return $messages[ $e->getMessage() ] ?? __( 'The usage check could not finish. Your previous report is retained; reload the details panel before retrying.', 'affstackdev-image-manager' ); } /** * The attachment and the fields of a check request: a POST with a valid nonce. * * @return array The attachment and the request's fields. * @throws \RuntimeException With request or nonce when the request is not valid. */ private static function request(): array { if ( 'POST' !== ( isset( $_SERVER['REQUEST_METHOD'] ) && is_string( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '' ) ) { throw new \RuntimeException( 'request' ); } $data = Plugin::fields( array( 'im_media_id', 'mode', 'run', 'cursor', '_wpnonce' ) ); foreach ( array( 'im_media_id', 'mode', 'run', 'cursor', '_wpnonce' ) as $key ) { if ( ! isset( $data[ $key ] ) || ! is_string( $data[ $key ] ) ) { throw new \RuntimeException( 'request' ); } } $id = absint( $data['im_media_id'] ); self::authorize( $id ); if ( ! wp_verify_nonce( $data['_wpnonce'], 'afsd_im_usage_' . $id ) ) { throw new \RuntimeException( 'nonce' ); } if ( ! ctype_digit( $data['cursor'] ) || ( $data['run'] && ! preg_match( '/^[a-f0-9]{32}$/D', $data['run'] ) ) ) { throw new \RuntimeException( 'request' ); } return array( $id, $data ); } /** * One step of the check over AJAX; answers with the report or the error. */ public static function ajax(): void { try { list( $id, $data ) = self::request(); self::step( $id, $data['mode'], $data['run'], (int) $data['cursor'] ); $back = isset( $data['back_url'] ) && is_string( $data['back_url'] ) ? wp_validate_redirect( $data['back_url'], Admin::url( 'images', array( 'im_media_view' => 'library' ) ) ) : Admin::url( 'images', array( 'im_media_view' => 'library' ) ); $html = Plugin::capture( static function () use ( $id, $back ): void { self::render( $id, $back ); } ); } catch ( \Throwable $e ) { wp_send_json_error( array( 'message' => self::error( $e ) ), in_array( $e->getMessage(), array( 'access', 'nonce' ), true ) ? 403 : 409 ); } wp_send_json_success( array( 'id' => $id, 'html' => $html, ) ); } /** * One step of the check through admin-post.php, then back to the details screen. */ public static function action(): void { try { list( $id, $data ) = self::request(); } catch ( \Throwable $e ) { wp_die( esc_html( self::error( $e ) ), '', array( 'response' => 403 ) ); } try { self::step( $id, $data['mode'], $data['run'], (int) $data['cursor'] ); } catch ( \Throwable $e ) { Reviewed_Store::set_transient( 'afsd_im_notice_' . get_current_user_id(), self::error( $e ), 120 ); } $back = isset( $data['back_url'] ) && is_string( $data['back_url'] ) ? wp_validate_redirect( $data['back_url'], Admin::url( 'images' ) ) : Admin::url( 'images' ); wp_safe_redirect( add_query_arg( array( 'im_tab' => 'images', 'im_image' => 0, 'im_media_view' => 'details', 'im_media_id' => $id, ), $back ) ); exit; } }