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