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