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