PluginProbe ʕ •ᴥ•ʔ
Media Cleaner: Clean your WordPress! / 7.2.5
Media Cleaner: Clean your WordPress! v7.2.5
7.2.6 7.2.5 7.2.4 7.2.3 7.2.2 7.2.1 7.2.0 7.1.1 7.1.0 7.0.9 7.0.8 trunk 3.6.8 3.6.9 3.7.0 3.8.0 3.9.0 4.0.0 4.0.2 4.0.4 4.0.6 4.0.7 4.1.0 4.2.0 4.2.2 4.2.3 4.2.4 4.2.5 4.4.0 4.4.2 4.4.4 4.4.6 4.4.7 4.4.8 4.5.0 4.5.4 4.5.6 4.5.7 4.5.8 4.6.2 4.6.3 4.8.0 4.8.4 5.0.0 5.0.1 5.1.0 5.1.1 5.1.3 5.2.0 5.2.1 5.2.4 5.4.0 5.4.1 5.4.2 5.4.3 5.4.4 5.4.5 5.4.6 5.4.9 5.5.0 5.5.1 5.5.2 5.5.3 5.5.4 5.5.7 5.5.8 5.6.1 5.6.2 5.6.3 5.6.4 6.0.1 6.0.2 6.0.3 6.0.4 6.0.5 6.0.6 6.0.7 6.0.8 6.0.9 6.1.2 6.1.3 6.1.4 6.1.5 6.1.6 6.1.7 6.1.8 6.1.9 6.2.0 6.2.1 6.2.3 6.2.4 6.2.5 6.2.6 6.2.7 6.2.8 6.3.0 6.3.1 6.3.2 6.3.4 6.3.5 6.3.7 6.3.8 6.3.9 6.4.0 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.4.7 6.4.8 6.4.9 6.5.0 6.5.1 6.5.2 6.5.3 6.5.4 6.5.5 6.5.6 6.5.7 6.5.8 6.5.9 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.6.6 6.6.7 6.6.8 6.6.9 6.7.0 6.7.1 6.7.2 6.7.3 6.7.4 6.7.5 6.7.6 6.7.7 6.7.8 6.7.9 6.8.0 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.8.6 6.8.7 6.8.8 6.8.9 6.9.0 6.9.1 6.9.2 6.9.3 6.9.4 6.9.5 6.9.6 6.9.7 6.9.8 6.9.9 7.0.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.0.6 7.0.7
media-cleaner / classes / core.php
media-cleaner / classes Last commit date
parsers 1 month ago admin.php 1 month ago core.php 1 month ago engine.php 1 month ago init.php 9 months ago mcp.php 1 month ago parsers.php 1 month ago rest.php 1 month ago runs.php 1 month ago support.php 1 month ago ui.php 1 month ago
core.php
3636 lines
1 <?php
2
3 if ( !class_exists( 'Meow_WPMC_Transient_Exception', false ) ) {
4 class Meow_WPMC_Transient_Exception extends RuntimeException {
5 private $retry_after_ms;
6
7 public function __construct( $message, $retry_after_ms = 2000, $previous = null ) {
8 parent::__construct( $message, 0, $previous );
9 $this->retry_after_ms = max( 250, min( 60000, (int) $retry_after_ms ) );
10 }
11
12 public function get_retry_after_ms() {
13 return $this->retry_after_ms;
14 }
15 }
16 }
17
18 class Meow_WPMC_Core {
19
20 // A file that exists but cannot be safely fingerprinted
21 const FINGERPRINT_UNSAFE = '@unsafe';
22
23
24 public $admin = null;
25 public $is_rest = false;
26 public $is_cli = false;
27 public $is_pro = false;
28 public $engine = null;
29 public $runs = null;
30 public $catch_timeout = true; // This will halt the plugin before reaching the PHP timeout.
31 public $types = "jpg|jpeg|jpe|gif|png|tiff|bmp|csv|svg|pdf|xls|xlsx|doc|docx|odt|wpd|rtf|tiff|mp3|mp4|mov|wav|lua|webp|avif|ico|woff2|woff|ttf|otf";
32 public $current_method = 'media';
33 public $servername = null; // meowapps.com (site URL without http/https)
34 public $site_url = null; // https://meowapps.com
35 public $upload_path = null; // /www/wp-content/uploads (path to uploads)
36 public $upload_url = null; // wp-content/uploads (uploads without domain)
37 private $option_name = 'wpmc_options';
38 private $nonce = null; // Nonce for the REST API
39
40 private $regex_file = '/[A-Za-z0-9-_,.\(\)\s]+[.]{1}(MIMETYPES)/';
41
42 private $refcache = array();
43 private $progress_key = 'wpmc_progress';
44 private $run_id = 0;
45 private $run_config = array();
46
47 private $debug_logs = null;
48 private $multilingual = false;
49 private $languages = array();
50 private $shortcode_analysis = false;
51 private $trash_migration_error = null;
52 private $parser_query_guard_active = false;
53 private $parser_query_guard_label = '';
54 private $parser_query_guard_warnings = array();
55 private $request_start_time = 0;
56 private $request_time_budget = null;
57
58 public function get_shortcode_analysis() {
59 return $this->shortcode_analysis;
60 }
61
62 public function is_debug() {
63 return $this->debug_logs;
64 }
65
66 public function __construct() {
67 $this->request_start_time = isset( $_SERVER['REQUEST_TIME_FLOAT'] )
68 ? (float) $_SERVER['REQUEST_TIME_FLOAT']
69 : microtime( true );
70 add_action( 'plugins_loaded', array( $this, 'plugins_loaded' ) );
71 add_action( 'delete_attachment', array( $this, 'delete_attachment_related_data' ), 10, 1 );
72 add_action( 'trashed_post', array( $this, 'delete_attachment_related_data' ), 10, 1 );
73 }
74
75 function plugins_loaded() {
76
77
78 if ( is_admin() ) {
79 new Meow_WPMC_UI( $this );
80 }
81
82 // Admin
83 $this->admin = new Meow_WPMC_Admin( $this );
84
85 // Advanced core
86 if ( class_exists( 'MeowPro_WPMC_Core' ) ) {
87 new MeowPro_WPMC_Core( $this );
88 }
89
90 // Only initialize variables if we are on a relevant screen
91 $pages = [ 'wpmc_dashboard', 'wpmc_settings' ];
92 $page = isset( $_GET["page"] ) ? sanitize_text_field( $_GET["page"] ) : null;
93 $is_wpmc_screen = in_array( $page, $pages );
94
95 // Check if this is a REST request specifically for Media Cleaner
96 $is_wpmc_rest = false;
97 $is_mcp_rest = false;
98 if ( MeowKit_WPMC_Helpers::is_rest() ) {
99 $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : '';
100 $is_wpmc_rest = strpos( $request_uri, '/media-cleaner/v1' ) !== false;
101 // MCP tools are served by AI Engine, on its own route. The scan engine
102 // still has to be loaded for them to run.
103 $is_mcp_rest = strpos( $request_uri, '/mcp/v1' ) !== false;
104 }
105
106 // Variables
107 $this->site_url = get_site_url();
108 $this->multilingual = $this->is_multilingual();
109 $this->languages = $this->get_languages();
110 $this->current_method = $this->get_option( 'method' );
111 $this->regex_file = str_replace( "MIMETYPES", $this->types, $this->regex_file );
112 $this->servername = (string) wp_parse_url( $this->site_url, PHP_URL_HOST );
113 $uploaddir = wp_upload_dir();
114 $this->upload_path = empty( $uploaddir['error'] ) ? untrailingslashit( wp_normalize_path( $uploaddir['basedir'] ) ) : '';
115 $this->upload_url = empty( $uploaddir['error'] ) ? untrailingslashit( $uploaddir['baseurl'] ) : '';
116 $this->debug_logs = $this->get_option( 'debuglogs' );
117 $this->is_rest = $is_wpmc_rest;
118 $this->is_cli = defined( 'WP_CLI' ) && WP_CLI;
119 $this->shortcode_analysis = !$this->get_option( 'shortcodes_disabled' );
120
121 global $wpmc;
122 $wpmc = $this;
123
124 $shouldLoad = ( defined( 'WP_CLI' ) && WP_CLI ) || $is_wpmc_screen || $is_wpmc_rest || $is_mcp_rest;
125
126 if ( ! $shouldLoad ) {
127 return;
128 }
129
130 $this->runs = new Meow_WPMC_Runs( $this );
131 if ( $this->is_rest || $this->is_cli || $is_mcp_rest ) $this->runs->maybe_upgrade();
132
133 // Language
134 load_plugin_textdomain( WPMC_DOMAIN, false, basename( WPMC_PATH ) . '/languages' );
135
136 // Install hooks and engine only if they might be used
137 if ( is_admin() || $this->is_rest || $this->is_cli || $is_mcp_rest ) {
138 add_action( 'wpmc_initialize_parsers', array( $this, 'initialize_parsers' ), 10, 0 );
139 add_filter( 'wp_unique_filename', array( $this, 'wp_unique_filename' ), 10, 3 );
140 $this->engine = new Meow_WPMC_Engine( $this, $this->admin );
141 }
142
143 // Only for REST
144 if ( $this->is_rest ) {
145 new Meow_WPMC_Rest( $this, $this->admin );
146 }
147
148 // MCP tools, served through AI Engine.
149 if ( class_exists( 'Meow_MWAI_Core' ) || isset( $GLOBALS['mwai'] ) ) {
150 new Meow_WPMC_MCP( $this );
151 }
152
153
154 }
155
156 public function set_run_context( $run_id ) {
157 if ( !$this->runs ) {
158 return new WP_Error( 'wpmc_runs_unavailable', __( 'The Media Cleaner run manager is unavailable.', 'media-cleaner' ) );
159 }
160 $run = $this->runs->assert_writable( (int) $run_id );
161 if ( is_wp_error( $run ) ) {
162 return $run;
163 }
164 if ( $run->status === 'paused' ) {
165 $run = $this->runs->resume( (int) $run_id );
166 if ( is_wp_error( $run ) ) return $run;
167 }
168 $this->run_id = (int) $run->id;
169 $this->current_method = $run->method;
170 $config = json_decode( (string) $run->config, true );
171 $this->run_config = is_array( $config ) ? $config : array();
172 $this->shortcode_analysis = !$this->get_option( 'shortcodes_disabled' );
173 return $run;
174 }
175
176 public function clear_run_context() {
177 $this->run_id = 0;
178 $this->run_config = array();
179 $this->current_method = $this->get_option( 'method' );
180 $this->shortcode_analysis = !$this->get_option( 'shortcodes_disabled' );
181 }
182
183 public function safe_do_action( $hook_name, ...$args ) {
184 global $wp_filter, $wp_actions, $wp_current_filter;
185 if ( empty( $wp_filter[ $hook_name ] ) || !( $wp_filter[ $hook_name ] instanceof WP_Hook ) ) {
186 return;
187 }
188 $wp_actions[ $hook_name ] = isset( $wp_actions[ $hook_name ] ) ? $wp_actions[ $hook_name ] + 1 : 1;
189 $wp_current_filter[] = $hook_name;
190 try {
191 foreach ( $wp_filter[ $hook_name ]->callbacks as $callbacks ) {
192 foreach ( $callbacks as $callback ) {
193 $function = $callback['function'];
194 $callback_name = $this->callback_name( $function );
195 $journal = null;
196 if ( $hook_name === 'wpmc_scan_once' && $this->run_id > 0 && $this->runs ) {
197 $journal = $this->runs->get_work( $this->run_id, 'scanOnce', $callback_name );
198 if ( $journal && $journal->status === 'complete' ) continue;
199 if ( !$journal ) {
200 if ( !$this->runs->enqueue_work( $this->run_id, 'scanOnce', 'parser', $callback_name ) ) {
201 throw new RuntimeException( sprintf( __( 'Media Cleaner could not journal parser %s.', 'media-cleaner' ), $callback_name ) );
202 }
203 $journal = $this->runs->get_work( $this->run_id, 'scanOnce', $callback_name );
204 }
205 if ( !$journal || !$this->runs->update_work( $journal->id, 'running', $journal->cursor_value ) ) {
206 throw new RuntimeException( sprintf( __( 'Media Cleaner could not start parser %s safely.', 'media-cleaner' ), $callback_name ) );
207 }
208 }
209 $accepted_args = max( 0, (int) $callback['accepted_args'] );
210 $call_args = $accepted_args === 0 ? array() : array_slice( $args, 0, $accepted_args );
211 $started = microtime( true );
212 $guard_was_active = $this->parser_query_guard_active;
213 $previous_guard_label = $this->parser_query_guard_label;
214 $this->parser_query_guard_active = true;
215 $this->parser_query_guard_label = $callback_name;
216 if ( !$guard_was_active ) add_filter( 'query', array( $this, 'guard_parser_query' ), PHP_INT_MAX );
217 try {
218 $this->timeout_check();
219 call_user_func_array( $function, $call_args );
220 if ( $journal ) {
221 $this->write_references();
222 if ( !$this->runs->update_work( $journal->id, 'complete', $journal->cursor_value ) ) {
223 throw new RuntimeException( sprintf( __( 'Media Cleaner could not complete parser %s safely.', 'media-cleaner' ), $callback_name ) );
224 }
225 }
226 }
227 catch ( Meow_WPMC_Transient_Exception $e ) {
228 if ( $journal ) $this->runs->update_work( $journal->id, 'pending', $journal->cursor_value, $e );
229 throw $e;
230 }
231 catch ( Throwable $e ) {
232 if ( $journal ) $this->runs->update_work( $journal->id, 'failed', $journal->cursor_value, $e );
233 throw new RuntimeException( sprintf( __( '%1$s failed in %2$s: %3$s', 'media-cleaner' ), $callback_name, $hook_name, $e->getMessage() ), 0, $e );
234 }
235 finally {
236 $this->parser_query_guard_active = $guard_was_active;
237 $this->parser_query_guard_label = $previous_guard_label;
238 if ( !$guard_was_active ) remove_filter( 'query', array( $this, 'guard_parser_query' ), PHP_INT_MAX );
239 }
240 $elapsed = microtime( true ) - $started;
241 $memory_limit = $this->parse_ini_bytes( ini_get( 'memory_limit' ) );
242 if ( $memory_limit > 0 && memory_get_usage( true ) > $memory_limit * 0.85 ) {
243 throw new Meow_WPMC_Transient_Exception( sprintf( __( '%1$s reached the safe parser memory budget in %2$s after %3$.1f seconds.', 'media-cleaner' ), $callback_name, $hook_name, $elapsed ), 5000 );
244 }
245 }
246 }
247 }
248 finally {
249 array_pop( $wp_current_filter );
250 }
251 }
252
253 public function guard_parser_query( $query ) {
254 if ( !$this->parser_query_guard_active || !is_string( $query ) ) return $query;
255 $normalized = preg_replace( '/\s+/', ' ', trim( $query ) );
256 if ( !preg_match( '/^(SELECT|WITH)\b/i', $normalized ) ) return $query;
257 if ( preg_match( '/\bLIMIT\s+\d+/i', $normalized ) ) return $query;
258 if ( preg_match( '/^SELECT\s+(?:DISTINCT\s+)?(?:COUNT|SUM|MIN|MAX|AVG|EXISTS)\s*\(/i', $normalized ) ) return $query;
259 if ( preg_match( '/\b(?:ID|post_id|meta_id|term_id|term_taxonomy_id|option_id|option_name|user_id)\s*(?:=|IN\s*\()/i', $normalized ) ) return $query;
260 $label = $this->parser_query_guard_label ?: 'A compatibility parser';
261 $message = sprintf(
262 __( '%s attempted an unbounded database query. Its Media Cleaner parser should use pagination.', 'media-cleaner' ),
263 $label
264 );
265 if ( empty( $this->parser_query_guard_warnings[ $label ] ) ) {
266 $this->parser_query_guard_warnings[ $label ] = true;
267 $this->log( $message );
268 }
269
270 // Strict mode is useful for parser development, but is unsafe for normal scans because
271 // WordPress and third-party APIs can legitimately issue SELECT queries without LIMIT.
272 if ( apply_filters( 'wpmc_strict_parser_query_guard', false, $normalized, $label ) ) {
273 throw new RuntimeException( $message );
274 }
275 return $query;
276 }
277
278 private function callback_name( $callback ) {
279 if ( is_string( $callback ) ) return $callback;
280 if ( is_array( $callback ) && count( $callback ) === 2 ) {
281 $owner = is_object( $callback[0] ) ? get_class( $callback[0] ) : $callback[0];
282 return $owner . '::' . $callback[1];
283 }
284 return $callback instanceof Closure ? 'closure' : 'unknown callback';
285 }
286
287 public function get_run_id( $for_write = false ) {
288 if ( $this->run_id > 0 ) {
289 return $this->run_id;
290 }
291 if ( $for_write ) {
292 return 0;
293 }
294 return $this->runs ? $this->runs->get_active_id() : max( 0, (int) get_option( Meow_WPMC_Runs::ACTIVE_RUN_OPTION, 0 ) );
295 }
296
297 public function get_nonce( $force = false ) {
298 if ( !$force && !is_user_logged_in() ) {
299 return null;
300 }
301 if ( isset( $this->nonce ) ) {
302 return $this->nonce;
303 }
304
305 $this->nonce = wp_create_nonce( 'wp_rest' );
306 return $this->nonce;
307 }
308
309 function initialize_parsers() {
310 include_once( 'parsers.php' );
311 new Meow_WPMC_Parsers();
312 }
313
314 function deepsleep( $seconds ) {
315 $start_time = time();
316 while( true ) {
317 if ( ( time() - $start_time ) > $seconds ) {
318 return false;
319 }
320 get_post( array( 'posts_per_page' => 50 ) );
321 }
322 }
323
324 private $start_time;
325 private $time_elapsed = 0;
326 private $time_remaining = 0;
327 private $item_scan_avg_time = 0;
328 private $wordpress_init_time = 0.5;
329 private $max_execution_time;
330 private $items_checked = 0;
331 private $items_count = 0;
332
333 function get_max_execution_time() {
334 if ( isset( $this->max_execution_time ) )
335 return $this->max_execution_time;
336
337 $this->max_execution_time = (int) ini_get( "max_execution_time" );
338 // An unlimited PHP worker can still sit behind a 30 or 60 second proxy.
339 // Use a conservative fallback so every REST batch remains bounded.
340 if ( $this->max_execution_time === 0 )
341 $this->max_execution_time = 30;
342 else if ( $this->max_execution_time < 5 )
343 $this->max_execution_time = 5;
344
345 return $this->max_execution_time;
346 }
347
348 public function get_request_time_budget() {
349 if ( $this->request_time_budget !== null ) return $this->request_time_budget;
350 $execution_limit = $this->get_max_execution_time();
351 $hard_budget = min( 30, $execution_limit );
352 $hard_budget = (float) apply_filters( 'wpmc_request_time_budget', $hard_budget, $execution_limit );
353 $hard_budget = max( 5, min( $hard_budget, max( 5, $execution_limit ) ) );
354 $reserve = max( 2.5, min( 6, $hard_budget * 0.2 ) );
355 $this->request_time_budget = max( 2.5, $hard_budget - $reserve );
356 return $this->request_time_budget;
357 }
358
359 public function parse_ini_bytes( $value ) {
360 $value = trim( (string) $value );
361 if ( $value === '' ) {
362 return 0;
363 }
364 if ( $value === '-1' ) {
365 return -1;
366 }
367 $unit = strtolower( substr( $value, -1 ) );
368 $number = (float) $value;
369 switch ( $unit ) {
370 case 'g':
371 $number *= 1024;
372 // Fall through.
373 case 'm':
374 $number *= 1024;
375 // Fall through.
376 case 'k':
377 $number *= 1024;
378 }
379 return (int) $number;
380 }
381
382 public function parser_budget_check( $started_at, $label = 'Parser' ) {
383 $this->timeout_check();
384 $budget = max( 3, min( 12, $this->get_request_time_budget() * 0.6 ) );
385 if ( !$this->is_cli && microtime( true ) - (float) $started_at > $budget ) {
386 throw new Meow_WPMC_Transient_Exception( sprintf( __( '%1$s reached its %2$.1f-second scan budget.', 'media-cleaner' ), sanitize_text_field( $label ), $budget ), 3000 );
387 }
388 $memory_limit = $this->parse_ini_bytes( ini_get( 'memory_limit' ) );
389 if ( $memory_limit > 0 && memory_get_usage( true ) > $memory_limit * 0.8 ) {
390 throw new Meow_WPMC_Transient_Exception( sprintf( __( '%s reached the safe parser memory limit.', 'media-cleaner' ), sanitize_text_field( $label ) ), 5000 );
391 }
392 }
393
394 public function run_paged_parser( $label, $fetch_page, $process_page, $page_size = 100, $cursor_resolver = null ) {
395 if ( !is_callable( $fetch_page ) || !is_callable( $process_page ) ) {
396 throw new InvalidArgumentException( __( 'A paged Media Cleaner parser requires callable fetch and process functions.', 'media-cleaner' ) );
397 }
398 if ( $cursor_resolver !== null && !is_callable( $cursor_resolver ) ) {
399 throw new InvalidArgumentException( __( 'A Media Cleaner parser cursor resolver must be callable.', 'media-cleaner' ) );
400 }
401 $label = sanitize_key( $label );
402 $page_size = max( 10, min( 250, (int) $page_size ) );
403 $work = null;
404 $offset = 0;
405 if ( $this->run_id > 0 && $this->runs ) {
406 $work = $this->runs->get_work( $this->run_id, 'parserPages', $label );
407 if ( $work && $work->status === 'complete' ) return true;
408 if ( !$work ) {
409 if ( !$this->runs->enqueue_work( $this->run_id, 'parserPages', 'parser', $label ) ) {
410 throw new RuntimeException( sprintf( __( 'Media Cleaner could not initialize parser page state for %s.', 'media-cleaner' ), $label ) );
411 }
412 $work = $this->runs->get_work( $this->run_id, 'parserPages', $label );
413 }
414 if ( !$work ) throw new RuntimeException( sprintf( __( 'Media Cleaner could not read parser page state for %s.', 'media-cleaner' ), $label ) );
415 $offset = max( 0, (int) $work->cursor_value );
416 }
417
418 $started = microtime( true );
419 do {
420 $this->timeout_check();
421 $rows = call_user_func( $fetch_page, $offset, $page_size );
422 global $wpdb;
423 if ( $wpdb->last_error ) throw new RuntimeException( sprintf( __( '%1$s database error: %2$s', 'media-cleaner' ), $label, $wpdb->last_error ) );
424 if ( is_wp_error( $rows ) ) throw new RuntimeException( $rows->get_error_message() );
425 if ( !is_array( $rows ) ) $rows = array();
426 $count = count( $rows );
427 $next_offset = $offset + $count;
428 if ( $count > 0 && $cursor_resolver ) {
429 $resolved_cursor = call_user_func( $cursor_resolver, $rows, $offset );
430 if ( !is_numeric( $resolved_cursor ) || (int) $resolved_cursor <= $offset ) {
431 throw new RuntimeException( sprintf(
432 __( 'Media Cleaner parser %s returned an invalid page cursor.', 'media-cleaner' ),
433 $label
434 ) );
435 }
436 $next_offset = (int) $resolved_cursor;
437 }
438 call_user_func( $process_page, $rows );
439 $this->write_references();
440 $offset = $next_offset;
441 $finished = $count < $page_size;
442 if ( $work && !$this->runs->update_work( $work->id, $finished ? 'complete' : 'pending', $offset ) ) {
443 throw new RuntimeException( sprintf( __( 'Media Cleaner could not checkpoint parser %s.', 'media-cleaner' ), $label ) );
444 }
445 if ( !$finished ) $this->parser_budget_check( $started, $label );
446 } while ( !$finished );
447 return true;
448 }
449
450 function timeout_check_start( $count ) {
451 $this->start_time = microtime( true );
452 $this->items_count = $count;
453 $this->items_checked = 0;
454 $this->item_scan_avg_time = 0;
455 $this->time_elapsed = 0;
456 $this->time_remaining = $this->get_request_time_budget() - ( microtime( true ) - $this->request_start_time );
457 $this->get_max_execution_time();
458 }
459
460 function timeout_get_elapsed() {
461 return round( $this->time_elapsed, 2 ) . 's';
462 }
463
464 function timeout_check() {
465 $now = microtime( true );
466 if ( empty( $this->start_time ) ) $this->start_time = $now;
467 $this->time_elapsed = $now - $this->start_time;
468 $this->time_remaining = $this->get_request_time_budget() - ( $now - $this->request_start_time );
469 if ( $this->catch_timeout && $this->timeout_should_yield() ) {
470 error_log("Media Cleaner Timeout! Check the Media Cleaner logs for more info.");
471 $this->log( "😵 Timeout! Some info for debug:" );
472 $this->log( "🍀 Elapsed time: $this->time_elapsed" );
473 $this->log( "🍀 WP init time: $this->wordpress_init_time" );
474 $this->log( "🍀 Remaining time: $this->time_remaining" );
475 $this->log( "🍀 Scan time per item: $this->item_scan_avg_time" );
476 $this->log( "🍀 PHP max_execution_time: $this->max_execution_time" );
477 throw new Meow_WPMC_Transient_Exception( __( 'Media Cleaner paused this batch before the server execution-time or memory limit.', 'media-cleaner' ), 2000 );
478 }
479 }
480
481 public function timeout_should_yield() {
482 $now = microtime( true );
483 $this->time_remaining = $this->get_request_time_budget() - ( $now - $this->request_start_time );
484 $next_item_reserve = max( 1.25, $this->item_scan_avg_time * 1.75 );
485 if ( !$this->is_cli && $this->time_remaining <= $next_item_reserve ) return true;
486 $memory_limit = $this->parse_ini_bytes( ini_get( 'memory_limit' ) );
487 return $memory_limit > 0 && memory_get_usage( true ) >= $memory_limit * 0.82;
488 }
489
490 function delete_attachment_related_data( $post_id ) {
491
492 if ( empty( $post_id ) ) return;
493
494 global $wpdb;
495 $table_name = $wpdb->prefix . "mclean_scan";
496 $run_id = $this->get_run_id();
497
498 if ( $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $wpdb->esc_like( $table_name ) ) ) === $table_name ) {
499 $wpdb->query( $wpdb->prepare( "DELETE FROM $table_name WHERE run_id = %d AND postId = %d", $run_id, $post_id ) );
500 } else {
501 // Table does not exist
502 }
503 }
504
505 function timeout_check_additem() {
506 $this->items_checked++;
507 $this->time_elapsed = microtime( true ) - $this->start_time;
508 $this->item_scan_avg_time = ceil( ( $this->time_elapsed / $this->items_checked ) * 10 ) / 10;
509 }
510
511 // This checks if a new uploaded filename isn't the same one as a currently
512 // filename in the trash (that would cause issues)
513 function wp_unique_filename( $filename, $ext, $dir ) {
514 $fullpath = trailingslashit( $dir ) . $filename;
515 $relativepath = $this->clean_uploaded_filename( $fullpath );
516 $trashfilepath = trailingslashit( $this->get_trashdir() ) . $relativepath;
517 if ( file_exists( $trashfilepath ) ) {
518 $path_parts = pathinfo( $fullpath );
519 $filename_noext = $path_parts['filename'];
520 $new_filename = $filename_noext . '-' . date('Ymd-His', time()) . '.' . $path_parts['extension'];
521 //error_log( 'POTENTIALLY TRASH PATH: ' . $trashfilepath );
522 //error_log( 'POTENTIALLY NEW FILE: ' . $new_filename );
523 return $new_filename;
524 }
525 return $filename;
526 }
527
528 function array_to_ids_or_urls( $meta, &$ids, &$urls, $recursive = false, $filters = array(), $depth = 0 ) {
529 if ( $depth > 64 || !is_array( $meta ) ) {
530 return;
531 }
532 foreach ( $meta as $k => $m ) {
533
534 if ( is_numeric( $m ) ) {
535
536 if ( !empty( $filters ) && is_array( $filters ) && !in_array( $k, $filters ) ) {
537 continue;
538 }
539
540 // Probably a Media ID
541 if ( $m > 0 )
542 {
543 array_push( $ids, $m );
544 }
545 }
546
547 else if ( is_array( $m ) ) {
548
549
550 if ( $recursive ) {
551 // If it's an array, we need to go deeper
552 $this->array_to_ids_or_urls( $m, $ids, $urls, true, $filters, $depth + 1 );
553 }
554
555 }
556 else if ( !empty( $m ) ) {
557
558 if ( !empty( $filters ) && is_array( $filters ) && !in_array( $k, $filters ) ) {
559 continue;
560 }
561
562 if ( is_string( $m ) && preg_match( '/^[\d\s,]+$/', $m ) && strpos( $m, ',' ) !== false ) {
563 // If this is a string that contains only digits, spaces, and commas, and contains at least one comma
564 // it is probably a list of IDs. So we should explode it to make an array
565 // Remove any spaces
566
567 $m = str_replace( ' ', '', $m );
568 $m = explode( ',', $m );
569
570 foreach ( $m as $mv ) {
571 if ( is_numeric( $mv ) && !in_array( (int)$mv, $ids ) ) {
572 array_push( $ids, (int)$mv );
573 }
574 }
575
576 continue;
577 }
578
579 // If it's a string, maybe it's a file (with an extension)
580 if ( preg_match( $this->regex_file, $m ) )
581 {
582 $clean_url = $this->clean_url( $m );
583 array_push( $urls, $clean_url );
584 }
585 }
586 }
587 }
588
589 function get_favicon() {
590 // Yoast SEO plugin
591 $vals = get_option( 'wpseo_titles' );
592 if ( !empty( $vals ) && isset( $vals['company_logo'] ) ) {
593 $url = $vals['company_logo'];
594 if ( $this->is_url( $url ) )
595 return $this->clean_url( $url );
596 }
597 }
598
599 function get_all_shortcodes_attributes( $html, $ids_attr = array(), $urls_attr = array() ) {
600 // Get all the shortcodes from html, and check for each attributes of the shortcode if it is an ID or a URL and add the value in an array to return
601 $urls_values = array();
602 $ids_values = array();
603
604 $pattern = get_shortcode_regex();
605 if ( preg_match_all( '/'. $pattern .'/s', $html, $matches ) )
606 {
607 foreach( $matches[0] as $key => $value) {
608 // $matches[3] return the shortcode attribute as string
609 // replace space with '&' for parse_str() function
610 $get = str_replace(" ", "&" , trim( $matches[3][$key] ) );
611 $get = str_replace('"', '' , $get );
612 parse_str( $get, $sub_output );
613
614 foreach ( $sub_output as $attr_key => $attr_value ) {
615
616 if ( in_array( $attr_key, $ids_attr ) ) {
617 if ( is_numeric( $attr_value ) && !in_array( (int)$attr_value, $ids_values ) ) {
618 array_push( $ids_values, (int)$attr_value );
619 }
620
621 // In case of separated by commas
622 else if ( strpos( $attr_value, ',' ) !== false ) {
623 $attr_value = str_replace(' ', '', $attr_value );
624 $pieces = explode( ',', $attr_value );
625 foreach ( $pieces as $pval ) {
626 if ( is_numeric( $pval ) && !in_array( (int)$pval, $ids_values ) ) {
627 array_push( $ids_values, (int)$pval );
628 }
629 }
630 }
631 }
632
633 else if ( in_array( $attr_key, $urls_attr ) ) {
634 if ( !empty( trim( $attr_value ) ) && !in_array( trim( $attr_value ), $urls_values ) && !is_numeric( trim( $attr_value ) ) && strpos( trim( $attr_value ), 'http' ) !== false ) {
635 array_push( $urls_values, trim( $this->clean_url( $attr_value ) ) );
636 }
637 }
638 }
639 }
640 }
641
642 // Remove duplicates
643 $urls_values = array_unique( $urls_values );
644 $ids_values = array_unique( $ids_values );
645
646 // Return the values
647 $values = array(
648 'urls' => $urls_values,
649 'ids' => $ids_values
650 );
651
652 return $values;
653
654 }
655
656
657
658 /**
659 * Recursively transforms a string with WordPress shortcodes into a
660 * hierarchical tree structure (an Abstract Syntax Tree).
661 *
662 * @param string $content The string containing the shortcodes.
663 * @return array An array of nodes, where each node can be a shortcode with its
664 * own 'children' array, or a simple text node.
665 */
666 function nested_shortcodes_to_array(string $content, $depth = 0): array
667 {
668 if ( $depth > 32 ) {
669 return array();
670 }
671 $nodes = [];
672 $last_pos = 0;
673
674 $pattern = '/\\[' . '(\\[?)' . '([\w-]+)' . '(?![\\w-])' . '(' . '[^\\]\\/]*' . '(?:' . '\\/(?!\\])' . '[^\\]\\/]*' . ')*?' . ')' . '(?:' . '(\\/)' . '\\]' . '|' . '\\]' . '(?:' . '(' . '[^\\[]*+' . '(?:' . '\\[(?!\\/\\2\\])' . '[^\\[]*+' . ')*+' . ')' . '\\[\\/\\2\\]' . ')?' . ')' . '(\\]?)/s';
675
676 // preg_match_all with PREG_OFFSET_CAPTURE is key to tracking positions.
677 if (preg_match_all($pattern, $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
678 foreach ($matches as $match) {
679 // Get the position and content of the full shortcode match
680 $match_start_pos = $match[0][1];
681 $match_full_string = $match[0][0];
682 $match_end_pos = $match_start_pos + strlen($match_full_string);
683
684 // 1. Capture any text that appeared *before* this shortcode
685 if ($match_start_pos > $last_pos) {
686 $text_content = substr($content, $last_pos, $match_start_pos - $last_pos);
687 if (trim($text_content) !== '') {
688 $nodes[] = [
689 'type' => 'text',
690 'content' => $text_content
691 ];
692 }
693 }
694
695 // 2. Process the shortcode match itself
696 $tag = $match[2][0];
697 $attributes_string = $match[3][0];
698 // Use isset since self-closing tags won't have inner content (group 5)
699 $inner_content = isset($match[5]) ? $match[5][0] : null;
700
701 // Parse attributes from the attribute string
702 $parsed_attributes = [];
703 if (preg_match_all('/([\w-]+)\s*=\s*(["\'])([^"\']*?)\2/', $attributes_string, $attr_matches)) {
704 foreach ($attr_matches[1] as $attr_index => $key) {
705 $parsed_attributes[$key] = $attr_matches[3][$attr_index];
706 }
707 }
708
709 $shortcode_node = [
710 'type' => 'shortcode',
711 'tag' => $tag,
712 'attributes' => $parsed_attributes,
713 ];
714
715 // 3. This is the recursion!
716 // If there is inner content, parse it with the same function.
717 if ($inner_content !== null) {
718 $children = $this->nested_shortcodes_to_array( $inner_content, $depth + 1 );
719 if (!empty($children)) {
720 $shortcode_node['children'] = $children;
721 }
722 }
723
724 $nodes[] = $shortcode_node;
725
726 // Update the last position to the end of the current match
727 $last_pos = $match_end_pos;
728 }
729 }
730
731 // 4. Capture any remaining text after the very last shortcode
732 if ($last_pos < strlen($content)) {
733 $text_content = substr($content, $last_pos);
734 if (trim($text_content) !== '') {
735 $nodes[] = [
736 'type' => 'text',
737 'content' => $text_content
738 ];
739 }
740 }
741
742 return $nodes;
743 }
744
745
746
747
748 function get_shortcode_attributes( $shortcode_tag, $post ) {
749 if ( has_shortcode( $post->post_content, $shortcode_tag ) ) {
750 $output = array();
751 //get shortcode regex pattern wordpress function
752 $pattern = get_shortcode_regex( [ $shortcode_tag ] );
753 if ( preg_match_all( '/'. $pattern .'/s', $post->post_content, $matches ) )
754 {
755 $keys = array();
756 $output = array();
757 foreach( $matches[0] as $key => $value) {
758 // $matches[3] return the shortcode attribute as string
759 // replace space with '&' for parse_str() function
760 $get = str_replace(" ", "&" , trim( $matches[3][$key] ) );
761 $get = str_replace('"', '' , $get );
762 parse_str( $get, $sub_output );
763
764 //get all shortcode attribute keys
765 $keys = array_unique( array_merge( $keys, array_keys( $sub_output )) );
766 $output[] = $sub_output;
767 }
768 if ( $keys && $output ) {
769 // Loop the output array and add the missing shortcode attribute key
770 foreach ($output as $key => $value) {
771 // Loop the shortcode attribute key
772 foreach ($keys as $attr_key) {
773 $output[$key][$attr_key] = isset( $output[$key] ) && isset( $output[$key] ) ? $output[$key][$attr_key] : NULL;
774 }
775 //sort the array key
776 ksort( $output[$key]);
777 }
778 }
779 }
780 return $output;
781 }
782 else {
783 return false;
784 }
785 }
786
787 // Simply use regex to get URLs from a string return an array of URLs
788 function get_urls_from_string( $string ) {
789 if ( $this->analysis_document_too_large( $string ) ) {
790 return array();
791 }
792 $urls = array();
793 // Replace the sanitized urls with the real ones to be sure to get them in the regex
794 $string = str_replace( '\\', '', $string );
795
796 $patterns = array(
797 // Full URLs with protocol
798 '/(https?:\/\/[^\s\"\'\>\<\?\#]+\.(' . $this->types . '))/i',
799
800 // Relative URLs starting with /wp-content/uploads or /uploads (without protocol)
801 '/(\/(?:wp-content\/)?uploads\/[^\s\"\'\>\<\?\#]+\.(' . $this->types . '))/i',
802 );
803
804 foreach ( $patterns as $pattern ) {
805 if ( preg_match_all( $pattern, $string, $matches ) ) {
806 foreach ( $matches[0] as $match ) {
807 $clean_url = $this->clean_url( $match );
808 $urls[] = $clean_url;
809 }
810 }
811 }
812
813 return array_unique( $urls );
814 }
815
816 function get_urls_from_html( $html ) {
817 if ( empty( $html ) ) {
818 return array();
819 }
820 if ( $this->analysis_document_too_large( $html ) ) {
821 return array();
822 }
823
824
825 // Proposal/fix by @copytrans
826 // Discussion: https://wordpress.org/support/topic/bug-in-core-php/#post-11647775
827 // Modified by Jordy again in 2021 for those who don't have MB enabled
828 if ( function_exists( 'mb_encode_numericentity' ) ) {
829 $convmap = [0x80, 0xffff, 0, 0xffff];
830 $html = mb_encode_numericentity( $html, $convmap, 'UTF-8' );
831 } else {
832 $html = preg_replace_callback(
833 '/[\x80-\xFF]/',
834 function( $match ) {
835 return '&#' . ord( $match[0] ) . ';';
836 },
837 $html
838 );
839 }
840
841 // Remove any base64 src from the HTML to prevent regex from getting stuck and crashing the site
842 // Handles both proper (data:image/...) and malformed (image/jpeg;base64,...) base64
843 // Also handles HTML-encoded quotes (&quot;) and multiline base64
844 $html = preg_replace( '/src=["\'](?:data:)?(?:image|video|audio)\/[^"\']+;base64,[^"\']*["\']/', '', $html );
845 $html = preg_replace( '/src=&quot;(?:data:)?(?:image|video|audio)\/[^&]+;base64,[^&]*&quot;/', '', $html );
846 // Catch any remaining base64 data that might cause regex issues (greedy catch-all)
847 $html = preg_replace( '/;base64,[a-zA-Z0-9+\/=\s]{1000,}/', '', $html );
848
849
850 // Resolve src-set and shortcodes
851 if ( $this->get_shortcode_analysis() ) {
852 $html = do_shortcode( $html );
853 if ( $this->analysis_document_too_large( $html ) ) {
854 return array();
855 }
856 }
857
858 // Create the DOM Document
859 if ( !class_exists("DOMDocument") ) {
860 error_log( 'Media Cleaner: The DOM extension for PHP is not installed.' );
861 throw new Error( 'The DOM extension for PHP is not installed.' );
862 }
863
864
865 if ( empty( $html ) ) {
866 return array();
867 }
868
869 $previous_libxml_errors = libxml_use_internal_errors( true );
870 $dom = new DOMDocument();
871 @$dom->loadHTML( $html, LIBXML_NONET | LIBXML_NOWARNING | LIBXML_NOERROR );
872 libxml_clear_errors();
873 libxml_use_internal_errors( $previous_libxml_errors );
874 $results = array();
875
876 // <meta> tags in <head> area
877 $metas = $dom->getElementsByTagName( 'meta' );
878 foreach ( $metas as $meta ) {
879 $property = $meta->getAttribute( 'property' );
880 if ( $property == 'og:image' || $property == 'og:image:secure_url' || $property == 'twitter:image' ) {
881 $url = $meta->getAttribute( 'content' );
882 if ( $this->is_url( $url ) ) {
883 $src = $this->clean_url( $url );
884 if ( !empty( $src ) ) {
885 array_push( $results, $src );
886 }
887 }
888 }
889 }
890
891
892
893 // Iframe documents are not fetched while scanning. Fetching page-controlled
894 // URLs here made analysis depend on networking, allowed recursive documents,
895 // and could execute expensive remote rendering paths.
896
897
898 // Images: src, srcset
899 $imgs = $dom->getElementsByTagName( 'img' );
900 foreach ( $imgs as $img ) {
901 //error_log($img->getAttribute('src'));
902 $src = $this->clean_url( $img->getAttribute('src') );
903 array_push( $results, $src );
904 $srcset = $img->getAttribute('srcset');
905 if ( !empty( $srcset ) ) {
906 $setImgs = explode( ',', trim( $srcset ) );
907 foreach ( $setImgs as $setImg ) {
908 $finalSetImg = explode( ' ', trim( $setImg ) );
909 if ( is_array( $finalSetImg ) ) {
910 array_push( $results, $this->clean_url( $finalSetImg[0] ) );
911 }
912 }
913 }
914 }
915
916 // Videos: src, poster, and attached file
917 $videos = $dom->getElementsByTagName( 'video' );
918 foreach ($videos as $video) {
919 // Get src attribute
920 $raw_video_src = $video->getAttribute( 'src' );
921 $src = $this->clean_url( $raw_video_src );
922 if ( !empty( $src ) ) {
923 $video_id = $this->custom_attachment_url_to_postid( $raw_video_src );
924
925 $attached_file = get_post_meta( $video_id, '_wp_attached_file', true );
926 if ( !empty( $attached_file ) ) {
927 array_push( $results, $attached_file );
928 }
929 }
930
931 // Get poster attribute
932 $raw_poster_src = $video->getAttribute( 'poster' );
933 $poster = $this->clean_url( $raw_poster_src );
934 if ( !empty( $poster ) ) {
935 $poster_id = $this->custom_attachment_url_to_postid( $raw_poster_src );
936
937 $attached_file = get_post_meta( $poster_id, '_wp_attached_file', true );
938 if ( !empty( $attached_file ) ) {
939 array_push( $results, $attached_file );
940 }
941 }
942
943 }
944
945 // Audios: src
946 $audios = $dom->getElementsByTagName( 'audio' );
947 foreach ( $audios as $audio ) {
948 //error_log($audio->getAttribute('src'));
949 $src = $this->clean_url( $audio->getAttribute('src') );
950 array_push( $results, $src );
951 }
952
953 // Sources: src
954 $audios = $dom->getElementsByTagName( 'source' );
955 foreach ( $audios as $audio ) {
956 //error_log($audio->getAttribute('src'));
957 $src = $this->clean_url( $audio->getAttribute('src') );
958 array_push( $results, $src );
959 }
960
961 // Links, href
962 $urls = $dom->getElementsByTagName( 'a' );
963 foreach ( $urls as $url ) {
964 $url_href = $url->getAttribute('href'); // mm change
965 if ( $this->is_url( $url_href ) ) { // mm change
966 $src = $this->clean_url( $url_href ); // mm change
967 if ( !empty( $src ) )
968 array_push( $results, $src );
969 }
970 }
971
972 // <link> tags in <head> area
973 $urls = $dom->getElementsByTagName( 'link' );
974 foreach ( $urls as $url ) {
975 $url_href = $url->getAttribute( 'href' );
976 if ( $this->is_url( $url_href ) ) {
977 $src = $this->clean_url( $url_href );
978 if ( !empty( $src ) ) {
979 array_push( $results, $src );
980 }
981 }
982 }
983
984 // PDF
985 preg_match_all( "/((https?:\/\/)?[^\\&\#\[\] \"\?]+\.pdf)/", $html, $res );
986 if ( !empty( $res ) && isset( $res[1] ) && count( $res[1] ) > 0 ) {
987 foreach ( $res[1] as $url ) {
988 if ( $this->is_url( $url ) )
989 array_push( $results, $this->clean_url( $url ) );
990 }
991 }
992
993 // Background images
994 preg_match_all( "/url\(\'?\"?((https?:\/\/)?[^\\&\#\[\] \"\?]+\.(jpe?g|gif|png))\'?\"?/", $html, $res );
995 if ( !empty( $res ) && isset( $res[1] ) && count( $res[1] ) > 0 ) {
996 foreach ( $res[1] as $url ) {
997 if ( $this->is_url( $url ) )
998 array_push( $results, $this->clean_url( $url ) );
999 }
1000 }
1001
1002 return $results;
1003 }
1004
1005 private function analysis_document_too_large( $value ) {
1006 if ( !is_string( $value ) ) {
1007 return false;
1008 }
1009 $limit = (int) $this->get_option( 'analysis_document_limit' );
1010 // A filter can still override the stored setting for advanced setups.
1011 $limit = (int) apply_filters( 'wpmc_max_analysis_document_bytes', $limit );
1012 // -1 disables the limit: documents are analyzed no matter their size.
1013 if ( $limit < 0 ) {
1014 return false;
1015 }
1016 // Keep a 1 MB floor so a misconfigured tiny limit cannot cripple analysis.
1017 $limit = max( 1024 * 1024, $limit );
1018 if ( strlen( $value ) > $limit ) {
1019 $this->log( sprintf(
1020 __( '⚠️ Skipped a content document larger than the %s analysis limit.', 'media-cleaner' ),
1021 size_format( $limit )
1022 ) );
1023 return true;
1024 }
1025 return false;
1026 }
1027
1028 /**
1029 *
1030 * Get the IDs and URLs from the blocks of a post.
1031 *
1032 * @param string $html The HTML content of the post.
1033 * @param string $prefix The prefix of the blocks to look for.
1034 * @param array $keys The keys to look for in the blocks.
1035 * @param array $urls The array to fill with the URLs.
1036 * @param array $ids The array to fill with the IDs.
1037 *
1038 */
1039 function get_from_blocks( $html, $prefix, $keys, &$urls, &$ids ) {
1040
1041 $blocks = parse_blocks( $html );
1042
1043 if ( ! is_array( $blocks ) || ! isset( $blocks[0] ) ) {
1044 return;
1045 }
1046
1047
1048 foreach ( $blocks as $block ) {
1049
1050 if ( strpos( $block['blockName'], $prefix ) === false ) {
1051 continue;
1052 }
1053
1054 $this->array_to_ids_or_urls( $block, $ids, $urls, true, $keys );
1055
1056 }
1057
1058
1059 }
1060 // Parse a meta, visit all the arrays, look for the attributes, fill $ids and $urls arrays
1061 // If rawMode is enabled, it will not check if the value is an ID or an URL, it will just returns it in URLs
1062 function get_from_meta( $meta, $lookFor, &$ids, &$urls, $rawMode = false, $depth = 0 ) {
1063 if ( $depth > 64 || ( !is_array( $meta ) && !is_object( $meta) ) ) {
1064 return;
1065 }
1066 foreach ( $meta as $key => $value ) {
1067 if ( is_object( $value ) || is_array( $value ) )
1068 $this->get_from_meta( $value, $lookFor, $ids, $urls, $rawMode, $depth + 1 );
1069 else if ( in_array( $key, $lookFor ) ) {
1070 if ( empty( $value ) ) {
1071 continue;
1072 }
1073 else if ( $rawMode ) {
1074 array_push( $urls, $value );
1075 }
1076 else if ( is_numeric( $value ) ) {
1077 // It this an ID?
1078 array_push( $ids, $value );
1079 }
1080 else {
1081 if ( $this->is_url( $value ) ) {
1082 // Is this an URL?
1083 array_push( $urls, $this->clean_url( $value ) );
1084 }
1085 else {
1086 // Is this an array of IDs, encoded as a string? (like "20,13")
1087 $pieces = explode( ',', $value );
1088 foreach ( $pieces as $pval ) {
1089 if ( is_numeric( $pval ) ) {
1090 array_push( $ids, $pval );
1091 }
1092 }
1093 }
1094 }
1095 }
1096 }
1097 }
1098
1099 function get_images_from_themes( &$ids, &$urls ) {
1100 // USE CURRENT THEME AND WP API
1101 $ch = get_custom_header();
1102 if ( !empty( $ch ) && !empty( $ch->url ) ) {
1103 array_push( $urls, $this->clean_url( $ch->url ) );
1104 }
1105 if ( !empty( $ch ) && !empty( $ch->thumbnail_url ) && $this->is_url( $ch->thumbnail_url ) ) {
1106 array_push( $urls, $this->clean_url( $ch->thumbnail_url ) );
1107 }
1108 if ( !empty( $ch ) && !empty( $ch->attachment_id ) ) {
1109 array_push( $ids, $ch->attachment_id );
1110 }
1111 $cl = get_custom_logo();
1112 if ( $this->is_url( $cl ) ) {
1113 $urls = array_merge( $this->get_urls_from_html( $cl ), $urls );
1114 }
1115 $custom_logo = get_theme_mod( 'custom_logo' );
1116 if ( !empty( $custom_logo ) && is_numeric( $custom_logo ) ) {
1117 array_push( $ids, (int)$custom_logo );
1118 }
1119 $si = get_site_icon_url();
1120 if ( $this->is_url( $si ) ) {
1121 array_push( $urls, $this->clean_url( $si ) );
1122 }
1123 $si_id = get_option( 'site_icon' );
1124 if ( !empty( $si_id ) && is_numeric( $si_id ) ) {
1125 array_push( $ids, (int)$si_id );
1126 }
1127 $cd = get_background_image();
1128 if ( $this->is_url( $cd ) ) {
1129 array_push( $urls, $this->clean_url( $cd ) );
1130 }
1131 $photography_hero_image = get_theme_mod( 'photography_hero_image' );
1132 if ( !empty( $photography_hero_image ) ) {
1133 array_push( $ids, $photography_hero_image );
1134 }
1135 $author_profile_picture = get_theme_mod( 'author_profile_picture' );
1136 if ( !empty( $author_profile_picture ) ) {
1137 array_push( $ids, $author_profile_picture );
1138 }
1139 if ( function_exists ( 'get_uploaded_header_images' ) ) {
1140 $header_images = get_uploaded_header_images();
1141 if ( !empty( $header_images ) ) {
1142 foreach ( $header_images as $hi ) {
1143 if ( !empty ( $hi['attachment_id'] ) ) {
1144 array_push( $ids, $hi['attachment_id'] );
1145 }
1146 }
1147 }
1148 }
1149 }
1150
1151 #region LOGS
1152
1153 function log( $data = null, $force = false ) {
1154 if ( !$this->debug_logs && !$force )
1155 return;
1156
1157 $php_logs = $this->get_option( 'php_error_logs' );
1158 $log_file_path = $this->get_logs_path();
1159 if ( !$log_file_path ) return false;
1160 if ( file_exists( $log_file_path ) && filesize( $log_file_path ) > 5 * 1024 * 1024 ) {
1161 if ( file_exists( $log_file_path . '.1' ) ) @unlink( $log_file_path . '.1' );
1162 @rename( $log_file_path, $log_file_path . '.1' );
1163 }
1164
1165 $fh = @fopen( $log_file_path, 'a' );
1166 if ( !$fh ) { return false; }
1167 $date = date( "Y-m-d H:i:s" );
1168 if ( is_null( $data ) ) {
1169 fwrite( $fh, "\n" );
1170 }
1171 else {
1172 $message = is_scalar( $data ) ? (string) $data : wp_json_encode( $data );
1173 fwrite( $fh, "$date: {$message}\n" );
1174 if ( $php_logs ) {
1175 error_log( "[MEDIA CLEANER] " . $message );
1176 }
1177 }
1178 fclose( $fh );
1179 return true;
1180 }
1181
1182 //WPMC_PREFIX
1183
1184 function get_logs_path() {
1185 $private_root = $this->ensure_private_root();
1186 if ( is_wp_error( $private_root ) ) return false;
1187 $log_dir = trailingslashit( $private_root ) . 'logs';
1188 if ( !is_dir( $log_dir ) ) {
1189 wp_mkdir_p( $log_dir );
1190 }
1191 $path = trailingslashit( $log_dir ) . 'media-cleaner.log';
1192 $old_path = $this->get_option( 'logs_path' );
1193 $legacy_path = wp_normalize_path( WPMC_PATH . '/logs/media-cleaner.log' );
1194 $old_normalized = is_string( $old_path ) ? wp_normalize_path( $old_path ) : '';
1195 if ( $old_normalized === $legacy_path && $old_normalized !== $path && is_file( $old_normalized ) ) {
1196 if ( !@rename( $old_normalized, $path ) ) {
1197 if ( @copy( $old_normalized, $path ) ) @unlink( $old_normalized );
1198 }
1199 }
1200 if ( !file_exists( $path ) ) {
1201 @touch( $path );
1202 }
1203 if ( $old_path !== $path ) {
1204 $options = $this->get_all_options();
1205 $options['logs_path'] = $path;
1206 $this->update_options( $options );
1207 }
1208 return $path;
1209 }
1210
1211
1212 function get_logs() {
1213 $log_file_path = $this->get_logs_path();
1214
1215 if ( !$log_file_path || !file_exists( $log_file_path ) ) {
1216 return __( 'No logs found.', 'media-cleaner' );
1217 }
1218
1219 $size = filesize( $log_file_path );
1220 $bytes = min( 512 * 1024, max( 0, (int) $size ) );
1221 $handle = fopen( $log_file_path, 'rb' );
1222 if ( !$handle ) return __( 'No logs found.', 'media-cleaner' );
1223 if ( $bytes < $size ) fseek( $handle, -$bytes, SEEK_END );
1224 $content = $bytes > 0 ? fread( $handle, $bytes ) : '';
1225 fclose( $handle );
1226 if ( $bytes < $size ) {
1227 $first_newline = strpos( $content, "\n" );
1228 $content = $first_newline === false ? '' : substr( $content, $first_newline + 1 );
1229 }
1230 $lines = explode( "\n", $content );
1231 $lines = array_filter( $lines );
1232 $lines = array_slice( array_reverse( $lines ), 0, 2000 );
1233 $content = implode( "\n", $lines );
1234 return $content;
1235 }
1236
1237 function clear_logs() {
1238 $logPath = $this->get_logs_path();
1239 if ( $logPath && file_exists( $logPath ) ) {
1240 unlink( $logPath );
1241 }
1242
1243 $options = $this->get_all_options();
1244 $options['logs_path'] = null;
1245 $this->update_options( $options );
1246 }
1247
1248 #endregion
1249
1250 /**
1251 *
1252 * HELPERS
1253 *
1254 */
1255
1256 private function random_ascii_chars($length = 8)
1257 {
1258 $characters = array_merge(range('A', 'Z'), range('a', 'z'), range('0', '9'));
1259 $characters_length = count($characters);
1260 $random_string = '';
1261
1262 for ($i = 0; $i < $length; $i++) {
1263 $random_string .= $characters[rand(0, $characters_length - 1)];
1264 }
1265
1266 return $random_string;
1267 }
1268
1269 private function get_private_root() {
1270 $secret = get_option( 'wpmc_trash_secret', null );
1271 if ( !$secret ) {
1272 $candidate = wp_generate_password( 32, false, false );
1273 $secret = add_option( 'wpmc_trash_secret', $candidate, '', false ) ? $candidate : get_option( 'wpmc_trash_secret' );
1274 }
1275 if ( !$secret ) $secret = wp_salt( 'auth' );
1276 $parent = defined( 'WPMC_TRASH_DIR' ) ? dirname( WPMC_TRASH_DIR ) : WP_CONTENT_DIR;
1277 $base = trailingslashit( $parent ) . '.media-cleaner-private-' . substr( hash( 'sha256', $secret ), 0, 20 );
1278 return untrailingslashit( wp_normalize_path( $base ) );
1279 }
1280
1281 private function protect_private_directory( $root ) {
1282 $guards = array(
1283 trailingslashit( $root ) . 'index.php' => "<?php\nhttp_response_code( 404 );\nexit;\n",
1284 trailingslashit( $root ) . '.htaccess' => "Options -Indexes\n<IfModule mod_authz_core.c>Require all denied</IfModule>\n<IfModule !mod_authz_core.c>Deny from all</IfModule>\n",
1285 trailingslashit( $root ) . 'web.config' => "<?xml version=\"1.0\"?><configuration><system.webServer><security><authorization><remove users=\"*\" roles=\"\" verbs=\"\"/><add accessType=\"Deny\" users=\"*\"/></authorization></security></system.webServer></configuration>",
1286 );
1287 foreach ( $guards as $path => $content ) {
1288 if ( !file_exists( $path ) && @file_put_contents( $path, $content ) === false ) return false;
1289 }
1290 return true;
1291 }
1292
1293 private function ensure_private_root() {
1294 $root = $this->get_private_root();
1295 if ( is_link( $root ) ) {
1296 return new WP_Error( 'wpmc_private_storage_unsafe', __( 'Media Cleaner private storage cannot be a symbolic link.', 'media-cleaner' ) );
1297 }
1298 if ( !is_dir( $root ) && !wp_mkdir_p( $root ) ) {
1299 return new WP_Error( 'wpmc_private_storage_unavailable', __( 'Media Cleaner could not create its private storage directory.', 'media-cleaner' ) );
1300 }
1301 if ( !$this->protect_private_directory( $root ) ) return new WP_Error( 'wpmc_private_storage_unprotected', __( 'Media Cleaner could not protect its private storage directory.', 'media-cleaner' ) );
1302 return $root;
1303 }
1304
1305 function get_trashdir() {
1306 $trash = defined( 'WPMC_TRASH_DIR' ) ? untrailingslashit( wp_normalize_path( WPMC_TRASH_DIR ) ) : trailingslashit( $this->get_private_root() ) . 'trash';
1307
1308 $legacy = trailingslashit( $this->upload_path ) . 'wpmc-trash';
1309 $trash_is_public = $trash === $this->upload_path || strpos( $trash, trailingslashit( $this->upload_path ) ) === 0;
1310 if ( is_dir( $legacy ) || is_link( $legacy ) ) {
1311 if ( $trash_is_public || file_exists( $trash ) || is_link( $legacy ) || !wp_mkdir_p( dirname( $trash ) ) || !@rename( $legacy, $trash ) ) {
1312 $this->trash_migration_error = new WP_Error(
1313 'wpmc_trash_migration_failed',
1314 __( 'The old public Media Cleaner trash could not be moved to private storage. Cleanup is blocked until that directory can be migrated.', 'media-cleaner' )
1315 );
1316 }
1317 }
1318 return $trash;
1319 }
1320
1321 private function ensure_trash_directory() {
1322 $trash = $this->get_trashdir();
1323 if ( is_wp_error( $this->trash_migration_error ) ) return $this->trash_migration_error;
1324 $private_root = $this->ensure_private_root();
1325 if ( is_wp_error( $private_root ) ) return $private_root;
1326 if ( $trash === $this->upload_path || strpos( $trash, trailingslashit( $this->upload_path ) ) === 0 ) {
1327 return new WP_Error( 'wpmc_public_trash_directory', __( 'Media Cleaner quarantine must be outside the public uploads directory.', 'media-cleaner' ) );
1328 }
1329 if ( !is_dir( $trash ) && !wp_mkdir_p( $trash ) ) {
1330 return new WP_Error( 'wpmc_trash_unavailable', __( 'Media Cleaner could not create its private trash directory.', 'media-cleaner' ) );
1331 }
1332 $trash_real = realpath( $trash );
1333 $upload_real = realpath( $this->upload_path );
1334 $trash_real = $trash_real ? untrailingslashit( wp_normalize_path( $trash_real ) ) : '';
1335 $upload_real = $upload_real ? untrailingslashit( wp_normalize_path( $upload_real ) ) : '';
1336 if ( $upload_real && ( $trash_real === $upload_real || strpos( $trash_real, trailingslashit( $upload_real ) ) === 0 ) ) {
1337 return new WP_Error( 'wpmc_public_trash_directory', __( 'Media Cleaner quarantine resolves inside the public uploads directory.', 'media-cleaner' ) );
1338 }
1339 if ( is_link( $trash ) || !$this->protect_private_directory( $trash ) ) {
1340 return new WP_Error( 'wpmc_trash_unprotected', __( 'Media Cleaner quarantine is unsafe or could not be protected.', 'media-cleaner' ) );
1341 }
1342 return wp_normalize_path( $trash );
1343 }
1344
1345 public function prepare_private_storage() {
1346 return $this->ensure_trash_directory();
1347 }
1348
1349 public function test_quarantine_roundtrip() {
1350 $trash = $this->ensure_trash_directory();
1351 if ( is_wp_error( $trash ) ) return $trash;
1352 $source = tempnam( $this->upload_path, '.wpmc-roundtrip-' );
1353 if ( !$source || file_put_contents( $source, 'media-cleaner-storage-test' ) === false ) {
1354 if ( $source && file_exists( $source ) ) @unlink( $source );
1355 return new WP_Error( 'wpmc_storage_test_create_failed', __( 'Media Cleaner could not create a storage test file.', 'media-cleaner' ) );
1356 }
1357 $relative = '.wpmc-roundtrip-' . wp_generate_uuid4();
1358 $quarantine = $this->resolve_trash_path( $relative );
1359 $returned = $source . '.returned';
1360 $success = !is_wp_error( $quarantine ) && @rename( $source, $quarantine ) && @rename( $quarantine, $returned ) && @unlink( $returned );
1361 foreach ( array( $source, is_wp_error( $quarantine ) ? null : $quarantine, $returned ) as $path ) {
1362 if ( $path && file_exists( $path ) ) @unlink( $path );
1363 }
1364 return $success ? true : new WP_Error( 'wpmc_storage_roundtrip_failed', __( 'Files cannot be moved safely into and back out of Media Cleaner quarantine. Check mount points and permissions.', 'media-cleaner' ) );
1365 }
1366
1367 public function resolve_trash_path( $relative_path, $must_exist = false ) {
1368 $relative = $this->normalize_upload_relative_path( $relative_path );
1369 if ( is_wp_error( $relative ) ) {
1370 return $relative;
1371 }
1372 $root = $this->ensure_trash_directory();
1373 if ( is_wp_error( $root ) ) {
1374 return $root;
1375 }
1376 $root_real = realpath( $root );
1377 if ( !$root_real ) {
1378 return new WP_Error( 'wpmc_trash_unavailable', __( 'Media Cleaner trash is unavailable.', 'media-cleaner' ) );
1379 }
1380 $root_real = untrailingslashit( wp_normalize_path( $root_real ) );
1381 $candidate = $root_real . ( $relative === '' ? '' : '/' . $relative );
1382 if ( is_link( $candidate ) ) {
1383 return new WP_Error( 'wpmc_trash_path_invalid', __( 'The trash path is unsafe.', 'media-cleaner' ) );
1384 }
1385 if ( $must_exist && !file_exists( $candidate ) ) {
1386 return new WP_Error( 'wpmc_trash_item_missing', __( 'The requested item no longer exists in Media Cleaner trash.', 'media-cleaner' ) );
1387 }
1388 if ( file_exists( $candidate ) ) {
1389 $resolved = wp_normalize_path( realpath( $candidate ) );
1390 if ( is_link( $candidate ) || ( $resolved !== $root_real && strpos( $resolved, trailingslashit( $root_real ) ) !== 0 ) ) {
1391 return new WP_Error( 'wpmc_trash_path_invalid', __( 'The trash path is unsafe.', 'media-cleaner' ) );
1392 }
1393 }
1394 else {
1395 $ancestor = dirname( $candidate );
1396 while ( !file_exists( $ancestor ) && dirname( $ancestor ) !== $ancestor ) {
1397 $ancestor = dirname( $ancestor );
1398 }
1399 $ancestor_real = realpath( $ancestor );
1400 $ancestor_real = $ancestor_real ? untrailingslashit( wp_normalize_path( $ancestor_real ) ) : '';
1401 if ( $ancestor_real !== $root_real && strpos( $ancestor_real, trailingslashit( $root_real ) ) !== 0 ) {
1402 return new WP_Error( 'wpmc_trash_path_invalid', __( 'The trash path is unsafe.', 'media-cleaner' ) );
1403 }
1404 }
1405 return $candidate;
1406 }
1407
1408 function get_trashurl() {
1409 // Trash is intentionally outside the public uploads URL.
1410 return null;
1411 }
1412
1413 function clean_ob(){
1414 $disabled = $this->get_option( 'output_buffer_cleaning_disabled' );
1415 $ob_content = ob_get_contents();
1416 if ( is_string( $ob_content ) && trim( $ob_content ) !== '' ) {
1417
1418 if ( $disabled ) {
1419 $this->log( "🚨 If the server's response was broken, try to let Output Buffer Cleaning enabled." );
1420 return;
1421 }
1422
1423 $this->log( "🧹 The response is broken due to output buffering, it will be cleaned." );
1424 $this->log( "📄 Output buffer content: " . $ob_content );
1425
1426 if ( ob_get_level() > 0 ) {
1427 ob_end_clean();
1428 }
1429 }
1430 }
1431
1432 /**
1433 *
1434 * I18N RELATED HELPERS
1435 *
1436 */
1437
1438 function is_multilingual() {
1439 return function_exists( 'icl_get_languages' );
1440 }
1441
1442 function get_languages() {
1443 $results = array();
1444 if ( $this->is_multilingual() ) {
1445 $languages = icl_get_languages();
1446 foreach ( $languages as $language ) {
1447 if ( isset( $language['code'] ) ) {
1448 array_push( $results, $language['code'] );
1449 }
1450 else if ( isset( $language['language_code'] ) ) {
1451 array_push( $results, $language['language_code'] );
1452 }
1453 }
1454 }
1455 return $results;
1456 }
1457
1458 function get_translated_media_ids( $mediaId ) {
1459 $translated_ids = array();
1460 foreach ( $this->languages as $language ) {
1461 $id = apply_filters( 'wpml_object_id', $mediaId, 'attachment', false, $language );
1462 if ( !empty( $id ) ) {
1463 array_push( $translated_ids, $id );
1464 }
1465 }
1466 return $translated_ids;
1467 }
1468
1469 /**
1470 *
1471 * DELETE / SCANNING / RESET
1472 *
1473 */
1474
1475 function recover_file( $path, &$did_move = null ) {
1476 $did_move = false;
1477 $relative = $this->normalize_upload_relative_path( $path );
1478 if ( is_wp_error( $relative ) ) return $relative;
1479 $original_path = $this->resolve_upload_path( $relative );
1480 $trash_path = $this->resolve_trash_path( $relative );
1481 if ( is_wp_error( $original_path ) ) return $original_path;
1482 if ( is_wp_error( $trash_path ) ) return $trash_path;
1483 if ( !file_exists( $trash_path ) ) {
1484 return file_exists( $original_path ) ? true : new WP_Error( 'wpmc_recovery_source_missing', __( 'The file exists in neither uploads nor Media Cleaner trash.', 'media-cleaner' ) );
1485 }
1486 if ( file_exists( $original_path ) ) {
1487 return new WP_Error( 'wpmc_recovery_collision', __( 'A file already exists at the recovery destination.', 'media-cleaner' ) );
1488 }
1489 if ( !wp_mkdir_p( dirname( $original_path ) ) || !@rename( $trash_path, $original_path ) ) {
1490 return new WP_Error( 'wpmc_recovery_move_failed', __( 'The file could not be moved out of Media Cleaner trash.', 'media-cleaner' ) );
1491 }
1492 $did_move = true;
1493 return true;
1494 }
1495
1496 function recover( $id, $operation_manifest = array() ) {
1497 $staged = $this->results_staged_error();
1498 if ( $staged ) return $staged;
1499 global $wpdb;
1500 $table_name = $wpdb->prefix . "mclean_scan";
1501 $issue = $this->get_issue( $id );
1502
1503 if ( empty( $issue ) ) {
1504 return new WP_Error( 'wpmc_issue_missing', __( 'The selected Media Cleaner result no longer exists.', 'media-cleaner' ) );
1505 }
1506 if ( empty( $operation_manifest['identity_validated'] ) ) {
1507 $identity = $this->validate_issue_manifest( $issue );
1508 if ( is_wp_error( $identity ) ) return $identity;
1509 }
1510
1511 // Files
1512 if ( $issue->type === 0 ) {
1513 $did_move = false;
1514 $moved = $this->recover_file( $issue->path, $did_move );
1515 if ( is_wp_error( $moved ) ) return $moved;
1516 $updated = $wpdb->query( $wpdb->prepare( "UPDATE $table_name SET deleted = 0 WHERE id = %d", $id ) );
1517 if ( $updated === false ) {
1518 if ( $did_move ) $this->trash_file( $issue->path );
1519 return new WP_Error( 'wpmc_recovery_database_failed', __( 'The file was moved back because its database state could not be updated.', 'media-cleaner' ) );
1520 }
1521 $this->log( "�
1522 Recovered {$issue->path}." );
1523 return true;
1524 }
1525 // Media
1526 else if ( $issue->type === 1 ) {
1527
1528 $paths = $this->get_paths_from_attachment( $issue->postId );
1529 $file_manifest = json_decode( (string) $issue->manifest, true );
1530 $file_manifest = is_array( $file_manifest ) ? $file_manifest : array();
1531 $recovered_paths = array();
1532 foreach ( $paths as $path ) {
1533 if ( array_key_exists( $path, $file_manifest ) && $file_manifest[ $path ] === null ) continue;
1534 $did_move = false;
1535 $result = $this->recover_file( $path, $did_move );
1536 if ( is_wp_error( $result ) ) {
1537 foreach ( array_reverse( $recovered_paths ) as $recovered_path ) {
1538 $this->trash_file( $recovered_path );
1539 }
1540 return $result;
1541 }
1542 if ( $did_move ) $recovered_paths[] = $path;
1543 }
1544 $previous_post_type = get_post_type( $issue->postId );
1545 $post_result = wp_update_post( array( 'ID' => $issue->postId, 'post_type' => 'attachment' ), true );
1546 if ( is_wp_error( $post_result ) || !$post_result ) {
1547 foreach ( array_reverse( $recovered_paths ) as $recovered_path ) {
1548 $this->trash_file( $recovered_path );
1549 }
1550 return is_wp_error( $post_result ) ? $post_result : new WP_Error( 'wpmc_recovery_post_failed', __( 'The attachment record could not be restored.', 'media-cleaner' ) );
1551 }
1552 $updated = $wpdb->query( $wpdb->prepare( "UPDATE $table_name SET deleted = 0 WHERE id = %d", $id ) );
1553 if ( $updated === false ) {
1554 if ( $previous_post_type && $previous_post_type !== 'attachment' ) {
1555 wp_update_post( array( 'ID' => $issue->postId, 'post_type' => $previous_post_type ) );
1556 }
1557 foreach ( array_reverse( $recovered_paths ) as $recovered_path ) {
1558 $this->trash_file( $recovered_path );
1559 }
1560 return new WP_Error( 'wpmc_recovery_database_failed', __( 'The attachment was restored, but Media Cleaner could not update its result record.', 'media-cleaner' ) );
1561 }
1562 $this->log( "�
1563 Recovered Media #{$issue->postId}." );
1564 return true;
1565 }
1566 return new WP_Error( 'wpmc_issue_type_invalid', __( 'The selected Media Cleaner result has an unsupported type.', 'media-cleaner' ) );
1567 }
1568
1569 function trash_file( $fileIssuePath, &$did_move = null ) {
1570 $did_move = false;
1571 $relative = $this->normalize_upload_relative_path( $fileIssuePath );
1572 if ( is_wp_error( $relative ) ) return $relative;
1573 $original_path = $this->resolve_upload_path( $relative );
1574 $trash_path = $this->resolve_trash_path( $relative );
1575 if ( is_wp_error( $original_path ) ) return $original_path;
1576 if ( is_wp_error( $trash_path ) ) return $trash_path;
1577 if ( !file_exists( $original_path ) ) {
1578 return file_exists( $trash_path ) ? true : new WP_Error( 'wpmc_delete_source_missing', __( 'The file exists in neither uploads nor Media Cleaner trash.', 'media-cleaner' ) );
1579 }
1580 if ( is_dir( $original_path ) || is_link( $original_path ) ) {
1581 return new WP_Error( 'wpmc_delete_unsafe_path', __( 'Media Cleaner will not move directories or symbolic links.', 'media-cleaner' ) );
1582 }
1583 if ( file_exists( $trash_path ) ) {
1584 return new WP_Error( 'wpmc_trash_collision', __( 'A different file already exists at the trash destination.', 'media-cleaner' ) );
1585 }
1586 if ( !wp_mkdir_p( dirname( $trash_path ) ) || !@rename( $original_path, $trash_path ) ) {
1587 return new WP_Error( 'wpmc_trash_move_failed', __( 'The file could not be moved into Media Cleaner trash.', 'media-cleaner' ) );
1588 }
1589 $did_move = true;
1590 $this->clean_dir( dirname( $original_path ) );
1591 return true;
1592 }
1593
1594 function repair( $id ) {
1595 $repair = $this->get_repair( $id );
1596 if ( empty( $repair ) ) {
1597 return new WP_Error( 'wpmc_repair_missing', __( 'The selected repair result no longer exists.', 'media-cleaner' ) );
1598 }
1599 $full_path = $this->get_full_upload_path( $repair->path );
1600 if ( !$full_path || !is_file( $full_path ) || is_link( $full_path ) ) {
1601 return new WP_Error( 'wpmc_repair_file_missing', __( 'The file to repair is missing or unsafe.', 'media-cleaner' ) );
1602 }
1603 $relative_path = $this->clean_uploaded_filename( $full_path );
1604 global $wpdb;
1605 $existing_id = (int) $wpdb->get_var( $wpdb->prepare(
1606 "SELECT post_id FROM $wpdb->postmeta WHERE meta_key = '_wp_attached_file' AND meta_value = %s LIMIT 1",
1607 $relative_path
1608 ) );
1609 $filetype = wp_check_filetype( basename( $full_path ), null );
1610 $wp_upload_dir = wp_upload_dir();
1611 $attachment = array(
1612 'guid' => trailingslashit( $wp_upload_dir['baseurl'] ) . str_replace( '%2F', '/', rawurlencode( $relative_path ) ),
1613 'post_mime_type' => $filetype['type'],
1614 'post_title' => preg_replace( '/\.[^.]+$/', '', basename( $full_path ) ),
1615 'post_content' => '',
1616 'post_status' => 'inherit'
1617 );
1618
1619 $attach_id = $existing_id ?: wp_insert_attachment( $attachment, $full_path, 0, true );
1620 if ( is_wp_error( $attach_id ) || !$attach_id ) {
1621 return is_wp_error( $attach_id ) ? $attach_id : new WP_Error( 'wpmc_repair_insert_failed', __( 'WordPress could not create the attachment record.', 'media-cleaner' ) );
1622 }
1623
1624 if ( wp_attachment_is_image( $attach_id ) ) {
1625 require_once( ABSPATH . 'wp-admin/includes/image.php' );
1626 $attach_data = wp_generate_attachment_metadata( $attach_id, $full_path );
1627 if ( is_wp_error( $attach_data ) || !is_array( $attach_data ) || !wp_update_attachment_metadata( $attach_id, $attach_data ) ) {
1628 return is_wp_error( $attach_data ) ? $attach_data : new WP_Error( 'wpmc_repair_metadata_failed', __( 'WordPress could not generate attachment metadata. The original file was left untouched.', 'media-cleaner' ) );
1629 }
1630 }
1631
1632 $table_name = $wpdb->prefix . "mclean_scan";
1633 $deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM $table_name WHERE run_id = %d AND (id = %d OR parentId = %d)", $this->get_run_id(), $id, $id ) );
1634 if ( $deleted === false ) {
1635 return new WP_Error( 'wpmc_repair_result_update_failed', __( 'The attachment was repaired, but Media Cleaner could not update its result record.', 'media-cleaner' ) );
1636 }
1637 $this->log( "�
1638 Repaired {$repair->path}." );
1639 return true;
1640 }
1641
1642 function ignore( $id, $ignore ) {
1643 $staged = $this->results_staged_error();
1644 if ( $staged ) return $staged;
1645 global $wpdb;
1646 $table_name = $wpdb->prefix . "mclean_scan";
1647 $issue = $this->get_issue( $id );
1648
1649 if ( empty( $issue ) ) {
1650 $this->log( "🚫 Issue #{$id} does not exist. Cannot ignore this." );
1651 return false;
1652 }
1653
1654 if ( !$ignore ) {
1655 $updated = $wpdb->query( $wpdb->prepare( "UPDATE $table_name SET ignored = 0 WHERE id = %d", $id ) );
1656 }
1657 else {
1658 // If it is in trash, recover it
1659 if ( $issue->deleted ) {
1660 $recovered = $this->recover( $id );
1661 if ( is_wp_error( $recovered ) || $recovered !== true ) {
1662 return is_wp_error( $recovered ) ? $recovered : new WP_Error( 'wpmc_ignore_recovery_failed', __( 'The item could not be recovered before it was ignored.', 'media-cleaner' ) );
1663 }
1664 }
1665 $updated = $wpdb->query( $wpdb->prepare( "UPDATE $table_name SET ignored = 1 WHERE id = %d", $id ) );
1666 }
1667 return $updated === false ? new WP_Error( 'wpmc_ignore_database_failed', __( 'Media Cleaner could not update the ignored state.', 'media-cleaner' ) ) : true;
1668 }
1669
1670 function clean_dir( $dir ) {
1671 $root = realpath( $this->upload_path );
1672 $current = realpath( $dir );
1673 if ( !$root || !$current || is_link( $dir ) ) return;
1674 $root = untrailingslashit( wp_normalize_path( $root ) );
1675 $current = untrailingslashit( wp_normalize_path( $current ) );
1676 if ( $current === $root || strpos( $current, trailingslashit( $root ) ) !== 0 ) return;
1677 try {
1678 $is_empty = !( new FilesystemIterator( $current, FilesystemIterator::SKIP_DOTS ) )->valid();
1679 }
1680 catch ( Throwable $e ) {
1681 return;
1682 }
1683 if ( $is_empty && @rmdir( $current ) ) {
1684 $this->clean_dir( dirname( $current ) );
1685 }
1686 }
1687
1688 function get_issue( $id ) {
1689 global $wpdb;
1690 $table_name = $wpdb->prefix . "mclean_scan";
1691 $run_id = $this->get_run_id();
1692 $issue = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $table_name WHERE id = %d AND run_id = %d", $id, $run_id ), OBJECT );
1693 if ( empty( $issue ) ) {
1694 return false;
1695 }
1696 $issue->id = (int)$issue->id;
1697 $issue->postId = (int)$issue->postId;
1698 $issue->type = (int)$issue->type;
1699 $issue->deleted = (int)$issue->deleted;
1700 $issue->ignored = (int)$issue->ignored;
1701 $issue->path = stripslashes( $issue->path );
1702 return $issue;
1703 }
1704
1705 function get_repair( $id ) {
1706 global $wpdb;
1707 $table_name = $wpdb->prefix . "mclean_scan";
1708 $run_id = $this->get_run_id();
1709 $repair = $wpdb->get_row( $wpdb->prepare( "SELECT
1710 main.id AS id,
1711 main.path AS path,
1712 GROUP_CONCAT(child.id) AS child_ids
1713 FROM
1714 $table_name AS main
1715 LEFT JOIN
1716 $table_name AS child ON main.id = child.parentId AND child.run_id = main.run_id
1717 WHERE main.id = %d AND main.run_id = %d
1718 GROUP BY main.id, main.path", $id, $run_id
1719 ), OBJECT );
1720 if ( empty( $repair ) ) {
1721 return false;
1722 }
1723
1724 // If $repair->path is null or empty return false
1725 if ( empty( $repair->path ) ) {
1726 $this->log( "🚫 Repair #{$id} does not have a path. Cannot repair this." );
1727 return false;
1728 }
1729
1730
1731 $repair->id = (int)$repair->id;
1732 $regex = "^(.*)(\\s\\(\\+.*)$";
1733 $repair->path = preg_replace( '/' . $regex . '/i', '$1', stripslashes( $repair->path ) );
1734 $repair->child_ids = $repair->child_ids ? explode( ',', $repair->child_ids ) : [];
1735 return $repair;
1736 }
1737
1738 function get_issues_to_repair( $order_by = 'id', $order = 'asc', $search = '', $skip = 0, $limit = 10 ) {
1739 global $wpdb;
1740 $table_name = $wpdb->prefix . "mclean_scan";
1741 $run_id = $this->get_run_id();
1742
1743 $search_clause = '';
1744 if ( !empty( $search ) ) {
1745 $search_clause = $wpdb->prepare("AND main.path LIKE %s", ( '%' . $search . '%' ));
1746 }
1747
1748 $order_clause = 'ORDER BY main.id ASC';
1749 if ( $order_by === 'path' ) {
1750 $order_clause = 'ORDER BY main.path ' . ( $order === 'asc' ? 'ASC' : 'DESC' );
1751 }
1752 else if ( $order_by === 'issue' ) {
1753 $order_clause = 'ORDER BY main.issue ' . ( $order === 'asc' ? 'ASC' : 'DESC' );
1754 }
1755 else if ( $order_by === 'size' ) {
1756 $order_clause = 'ORDER BY main.size ' . ( $order === 'asc' ? 'ASC' : 'DESC' );
1757 }
1758
1759 $result = $wpdb->get_results( $wpdb->prepare( "SELECT
1760 main.id AS id,
1761 main.path AS path,
1762 GROUP_CONCAT(child.id) AS child_ids,
1763 GROUP_CONCAT(child.path) AS child_paths,
1764 main.type AS type,
1765 main.postId AS postId,
1766 main.size AS size,
1767 main.ignored AS ignored,
1768 main.deleted AS deleted,
1769 main.issue AS issue
1770 FROM
1771 $table_name AS main
1772 LEFT JOIN
1773 $table_name AS child ON main.id = child.parentId AND child.run_id = main.run_id
1774 WHERE
1775 main.run_id = %d AND main.path IS NOT NULL AND main.parentId IS NULL
1776 AND main.deleted = 0 AND main.ignored = 0
1777 AND main.type = 0
1778 $search_clause
1779 GROUP BY main.id
1780 $order_clause
1781 LIMIT %d, %d;
1782 ", $run_id, $skip, $limit ) );
1783
1784 return $result;
1785 }
1786
1787 function get_repair_ids ( $search = '', $cursor = 0, $limit = 100 ) {
1788 global $wpdb;
1789 $table_name = $wpdb->prefix . "mclean_scan";
1790 $run_id = $this->get_run_id();
1791 $cursor = absint( $cursor );
1792 $limit = max( 1, min( 100, absint( $limit ) ) );
1793
1794 $search_clause = '';
1795 if ( !empty( $search ) ) {
1796 $search_clause = $wpdb->prepare( "AND main.path LIKE %s", '%' . $wpdb->esc_like( $search ) . '%' );
1797 }
1798
1799 return $wpdb->get_col( $wpdb->prepare( "SELECT main.id
1800 FROM $table_name AS main
1801 WHERE
1802 main.run_id = %d
1803 AND main.id > %d
1804 AND main.path IS NOT NULL
1805 AND main.parentId IS NULL
1806 AND main.deleted = 0 AND main.ignored = 0 AND main.type = 0
1807 $search_clause
1808 ORDER BY main.id ASC
1809 LIMIT %d", $run_id, $cursor, $limit )
1810 );
1811 }
1812
1813 function get_stats_of_issues_to_repair( $search = '' ) {
1814 global $wpdb;
1815 $table_name = $wpdb->prefix . "mclean_scan";
1816 $run_id = $this->get_run_id();
1817
1818 $search_clause = '';
1819 if ( !empty( $search ) ) {
1820 $search_clause = $wpdb->prepare("AND main.path LIKE %s", ( '%' . $search . '%' ));
1821 }
1822
1823 return $wpdb->get_row( $wpdb->prepare( "SELECT
1824 COUNT(id) AS entries,
1825 SUM(size) AS size
1826 FROM (
1827 SELECT
1828 COUNT(DISTINCT main.id) as id,
1829 main.size as size
1830 FROM
1831 $table_name AS main
1832 LEFT JOIN
1833 $table_name AS child ON main.id = child.parentId AND child.run_id = main.run_id
1834 WHERE
1835 main.run_id = %d AND main.path IS NOT NULL AND main.parentId IS NULL AND main.deleted = 0 AND main.ignored = 0 AND main.type = 0
1836 $search_clause
1837 GROUP BY main.id
1838 ) t;
1839 ", $run_id ) );
1840 }
1841
1842 function get_count_of_issues_to_repair( $search ) {
1843 $stats = $this->get_stats_of_issues_to_repair( $search );
1844 return $stats->entries;
1845 }
1846
1847 function delete( $id, $operation_manifest = array() ) {
1848 global $wpdb;
1849 $table_name = $wpdb->prefix . "mclean_scan";
1850 $issue = $this->get_issue( $id );
1851
1852 if ( empty( $issue ) ) {
1853 return new WP_Error( 'wpmc_issue_missing', __( 'The selected Media Cleaner result no longer exists.', 'media-cleaner' ) );
1854 }
1855 if ( empty( $operation_manifest['identity_validated'] ) ) {
1856 $identity = $this->validate_issue_manifest( $issue );
1857 if ( is_wp_error( $identity ) ) return $identity;
1858 }
1859
1860 $regex = "^(.*)(\\s\\(\\+.*)$";
1861 $issue->path = preg_replace( '/' . $regex . '/i', '$1', $issue->path ); // remove " (+ 6 files)" from path
1862 $skip_trash = $this->get_option( 'skip_trash' );
1863 $was_deleted = isset( $operation_manifest['initial_deleted'] ) ? (bool) $operation_manifest['initial_deleted'] : $issue->deleted === 1;
1864
1865 $staged = $this->results_staged_error();
1866 if ( $staged ) return $staged;
1867
1868 // Trashing something new is only as safe as the analysis behind it, so it needs
1869 // results from a finished scan of this version. Emptying the trash does not:
1870 // that file was already set aside on purpose, and refusing here would strand
1871 // it. This is the one place deletion is allowed or refused, so the REST API,
1872 // MCP and WP-CLI all get the same answer. The capability is checked by the
1873 // callers, since WP-CLI has no current user.
1874 if ( !$was_deleted && ( !$this->runs || !$this->runs->cleanup_allowed() ) ) {
1875 return new WP_Error( 'wpmc_cleanup_needs_scan',
1876 __( 'Media Cleaner needs the results of a completed scan from this version before it can delete anything. Run a scan first. Your trash is untouched and can still be recovered or emptied.', 'media-cleaner' ) );
1877 }
1878
1879 if ( $issue->type === 0 ) {
1880 if ( $was_deleted ) {
1881 $trash_path = $this->resolve_trash_path( $issue->path );
1882 if ( is_wp_error( $trash_path ) ) return $trash_path;
1883 if ( file_exists( $trash_path ) && ( is_dir( $trash_path ) || is_link( $trash_path ) || !@unlink( $trash_path ) ) ) {
1884 return new WP_Error( 'wpmc_permanent_delete_failed', __( 'The file could not be permanently removed from Media Cleaner trash.', 'media-cleaner' ) );
1885 }
1886 $deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM $table_name WHERE id = %d", $id ) );
1887 return $deleted === false ? new WP_Error( 'wpmc_delete_database_failed', __( 'The file was removed, but its Media Cleaner record could not be deleted.', 'media-cleaner' ) ) : true;
1888 }
1889 else if ( $skip_trash ) {
1890 $original_path = $this->resolve_upload_path( $issue->path );
1891 if ( is_wp_error( $original_path ) ) return $original_path;
1892 if ( file_exists( $original_path ) && ( is_dir( $original_path ) || is_link( $original_path ) || !@unlink( $original_path ) ) ) {
1893 return new WP_Error( 'wpmc_permanent_delete_failed', __( 'The file could not be permanently deleted.', 'media-cleaner' ) );
1894 }
1895 $this->clean_dir( dirname( $original_path ) );
1896 $deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM $table_name WHERE id = %d", $id ) );
1897 return $deleted === false ? new WP_Error( 'wpmc_delete_database_failed', __( 'The file was removed, but its Media Cleaner record could not be deleted.', 'media-cleaner' ) ) : true;
1898 }
1899 $did_move = false;
1900 $trashed = $this->trash_file( $issue->path, $did_move );
1901 if ( is_wp_error( $trashed ) ) return $trashed;
1902 $updated = $wpdb->query( $wpdb->prepare( "UPDATE $table_name SET deleted = 1, ignored = 0, time = NOW() WHERE id = %d", $id ) );
1903 if ( $updated === false ) {
1904 if ( $did_move ) $this->recover_file( $issue->path );
1905 return new WP_Error( 'wpmc_trash_database_failed', __( 'The file was restored because its Media Cleaner state could not be updated.', 'media-cleaner' ) );
1906 }
1907 return true;
1908 }
1909
1910 if ( $issue->type === 1 ) {
1911 if ( $was_deleted || $skip_trash ) {
1912 if ( $issue->deleted === 1 ) {
1913 $recovered = $this->recover( $id );
1914 if ( is_wp_error( $recovered ) || $recovered !== true ) {
1915 return is_wp_error( $recovered ) ? $recovered : new WP_Error( 'wpmc_media_recovery_failed', __( 'The attachment could not be restored before permanent deletion.', 'media-cleaner' ) );
1916 }
1917 }
1918 $deleted_attachment = get_post( $issue->postId ) ? wp_delete_attachment( $issue->postId, true ) : true;
1919 if ( !$deleted_attachment ) {
1920 return new WP_Error( 'wpmc_attachment_delete_failed', __( 'WordPress could not permanently delete the attachment.', 'media-cleaner' ) );
1921 }
1922 $deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM $table_name WHERE id = %d", $id ) );
1923 return $deleted === false ? new WP_Error( 'wpmc_delete_database_failed', __( 'The attachment was removed, but its Media Cleaner record could not be deleted.', 'media-cleaner' ) ) : true;
1924 }
1925
1926 $paths = $this->get_paths_from_attachment( $issue->postId );
1927 $file_manifest = json_decode( (string) $issue->manifest, true );
1928 $file_manifest = is_array( $file_manifest ) ? $file_manifest : array();
1929 $trashed_paths = array();
1930 foreach ( $paths as $path ) {
1931 if ( array_key_exists( $path, $file_manifest ) && $file_manifest[ $path ] === null ) continue;
1932 $did_move = false;
1933 $result = $this->trash_file( $path, $did_move );
1934 if ( is_wp_error( $result ) ) {
1935 foreach ( array_reverse( $trashed_paths ) as $trashed_path ) {
1936 $this->recover_file( $trashed_path );
1937 }
1938 return $result;
1939 }
1940 if ( $did_move ) $trashed_paths[] = $path;
1941 }
1942 $previous_post_type = get_post_type( $issue->postId );
1943 $post_result = wp_update_post( array( 'ID' => $issue->postId, 'post_type' => 'wmpc-trash' ), true );
1944 if ( is_wp_error( $post_result ) || !$post_result ) {
1945 foreach ( array_reverse( $trashed_paths ) as $trashed_path ) {
1946 $this->recover_file( $trashed_path );
1947 }
1948 return is_wp_error( $post_result ) ? $post_result : new WP_Error( 'wpmc_attachment_trash_failed', __( 'The attachment record could not be moved to Media Cleaner trash.', 'media-cleaner' ) );
1949 }
1950 $updated = $wpdb->query( $wpdb->prepare( "UPDATE $table_name SET deleted = 1, ignored = 0, time = NOW() WHERE id = %d", $id ) );
1951 if ( $updated === false ) {
1952 if ( $previous_post_type && $previous_post_type !== 'wmpc-trash' ) {
1953 wp_update_post( array( 'ID' => $issue->postId, 'post_type' => $previous_post_type ) );
1954 }
1955 foreach ( array_reverse( $trashed_paths ) as $trashed_path ) {
1956 $this->recover_file( $trashed_path );
1957 }
1958 return new WP_Error( 'wpmc_trash_database_failed', __( 'The attachment was restored because its Media Cleaner state could not be updated.', 'media-cleaner' ) );
1959 }
1960 return true;
1961 }
1962 return new WP_Error( 'wpmc_issue_type_invalid', __( 'The selected Media Cleaner result has an unsupported type.', 'media-cleaner' ) );
1963 }
1964
1965 function force_trash( $initialize = false, $limit = 100 ) {
1966 global $wpdb;
1967 $staged = $this->results_staged_error();
1968 if ( $staged ) return $staged;
1969 $run_id = $this->get_run_id();
1970 $table_name = $wpdb->prefix . 'mclean_scan';
1971 $tracked_items = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $table_name WHERE run_id = %d AND deleted = 1", $run_id ) );
1972 if ( $tracked_items > 0 ) {
1973 return new WP_Error(
1974 'wpmc_force_trash_has_tracked_items',
1975 __( 'Retry the tracked trash items individually before removing untracked quarantine files.', 'media-cleaner' ),
1976 array( 'status' => 409 )
1977 );
1978 }
1979 $phase = 'cleanuptrash';
1980 $limit = max( 1, min( 100, (int) $limit ) );
1981 $trash = $this->ensure_trash_directory();
1982 if ( is_wp_error( $trash ) ) return $trash;
1983
1984 if ( $initialize ) {
1985 if ( !$this->runs->clear_work( $run_id, $phase ) || !$this->runs->enqueue_work( $run_id, $phase, 'directory', '' ) ) {
1986 return new WP_Error( 'wpmc_trash_queue_failed', __( 'Media Cleaner could not initialize the trash cleanup queue.', 'media-cleaner' ) );
1987 }
1988 }
1989 if ( $this->runs->failed_work_count( $run_id, $phase ) > 0 ) {
1990 return new WP_Error( 'wpmc_trash_cleanup_failed', __( 'A trash item could not be removed. Result records were preserved.', 'media-cleaner' ) );
1991 }
1992
1993 $work = $this->runs->next_work( $run_id, $phase );
1994 if ( !$work ) {
1995 $deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM $table_name WHERE run_id = %d AND deleted = 1", $run_id ) );
1996 if ( $deleted === false ) {
1997 return new WP_Error( 'wpmc_trash_database_failed', __( 'Trash files were removed, but Media Cleaner could not update its result records.', 'media-cleaner' ) );
1998 }
1999 $this->runs->clear_work( $run_id, $phase );
2000 return array( 'finished' => true, 'processed' => 0, 'pending' => 0 );
2001 }
2002
2003 $relative = (string) $work->target_key;
2004 $directory = $this->resolve_trash_path( $relative, true );
2005 if ( is_wp_error( $directory ) || !is_dir( $directory ) || is_link( $directory ) ) {
2006 $error = is_wp_error( $directory ) ? $directory : new WP_Error( 'wpmc_trash_directory_unsafe', __( 'A queued trash directory is missing or unsafe.', 'media-cleaner' ) );
2007 $this->runs->update_work( $work->id, 'failed', 0, $error );
2008 return $error;
2009 }
2010
2011 $processed = 0;
2012 try {
2013 $iterator = new FilesystemIterator( $directory, FilesystemIterator::SKIP_DOTS );
2014 foreach ( $iterator as $entry ) {
2015 if ( $entry->isLink() ) {
2016 throw new RuntimeException( __( 'Media Cleaner will not remove a symbolic link from trash.', 'media-cleaner' ) );
2017 }
2018 if ( $entry->isDir() ) {
2019 $child = ltrim( $relative . '/' . $entry->getFilename(), '/' );
2020 if ( !$this->runs->enqueue_work( $run_id, $phase, 'directory', $child ) ) {
2021 throw new RuntimeException( __( 'A trash subdirectory could not be queued.', 'media-cleaner' ) );
2022 }
2023 if ( !$this->runs->update_work( $work->id, 'waiting', 0 ) ) return new WP_Error( 'wpmc_trash_queue_failed', __( 'Media Cleaner could not checkpoint a trash directory.', 'media-cleaner' ) );
2024 return array( 'finished' => false, 'processed' => $processed, 'pending' => $this->runs->pending_work_count( $run_id, $phase ) );
2025 }
2026 if ( !@unlink( $entry->getPathname() ) ) {
2027 throw new RuntimeException( sprintf( __( 'The trash file %s could not be removed.', 'media-cleaner' ), $entry->getFilename() ) );
2028 }
2029 $processed++;
2030 if ( $processed >= $limit ) break;
2031 }
2032 }
2033 catch ( Throwable $e ) {
2034 $error = new WP_Error( 'wpmc_trash_item_delete_failed', $e->getMessage() );
2035 $this->runs->update_work( $work->id, 'failed', 0, $error );
2036 return $error;
2037 }
2038
2039 $has_entries = false;
2040 try {
2041 $check = new FilesystemIterator( $directory, FilesystemIterator::SKIP_DOTS );
2042 $has_entries = $check->valid();
2043 }
2044 catch ( UnexpectedValueException $e ) {
2045 $has_entries = true;
2046 }
2047 if ( $has_entries ) {
2048 if ( !$this->runs->update_work( $work->id, 'running', 0 ) ) return new WP_Error( 'wpmc_trash_queue_failed', __( 'Media Cleaner could not checkpoint a trash batch.', 'media-cleaner' ) );
2049 }
2050 else {
2051 if ( $relative !== '' && !@rmdir( $directory ) ) {
2052 $error = new WP_Error( 'wpmc_trash_directory_delete_failed', __( 'An empty trash directory could not be removed.', 'media-cleaner' ) );
2053 $this->runs->update_work( $work->id, 'failed', 0, $error );
2054 return $error;
2055 }
2056 if ( !$this->runs->update_work( $work->id, 'complete', 0 ) ) return new WP_Error( 'wpmc_trash_queue_failed', __( 'Media Cleaner could not complete a trash directory checkpoint.', 'media-cleaner' ) );
2057 $parent = $relative === '' ? '' : dirname( $relative );
2058 if ( !$this->runs->wake_work( $run_id, $phase, $parent === '.' ? '' : $parent ) ) return new WP_Error( 'wpmc_trash_queue_failed', __( 'Media Cleaner could not resume the parent trash directory.', 'media-cleaner' ) );
2059 }
2060
2061 return array(
2062 'finished' => false,
2063 'processed' => $processed,
2064 'pending' => $this->runs->pending_work_count( $run_id, $phase ),
2065 );
2066 }
2067
2068 // A last resort for trash items that can no longer be emptied the normal way:
2069 // their file has disappeared, changed, or become unsafe since the scan, so
2070 // validate_issue_manifest() refuses them and the row stays stuck in the trash
2071 // with the "run a new scan" error. This removes exactly those broken rows (and
2072 // any physical file still sitting in quarantine), and leaves healthy, still
2073 // recoverable trash untouched. It is safe precisely because it only acts on
2074 // items whose backing file is already gone or unverifiable. Returns the count.
2075 function force_clean_trash() {
2076 $staged = $this->results_staged_error();
2077 if ( $staged ) return $staged;
2078 global $wpdb;
2079 $table_name = $wpdb->prefix . 'mclean_scan';
2080 $run_id = $this->get_run_id();
2081 $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM $table_name WHERE run_id = %d AND deleted = 1", $run_id ) );
2082 if ( $rows === null ) {
2083 return new WP_Error( 'wpmc_force_clean_trash_failed', __( 'Media Cleaner could not read the trash records.', 'media-cleaner' ) );
2084 }
2085 $removed = 0;
2086 foreach ( $rows as $issue ) {
2087 // Only the items that cannot be cleaned normally: a healthy trash item
2088 // validates fine and is left recoverable.
2089 if ( !is_wp_error( $this->validate_issue_manifest( $issue ) ) ) continue;
2090
2091 // Best effort: drop any file still physically in quarantine, then the row.
2092 // Directories and symlinks are never removed, and a missing or unsafe path
2093 // is skipped rather than fatal — the point is to unstick the record.
2094 $manifest = json_decode( (string) $issue->manifest, true );
2095 $relatives = is_array( $manifest ) ? array_keys( $manifest ) : array();
2096 if ( empty( $relatives ) && (int) $issue->type === 0 ) {
2097 $relatives[] = preg_replace( '/\s\(\+.*$/', '', (string) $issue->path );
2098 }
2099 foreach ( $relatives as $relative ) {
2100 $trash_path = $this->resolve_trash_path( $relative );
2101 if ( is_wp_error( $trash_path ) ) continue;
2102 if ( file_exists( $trash_path ) && !is_dir( $trash_path ) && !is_link( $trash_path ) ) {
2103 @unlink( $trash_path );
2104 }
2105 }
2106 if ( $wpdb->query( $wpdb->prepare( "DELETE FROM $table_name WHERE id = %d", $issue->id ) ) !== false ) {
2107 $removed++;
2108 }
2109 }
2110 return $removed;
2111 }
2112
2113 /**
2114 *
2115 * SCANNING / RESET
2116 *
2117 */
2118
2119 function add_reference_url( $urlOrUrls, $type, $origin = null, $extra = null ) {
2120 $urlOrUrls = !is_array( $urlOrUrls ) ? array( $urlOrUrls ) : $urlOrUrls;
2121 foreach ( $urlOrUrls as $url ) {
2122 // With files, we need both filename without resolution and filename with resolution, it's important
2123 // to make sure the original file is not deleted if a size exists for it.
2124 // With media, all URLs should be without resolution to make sure it matches Media.
2125 $no_res_url = $this->clean_url_from_resolution( $url );
2126
2127 $this->add_reference( null, $url, $type, $origin, $extra );
2128 $this->add_reference( 0, $no_res_url, $type, $origin, $extra );
2129
2130 if ( $this->multilingual ) {
2131 if ( $this->current_method == 'media' ) {
2132 $id = $this->get_id_from_clean_url( $no_res_url, false );
2133 if( $id ) $this->add_reference_id( $id, $type, $origin, $extra );
2134 }
2135 }
2136 }
2137 }
2138
2139 /**
2140 * Add an issue to the mclean_scan table.
2141 *
2142 * @param string $path The path to the file (relative to uploads).
2143 * @param string $issue The issue code/type.
2144 * @param int|null $postId Optional post ID related to the issue.
2145 */
2146 function add_issue( $path, $issue, $postId = null ) {
2147 global $wpdb;
2148 $table_name = $wpdb->prefix . "mclean_scan";
2149 $clean_path = $this->clean_uploaded_filename( $path );
2150 if ( $clean_path === '' ) throw new RuntimeException( __( 'Media Cleaner received an invalid issue path.', 'media-cleaner' ) );
2151 $path_hash = hash( 'sha256', $clean_path );
2152 $run_id = $this->get_run_id( true );
2153 if ( $run_id < 1 ) {
2154 throw new RuntimeException( __( 'An active scan run is required before adding issues.', 'media-cleaner' ) );
2155 }
2156 $filepath = $this->resolve_upload_path( $clean_path );
2157 if ( is_wp_error( $filepath ) ) throw new RuntimeException( $filepath->get_error_message() );
2158 $filesize = file_exists( $filepath ) ? filesize( $filepath ) : 0;
2159 $manifest = $this->build_file_manifest( array( $clean_path ) );
2160 if ( is_wp_error( $manifest ) ) throw new RuntimeException( $manifest->get_error_message() );
2161
2162 // Check if this issue already exists
2163 $existing = $wpdb->get_var( $wpdb->prepare(
2164 "SELECT id FROM $table_name WHERE run_id = %d AND path_hash = %s AND path = %s AND issue = %s",
2165 $run_id, $path_hash, $clean_path, $issue
2166 ) );
2167
2168 if ( $existing ) {
2169 return; // Issue already exists
2170 }
2171
2172 // Find potential parent
2173 $potentialParentPath = $this->clean_url_from_resolution( $clean_path );
2174 $parentId = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM $table_name WHERE run_id = %d AND path_hash = %s AND path = %s", $run_id, hash( 'sha256', $potentialParentPath ), $potentialParentPath ) );
2175 $parentId = $parentId ? (int)$parentId : null;
2176
2177 $inserted = $wpdb->insert( $table_name,
2178 array(
2179 'run_id' => $run_id,
2180 'time' => current_time('mysql'),
2181 'type' => 0,
2182 'postId' => $postId,
2183 'path' => $clean_path,
2184 'path_hash' => $path_hash,
2185 'manifest' => wp_json_encode( $manifest ),
2186 'size' => $filesize,
2187 'issue' => $issue,
2188 'parentId' => $parentId
2189 )
2190 );
2191 if ( $inserted === false ) throw new RuntimeException( sprintf( __( 'Media Cleaner could not store an issue: %s', 'media-cleaner' ), $wpdb->last_error ) );
2192 }
2193
2194 function add_reference_id( $idOrIds, $type, $origin = null, $extra = null ) {
2195 $idOrIds = !is_array( $idOrIds ) ? array( $idOrIds ) : $idOrIds;
2196 foreach ( $idOrIds as $id ) {
2197 $this->add_reference( $id, "", $type, $origin );
2198 if ( $this->multilingual ) {
2199 $translatedIds = $this->get_translated_media_ids( (int)$id );
2200
2201 // Test for WPML
2202 // if ( $id === '350') {
2203 // $translatedIds = $this->get_translated_media_ids( (int)$id );
2204 // $count = count($translatedIds);
2205 // error_log( "${id} => ${count}" );
2206 // }
2207
2208 if ( !empty( $translatedIds ) ) {
2209 foreach ( $translatedIds as $translatedId ) {
2210 $this->add_reference( $translatedId, "", $type, $origin );
2211 }
2212 }
2213 }
2214 }
2215 }
2216
2217
2218 // Returns the reference with the type, origin, related to a Media ID it is referenced
2219 public function get_reference_for_media_id( $id ) {
2220 global $wpdb;
2221 $table_name = $wpdb->prefix . "mclean_refs";
2222 $run_id = $this->get_run_id();
2223 $refs = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM $table_name WHERE run_id = %d AND mediaId = %d", $run_id, $id ), OBJECT );
2224 if ( empty( $refs ) ) {
2225 return false;
2226 }
2227 $ref = $refs[0];
2228 $ref->id = (int)$ref->id;
2229 $ref->mediaId = (int)$ref->mediaId;
2230 $ref->originType = stripslashes( $ref->originType );
2231 $ref->origin = stripslashes( $ref->origin );
2232 $ref->parentId = empty( $ref->parentId ) ? null : (int)$ref->parentId;
2233 return $ref;
2234 }
2235
2236 // Return the references related to a Post ID
2237 public function get_references_for_post_id( $id ) {
2238 global $wpdb;
2239 $table_name = $wpdb->prefix . "mclean_refs";
2240 $run_id = $this->get_run_id();
2241 $refs = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM $table_name WHERE run_id = %d AND originType LIKE %s", $run_id, "%[$id]" ), OBJECT );
2242 if ( empty( $refs ) ) {
2243 return [];
2244 }
2245 $fresh_refs = array();
2246 foreach ( $refs as $ref ) {
2247 $mediaId = (int)$ref->mediaId > 0 ? (int)$ref->mediaId : null;
2248 if ( !$mediaId && !empty( $ref->mediaUrl ) ) {
2249 $mediaId = $this->find_media_id_from_file( $ref->mediaUrl, false );
2250 $mediaId = !empty( $mediaId ) ? (int)$mediaId : null;
2251 }
2252 if ( !$mediaId ) {
2253 continue;
2254 }
2255 array_push( $fresh_refs, [
2256 'id' => (int)$ref->id,
2257 'mediaId' => $mediaId,
2258 'mediaUrl' => $ref->mediaUrl,
2259 'originType' => $ref->originType,
2260 'origin' => $ref->origin,
2261 'parentId' => empty( $ref->parentId ) ? null : (int)$ref->parentId,
2262 ] );
2263 }
2264 return $fresh_refs;
2265 }
2266
2267 // The references are actually not being added directly in the DB, they are being pushed
2268 // into a cache ($this->refcache), then written to the database via write_references().
2269 private function add_reference( $id, $url, $type, $origin = null, $extra = null ) {
2270 $type = substr( sanitize_text_field( (string) $type ), 0, 191 );
2271 if ( $origin !== null && !is_scalar( $origin ) ) $origin = wp_json_encode( $origin );
2272 // The origin is only a label for where the reference was found. If malformed content
2273 // makes it huge, trim it rather than dropping the reference: the reference marks a file
2274 // as used, and losing it could let a used file be deleted.
2275 if ( is_string( $origin ) && strlen( $origin ) > 8192 ) {
2276 $origin = substr( $origin, 0, 8192 );
2277 }
2278 if ( !empty( $id ) ) {
2279 $this->queue_reference( array( 'id' => $id, 'url' => null, 'type' => $type, 'origin' => $origin ) );
2280 }
2281 if ( !empty( $url ) ) {
2282 // A real media path is never this large, so an oversized "URL" is a parser
2283 // mis-extraction (a data: URI, a concatenated path blob, etc.). It can never match
2284 // a real file, so skip it instead of aborting the whole scan — the same treatment
2285 // the http/javascript URLs get just below. This is what stranded Divi users in 7.2.
2286 if ( is_string( $url ) && strlen( $url ) > 4096 ) {
2287 return;
2288 }
2289 // The URL shouldn't contain http, https, javascript at the beginning (and there are probably many more cases)
2290 // The URL must be cleaned before being passed as a reference.
2291 if ( substr( $url, 0, 5 ) === "http:" || substr( $url, 0, 6 ) === "https:" || substr( $url, 0, 11 ) === "javascript:" ) {
2292 return;
2293 }
2294 $this->queue_reference( array( 'id' => null, 'url' => $url, 'type' => $type, 'origin' => $origin ) );
2295 }
2296 }
2297
2298 private function queue_reference( $reference ) {
2299 $this->refcache[] = $reference;
2300 if ( count( $this->refcache ) >= 250 ) $this->write_references();
2301 }
2302
2303 function insert_references($entries)
2304 {
2305 global $wpdb;
2306 $table = $wpdb->prefix . "mclean_refs";
2307 $run_id = $this->get_run_id( true );
2308 if ( $run_id < 1 ) {
2309 throw new RuntimeException( __( 'An active scan run is required before adding references.', 'media-cleaner' ) );
2310 }
2311
2312 $refs_buffer = $this->get_option( 'refs_buffer' );
2313 if ( empty( $refs_buffer ) || $refs_buffer < 1 ) {
2314 $refs_buffer = 500;
2315 }
2316
2317 $values = array();
2318 $place_holders = array();
2319 $entry_count = 0;
2320
2321 $entries = array_unique( $entries, SORT_REGULAR );
2322
2323 foreach ( $entries as $value ) {
2324 $origin = isset( $value['origin'] ) ? $value['origin'] : null;
2325 if ( !is_null( $value['id'] ) ) {
2326 // Media Reference
2327 $hash = md5( $value['id'] . '|' . $value['type'] . '|' . $origin );
2328 array_push( $values, $run_id, $value['id'], $value['type'], $origin, $hash );
2329 $place_holders[] = "('%d', '%d', NULL, NULL, '%s', '%s', NULL, '%s')";
2330
2331 if ( $this->debug_logs ) {
2332 $this->log( "+ Media #{$value['id']} (as ID)" );
2333 }
2334 $entry_count++;
2335 }
2336 else if ( !is_null( $value['url'] ) ) {
2337 // File Reference
2338 $parent_id = isset( $value['parentId'] ) ? (int) $value['parentId'] : null;
2339 $hash = md5( '|' . $value['url'] . '|' . $value['type'] . '|' . $origin . '|' . $parent_id );
2340 $url_hash = hash( 'sha256', $value['url'] );
2341 if ( $parent_id !== null ) {
2342 array_push( $values, $run_id, $value['url'], $url_hash, $value['type'], $origin, $parent_id, $hash );
2343 $place_holders[] = "('%d', NULL, '%s', '%s', '%s', '%s', '%d', '%s')";
2344 if ( $this->debug_logs ) {
2345 $this->log( "{$value['url']} (as URL) (ParentID: {$value['parentId']})" );
2346 }
2347 }
2348 else {
2349 array_push( $values, $run_id, $value['url'], $url_hash, $value['type'], $origin, $hash );
2350 $place_holders[] = "('%d', NULL, '%s', '%s', '%s', '%s', NULL, '%s')";
2351 if ( $this->debug_logs ) {
2352 $this->log( "{$value['url']} (as URL)" );
2353 }
2354 }
2355 $entry_count++;
2356 }
2357
2358 // Flush to DB when buffer is full
2359 if ( $entry_count >= $refs_buffer ) {
2360 $this->log( "Flushing $entry_count references to the database..." );
2361 $this->flush_references_to_db( $table, $values, $place_holders );
2362 $values = array();
2363 $place_holders = array();
2364 $entry_count = 0;
2365 }
2366 }
2367
2368 // Flush remaining entries
2369 if ( !empty( $values ) ) {
2370 $this->log( "Flushing remaining $entry_count references to the database..." );
2371 $this->flush_references_to_db( $table, $values, $place_holders );
2372 }
2373 }
2374
2375 function flush_references_to_db( $table, $values, $place_holders ) {
2376 global $wpdb;
2377 if ( empty( $values ) ) {
2378 return;
2379 }
2380 $query = "INSERT IGNORE INTO $table (run_id, mediaId, mediaUrl, mediaUrl_hash, originType, origin, parentId, ref_hash) VALUES ";
2381 $query .= implode( ', ', $place_holders );
2382 $prepared = $wpdb->prepare( "$query ", $values );
2383 $result = $wpdb->query( $prepared );
2384 if ( $result === false ) {
2385 throw new RuntimeException( sprintf( __( 'Could not store Media Cleaner references: %s', 'media-cleaner' ), $wpdb->last_error ) );
2386 }
2387 }
2388
2389 function reset_progress() {
2390 // Reset the progress by deleting the transient.
2391 delete_transient( $this->progress_key );
2392 }
2393
2394 function clear_step_progress() {
2395 // Clear step progress when scanning completes
2396 delete_transient( $this->progress_key );
2397 }
2398
2399 function save_progress( $step, $data = array() ) {
2400 if ( $this->run_id > 0 && $this->runs ) {
2401 // Target lists are intentionally not checkpointed. They can be enormous and
2402 // are rebuilt from deterministic server-side cursors when a scan resumes.
2403 unset( $data['targets'], $data['doneTargets'] );
2404 $saved = $this->runs->checkpoint( $this->run_id, $step, $data );
2405 if ( is_wp_error( $saved ) ) throw new RuntimeException( $saved->get_error_message() );
2406 if ( $saved !== true ) throw new RuntimeException( __( 'Media Cleaner could not persist the scan checkpoint.', 'media-cleaner' ) );
2407 return true;
2408 }
2409 // Save progress with step and optional data
2410 // Data can include type, limit, limitSize, and any other progress information
2411 $progress = array(
2412 'step' => $step,
2413 'time' => time(),
2414 'data' => $data
2415 );
2416
2417 return set_transient( $this->progress_key, $progress, 0 );
2418 }
2419
2420 function get_progress() {
2421 if ( $this->runs ) {
2422 $run = $this->runs->get_resumable();
2423 if ( $run ) {
2424 $public = $this->runs->to_array( $run );
2425 return array(
2426 'runId' => $public['id'],
2427 'status' => $public['status'],
2428 'step' => $public['phase'],
2429 'time' => strtotime( $public['updated_at'] . ' UTC' ),
2430 'data' => $public['checkpoint'],
2431 'errors' => $public['errors'],
2432 );
2433 }
2434 }
2435 return get_transient( $this->progress_key );
2436 }
2437
2438 function get_step_progress() {
2439 $options = $this->get_all_options();
2440 return isset( $options['step_progress'] ) ? $options['step_progress'] : null;
2441 }
2442
2443 // The cache containing the references is wrote to the DB.
2444 function write_references() {
2445 global $wpdb;
2446 $table = $wpdb->prefix . "mclean_refs";
2447 $run_id = $this->get_run_id( true );
2448
2449 $potential_parents = array();
2450 $potential_children = array();
2451
2452 foreach ( $this->refcache as $value ) {
2453 $potentialParentPath = !is_null( $value['url'] ) ? $this->clean_url_from_resolution( $value['url'] ) : null;
2454 if ( $potentialParentPath === $value['url'] ) {
2455 $potential_parents[] = $value;
2456 }
2457 else {
2458 $potential_children[] = $value;
2459 }
2460 }
2461
2462 $this->insert_references( $potential_parents );
2463
2464 // Resolve parentId for potential children
2465 foreach ( $potential_children as &$child ) {
2466 $potentialParentPath = $this->clean_url_from_resolution( $child['url'] );
2467 $parentId = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM $table WHERE run_id = %d AND mediaUrl_hash = %s AND mediaUrl = %s", $run_id, hash( 'sha256', $potentialParentPath ), $potentialParentPath ) );
2468 if ( !empty( $parentId ) ) {
2469 $child['parentId'] = (int)$parentId;
2470 }
2471 }
2472
2473 // Insert potential children with resolved parentIds
2474 $this->insert_references( $potential_children );
2475 $this->refcache = array();
2476 }
2477
2478 function check_is_ignore( $file ) {
2479 global $wpdb;
2480 $table_name = $wpdb->prefix . "mclean_scan";
2481 $run_id = $this->get_run_id();
2482 $clean_file = $this->clean_uploaded_filename( $file );
2483 $count = $wpdb->get_var( $wpdb->prepare(
2484 "SELECT COUNT(*) FROM $table_name
2485 WHERE run_id = %d AND ignored = 1 AND path_hash = %s AND path = %s",
2486 $run_id,
2487 hash( 'sha256', $clean_file ),
2488 $clean_file
2489 ) );
2490 if ( $count > 0 ) {
2491 $this->log( "🚫 Could not trash $file." );
2492 }
2493 return ($count > 0);
2494 }
2495
2496 function find_media_id_from_file( $file, $doLog ) {
2497 global $wpdb;
2498 $postmeta_table_name = $wpdb->prefix . 'postmeta';
2499 $file = $this->clean_uploaded_filename( $file );
2500 $sql = $wpdb->prepare( "SELECT post_id
2501 FROM {$postmeta_table_name}
2502 WHERE meta_key = '_wp_attached_file'
2503 AND meta_value = %s", $file
2504 );
2505 $ret = $wpdb->get_var( $sql );
2506 if ( $doLog ) {
2507 if ( empty( $ret ) )
2508 $this->log( "🚫 File $file not found as _wp_attached_file (Library)." );
2509 else {
2510 $this->log( "�
2511 File $file found as Media $ret." );
2512 }
2513 }
2514
2515 return $ret;
2516 }
2517
2518 function get_thumbnails_urls( $id, $sizes_as_key = false ) {
2519 $sizes = get_intermediate_image_sizes();
2520 // For each size use wp_get_attachment_image_src() to get the URL
2521 $urls = array();
2522 foreach ( $sizes as $size ) {
2523 $src = wp_get_attachment_image_src( $id, $size );
2524 if ( $src ) {
2525 $urls[$size] = $this->clean_url( $src[0] );
2526 }
2527 }
2528
2529 return $sizes_as_key ? $urls : array_values( $urls );
2530 }
2531
2532 function get_thumbnails_urls_from_srcset( $media, $size = 'full' ) {
2533
2534 $id = is_numeric( $media ) ? (int)$media : $this->get_id_from_clean_url( $media, false );
2535
2536 $image_size = $this->get_attachment_size_by_id( $id, $size );
2537
2538 $sizes = array_keys( $this->get_image_sizes() );
2539 $sizes[] = $image_size;
2540
2541 $urls = array();
2542 foreach ( $sizes as $image_size ) {
2543 $srcset = wp_get_attachment_image_srcset( $id, $image_size );
2544
2545 // Extract URLs from srcset
2546 if ( !empty( $srcset ) ) {
2547 $srcset = explode( ', ', $srcset );
2548 foreach ( $srcset as $src ) {
2549 $parts = explode( ' ', $src );
2550 $url = trim( $parts[0] );
2551 if ( !empty( $url ) ) {
2552 $urls[] = $this->clean_url( $url );
2553 }
2554 }
2555 }
2556 }
2557
2558 return $urls;
2559
2560 }
2561
2562 function get_attachment_size_by_id( $attachment_id, $default_size = 'full' ) {
2563
2564 if ( ! $attachment_id ) {
2565 return $default_size;
2566 }
2567
2568 $url = wp_get_attachment_url( $attachment_id );
2569 if ( ! $url ) {
2570 return $default_size;
2571 }
2572
2573 $metadata = wp_get_attachment_metadata( $attachment_id );
2574
2575 if ( ! is_array( $metadata ) ) {
2576 return $default_size;
2577 }
2578
2579 $size = $default_size;
2580
2581 if ( isset( $metadata['file'] ) && strpos( $url, $metadata['file'] ) === ( strlen( $url ) - strlen( $metadata['file'] ) ) ) {
2582 $size = array( $metadata['width'], $metadata['height'] );
2583 } elseif ( preg_match( '/-(\d+)x(\d+)\.(jpg|jpeg|gif|png|svg|webp)$/', $url, $match ) ) {
2584 // Get the image width and height.
2585 // Example: https://regex101.com/r/7JwGz7/1.
2586 $size = array( $match[1], $match[2] );
2587 }
2588
2589 return $size;
2590 }
2591
2592 function get_image_sizes() {
2593 $sizes = array();
2594 global $_wp_additional_image_sizes;
2595 foreach ( get_intermediate_image_sizes() as $s ) {
2596 $crop = false;
2597 if ( isset( $_wp_additional_image_sizes[$s] ) ) {
2598 $width = intval( $_wp_additional_image_sizes[$s]['width'] );
2599 $height = intval( $_wp_additional_image_sizes[$s]['height'] );
2600 $crop = $_wp_additional_image_sizes[$s]['crop'];
2601 } else {
2602 $width = get_option( $s.'_size_w' );
2603 $height = get_option( $s.'_size_h' );
2604 $crop = get_option( $s.'_crop' );
2605 }
2606 $sizes[$s] = array( 'width' => $width, 'height' => $height, 'crop' => $crop );
2607 }
2608 return $sizes;
2609 }
2610
2611 /**
2612 * Get all registered thumbnail sizes formatted for the UI.
2613 * Returns an array of sizes with name, shortname, width, and height.
2614 */
2615 function get_thumbnail_sizes() {
2616 $sizes = $this->get_image_sizes();
2617 $result = array();
2618 foreach ( $sizes as $name => $size ) {
2619 // Generate a shortname (first 2 letters uppercase)
2620 $shortname = strtoupper( substr( preg_replace( '/[^a-zA-Z]/', '', $name ), 0, 2 ) );
2621 $result[] = array(
2622 'name' => $name,
2623 'shortname' => $shortname,
2624 'width' => $size['width'] ? intval( $size['width'] ) : null,
2625 'height' => $size['height'] ? intval( $size['height'] ) : null,
2626 'crop' => $size['crop'],
2627 );
2628 }
2629 return $result;
2630 }
2631
2632 function clean_url_from_resolution( $url ) {
2633 if ( !isset( $url ) ) return $url;
2634
2635 $pattern = '/[_-]\d+x\d+(?=\.[a-z]{3,4}$)/';
2636 $url = preg_replace( $pattern, '', $url );
2637 return $url;
2638 }
2639
2640 function is_url( $url ) {
2641 return ( (
2642 !empty( $url ) ) &&
2643 is_string( $url ) &&
2644 strlen( $url ) > 4 && (
2645 strtolower( substr( $url, 0, 4) ) == 'http' || $url[0] == '/'
2646 )
2647 );
2648 }
2649
2650 function get_id_from_clean_url( $clean_url ) {
2651 $found = false;
2652 $id = 0;
2653
2654 if( !$found ) {
2655 $id = $this->find_media_id_from_file( $clean_url, false );
2656 if ( $id ) {
2657 $is_attachment = get_post_type( $id ) === 'attachment';
2658 if ( $is_attachment ) {
2659 $found = true;
2660 }
2661 }
2662 }
2663
2664 if( !$found ) {
2665 $id = $this->custom_attachment_url_to_postid( $clean_url );
2666 if ( $id ) {
2667 $is_attachment = get_post_type( $id ) === 'attachment';
2668 if ( $is_attachment ) {
2669 $found = true;
2670 }
2671 }
2672 }
2673
2674 if ( !$found ) {
2675 $id = $this->resolve_from_database( $clean_url );
2676 if ( $id ) {
2677 $is_attachment = get_post_type( $id ) === 'attachment';
2678 if ( $is_attachment ) {
2679 $found = true;
2680 }
2681 }
2682 }
2683
2684
2685 return $found ? $id : null;
2686 }
2687
2688 function resolve_from_database( $url ) {
2689 global $wpdb;
2690 $pattern = '/[_-]\d+x\d+(?=\.[a-z]{3,4}$)/';
2691 $url = preg_replace( $pattern, '', $url );
2692 $url = $this->get_pathinfo_from_image_src( $url );
2693 $query = $wpdb->prepare( "SELECT ID FROM $wpdb->posts WHERE guid LIKE '%s'", '%' . $url . '%' );
2694 $attachment = $wpdb->get_col( $query );
2695 return empty( $attachment ) ? null : $attachment[0];
2696 }
2697
2698 function get_pathinfo_from_image_src( $image_src ) {
2699 $uploads = wp_upload_dir();
2700 $uploads_url = trailingslashit( $uploads['baseurl'] );
2701 if ( strpos( $image_src, $uploads_url ) === 0 )
2702 return ltrim( substr( $image_src, strlen( $uploads_url ) ), '/');
2703 else if ( strpos( $image_src, wp_make_link_relative( $uploads_url ) ) === 0 )
2704 return ltrim( substr( $image_src, strlen( wp_make_link_relative( $uploads_url ) ) ), '/');
2705 $img_info = parse_url( $image_src );
2706 return ltrim( $img_info['path'], '/' );
2707 }
2708
2709 function clean_url_from_resolution_ref( &$url ) {
2710 $url = $this->clean_url_from_resolution( $url );
2711 }
2712
2713 // From a url to the shortened and cleaned url (for example '2013/02/file.png')
2714 function clean_url( $url ) {
2715 if ( !is_string( $url ) || $url === '' || $this->upload_url === '' ) {
2716 return null;
2717 }
2718 $url = html_entity_decode( $url, ENT_QUOTES, 'UTF-8' );
2719 $base_path = (string) wp_parse_url( $this->upload_url, PHP_URL_PATH );
2720 $url_path = (string) wp_parse_url( $url, PHP_URL_PATH );
2721 if ( $url_path === '' || $base_path === '' ) {
2722 return null;
2723 }
2724 // The host is deliberately not compared: content routinely references the
2725 // uploads directory through www/non-www variants, CDN aliases, or image
2726 // proxies, and a missed reference is far more dangerous than an extra one.
2727 // The uploads path is matched anywhere so proxy prefixes also resolve.
2728 $marker = trailingslashit( $base_path );
2729 $position = strpos( $url_path, $marker );
2730 if ( $position === false ) {
2731 return null;
2732 }
2733 $relative = substr( $url_path, $position + strlen( $marker ) );
2734 $normalized = $this->normalize_upload_relative_path( urldecode( $relative ) );
2735 return is_wp_error( $normalized ) ? null : $normalized;
2736 }
2737
2738 function custom_attachment_url_to_postid( $url ) {
2739 global $wpdb;
2740
2741 // Remove the query string
2742 $url = preg_replace('/\?.*/', '', $url);
2743
2744 // Try to find the attachment ID by matching the URL with the guid
2745 $attachment = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM $wpdb->posts WHERE guid LIKE %s AND post_type = 'attachment';", '%' . $wpdb->esc_like( $url ) ) );
2746
2747 // If found, return the first attachment ID
2748 if ( !empty( $attachment ) ) {
2749 return ( int )$attachment[0];
2750 }
2751
2752 // If not found, try to match the URL without the upload directory path
2753 $upload_dir = wp_upload_dir();
2754 $url_relative = str_replace( $upload_dir['baseurl'] . '/', '', $url );
2755
2756 $attachment = $wpdb->get_col( $wpdb->prepare( "SELECT post_id FROM $wpdb->postmeta WHERE meta_key = '_wp_attached_file' AND meta_value LIKE %s;", '%' . $wpdb->esc_like( $url_relative ) ) );
2757
2758 // If found, return the first attachment ID
2759 if ( !empty( $attachment ) ) {
2760 return ( int )$attachment[0];
2761 }
2762
2763 // If still not found, return 0
2764 return 0;
2765 }
2766
2767 // From a fullpath to the shortened and cleaned path (for example '2013/02/file.png')
2768 // Original version by Jordy
2769 // function clean_uploaded_filename( $fullpath ) {
2770 // $basedir = $this->upload_path;
2771 // $file = str_replace( $basedir, '', $fullpath );
2772 // $file = str_replace( "./", "", $file );
2773 // $file = trim( $file, "/" );
2774 // return $file;
2775 // }
2776
2777 // From a fullpath to the shortened and cleaned path (for example '2013/02/file.png')
2778 // Faster version, more difficult to read, by Mike Meinz
2779 function clean_uploaded_filename( $fullpath ) {
2780 if ( !is_string( $fullpath ) || $fullpath === '' ) {
2781 return '';
2782 }
2783 if ( preg_match( '#^https?://#i', $fullpath ) ) {
2784 $clean = $this->clean_url( $fullpath );
2785 return $clean === null ? '' : $clean;
2786 }
2787 $path = wp_normalize_path( rawurldecode( $fullpath ) );
2788 $base = untrailingslashit( wp_normalize_path( $this->upload_path ) );
2789 if ( $base !== '' && ( $path === $base || strpos( $path, trailingslashit( $base ) ) === 0 ) ) {
2790 $path = ltrim( substr( $path, strlen( $base ) ), '/' );
2791 }
2792 else if ( substr( $path, 0, 1 ) === '/' ) {
2793 return '';
2794 }
2795 $normalized = $this->normalize_upload_relative_path( $path );
2796 return is_wp_error( $normalized ) ? '' : $normalized;
2797 }
2798
2799 public function build_file_manifest( $paths ) {
2800 $manifest = array();
2801 foreach ( array_values( array_unique( (array) $paths ) ) as $path ) {
2802 $relative = $this->normalize_upload_relative_path( $this->clean_uploaded_filename( $path ) );
2803 if ( is_wp_error( $relative ) ) return $relative;
2804 $absolute = $this->resolve_upload_path( $relative );
2805 if ( is_wp_error( $absolute ) ) {
2806 // Instead of propagating an error, mark the file as unsafe and continue.
2807 // This allows a scan to complete even if some files can't be fingerprinted.
2808 $code = $absolute->get_error_code();
2809 if ( $code === 'wpmc_symlink_rejected' || $code === 'wpmc_path_outside_uploads' ) {
2810 $manifest[ $relative ] = self::FINGERPRINT_UNSAFE;
2811 continue;
2812 }
2813 return $absolute;
2814 }
2815 $manifest[ $relative ] = $this->file_fingerprint( $absolute );
2816 }
2817 return $manifest;
2818 }
2819
2820 // Returns null for a file that does not exist, a 64-char sha256 fingerprint for a
2821 // normal file, or the FINGERPRINT_UNSAFE sentinel for a file that exists but cannot be
2822 // safely fingerprinted.
2823 public function file_fingerprint( $absolute_path ) {
2824 if ( !file_exists( $absolute_path ) ) return null;
2825 if ( !is_file( $absolute_path ) || !is_readable( $absolute_path ) || is_link( $absolute_path ) ) {
2826 return self::FINGERPRINT_UNSAFE;
2827 }
2828 $stat = @lstat( $absolute_path );
2829 $handle = @fopen( $absolute_path, 'rb' );
2830 if ( !$stat || !$handle ) {
2831 if ( $handle ) fclose( $handle );
2832 return self::FINGERPRINT_UNSAFE;
2833 }
2834 $context = hash_init( 'sha256' );
2835 hash_update( $context, wp_json_encode( array(
2836 'size' => isset( $stat['size'] ) ? (int) $stat['size'] : 0,
2837 'mtime' => isset( $stat['mtime'] ) ? (int) $stat['mtime'] : 0,
2838 'ino' => isset( $stat['ino'] ) ? (int) $stat['ino'] : 0,
2839 'dev' => isset( $stat['dev'] ) ? (int) $stat['dev'] : 0,
2840 ) ) );
2841 $sample_size = 64 * 1024;
2842 $first = fread( $handle, $sample_size );
2843 if ( $first === false ) {
2844 fclose( $handle );
2845 return self::FINGERPRINT_UNSAFE;
2846 }
2847 hash_update( $context, $first );
2848 if ( (int) $stat['size'] > $sample_size ) {
2849 fseek( $handle, max( 0, (int) $stat['size'] - $sample_size ), SEEK_SET );
2850 $last = fread( $handle, $sample_size );
2851 if ( $last === false ) {
2852 fclose( $handle );
2853 return self::FINGERPRINT_UNSAFE;
2854 }
2855 hash_update( $context, $last );
2856 }
2857 fclose( $handle );
2858 return hash_final( $context );
2859 }
2860
2861 public function validate_issue_manifest( $issue ) {
2862 $manifest = json_decode( (string) $issue->manifest, true );
2863 if ( !is_array( $manifest ) ) return true;
2864 foreach ( $manifest as $relative => $expected ) {
2865 // We throw an error for the unasfe tag only in the context of cleanup, not in the context of scanning.
2866 if ( $expected === self::FINGERPRINT_UNSAFE ) {
2867 return new WP_Error( 'wpmc_file_unsafe_for_cleanup', __( 'This item includes a file that cannot be safely verified (a symbolic link, an unreadable file, or a special file), so Media Cleaner will not touch it. Fix or remove that file, then run a new scan.', 'media-cleaner' ) );
2868 }
2869 $upload = $this->resolve_upload_path( $relative );
2870 $trash = $this->resolve_trash_path( $relative );
2871 if ( is_wp_error( $upload ) ) return $upload;
2872 if ( is_wp_error( $trash ) ) return $trash;
2873 $existing = file_exists( $upload ) ? $upload : ( file_exists( $trash ) ? $trash : null );
2874 if ( $expected === null ) {
2875 if ( $existing ) return new WP_Error( 'wpmc_file_changed_since_scan', __( 'A file appeared after the scan. Run a new scan before cleanup.', 'media-cleaner' ) );
2876 continue;
2877 }
2878 if ( !$existing ) return new WP_Error( 'wpmc_file_changed_since_scan', __( 'A file disappeared after the scan. Run a new scan before cleanup.', 'media-cleaner' ) );
2879 // A real fingerprint that no longer matches — including a file that has since
2880 // become unsafe and now returns the sentinel — means the file changed.
2881 $current = $this->file_fingerprint( $existing );
2882 if ( !hash_equals( (string) $expected, (string) $current ) ) {
2883 return new WP_Error( 'wpmc_file_changed_since_scan', __( 'A file changed after the scan. Run a new scan before cleanup.', 'media-cleaner' ) );
2884 }
2885 }
2886 return true;
2887 }
2888
2889 public function normalize_upload_relative_path( $path ) {
2890 if ( !is_string( $path ) || strpos( $path, "\0" ) !== false ) {
2891 return new WP_Error( 'wpmc_invalid_path', __( 'The storage path is invalid.', 'media-cleaner' ) );
2892 }
2893 $path = wp_normalize_path( rawurldecode( trim( $path ) ) );
2894 if ( preg_match( '#^[a-zA-Z]:/#', $path ) || strpos( $path, '://' ) !== false ) {
2895 return new WP_Error( 'wpmc_absolute_path', __( 'Absolute storage paths are not allowed.', 'media-cleaner' ) );
2896 }
2897 $path = ltrim( $path, '/' );
2898 $segments = array();
2899 foreach ( explode( '/', $path ) as $segment ) {
2900 if ( $segment === '' || $segment === '.' ) {
2901 continue;
2902 }
2903 if ( $segment === '..' ) {
2904 return new WP_Error( 'wpmc_path_traversal', __( 'The storage path attempts to leave the uploads directory.', 'media-cleaner' ) );
2905 }
2906 $segments[] = $segment;
2907 }
2908 return implode( '/', $segments );
2909 }
2910
2911 public function resolve_upload_path( $relative_path, $must_exist = false ) {
2912 $relative = $this->normalize_upload_relative_path( $relative_path );
2913 if ( is_wp_error( $relative ) ) {
2914 return $relative;
2915 }
2916 $base = realpath( $this->upload_path );
2917 if ( !$base ) {
2918 return new WP_Error( 'wpmc_upload_root_missing', __( 'The uploads directory is unavailable.', 'media-cleaner' ) );
2919 }
2920 $base = untrailingslashit( wp_normalize_path( $base ) );
2921 $candidate = $base . ( $relative === '' ? '' : '/' . $relative );
2922 if ( is_link( $candidate ) ) {
2923 return new WP_Error( 'wpmc_symlink_rejected', __( 'Symbolic links are not modified by Media Cleaner.', 'media-cleaner' ) );
2924 }
2925 $existing = realpath( $candidate );
2926 if ( $must_exist && !$existing ) {
2927 return new WP_Error( 'wpmc_path_missing', __( 'The requested file no longer exists.', 'media-cleaner' ) );
2928 }
2929 $resolved = $existing ? wp_normalize_path( $existing ) : $candidate;
2930 if ( $existing && $resolved !== $base && strpos( $resolved, trailingslashit( $base ) ) !== 0 ) {
2931 return new WP_Error( 'wpmc_path_outside_uploads', __( 'The requested path is outside the uploads directory.', 'media-cleaner' ) );
2932 }
2933 if ( !$existing ) {
2934 $ancestor = dirname( $candidate );
2935 while ( !file_exists( $ancestor ) && dirname( $ancestor ) !== $ancestor ) {
2936 $ancestor = dirname( $ancestor );
2937 }
2938 $ancestor_real = realpath( $ancestor );
2939 $ancestor_real = $ancestor_real ? untrailingslashit( wp_normalize_path( $ancestor_real ) ) : '';
2940 if ( $ancestor_real !== $base && strpos( $ancestor_real, trailingslashit( $base ) ) !== 0 ) {
2941 return new WP_Error( 'wpmc_path_outside_uploads', __( 'The requested path is outside the uploads directory.', 'media-cleaner' ) );
2942 }
2943 }
2944 if ( $existing && is_link( $candidate ) ) {
2945 return new WP_Error( 'wpmc_symlink_rejected', __( 'Symbolic links are not modified by Media Cleaner.', 'media-cleaner' ) );
2946 }
2947 return $candidate;
2948 }
2949
2950 /**
2951 * Check if the file or the Media ID is used in the install.
2952 * That file or ID will be checked against the database of references created by the plugin
2953 * by the parsers.
2954 */
2955 function reference_exists( $file, $mediaId ) {
2956 global $wpdb;
2957
2958 $table = $wpdb->prefix . "mclean_refs";
2959 $run_id = $this->get_run_id();
2960
2961 $row = null;
2962 if ( !empty( $mediaId ) ) {
2963 $row = $wpdb->get_row( $wpdb->prepare( "SELECT originType FROM $table WHERE run_id = %d AND mediaId = %d LIMIT 1", $run_id, $mediaId ) );
2964 if ( !empty( $row ) ) {
2965 $origin = $row->originType === 'MEDIA LIBRARY' ? 'Media Library' : 'content';
2966 $this->log( "�
2967 Media #{$mediaId} used by {$origin}" );
2968 return $row->originType;
2969 }
2970 }
2971 if ( !empty( $file ) ) {
2972 $row = $wpdb->get_row( $wpdb->prepare(
2973 "SELECT originType FROM $table WHERE run_id = %d AND mediaUrl_hash = %s AND mediaUrl = %s LIMIT 1",
2974 $run_id,
2975 hash( 'sha256', $file ),
2976 $file
2977 ) );
2978 if ( !empty( $row ) ) {
2979 $origin = $row->originType === 'MEDIA LIBRARY' ? 'Media Library' : 'content';
2980 $this->log( "�
2981 File {$file} used by {$origin}" );
2982 return $row->originType;
2983 }
2984 }
2985 return false;
2986 }
2987
2988 function get_full_upload_path( $relative_path ) {
2989 $full_path = $this->resolve_upload_path( $relative_path );
2990 return is_wp_error( $full_path ) ? null : $full_path;
2991 }
2992
2993 function get_paths_from_attachment( $attachmentId ) {
2994 $paths = array();
2995 $fullpath = get_attached_file( $attachmentId );
2996 if ( empty( $fullpath ) ) {
2997 $this->log( 'Could not find attached file for Media ID ' . $attachmentId );
2998 return array();
2999 }
3000 $mainfile = $this->clean_uploaded_filename( $fullpath );
3001 array_push( $paths, $mainfile );
3002 $baseUp = pathinfo( $mainfile );
3003 $filespath = trailingslashit( $this->upload_path ) . trailingslashit( $baseUp['dirname'] );
3004 $meta = wp_get_attachment_metadata( $attachmentId );
3005 if ( isset( $meta['original_image'] ) ) {
3006 $original_image = $this->clean_uploaded_filename( $filespath . $meta['original_image'] );
3007 array_push( $paths, $original_image );
3008 }
3009 $isImage = isset( $meta, $meta['width'], $meta['height'] );
3010 $sizes = $this->get_image_sizes();
3011 if ( $isImage && isset( $meta['sizes'] ) ) {
3012 foreach ( $meta['sizes'] as $name => $attr ) {
3013 if ( isset( $attr['file'] ) ) {
3014 $file = $this->clean_uploaded_filename( $filespath . $attr['file'] );
3015 array_push( $paths, $file );
3016 }
3017 }
3018 }
3019 return $paths;
3020 }
3021
3022 function is_media_ignored( $attachmentId ) {
3023 global $wpdb;
3024 $table_name = $wpdb->prefix . "mclean_scan";
3025 $run_id = $this->get_run_id();
3026 $issue = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $table_name WHERE run_id = %d AND postId = %d", $run_id, $attachmentId ), OBJECT );
3027 //error_log( $attachmentId );
3028 //error_log( print_r( $issue, 1 ) );
3029 if ( $issue && $issue->ignored )
3030 return true;
3031 return false;
3032 }
3033
3034 function check_media( $attachmentId, $checkOnly = false ) {
3035
3036 // Is Media ID ignored, consider as used.
3037 if ( $this->is_media_ignored( $attachmentId ) ) {
3038 return true;
3039 }
3040
3041 // Remove everything related to this media from the database.
3042 if ( !$checkOnly ) {
3043 $this->delete_attachment_related_data( $attachmentId );
3044 }
3045
3046 $size = 0;
3047 $countfiles = 0;
3048 $check_content = (bool) $this->get_option( 'content' );
3049 $check_broken_media = !$check_content;
3050 $fullpath = get_attached_file( $attachmentId );
3051 $is_broken = apply_filters( 'wpmc_is_file_broken', !file_exists( $fullpath ), $attachmentId );
3052
3053 // It's a broken-only scan
3054 if ( $check_broken_media && !$is_broken ) {
3055 $is_considered_used = apply_filters( 'wpmc_check_media', true, $attachmentId, false );
3056 return $is_considered_used;
3057 }
3058
3059 // Let's analyze the usage of each path (thumbnails included) for this Media ID.
3060 $issue = 'NO_CONTENT';
3061 $paths = $this->get_paths_from_attachment( $attachmentId );
3062 foreach ( $paths as $path ) {
3063
3064 // If it's found in the content, we stop the scan right away
3065 if ( $check_content && $this->reference_exists( $path, $attachmentId ) ) {
3066 $is_considered_used = apply_filters( 'wpmc_check_media', true, $attachmentId, false );
3067 if ( $is_considered_used ) {
3068 return true;
3069 }
3070 }
3071
3072 // Let's count the size of the files for later, in case it's unused
3073 $filepath = trailingslashit( $this->upload_path ) . $path;
3074 if ( file_exists( $filepath ) )
3075 $size += filesize( $filepath );
3076 $countfiles++;
3077 }
3078
3079 // This Media ID seems not in used (or broken)
3080 // Let's double-check through the filter (overridable by users)
3081 $is_considered_used = apply_filters( 'wpmc_check_media', false, $attachmentId, $is_broken );
3082 if ( !$is_considered_used ) {
3083 if ( $is_broken ) {
3084 $this->log( "🚫 File {$fullpath} does not exist." );
3085 $issue = 'ORPHAN_MEDIA';
3086 }
3087 if ( !$checkOnly ) {
3088 global $wpdb;
3089 $table_name = $wpdb->prefix . "mclean_scan";
3090 $mainfile = $this->clean_uploaded_filename( $fullpath );
3091 $display_path = $mainfile !== '' ? $mainfile . ( $countfiles > 0 ? ( " (+ " . $countfiles . " thumbnails)" ) : "" ) : sprintf( 'Media #%d (missing attached file)', $attachmentId );
3092 $manifest = $this->build_file_manifest( $paths );
3093 if ( is_wp_error( $manifest ) ) throw new RuntimeException( $manifest->get_error_message() );
3094 $inserted = $wpdb->insert( $table_name,
3095 array(
3096 'run_id' => $this->get_run_id( true ),
3097 'time' => current_time('mysql'),
3098 'type' => 1,
3099 'size' => $size,
3100 'path' => $display_path,
3101 'path_hash' => hash( 'sha256', $display_path ),
3102 'manifest' => wp_json_encode( $manifest ),
3103 'postId' => $attachmentId,
3104 'issue' => $issue
3105 )
3106 );
3107 if ( $inserted === false ) throw new RuntimeException( sprintf( __( 'Media Cleaner could not store a media issue: %s', 'media-cleaner' ), $wpdb->last_error ) );
3108 }
3109 }
3110 return $is_considered_used;
3111 }
3112
3113 // Delete all issues
3114 function reset_issues( $includingIgnored = false ) {
3115 global $wpdb;
3116 $table_name = $wpdb->prefix . "mclean_scan";
3117 $run_id = $this->get_run_id( true );
3118 if ( $run_id < 1 ) throw new RuntimeException( __( 'A writable scan run is required before resetting issues.', 'media-cleaner' ) );
3119 if ( $includingIgnored ) {
3120 $deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM $table_name WHERE run_id = %d AND deleted = 0", $run_id ) );
3121 }
3122 else {
3123 $deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM $table_name WHERE run_id = %d AND ignored = 0 AND deleted = 0", $run_id ) );
3124 }
3125 if ( $deleted === false ) throw new RuntimeException( sprintf( __( 'Media Cleaner could not reset staged issues: %s', 'media-cleaner' ), $wpdb->last_error ) );
3126 }
3127
3128 function is_image_extension( $ext ) {
3129 $ext = strtolower( $ext );
3130 $valid = apply_filters( 'wpmc_valid_image_extensions', array( 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'ico', 'webp', 'avif' ) );
3131
3132 return in_array( $ext, $valid );
3133
3134 }
3135
3136
3137 function reset_references() {
3138 global $wpdb;
3139 $table_name = $wpdb->prefix . "mclean_refs";
3140 $run_id = $this->get_run_id( true );
3141 if ( $run_id < 1 ) throw new RuntimeException( __( 'A writable scan run is required before resetting references.', 'media-cleaner' ) );
3142 $deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM $table_name WHERE run_id = %d", $run_id ) );
3143 if ( $deleted === false ) throw new RuntimeException( sprintf( __( 'Media Cleaner could not reset staged references: %s', 'media-cleaner' ), $wpdb->last_error ) );
3144 }
3145
3146 function get_issue_for_postId( $postId ) {
3147 global $wpdb;
3148 $table_name = $wpdb->prefix . "mclean_scan";
3149 $run_id = $this->get_run_id();
3150 $issue = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $table_name WHERE run_id = %d AND postId = %d", $run_id, $postId ), OBJECT );
3151 return $issue;
3152 }
3153
3154 function echo_issue( $issue ) {
3155 if ( $issue == 'NO_CONTENT' ) {
3156 _e( "Not found in content", 'media-cleaner' );
3157 }
3158 else if ( $issue == 'ORPHAN_FILE' ) {
3159 _e( "Not in Library", 'media-cleaner' );
3160 }
3161 else if ( $issue == 'ORPHAN_RETINA' ) {
3162 _e( "Orphan Retina", 'media-cleaner' );
3163 }
3164 else if ( $issue == 'ORPHAN_WEBP' ) {
3165 _e( "Orphan WebP", 'media-cleaner' );
3166 }
3167 else if ( $issue == 'ORPHAN_MEDIA' ) {
3168 _e( "No attached file", 'media-cleaner' );
3169 }
3170 else {
3171 echo $issue;
3172 }
3173 }
3174
3175 function get_uploads_directory_hierarchy() {
3176 $uploads_dir = wp_upload_dir();
3177 if ( !empty( $uploads_dir['error'] ) ) {
3178 throw new RuntimeException( $uploads_dir['error'] );
3179 }
3180 $base_dir = wp_normalize_path( $uploads_dir['basedir'] );
3181 $root = '/' . wp_basename( $base_dir );
3182 $directories = array();
3183
3184 // Get all subdirectories of the base directory
3185 $dir_iterator = new RecursiveDirectoryIterator( $base_dir, FilesystemIterator::KEY_AS_PATHNAME | FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS );
3186 $iterator = new RecursiveIteratorIterator( $dir_iterator, RecursiveIteratorIterator::SELF_FIRST );
3187
3188 $max_directories = (int) apply_filters( 'wpmc_directory_picker_limit', 5000 );
3189 foreach ( $iterator as $file ) {
3190 if ( count( $directories ) >= $max_directories ) {
3191 break;
3192 }
3193 if ( $file->isDir() ) {
3194 // Normalize path for consistency
3195 $file_path = wp_normalize_path( $file->getPathname() );
3196 // Remove base_dir from path
3197 $directory = str_replace( $base_dir, '', $file_path );
3198 if ( $directory ) {
3199 $directories[] = $root . $directory;
3200 }
3201 }
3202 }
3203
3204 // Return the hierarchy as a JSON file
3205 return wp_json_encode( $directories );
3206 }
3207
3208 /**
3209 *
3210 * Roles & Access Rights
3211 *
3212 */
3213 public function can_access_settings() {
3214 return apply_filters( 'wpmc_allow_setup', current_user_can( 'manage_options' ) );
3215 }
3216
3217 // Cached per request: the mutators below can be called in batches of 100.
3218 private $results_staged = null;
3219
3220 public function can_access_features() {
3221 return apply_filters( 'wpmc_allow_usage', current_user_can( 'manage_options' ) );
3222 }
3223
3224 public function can_cleanup() {
3225 $allowed = current_user_can( 'manage_options' ) && $this->runs && $this->runs->cleanup_allowed();
3226 return $allowed && apply_filters( 'wpmc_allow_cleanup', true );
3227 }
3228
3229 /**
3230 * A staged scan copies the ignored and trashed results into itself when it starts,
3231 * and that copy replaces them when it publishes. Changing them in the meantime
3232 * would be silently undone, and a trashed row coming back after its file was
3233 * recovered would let the next Empty Trash erase a file that is in use.
3234 *
3235 * So the results hold still while a scan is staged over them. The scan is the
3236 * user's own, and publishing or cancelling it releases them.
3237 */
3238 private function results_staged_error() {
3239 if ( $this->results_staged === null ) {
3240 $this->results_staged = $this->runs && $this->runs->get_resumable() ? true : false;
3241 }
3242 if ( !$this->results_staged ) return null;
3243 return new WP_Error( 'wpmc_scan_staged',
3244 __( 'A scan is staged over these results, so they cannot be changed yet. Publish it or cancel it first, then try again.', 'media-cleaner' ),
3245 array( 'status' => 409 ) );
3246 }
3247
3248 #region Options
3249
3250 function list_options() {
3251 return array(
3252 'method' => 'media',
3253 'content' => true,
3254 'filesystem_content' => true,
3255 'media_library' => false,
3256 'live_content' => false,
3257 'debuglogs' => false,
3258 'images_only' => false,
3259 'attach_is_use' => false,
3260 'thumbnails_only' => false,
3261 'dirs_filter' => '',
3262 'files_filter' => '',
3263 'hide_thumbnails' => false,
3264 'hide_warning' => false,
3265 'skip_trash' => false,
3266 'medias_buffer' => 100,
3267 'posts_buffer' => 5,
3268 'analysis_buffer' => 100,
3269 'file_op_buffer' => 20,
3270 'uploads_file_buffer' => 500,
3271 'delay' => 100,
3272 'refs_buffer' => 500,
3273 'analysis_document_limit' => 8 * 1024 * 1024,
3274 'shortcodes_disabled' => false,
3275
3276 'output_buffer_cleaning_disabled' => false,
3277 'php_error_logs' => false,
3278 'posts_per_page' => 10,
3279 'clean_uninstall' => false,
3280 'repair_mode' => false,
3281 'expert_mode' => false,
3282 'mcp_support' => false,
3283 'logs_path' => null,
3284 'thumbnail_force_issues' => [],
3285 );
3286 }
3287
3288 function reset_options() {
3289 delete_option( $this->option_name );
3290 }
3291
3292 function get_option( $option ) {
3293 if ( $this->run_id > 0 && array_key_exists( $option, $this->run_config ) ) {
3294 return $this->run_config[ $option ];
3295 }
3296 $options = $this->get_all_options();
3297 return $options[$option];
3298 }
3299
3300 function get_all_options() {
3301 $options = get_option( $this->option_name, null );
3302 $options = $this->check_options( $options );
3303 return $options;
3304 }
3305
3306 // Let's work on this function if we need it.
3307 // Right now, it looks like the options are all updated at the same time.
3308
3309 // function update_option( $option, $value ) {
3310 // if ( !array_key_exists( $name, $options ) ) {
3311 // return new WP_REST_Response([ 'success' => false, 'message' => 'This option does not exist.' ], 200 );
3312 // }
3313 // $value = is_bool( $params['value'] ) ? ( $params['value'] ? '1' : '' ) : $params['value'];
3314 // }
3315
3316 function update_options( $options ) {
3317 $current = get_option( $this->option_name, array() );
3318 $current = is_array( $current ) ? $current : array();
3319 $options = is_array( $options ) ? $options : array();
3320 $clean = array();
3321 foreach ( $this->list_options() as $name => $default ) {
3322 $value = array_key_exists( $name, $options ) ? $options[ $name ] : ( array_key_exists( $name, $current ) ? $current[ $name ] : $default );
3323 $clean[ $name ] = $this->sanitize_option_value( $name, $value, $default );
3324 }
3325 update_option( $this->option_name, $clean, false );
3326 $options = $this->sanitize_options();
3327 return $options;
3328 }
3329
3330 public function sanitize_scan_config( $config ) {
3331 $options = $this->get_all_options();
3332 $config = is_array( $config ) ? $config : array();
3333 $names = array(
3334 'content', 'filesystem_content', 'media_library', 'images_only', 'attach_is_use',
3335 'thumbnails_only', 'dirs_filter', 'files_filter', 'shortcodes_disabled',
3336 'thumbnail_force_issues', 'posts_buffer', 'medias_buffer', 'analysis_buffer',
3337 'uploads_file_buffer', 'refs_buffer', 'delay', 'analysis_document_limit',
3338 );
3339 $snapshot = array();
3340 foreach ( $names as $name ) {
3341 $default = array_key_exists( $name, $options ) ? $options[ $name ] : null;
3342 $value = array_key_exists( $name, $config ) ? $config[ $name ] : $default;
3343 $snapshot[ $name ] = $this->sanitize_option_value( $name, $value, $default );
3344 }
3345 return $snapshot;
3346 }
3347
3348 private function sanitize_option_value( $name, $value, $default ) {
3349 $boolean_options = array(
3350 'content', 'filesystem_content', 'media_library', 'live_content', 'debuglogs',
3351 'images_only', 'attach_is_use', 'thumbnails_only', 'hide_thumbnails', 'hide_warning',
3352 'skip_trash', 'shortcodes_disabled', 'output_buffer_cleaning_disabled',
3353 'php_error_logs', 'clean_uninstall', 'repair_mode', 'expert_mode',
3354 'mcp_support',
3355 );
3356 if ( in_array( $name, $boolean_options, true ) ) {
3357 return rest_sanitize_boolean( $value );
3358 }
3359
3360 $ranges = array(
3361 'medias_buffer' => array( 1, 500 ),
3362 'posts_buffer' => array( 1, 100 ),
3363 'analysis_buffer' => array( 1, 500 ),
3364 'file_op_buffer' => array( 1, 100 ),
3365 'uploads_file_buffer' => array( 10, 1000 ),
3366 'delay' => array( 0, 10000 ),
3367 'refs_buffer' => array( 10, 1000 ),
3368 'posts_per_page' => array( 5, 100 ),
3369 );
3370 if ( isset( $ranges[ $name ] ) ) {
3371 $number = is_numeric( $value ) ? (int) $value : (int) $default;
3372 return max( $ranges[ $name ][0], min( $ranges[ $name ][1], $number ) );
3373 }
3374
3375 if ( $name === 'analysis_document_limit' ) {
3376 $number = is_numeric( $value ) ? (int) $value : (int) $default;
3377 // -1 is the sentinel for "no limit"; any other value is floored at 1 MB.
3378 return $number === -1 ? -1 : max( 1024 * 1024, $number );
3379 }
3380
3381 if ( $name === 'method' ) {
3382 $value = sanitize_key( $value );
3383 return in_array( $value, array( 'media', 'files', 'duplicates', 'optimize_thumbnails' ), true ) ? $value : 'media';
3384 }
3385 if ( in_array( $name, array( 'dirs_filter', 'files_filter' ), true ) ) {
3386 $value = is_string( $value ) ? trim( $value ) : '';
3387 return $value !== '' && @preg_match( $value, '' ) === false ? '' : $value;
3388 }
3389 if ( $name === 'thumbnail_force_issues' ) {
3390 return is_array( $value ) ? array_values( array_unique( array_map( 'sanitize_key', $value ) ) ) : array();
3391 }
3392 if ( $name === 'logs_path' ) {
3393 return is_string( $value ) ? sanitize_text_field( $value ) : null;
3394 }
3395 return is_scalar( $value ) || is_array( $value ) ? $value : $default;
3396 }
3397
3398 // Upgrade from the old way of storing options to the new way.
3399 function check_options( $options = [] ) {
3400 $plugin_options = $this->list_options();
3401 $options = empty( $options ) ? [] : $options;
3402 $clean_options = array_intersect_key( $options, $plugin_options );
3403 $hasChanges = count( $clean_options ) !== count( $options );
3404 $options = $clean_options;
3405 foreach ( $plugin_options as $option => $default ) {
3406 // The option already exists
3407 if ( isset( $options[$option] ) ) {
3408 continue;
3409 }
3410 // The option does not exist, so we need to add it.
3411 // Let's use the old value if any, or the default value.
3412 $options[$option] = get_option( 'wpmc_' . $option, $default );
3413 delete_option( 'wpmc_' . $option );
3414 $hasChanges = true;
3415 }
3416 if ( $hasChanges ) {
3417 update_option( $this->option_name , $options );
3418 }
3419
3420 // Runtime information is not persisted with settings. In particular, scan
3421 // checkpoints must never be copied into the options row.
3422 $options['thumbnail_sizes'] = $this->get_thumbnail_sizes();
3423 global $mwai;
3424 $options['mwai_has_mcp'] = !empty( $mwai ) && method_exists( $mwai, 'hasMCP' ) && $mwai->hasMCP();
3425
3426 return $options;
3427 }
3428
3429 // Validate and keep the options clean and logical.
3430 function sanitize_options() {
3431 $options = get_option( $this->option_name, array() );
3432 $options = is_array( $options ) ? $options : array();
3433 $clean = array();
3434 foreach ( $this->list_options() as $name => $default ) {
3435 $value = array_key_exists( $name, $options ) ? $options[ $name ] : $default;
3436 $clean[ $name ] = $this->sanitize_option_value( $name, $value, $default );
3437 }
3438 if ( $clean !== $options ) {
3439 update_option( $this->option_name, $clean, false );
3440 }
3441 return $this->check_options( $clean );
3442 }
3443
3444 #endregion
3445 }
3446
3447 // Check the DB. If does not exist, let's create it.
3448 function wpmc_check_database() {
3449 wpmc_create_database();
3450 }
3451
3452 function wpmc_create_database() {
3453 global $wpdb;
3454 $table_name = $wpdb->prefix . "mclean_scan";
3455 $charset_collate = $wpdb->get_charset_collate();
3456 $sql = "CREATE TABLE $table_name (
3457 id BIGINT(20) NOT NULL AUTO_INCREMENT,
3458 run_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
3459 time DATETIME NULL,
3460 type TINYINT(1) NOT NULL,
3461 postId BIGINT(20) NULL,
3462 path TEXT NULL,
3463 path_hash CHAR(64) NULL,
3464 manifest LONGTEXT NULL,
3465 size BIGINT(20) UNSIGNED NULL,
3466 ignored TINYINT(1) NOT NULL DEFAULT 0,
3467 deleted TINYINT(1) NOT NULL DEFAULT 0,
3468 issue VARCHAR(191) NOT NULL,
3469 parentId BIGINT(20) NULL,
3470 PRIMARY KEY (id),
3471 KEY run_state_index (run_id, deleted, ignored, id),
3472 KEY run_post_index (run_id, postId),
3473 KEY run_path_index (run_id, path_hash),
3474 KEY run_parent_index (run_id, parentId)
3475 ) " . $charset_collate . ";" ;
3476 require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
3477 dbDelta( $sql );
3478
3479 $table_name = $wpdb->prefix . "mclean_refs";
3480 $charset_collate = $wpdb->get_charset_collate();
3481 // This key doesn't work on too many installs because of the 'Specified key was too long' issue
3482 // KEY mediaLookUp (mediaId, mediaUrl)
3483 $sql = "CREATE TABLE $table_name (
3484 id BIGINT(20) NOT NULL AUTO_INCREMENT,
3485 run_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
3486 mediaId BIGINT(20) NULL,
3487 mediaUrl TEXT NULL,
3488 mediaUrl_hash CHAR(64) NULL,
3489 originType VARCHAR(191) NOT NULL,
3490 origin TEXT NULL,
3491 parentId BIGINT(20) NULL,
3492 ref_hash VARCHAR(32) NULL,
3493 PRIMARY KEY (id),
3494 KEY run_media_index (run_id, mediaId),
3495 KEY run_url_index (run_id, mediaUrl_hash),
3496 KEY run_origin_index (run_id, originType),
3497 UNIQUE KEY run_ref_hash_unique (run_id, ref_hash)
3498 ) " . $charset_collate . ";";
3499 require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
3500 dbDelta( $sql );
3501
3502 $runs_table = $wpdb->prefix . 'mclean_runs';
3503 $sql = "CREATE TABLE $runs_table (
3504 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
3505 owner_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
3506 method VARCHAR(32) NOT NULL,
3507 status VARCHAR(24) NOT NULL,
3508 phase VARCHAR(64) NOT NULL,
3509 config LONGTEXT NULL,
3510 checkpoint LONGTEXT NULL,
3511 counters LONGTEXT NULL,
3512 errors LONGTEXT NULL,
3513 error_count INT(10) UNSIGNED NOT NULL DEFAULT 0,
3514 created_at DATETIME NOT NULL,
3515 updated_at DATETIME NOT NULL,
3516 heartbeat_at DATETIME NOT NULL,
3517 finished_at DATETIME NULL,
3518 published_at DATETIME NULL,
3519 PRIMARY KEY (id),
3520 KEY status_heartbeat_index (status, heartbeat_at),
3521 KEY owner_status_index (owner_id, status)
3522 ) " . $charset_collate . ";";
3523 dbDelta( $sql );
3524
3525 $work_table = $wpdb->prefix . 'mclean_work';
3526 $sql = "CREATE TABLE $work_table (
3527 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
3528 run_id BIGINT(20) UNSIGNED NOT NULL,
3529 phase VARCHAR(64) NOT NULL,
3530 target_type VARCHAR(32) NOT NULL,
3531 target_key TEXT NOT NULL,
3532 target_hash CHAR(64) NOT NULL,
3533 cursor_value BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
3534 status VARCHAR(24) NOT NULL DEFAULT 'pending',
3535 attempts SMALLINT(5) UNSIGNED NOT NULL DEFAULT 0,
3536 last_error TEXT NULL,
3537 snapshot_token CHAR(64) NULL,
3538 updated_at DATETIME NOT NULL,
3539 PRIMARY KEY (id),
3540 UNIQUE KEY run_target_unique (run_id, phase, target_hash),
3541 KEY lease_index (run_id, phase, status, id)
3542 ) " . $charset_collate . ";";
3543 dbDelta( $sql );
3544
3545 $operations_table = $wpdb->prefix . 'mclean_operations';
3546 $sql = "CREATE TABLE $operations_table (
3547 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
3548 run_id BIGINT(20) UNSIGNED NOT NULL,
3549 issue_id BIGINT(20) UNSIGNED NOT NULL,
3550 operation VARCHAR(24) NOT NULL,
3551 state VARCHAR(32) NOT NULL,
3552 request_key VARCHAR(64) NOT NULL,
3553 manifest LONGTEXT NULL,
3554 error_code VARCHAR(64) NULL,
3555 error_message TEXT NULL,
3556 created_at DATETIME NOT NULL,
3557 updated_at DATETIME NOT NULL,
3558 PRIMARY KEY (id),
3559 UNIQUE KEY request_issue_unique (request_key, issue_id, operation),
3560 KEY run_state_index (run_id, state, id)
3561 ) " . $charset_collate . ";";
3562 dbDelta( $sql );
3563
3564 $duplicates_table = $wpdb->prefix . 'mclean_duplicates';
3565 $sql = "CREATE TABLE $duplicates_table (
3566 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
3567 run_id BIGINT(20) UNSIGNED NOT NULL,
3568 media_id BIGINT(20) UNSIGNED NOT NULL,
3569 path TEXT NOT NULL,
3570 path_hash CHAR(64) NOT NULL,
3571 size BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
3572 content_hash CHAR(64) NULL,
3573 is_canonical TINYINT(1) NOT NULL DEFAULT 0,
3574 PRIMARY KEY (id),
3575 UNIQUE KEY run_media_unique (run_id, media_id),
3576 KEY run_size_index (run_id, size),
3577 KEY run_content_index (run_id, content_hash)
3578 ) " . $charset_collate . ";";
3579 dbDelta( $sql );
3580 }
3581
3582 function wpmc_remove_database() {
3583 global $wpdb;
3584 $table_name1 = $wpdb->prefix . "mclean_scan";
3585 $table_name2 = $wpdb->prefix . "mclean_refs";
3586 $table_name3 = $wpdb->prefix . "wpmcleaner";
3587 $table_name4 = $wpdb->prefix . "mclean_runs";
3588 $table_name5 = $wpdb->prefix . "mclean_work";
3589 $table_name6 = $wpdb->prefix . "mclean_operations";
3590 $table_name7 = $wpdb->prefix . "mclean_duplicates";
3591 $sql = "DROP TABLE IF EXISTS $table_name1, $table_name2, $table_name3, $table_name4, $table_name5, $table_name6, $table_name7;";
3592 $wpdb->query( $sql );
3593 }
3594
3595 #region Install / Uninstall
3596
3597 /*
3598 INSTALL / UNINSTALL
3599 */
3600
3601 function wpmc_install() {
3602 $previous_schema = (int) get_option( Meow_WPMC_Runs::SCHEMA_OPTION, 0 );
3603 wpmc_create_database();
3604 $runs = new Meow_WPMC_Runs( null );
3605 if ( $runs->tables_exist( true ) ) {
3606 if ( $previous_schema < 2 ) {
3607 delete_transient( 'wpmc_progress' );
3608 }
3609 // Schema 2 to 4 force-disabled shortcode analysis, which caused false
3610 // positives for rendered shortcodes (Foo Gallery and others). Undo it once.
3611 if ( $previous_schema >= 2 && $previous_schema < 5 ) {
3612 $options = get_option( 'wpmc_options', array() );
3613 if ( is_array( $options ) && !empty( $options['shortcodes_disabled'] ) ) {
3614 $options['shortcodes_disabled'] = false;
3615 update_option( 'wpmc_options', $options, false );
3616 }
3617 }
3618 update_option( Meow_WPMC_Runs::SCHEMA_OPTION, Meow_WPMC_Runs::SCHEMA_VERSION, false );
3619 }
3620 }
3621
3622 function wpmc_reset () {
3623 wpmc_remove_database();
3624 delete_option( Meow_WPMC_Runs::ACTIVE_RUN_OPTION );
3625 delete_option( Meow_WPMC_Runs::LOCK_OPTION );
3626 delete_option( Meow_WPMC_Runs::SCHEMA_OPTION );
3627 delete_option( Meow_WPMC_Runs::SCHEMA_LOCK_OPTION );
3628 wpmc_create_database();
3629 $runs = new Meow_WPMC_Runs( null );
3630 if ( $runs->tables_exist( true ) ) {
3631 update_option( Meow_WPMC_Runs::SCHEMA_OPTION, Meow_WPMC_Runs::SCHEMA_VERSION, false );
3632 }
3633 }
3634
3635 #endregion
3636