'used_ids', 'offset' => 0, // '+' statt array_merge(): beide Arrays haben Attachment-IDs als // Schluessel, array_merge() wuerde numerische Schluessel neu // durchnummerieren und die Werte dadurch faktisch verwerfen. 'used_ids' => self::used_ids_from_options() + self::used_ids_from_termmeta_and_usermeta(), 'known_paths' => array(), 'moved_count' => 0, 'failed_count' => 0, 'total_posts' => (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Scan ueber alle Beitragstypen in Batches; WP_Query filtert Typen/Status anders, und das Ergebnis muss aktuell sein, kein Cache. "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type NOT IN ('attachment','revision') AND post_status NOT IN ('auto-draft','trash')" ), 'total_attachments' => (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'attachment'" ), 'fs_years' => null, 'fs_current_files' => array(), 'fs_current_year' => null, ); set_transient( self::STATE_KEY, $state, HOUR_IN_SECONDS ); return $state; } public static function get_state() { $state = get_transient( self::STATE_KEY ); return $state ? $state : null; } public static function reset() { delete_transient( self::STATE_KEY ); } /** Fuehrt einen Batch der aktuellen Scan-Phase aus und liefert den aktualisierten Zustand. */ public static function run_batch() { $state = self::get_state(); if ( ! $state ) { $state = self::start(); } switch ( $state['phase'] ) { case 'used_ids': $state = self::batch_used_ids( $state ); break; case 'attachments': $state = self::batch_attachments( $state ); break; case 'filesystem': $state = self::batch_filesystem( $state ); break; } $state['failed_count'] += Wp360_Media_Cleaner_Mover::pull_failed_count(); set_transient( self::STATE_KEY, $state, HOUR_IN_SECONDS ); return $state; } private static function key_hints_media( $key ) { return is_string( $key ) && '' !== $key && preg_match( self::MEDIA_KEY_PATTERN, $key ); } /** * Sammelt Attachment-IDs aus einem rohen Options-/Meta-Wert: eine Zahl, * eine kommagetrennte ID-Liste (WooCommerce-Produktgalerie "12,34") oder * ein serialisiertes Array/Objekt (ACF-Gallery, Theme-Mods, Widgets, * header_image_data). Zahlen zaehlen nur unter einem medienbezogenen * Schluessel (MEDIA_KEY_PATTERN); innerhalb von Arrays reicht ein * medienbezogener Eltern-Schluessel fuer die Listenelemente darunter, * inklusive "id"/"ids"-Feldern wie bei Widget-Bildern oder * ACF-Bild-Arrays. * * @param string|int $key Options-/Meta-Name. * @param mixed $value Roher Wert aus der Datenbank. * @param array $used attachment_id => true (wird ergaenzt). */ private static function collect_ids( $key, $value, array &$used ) { if ( ! is_string( $value ) ) { return; } if ( preg_match( '/^[0-9]+(,[0-9]+)*$/', $value ) ) { if ( self::key_hints_media( $key ) ) { self::add_id_list( $value, $used ); } return; } if ( 0 !== strpos( $value, 'a:' ) && 0 !== strpos( $value, 'O:' ) ) { return; } $data = @unserialize( $value, array( 'allowed_classes' => array( 'stdClass' ) ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.PHP.DiscouragedPHPFunctions if ( is_object( $data ) ) { $data = get_object_vars( $data ); } if ( is_array( $data ) ) { self::walk_serialized( $data, self::key_hints_media( $key ), $used ); } } private static function add_id_list( $list, array &$used ) { foreach ( explode( ',', $list ) as $id ) { if ( (int) $id > 0 ) { $used[ (int) $id ] = true; } } } private static function walk_serialized( array $data, $parent_hints_media, array &$used ) { foreach ( $data as $key => $value ) { $hints = self::key_hints_media( $key ) || ( $parent_hints_media && ( is_int( $key ) || in_array( strtolower( (string) $key ), array( 'id', 'ids' ), true ) ) ); if ( is_object( $value ) ) { $value = get_object_vars( $value ); } if ( is_array( $value ) ) { self::walk_serialized( $value, $hints, $used ); continue; } if ( ! $hints ) { continue; } if ( is_int( $value ) ) { if ( $value > 0 ) { $used[ $value ] = true; } } elseif ( is_string( $value ) && preg_match( '/^[0-9]+(,[0-9]+)*$/', $value ) ) { self::add_id_list( $value, $used ); } } } private static function id_value_where( $column ) { return sprintf( self::ID_VALUE_WHERE, $column, $column, $column ); } /** * Attachment-IDs aus wp_options (site_icon, custom_logo in theme_mods, * Widget-Bilder, WooCommerce-Platzhalter, ACF-Optionsseiten). Mit "_" * beginnende Schluessel (ACF-Feldschluessel-Zeilen, Transients) bleiben * aussen vor. */ private static function used_ids_from_options() { global $wpdb; $used = array(); $rows = $wpdb->get_results( "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name NOT LIKE '\_%' AND " . self::id_value_where( 'option_value' ) ); // phpcs:ignore foreach ( $rows as $row ) { self::collect_ids( $row->option_name, $row->option_value, $used ); } return $used; } /** * Attachment-IDs, die in Term- oder User-Meta referenziert sind (z. B. * Kategorie-/Schlagwort-Bild, Autoren-Avatar). Beide Tabellen sind i. d. R. * klein genug fuer einen einzigen Durchlauf ohne Batching, deshalb hier in * start() statt pro Batch. Anders als bei used_ids_from_options() KEIN * Ausschluss von mit "_" beginnenden Schluesseln -- dieser dient dort nur * dazu, ACFs eigene Feldschluessel-Zeilen und WordPress-Transients in * wp_options zu ueberspringen; in termmeta/usermeta beginnen relevante * Schluessel (z. B. interne Avatar-Felder) haeufig selbst mit "_". */ private static function used_ids_from_termmeta_and_usermeta() { global $wpdb; $used = array(); foreach ( array( $wpdb->termmeta, $wpdb->usermeta ) as $table ) { $rows = $wpdb->get_results( "SELECT meta_key, meta_value FROM {$table} WHERE " . self::id_value_where( 'meta_value' ) ); // phpcs:ignore foreach ( $rows as $row ) { self::collect_ids( $row->meta_key, $row->meta_value, $used ); } } return $used; } /** * Phase 1: Attachment-IDs sammeln, die irgendwo im Projekt referenziert sind * (Beitragsbild, ACF-Bildfeld/-Repeater, ACF-Gallery, Content-Referenz). */ private static function batch_used_ids( array $state ) { global $wpdb; $post_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Scan ueber alle Beitragstypen in Batches; WP_Query filtert Typen/Status anders, und das Ergebnis muss aktuell sein, kein Cache. $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type NOT IN ('attachment','revision') AND post_status NOT IN ('auto-draft','trash') ORDER BY ID LIMIT %d OFFSET %d", self::BATCH_SIZE, $state['offset'] ) ); if ( empty( $post_ids ) ) { $state['phase'] = 'attachments'; $state['offset'] = 0; return $state; } $ids_in = implode( ',', array_map( 'intval', $post_ids ) ); // Zahlenwerte (Bildfelder, Repeater-Subfelder, _thumbnail_id), // kommagetrennte ID-Listen wie WooCommerces _product_image_gallery // ("123,456,789") und serialisierte Arrays (ACF-Gallery u. ae.) -- // nur unter medienbezogenen Meta-Schluesseln, siehe collect_ids(). $meta_rows = $wpdb->get_results( "SELECT meta_key, meta_value FROM {$wpdb->postmeta} WHERE post_id IN ({$ids_in}) AND " . self::id_value_where( 'meta_value' ) ); // phpcs:ignore foreach ( $meta_rows as $row ) { self::collect_ids( $row->meta_key, $row->meta_value, $state['used_ids'] ); } // Content: Gutenberg wp-image-{ID}-Klasse. $contents = $wpdb->get_col( "SELECT post_content FROM {$wpdb->posts} WHERE ID IN ({$ids_in})" ); // phpcs:ignore foreach ( $contents as $content ) { $content = (string) $content; if ( preg_match_all( '/wp-image-(\d+)/', $content, $m ) ) { foreach ( $m[1] as $id ) { $state['used_ids'][ (int) $id ] = true; } } // Block-Attribute im Kommentar-JSON (z. B. Cover-/Group-Block- // Hintergrundbild), speichern die ID roh als JSON-Zahl statt als // wp-image-Klasse oder PHP-serialisiertes Array. if ( preg_match_all( '/"id":(\d+)/', $content, $m ) ) { foreach ( $m[1] as $id ) { $state['used_ids'][ (int) $id ] = true; } } // Gallery-Block: "ids":[1,2,3]. if ( preg_match_all( '/"ids":\[([\d,]+)\]/', $content, $m ) ) { foreach ( $m[1] as $id_list ) { foreach ( explode( ',', $id_list ) as $id ) { $state['used_ids'][ (int) $id ] = true; } } } // Klassischer [gallery ids="1,2,3"]-Shortcode. if ( preg_match_all( '/\[gallery[^\]]*\bids=["\']([\d,\s]+)["\']/', $content, $m ) ) { foreach ( $m[1] as $id_list ) { foreach ( explode( ',', $id_list ) as $id ) { $state['used_ids'][ (int) trim( $id ) ] = true; } } } // WPBakery/Visual-Composer-Shortcode-Attribute wie // [vc_single_image image="123"] oder [vc_gallery images="12,13,14"]: // referenzieren per ID, nicht per Dateiname/URL -- anders als z. B. // Divi (dort steht i. d. R. die volle URL im Attribut, die bereits // vom Dateiname-Sicherheitscheck in bulk_filename_safety_check() // abgedeckt ist). if ( preg_match_all( '/\bimages?=["\']([\d,\s]+)["\']/', $content, $m ) ) { foreach ( $m[1] as $id_list ) { foreach ( explode( ',', $id_list ) as $id ) { $state['used_ids'][ (int) trim( $id ) ] = true; } } } } $state['offset'] += count( $post_ids ); return $state; } /** * Phase 2: Attachments durchgehen. Komplett unbenutzte werden ganz verschoben * (inkl. aller Bildgroessen), benutzte behalten nur noch aktuell registrierte * Bildgroessen, veraltete Groessen-Dateien wandern einzeln. */ private static function batch_attachments( array $state ) { global $wpdb; $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Scan ueber alle Beitragstypen in Batches; WP_Query filtert Typen/Status anders, und das Ergebnis muss aktuell sein, kein Cache. $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'attachment' ORDER BY ID LIMIT %d OFFSET %d", self::BATCH_SIZE, $state['offset'] ) ); if ( empty( $rows ) ) { $state['phase'] = 'filesystem'; $state['offset'] = 0; return $state; } $active_sizes = get_intermediate_image_sizes(); // Pass 1: pro Attachment Pfade/Groessen ermitteln und in "eindeutig // benutzt" vs. "Kandidat fuer die Sicherheitspruefung" einsortieren. $attachments = array(); $candidate_filenames = array(); foreach ( $rows as $row ) { $id = (int) $row->ID; $file = get_post_meta( $id, '_wp_attached_file', true ); if ( ! $file ) { continue; } $state['known_paths'][ $file ] = true; $meta = wp_get_attachment_metadata( $id ); $dir = dirname( $file ); $dir_prefix = ( $dir === '.' ? '' : $dir . '/' ); $size_paths = array(); if ( ! empty( $meta['sizes'] ) && is_array( $meta['sizes'] ) ) { foreach ( $meta['sizes'] as $size_name => $size_data ) { if ( empty( $size_data['file'] ) ) { continue; } $rel = $dir_prefix . $size_data['file']; $state['known_paths'][ $rel ] = true; // Manche Formate (z. B. SVGs ueber "SVG Support") liefern // Groessen-Metadaten, deren "file" auf dieselbe Datei wie // das Original zeigt -- das darf niemals als "veraltete // Groesse" behandelt und separat verschoben werden, sonst // verschwindet die Hauptdatei eines noch genutzten // Attachments mit. if ( $rel === $file ) { continue; } $size_paths[ $size_name ] = $rel; } } // WPs eigenes Big-Image-Downscaling (Upload groesser als der // Schwellenwert) behaelt das echte Original als Geschwisterdatei, // nur ueber $meta['original_image'] referenziert (nicht in // $meta['sizes']). Ohne diese Zeile wuerde Phase 3 sie als // "filesystem_orphan" einstufen und WPs "Original wiederherstellen" // im Medien-Editor liefe ins Leere. $original_image_rel = null; if ( ! empty( $meta['original_image'] ) ) { $original_image_rel = $dir_prefix . $meta['original_image']; $state['known_paths'][ $original_image_rel ] = true; } $is_used = isset( $state['used_ids'][ $id ] ); $attachments[ $id ] = array( 'file' => $file, 'size_paths' => $size_paths, 'original_image_rel' => $original_image_rel, 'is_used' => $is_used, ); // Ein Text kann auch direkt auf eine Groessen-Variante (bild-300x200.jpg) // oder das Original eines verkleinerten Bildes (bild.jpg neben // bild-scaled.jpg) verlinken -- deren Dateinamen enthalten den // Dateinamen der Hauptdatei nicht. Deshalb suchen wir nach allen. if ( ! $is_used ) { $names = array( wp_basename( $file ) ); foreach ( $size_paths as $rel ) { $names[] = wp_basename( $rel ); } if ( $original_image_rel ) { $names[] = wp_basename( $original_image_rel ); } $candidate_filenames[ $id ] = array_values( array_unique( $names ) ); } else { // Benutzter Anhang: eine nicht mehr registrierte Groesse darf nur // wandern, wenn nichts direkt auf sie verlinkt. foreach ( $size_paths as $size_name => $rel ) { if ( ! in_array( $size_name, $active_sizes, true ) ) { $candidate_filenames[ $id . '|' . $size_name ] = array( wp_basename( $rel ) ); } } } } // Pass 2: EIN gebuendelter Sicherheitscheck fuer alle Kandidaten dieses // Batches statt einer einzelnen LIKE-Volltextsuche pro Datei (nicht // indexierbar -- bei vielen Kandidaten sonst spuerbar langsam). Deckt // zugleich Postmeta (z. B. ACF-WYSIWYG-Felder) und wp_options (Widgets, // Theme-Mods wie Custom-Logo, Site-Icon) ab, nicht nur post_content. $confirmed_used = empty( $candidate_filenames ) ? array() : self::bulk_filename_safety_check( $candidate_filenames ); if ( null === $confirmed_used ) { $confirmed_used = array_fill_keys( array_keys( $candidate_filenames ), true ); } foreach ( $attachments as $id => $a ) { $is_used = $a['is_used'] || isset( $confirmed_used[ $id ] ); if ( ! $is_used ) { Wp360_Media_Cleaner_Mover::move_to_legacy( $a['file'], $id, 'unused_attachment' ); ++$state['moved_count']; foreach ( $a['size_paths'] as $rel ) { if ( Wp360_Media_Cleaner_Mover::move_to_legacy( $rel, $id, 'unused_attachment' ) ) { ++$state['moved_count']; } } if ( $a['original_image_rel'] && Wp360_Media_Cleaner_Mover::move_to_legacy( $a['original_image_rel'], $id, 'unused_attachment' ) ) { ++$state['moved_count']; } } else { foreach ( $a['size_paths'] as $size_name => $rel ) { if ( in_array( $size_name, $active_sizes, true ) || isset( $confirmed_used[ $id . '|' . $size_name ] ) ) { continue; } if ( Wp360_Media_Cleaner_Mover::move_to_legacy( $rel, $id, 'orphaned_size' ) ) { ++$state['moved_count']; } } } } $state['offset'] += count( $rows ); return $state; } /** * Letzte Sicherheitspruefung fuer Attachments, die ueber IDs nicht als * benutzt erkannt wurden: kommt ihr Dateiname roh (URL, Text, HTML) irgendwo * in post_content, Postmeta oder wp_options vor? EIN REGEXP-Query je Tabelle * mit allen Kandidaten-Dateinamen des Batches als Alternation statt bis zu * N einzelner LIKE-Queries -- die Treffer-Texte werden danach in PHP * (strpos, exakter Substring-Vergleich wie zuvor per esc_like) den * einzelnen Kandidaten zugeordnet. * * wp_options deckt u. a. Custom-Logo/Header-Bild (Theme-Mods) und * Text-/Custom-HTML-Widgets ab -- diese liegen weder in post_content noch * in postmeta. * * @param array $candidate_filenames Kandidaten-Schluessel => Liste von Dateinamen (ohne Pfad); ein Treffer fuer irgendeinen Namen genuegt. * @return array|null Kandidaten-Schluessel => true fuer Kandidaten, die doch noch benutzt sind; null, wenn eine Abfrage fehlschlug. */ private static function bulk_filename_safety_check( array $candidate_filenames ) { global $wpdb; $all_names = array_unique( array_merge( ...array_values( $candidate_filenames ) ) ); $pattern = implode( '|', array_map( function ( $filename ) { return preg_quote( $filename, '/' ); }, $all_names ) ); // postmeta einer Attachment-Post selbst (_wp_attached_file, // _wp_attachment_metadata, ...) enthaelt IMMER deren eigenen Dateinamen -- // ohne den Ausschluss wuerde jeder Kandidat sich staendig selbst als // "benutzt" bestaetigen und die Erkennung waere faktisch wirkungslos. // Es zaehlt nur ein Dateiname-Treffer in POSTMETA EINES ANDEREN Posts // (z. B. ein ACF-WYSIWYG-Textfeld) als echtes Nutzungssignal. $queries = array( $wpdb->prepare( "SELECT post_content FROM {$wpdb->posts} WHERE post_content REGEXP %s", $pattern ), // phpcs:ignore $wpdb->prepare( "SELECT meta_value FROM {$wpdb->postmeta} WHERE meta_value REGEXP %s AND post_id NOT IN (SELECT ID FROM {$wpdb->posts} WHERE post_type = 'attachment')", $pattern ), // phpcs:ignore $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name NOT LIKE '\_%' AND option_value REGEXP %s", $pattern ), // phpcs:ignore ); // Scheitert eine Abfrage (MySQL 8: regexp_time_limit oder Stack-Limit // bei langer Alternation auf grossen Seiten), liefert get_col() ein // leeres Array -- ohne diese Pruefung galte dann KEIN Kandidat als // benutzt, und der ganze Batch wanderte nach uploads-legacy, auch // Bilder, die live eingebunden sind. Im Zweifel also nichts verschieben. $matched_content = array(); foreach ( $queries as $query ) { $matched_content = array_merge( $matched_content, $wpdb->get_col( $query ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- oben vorbereitet; Scan-Ergebnis darf nicht gecacht werden. if ( '' !== $wpdb->last_error ) { return null; } } if ( empty( $matched_content ) ) { return array(); } $haystack = implode( "\n", $matched_content ); $confirmed = array(); foreach ( $candidate_filenames as $key => $filenames ) { foreach ( $filenames as $filename ) { if ( strpos( $haystack, $filename ) !== false ) { $confirmed[ $key ] = true; break; } } } return $confirmed; } /** * Phase 3: Dateisystem nach Dateien durchsuchen, die zu keinem Attachment * gehoeren (nie in der Mediathek registriert). Nur Jahres-Ordner (uebliche * WP-Upload-Struktur), keine Plugin-eigenen Unterordner. */ private static function batch_filesystem( array $state ) { if ( $state['fs_years'] === null ) { $uploads = Wp360_Media_Cleaner_Mover::uploads_dir_path(); $state['fs_years'] = array(); foreach ( (array) @scandir( $uploads ) as $entry ) { if ( preg_match( '/^\d{4}$/', $entry ) && is_dir( $uploads . '/' . $entry ) ) { $state['fs_years'][] = $entry; } } } $processed = 0; while ( $processed < self::BATCH_SIZE ) { if ( empty( $state['fs_current_files'] ) ) { if ( empty( $state['fs_years'] ) ) { $state['phase'] = 'done'; break; } $year = array_shift( $state['fs_years'] ); $state['fs_current_year'] = $year; $year_dir = trailingslashit( Wp360_Media_Cleaner_Mover::uploads_dir_path() ) . $year; $state['fs_current_files'] = self::list_files_recursive( $year_dir, $year . '/' ); continue; } $relative = array_shift( $state['fs_current_files'] ); ++$processed; if ( isset( $state['known_paths'][ $relative ] ) ) { continue; } if ( Wp360_Media_Cleaner_Mover::move_to_legacy( $relative, null, 'filesystem_orphan' ) ) { ++$state['moved_count']; } } return $state; } /** * Nur Dateitypen, die WordPress auch als Upload annimmt, gelten als * verwaiste Medien. Alles andere -- .php, index.php/index.html als Schutz * gegen Verzeichnislisten, web.config -- bleibt liegen: Verschoben nach * uploads-legacy/ verloere es den Schutz der uploads-Regeln (unter nginx * wuerde eine dort abgelegte Webshell dadurch ausfuehrbar), und ein * entfernter index.php-Waechter oeffnete die Verzeichnisliste wieder. */ private static function is_movable_media_file( $filename ) { if ( preg_match( '/^(index\.(php|html?)|web\.config)$/i', $filename ) ) { return false; } $type = wp_check_filetype( $filename ); return ! empty( $type['ext'] ) && ! preg_match( '/^(php\d?|phtml|phar|pht|shtml?|cgi|pl|py|sh|asp|aspx|jsp|htaccess|ini)$/i', $type['ext'] ); } /** Rekursive Dateiliste relativ zu uploads/, mit $prefix versehen. */ private static function list_files_recursive( $dir, $prefix ) { $files = array(); if ( ! is_dir( $dir ) ) { return $files; } foreach ( (array) @scandir( $dir ) as $entry ) { // Dotfiles (.htaccess, .user.ini) und Symlinks nie anfassen: ein // Symlink-Ordner fuehrte rename() aus uploads hinaus, eine // Symlink-Schleife in eine Endlosrekursion. if ( '.' === $entry[0] ) { continue; } $path = $dir . '/' . $entry; if ( is_link( $path ) ) { continue; } if ( is_dir( $path ) ) { $files = array_merge( $files, self::list_files_recursive( $path, $prefix . $entry . '/' ) ); } elseif ( self::is_movable_media_file( $entry ) ) { $files[] = $prefix . $entry; } } return $files; } /** Grobe Fortschrittsangabe in Prozent fuer die Anzeige. */ public static function progress_percent( array $state ) { switch ( $state['phase'] ) { case 'used_ids': $total = max( 1, $state['total_posts'] ); return (int) round( 33 * min( 1, $state['offset'] / $total ) ); case 'attachments': $total = max( 1, $state['total_attachments'] ); return 33 + (int) round( 33 * min( 1, $state['offset'] / $total ) ); case 'filesystem': return 90; case 'done': return 100; default: return 0; } } }