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