PluginProbe ʕ •ᴥ•ʔ
Media Cleaner: Clean your WordPress! / 6.7.2
Media Cleaner: Clean your WordPress! v6.7.2
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 3 years ago admin.php 2 years ago core.php 2 years ago engine.php 2 years ago init.php 2 years ago parsers.php 5 years ago rest.php 2 years ago support.php 2 years ago ui.php 3 years ago
core.php
1799 lines
1 <?php
2
3 class Meow_WPMC_Core {
4
5 public $admin = null;
6 public $is_rest = false;
7 public $is_cli = false;
8 public $is_pro = false;
9 public $engine = null;
10 public $catch_timeout = true; // This will halt the plugin before reaching the PHP timeout.
11 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";
12 public $current_method = 'media';
13 public $servername = null; // meowapps.com (site URL without http/https)
14 public $site_url = null; // https://meowapps.com
15 public $upload_path = null; // /www/wp-content/uploads (path to uploads)
16 public $upload_url = null; // wp-content/uploads (uploads without domain)
17 private $option_name = 'wpmc_options';
18
19 private $regex_file = '/[A-Za-z0-9-_,.\(\)\s]+[.]{1}(MIMETYPES)/';
20 private $refcache = array();
21 private $check_content = null;
22 private $debug_logs = null;
23 private $multilingual = false;
24 private $languages = array();
25
26 public function __construct() {
27 add_action( 'plugins_loaded', array( $this, 'plugins_loaded' ) );
28 add_action( 'init', array( $this, 'init' ) );
29 add_action( 'delete_attachment', array( $this, 'delete_attachment_related_data' ), 10, 1 );
30 add_action( 'trashed_post', array( $this, 'delete_attachment_related_data' ), 10, 1 );
31 }
32
33 function plugins_loaded() {
34
35 // Variables
36 $this->site_url = get_site_url();
37 $this->multilingual = $this->is_multilingual();
38 $this->languages = $this->get_languages();
39 $this->current_method = $this->get_option( 'method' );
40 $this->regex_file = str_replace( "MIMETYPES", $this->types, $this->regex_file );
41 $this->servername = str_replace( 'http://', '', str_replace( 'https://', '', $this->site_url ) );
42 $uploaddir = wp_upload_dir();
43 $this->upload_path = $uploaddir['basedir'];
44 $this->upload_url = substr( $uploaddir['baseurl'], strlen( $this->site_url ) );
45 $this->check_content = $this->get_option( 'content' );
46 $this->debug_logs = $this->get_option( 'debuglogs' );
47 $this->is_rest = MeowCommon_Helpers::is_rest();
48 $this->is_cli = defined( 'WP_CLI' ) && WP_CLI;
49
50 global $wpmc;
51 $wpmc = $this;
52
53 // Language
54 load_plugin_textdomain( WPMC_DOMAIN, false, basename( WPMC_PATH ) . '/languages' );
55
56 // Admin
57 $this->admin = new Meow_WPMC_Admin( $this );
58
59 // Advanced core
60 if ( class_exists( 'MeowPro_WPMC_Core' ) ) {
61 new MeowPro_WPMC_Core( $this );
62 }
63
64 // Install hooks and engine only if they might be used
65 if ( is_admin() || $this->is_rest || $this->is_cli ) {
66 add_action( 'wpmc_initialize_parsers', array( $this, 'initialize_parsers' ), 10, 0 );
67 add_filter( 'wp_unique_filename', array( $this, 'wp_unique_filename' ), 10, 3 );
68 $this->engine = new Meow_WPMC_Engine( $this, $this->admin );
69 }
70
71 // Only for REST
72 if ( $this->is_rest ) {
73 new Meow_WPMC_Rest( $this, $this->admin );
74 }
75
76 if ( is_admin() ) {
77 new Meow_WPMC_UI( $this );
78 }
79 }
80
81 function init() {
82 remove_action( 'wp_scheduled_delete', 'wp_scheduled_delete' );
83 }
84
85 function initialize_parsers() {
86 include_once( 'parsers.php' );
87 new Meow_WPMC_Parsers();
88 }
89
90 function deepsleep( $seconds ) {
91 $start_time = time();
92 while( true ) {
93 if ( ( time() - $start_time ) > $seconds ) {
94 return false;
95 }
96 get_post( array( 'posts_per_page' => 50 ) );
97 }
98 }
99
100 private $start_time;
101 private $time_elapsed = 0;
102 private $time_remaining = 0;
103 private $item_scan_avg_time = 0;
104 private $wordpress_init_time = 0.5;
105 private $max_execution_time;
106 private $items_checked = 0;
107 private $items_count = 0;
108
109 function get_max_execution_time() {
110 if ( isset( $this->max_execution_time ) )
111 return $this->max_execution_time;
112
113 $this->max_execution_time = ini_get( "max_execution_time" );
114 if ( empty( $this->max_execution_time ) || $this->max_execution_time < 5 )
115 $this->max_execution_time = 30;
116
117 return $this->max_execution_time;
118 }
119
120 function timeout_check_start( $count ) {
121 $this->start_time = time();
122 $this->items_count = $count;
123 $this->get_max_execution_time();
124 }
125
126 function timeout_get_elapsed() {
127 return $this->time_elapsed . 'ms';
128 }
129
130 function timeout_check() {
131 $this->time_elapsed = time() - $this->start_time;
132 $this->time_remaining = $this->max_execution_time - $this->wordpress_init_time - $this->time_elapsed;
133 if ( $this->catch_timeout ) {
134 if ( $this->time_remaining - $this->item_scan_avg_time < 0 ) {
135 error_log("Media Cleaner Timeout! Check the Media Cleaner logs for more info.");
136 $this->log( "😵 Timeout! Some info for debug:" );
137 $this->log( "🍀 Elapsed time: $this->time_elapsed" );
138 $this->log( "🍀 WP init time: $this->wordpress_init_time" );
139 $this->log( "🍀 Remaining time: $this->time_remaining" );
140 $this->log( "🍀 Scan time per item: $this->item_scan_avg_time" );
141 $this->log( "🍀 PHP max_execution_time: $this->max_execution_time" );
142 header("HTTP/1.0 408 Request Timeout");
143 exit;
144 }
145 }
146 }
147
148 function delete_attachment_related_data( $post_id ) {
149 global $wpdb;
150 $table_name = $wpdb->prefix . "mclean_scan";
151 $wpdb->query( $wpdb->prepare( "DELETE FROM $table_name WHERE postId = %d", $post_id ) );
152 }
153
154 function timeout_check_additem() {
155 $this->items_checked++;
156 $this->time_elapsed = time() - $this->start_time;
157 $this->item_scan_avg_time = ceil( ( $this->time_elapsed / $this->items_checked ) * 10 ) / 10;
158 }
159
160 // This checks if a new uploaded filename isn't the same one as a currently
161 // filename in the trash (that would cause issues)
162 function wp_unique_filename( $filename, $ext, $dir ) {
163 $fullpath = trailingslashit( $dir ) . $filename;
164 $relativepath = $this->clean_uploaded_filename( $fullpath );
165 $trashfilepath = trailingslashit( $this->get_trashdir() ) . $relativepath;
166 if ( file_exists( $trashfilepath ) ) {
167 $path_parts = pathinfo( $fullpath );
168 $filename_noext = $path_parts['filename'];
169 $new_filename = $filename_noext . '-' . date('Ymd-His', time()) . '.' . $path_parts['extension'];
170 //error_log( 'POTENTIALLY TRASH PATH: ' . $trashfilepath );
171 //error_log( 'POTENTIALLY NEW FILE: ' . $new_filename );
172 return $new_filename;
173 }
174 return $filename;
175 }
176
177 function array_to_ids_or_urls( &$meta, &$ids, &$urls ) {
178 foreach ( $meta as $k => $m ) {
179 if ( is_numeric( $m ) ) {
180 // Probably a Media ID
181 if ( $m > 0 )
182 array_push( $ids, $m );
183 }
184 else if ( is_array( $m ) ) {
185 // If it's an array with a width, probably that the index is the Media ID
186 if ( isset( $m['width'] ) && is_numeric( $k ) ) {
187 if ( $k > 0 )
188 array_push( $ids, $k );
189 }
190 }
191 else if ( !empty( $m ) ) {
192 // If it's a string, maybe it's a file (with an extension)
193 if ( preg_match( $this->regex_file, $m ) )
194 array_push( $urls, $m );
195 }
196 }
197 }
198
199 function get_favicon() {
200 // Yoast SEO plugin
201 $vals = get_option( 'wpseo_titles' );
202 if ( !empty( $vals ) ) {
203 $url = $vals['company_logo'];
204 if ( $this->is_url( $url ) )
205 return $this->clean_url( $url );
206 }
207 }
208
209 function get_shortcode_attributes( $shortcode_tag, $post ) {
210 if ( has_shortcode( $post->post_content, $shortcode_tag ) ) {
211 $output = array();
212 //get shortcode regex pattern wordpress function
213 $pattern = get_shortcode_regex( [ $shortcode_tag ] );
214 if ( preg_match_all( '/'. $pattern .'/s', $post->post_content, $matches ) )
215 {
216 $keys = array();
217 $output = array();
218 foreach( $matches[0] as $key => $value) {
219 // $matches[3] return the shortcode attribute as string
220 // replace space with '&' for parse_str() function
221 $get = str_replace(" ", "&" , trim( $matches[3][$key] ) );
222 $get = str_replace('"', '' , $get );
223 parse_str( $get, $sub_output );
224
225 //get all shortcode attribute keys
226 $keys = array_unique( array_merge( $keys, array_keys( $sub_output )) );
227 $output[] = $sub_output;
228 }
229 if ( $keys && $output ) {
230 // Loop the output array and add the missing shortcode attribute key
231 foreach ($output as $key => $value) {
232 // Loop the shortcode attribute key
233 foreach ($keys as $attr_key) {
234 $output[$key][$attr_key] = isset( $output[$key] ) && isset( $output[$key] ) ? $output[$key][$attr_key] : NULL;
235 }
236 //sort the array key
237 ksort( $output[$key]);
238 }
239 }
240 }
241 return $output;
242 }
243 else {
244 return false;
245 }
246 }
247
248 function get_urls_from_html( $html ) {
249 if ( empty( $html ) ) {
250 return array();
251 }
252
253 // Proposal/fix by @copytrans
254 // Discussion: https://wordpress.org/support/topic/bug-in-core-php/#post-11647775
255 // Modified by Jordy again in 2021 for those who don't have MB enabled
256 if ( function_exists( 'mb_convert_encoding' ) ) {
257 $html = mb_convert_encoding( $html, 'HTML-ENTITIES', 'UTF-8' );
258 }
259 else {
260 $html = htmlspecialchars_decode( utf8_decode( htmlentities( $html, ENT_COMPAT, 'utf-8', false ) ) );
261 }
262
263 // Resolve src-set and shortcodes
264 if ( !$this->get_option( 'shortcodes_disabled' ) ) {
265 $html = do_shortcode( $html );
266 }
267
268 // TODO: Since WP 5.5, wp_filter_content_tags should be used instead of wp_make_content_images_responsive.
269 $html = function_exists( 'wp_filter_content_tags' ) ? wp_filter_content_tags( $html ) :
270 wp_make_content_images_responsive( $html );
271
272 // Create the DOM Document
273 if ( !class_exists("DOMDocument") ) {
274 error_log( 'Media Cleaner: The DOM extension for PHP is not installed.' );
275 throw new Error( 'The DOM extension for PHP is not installed.' );
276 }
277
278 if ( empty( $html ) ) {
279 return array();
280 }
281
282 libxml_use_internal_errors(true);
283 $dom = new DOMDocument();
284 @$dom->loadHTML( $html );
285 libxml_clear_errors();
286 $results = array();
287
288 // <meta> tags in <head> area
289 $metas = $dom->getElementsByTagName( 'meta' );
290 foreach ( $metas as $meta ) {
291 $property = $meta->getAttribute( 'property' );
292 if ( $property == 'og:image' || $property == 'og:image:secure_url' || $property == 'twitter:image' ) {
293 $url = $meta->getAttribute( 'content' );
294 if ( $this->is_url( $url ) ) {
295 $src = $this->clean_url( $url );
296 if ( !empty( $src ) ) {
297 array_push( $results, $src );
298 }
299 }
300 }
301 }
302
303 // IFrames (by Mike Meinz)
304 $iframes = $dom->getElementsByTagName( 'iframe' );
305 foreach( $iframes as $iframe ) {
306 $iframe_src = $iframe->getAttribute( 'src' );
307 // Ignore if the iframe src is not on this server
308 if ( ( strpos( $iframe_src, $this->servername ) !== false) || ( substr( $iframe_src, 0, 1 ) == "/" ) ) {
309 // Create a new DOM Document to hold iframe
310 $iframe_doc = new DOMDocument();
311 // Load the url's contents into the DOM
312 libxml_use_internal_errors( true ); // ignore html formatting problems
313 $rslt = @$iframe_doc->loadHTMLFile( $iframe_src );
314 libxml_clear_errors();
315 libxml_use_internal_errors( false );
316 if ( $rslt ) {
317 // Get the resulting html
318 $iframe_html = $iframe_doc->saveHTML();
319 if ( $iframe_html !== false ) {
320 // Scan for links in the iframe
321 $iframe_urls = $this->get_urls_from_html( $iframe_html ); // Recursion
322 if ( !empty( $iframe_urls ) ) {
323 $results = array_merge( $results, $iframe_urls );
324 }
325 }
326 }
327 else {
328 $this->log( '🚫 Failed to load iframe: ' . $iframe_src );
329 }
330 }
331 }
332
333
334 // Images: src, srcset
335 $imgs = $dom->getElementsByTagName( 'img' );
336 foreach ( $imgs as $img ) {
337 //error_log($img->getAttribute('src'));
338 $src = $this->clean_url( $img->getAttribute('src') );
339 array_push( $results, $src );
340 $srcset = $img->getAttribute('srcset');
341 if ( !empty( $srcset ) ) {
342 $setImgs = explode( ',', trim( $srcset ) );
343 foreach ( $setImgs as $setImg ) {
344 $finalSetImg = explode( ' ', trim( $setImg ) );
345 if ( is_array( $finalSetImg ) ) {
346 array_push( $results, $this->clean_url( $finalSetImg[0] ) );
347 }
348 }
349 }
350 }
351
352 // Videos: src
353 $videos = $dom->getElementsByTagName( 'video' );
354 foreach ( $videos as $video ) {
355 //error_log($video->getAttribute('src'));
356 $src = $this->clean_url( $video->getAttribute('src') );
357 array_push( $results, $src );
358 }
359
360 // Audios: src
361 $audios = $dom->getElementsByTagName( 'audio' );
362 foreach ( $audios as $audio ) {
363 //error_log($audio->getAttribute('src'));
364 $src = $this->clean_url( $audio->getAttribute('src') );
365 array_push( $results, $src );
366 }
367
368 // Sources: src
369 $audios = $dom->getElementsByTagName( 'source' );
370 foreach ( $audios as $audio ) {
371 //error_log($audio->getAttribute('src'));
372 $src = $this->clean_url( $audio->getAttribute('src') );
373 array_push( $results, $src );
374 }
375
376 // Links, href
377 $urls = $dom->getElementsByTagName( 'a' );
378 foreach ( $urls as $url ) {
379 $url_href = $url->getAttribute('href'); // mm change
380 if ( $this->is_url( $url_href ) ) { // mm change
381 $src = $this->clean_url( $url_href ); // mm change
382 if ( !empty( $src ) )
383 array_push( $results, $src );
384 }
385 }
386
387 // <link> tags in <head> area
388 $urls = $dom->getElementsByTagName( 'link' );
389 foreach ( $urls as $url ) {
390 $url_href = $url->getAttribute( 'href' );
391 if ( $this->is_url( $url_href ) ) {
392 $src = $this->clean_url( $url_href );
393 if ( !empty( $src ) ) {
394 array_push( $results, $src );
395 }
396 }
397 }
398
399 // PDF
400 preg_match_all( "/((https?:\/\/)?[^\\&\#\[\] \"\?]+\.pdf)/", $html, $res );
401 if ( !empty( $res ) && isset( $res[1] ) && count( $res[1] ) > 0 ) {
402 foreach ( $res[1] as $url ) {
403 if ( $this->is_url( $url ) )
404 array_push( $results, $this->clean_url( $url ) );
405 }
406 }
407
408 // Background images
409 preg_match_all( "/url\(\'?\"?((https?:\/\/)?[^\\&\#\[\] \"\?]+\.(jpe?g|gif|png))\'?\"?/", $html, $res );
410 if ( !empty( $res ) && isset( $res[1] ) && count( $res[1] ) > 0 ) {
411 foreach ( $res[1] as $url ) {
412 if ( $this->is_url( $url ) )
413 array_push( $results, $this->clean_url( $url ) );
414 }
415 }
416
417 return $results;
418 }
419
420 // Parse a meta, visit all the arrays, look for the attributes, fill $ids and $urls arrays
421 // If rawMode is enabled, it will not check if the value is an ID or an URL, it will just returns it in URLs
422 function get_from_meta( $meta, $lookFor, &$ids, &$urls, $rawMode = false ) {
423 if ( !is_array( $meta ) && !is_object( $meta) ) {
424 return;
425 }
426 foreach ( $meta as $key => $value ) {
427 if ( is_object( $value ) || is_array( $value ) )
428 $this->get_from_meta( $value, $lookFor, $ids, $urls, $rawMode );
429 else if ( in_array( $key, $lookFor ) ) {
430 if ( empty( $value ) ) {
431 continue;
432 }
433 else if ( $rawMode ) {
434 array_push( $urls, $value );
435 }
436 else if ( is_numeric( $value ) ) {
437 // It this an ID?
438 array_push( $ids, $value );
439 }
440 else {
441 if ( $this->is_url( $value ) ) {
442 // Is this an URL?
443 array_push( $urls, $this->clean_url( $value ) );
444 }
445 else {
446 // Is this an array of IDs, encoded as a string? (like "20,13")
447 $pieces = explode( ',', $value );
448 foreach ( $pieces as $pval ) {
449 if ( is_numeric( $pval ) ) {
450 array_push( $ids, $pval );
451 }
452 }
453 }
454 }
455 }
456 }
457 }
458
459 function get_images_from_themes( &$ids, &$urls ) {
460 // USE CURRENT THEME AND WP API
461 $ch = get_custom_header();
462 if ( !empty( $ch ) && !empty( $ch->url ) ) {
463 array_push( $urls, $this->clean_url( $ch->url ) );
464 }
465 if ( $this->is_url( $ch->thumbnail_url ) ) {
466 array_push( $urls, $this->clean_url( $ch->thumbnail_url ) );
467 }
468 if ( !empty( $ch ) && !empty( $ch->attachment_id ) ) {
469 array_push( $ids, $ch->attachment_id );
470 }
471 $cl = get_custom_logo();
472 if ( $this->is_url( $cl ) ) {
473 $urls = array_merge( $this->get_urls_from_html( $cl ), $urls );
474 }
475 $custom_logo = get_theme_mod( 'custom_logo' );
476 if ( !empty( $custom_logo ) && is_numeric( $custom_logo ) ) {
477 array_push( $ids, (int)$custom_logo );
478 }
479 $si = get_site_icon_url();
480 if ( $this->is_url( $si ) ) {
481 array_push( $urls, $this->clean_url( $si ) );
482 }
483 $si_id = get_option( 'site_icon' );
484 if ( !empty( $si_id ) && is_numeric( $si_id ) ) {
485 array_push( $ids, (int)$si_id );
486 }
487 $cd = get_background_image();
488 if ( $this->is_url( $cd ) ) {
489 array_push( $urls, $this->clean_url( $cd ) );
490 }
491 $photography_hero_image = get_theme_mod( 'photography_hero_image' );
492 if ( !empty( $photography_hero_image ) ) {
493 array_push( $ids, $photography_hero_image );
494 }
495 $author_profile_picture = get_theme_mod( 'author_profile_picture' );
496 if ( !empty( $author_profile_picture ) ) {
497 array_push( $ids, $author_profile_picture );
498 }
499 if ( function_exists ( 'get_uploaded_header_images' ) ) {
500 $header_images = get_uploaded_header_images();
501 if ( !empty( $header_images ) ) {
502 foreach ( $header_images as $hi ) {
503 if ( !empty ( $hi['attachment_id'] ) ) {
504 array_push( $ids, $hi['attachment_id'] );
505 }
506 }
507 }
508 }
509 }
510
511 function logs_directory_check() {
512 if ( !file_exists( WPMC_PATH . '/logs/' ) ) {
513 mkdir( WPMC_PATH . '/logs/', 0777 );
514 }
515 }
516
517 function log( $data = null, $force = false ) {
518 if ( !$this->debug_logs && !$force )
519 return;
520 error_log( $data );
521 $this->logs_directory_check();
522 $fh = @fopen( WPMC_PATH . '/logs/media-cleaner.log', 'a' );
523 if ( !$fh )
524 return false;
525 $date = current_datetime()->format( 'Y-m-d H:i:s' );
526 if ( is_null( $data ) )
527 fwrite( $fh, "\n" );
528 else
529 fwrite( $fh, "$date: {$data}\n" );
530 fclose( $fh );
531 return true;
532 }
533
534 /**
535 *
536 * HELPERS
537 *
538 */
539
540 function get_trashdir() {
541 return trailingslashit( $this->upload_path ) . 'wpmc-trash';
542 }
543
544 function get_trashurl() {
545 return trailingslashit( $this->upload_url ) . 'wpmc-trash';
546 }
547
548 function clean_ob(){
549 $disabled = $this->get_option( 'output_buffer_cleaning_disabled' );
550 $ob_content = ob_get_contents();
551 if ( !empty( trim( $ob_content ) ) ) {
552
553 if ( $disabled ) {
554 $this->log( "🚨 If the server's response was broken, try to let Output Buffer Cleaning enabled." );
555 return;
556 }
557
558 $this->log( "🧹 The response is broken due to output buffering, it will be cleaned." );
559 $this->log( "📄 Output buffer content: " . $ob_content );
560
561 ob_end_clean();
562 }
563 }
564
565 /**
566 *
567 * I18N RELATED HELPERS
568 *
569 */
570
571 function is_multilingual() {
572 return function_exists( 'icl_get_languages' );
573 }
574
575 function get_languages() {
576 $results = array();
577 if ( $this->is_multilingual() ) {
578 $languages = icl_get_languages();
579 foreach ( $languages as $language ) {
580 if ( isset( $language['code'] ) ) {
581 array_push( $results, $language['code'] );
582 }
583 else if ( isset( $language['language_code'] ) ) {
584 array_push( $results, $language['language_code'] );
585 }
586 }
587 }
588 return $results;
589 }
590
591 function get_translated_media_ids( $mediaId ) {
592 $translated_ids = array();
593 foreach ( $this->languages as $language ) {
594 $id = apply_filters( 'wpml_object_id', $mediaId, 'attachment', false, $language );
595 if ( !empty( $id ) ) {
596 array_push( $translated_ids, $id );
597 }
598 }
599 return $translated_ids;
600 }
601
602 /**
603 *
604 * DELETE / SCANNING / RESET
605 *
606 */
607
608 function recover_file( $path ) {
609 $originalPath = trailingslashit( $this->upload_path ) . $path;
610 $trashPath = trailingslashit( $this->get_trashdir() ) . $path;
611 if ( !file_exists( $trashPath ) ) {
612 $this->log( "🚫 The file $originalPath actually does not exist in the trash." );
613 return true;
614 }
615 $path_parts = pathinfo( $originalPath );
616 if ( !file_exists( $path_parts['dirname'] ) && !wp_mkdir_p( $path_parts['dirname'] ) ) {
617 die( 'Failed to create folder.' );
618 }
619 if ( !rename( $trashPath, $originalPath ) ) {
620 die( 'Failed to move the file.' );
621 }
622 return true;
623 }
624
625 function recover( $id ) {
626 global $wpdb;
627 $table_name = $wpdb->prefix . "mclean_scan";
628 $issue = $this->get_issue( $id );
629
630 if ( empty( $issue ) ) {
631 $this->log( "🚫 Issue #{$id} does not exist. Cannot recover this." );
632 return false;
633 }
634
635 // Files
636 if ( $issue->type === 0 ) {
637 $this->recover_file( $issue->path );
638 $wpdb->query( $wpdb->prepare( "UPDATE $table_name SET deleted = 0 WHERE id = %d", $id ) );
639 $this->log( "�
640 Recovered {$issue->path}." );
641 return true;
642 }
643 // Media
644 else if ( $issue->type === 1 ) {
645
646 // If there is no file attached, doesn't handle the files
647 $fullpath = get_attached_file( $issue->postId );
648 if ( empty( $fullpath ) ) {
649 $this->log( "🚫 Media #{$issue->postId} does not have attached file anymore." );
650 error_log( "Media #{$issue->postId} does not have attached file anymore." );
651 return false;
652 }
653
654 $paths = $this->get_paths_from_attachment( $issue->postId );
655 foreach ( $paths as $path ) {
656 if ( !$this->recover_file( $path ) ) {
657 $this->log( "🚫 Could not recover $path." );
658 error_log( "Media Cleaner: Could not recover $path." );
659 }
660 }
661 if ( !wp_update_post( array( 'ID' => $issue->postId, 'post_type' => 'attachment' ) ) ) {
662 $this->log( "🚫 Failed to Untrash Post {$issue->postId} (but deleted it from Cleaner DB)." );
663 error_log( "Media Cleaner: Failed to Untrash Post {$issue->postId} (but deleted it from Cleaner DB)." );
664 return false;
665 }
666 $wpdb->query( $wpdb->prepare( "UPDATE $table_name SET deleted = 0 WHERE id = %d", $id ) );
667 $this->log( "�
668 Recovered Media #{$issue->postId}." );
669 return true;
670 }
671 }
672
673 function trash_file( $fileIssuePath ) {
674 $originalPath = trailingslashit( $this->upload_path ) . $fileIssuePath;
675 $trashPath = trailingslashit( $this->get_trashdir() ) . $fileIssuePath;
676 $path_parts = pathinfo( $trashPath );
677
678 try {
679 if ( !file_exists( $path_parts['dirname'] ) && !wp_mkdir_p( $path_parts['dirname'] ) ) {
680 $this->log( "🚫 Could not create the trash directory for Media Cleaner." );
681 error_log( "Media Cleaner: Could not create the trash directory." );
682 return false;
683 }
684 // Rename the file (move). 'is_dir' is just there for security (no way we should move a whole directory)
685 if ( is_dir( $originalPath ) ) {
686 $this->log( "🚫 Attempted to delete a directory instead of a file ($originalPath). Can't do that." );
687 error_log( "Media Cleaner: Attempted to delete a directory instead of a file ($originalPath). Can't do that." );
688 return false;
689 }
690 if ( !file_exists( $originalPath ) ) {
691 $this->log( "🚫 The file $originalPath actually does not exist." );
692 error_log( "Media Cleaner: The file $originalPath actually does not exist." );
693 return true;
694 }
695 if ( !@rename( $originalPath, $trashPath ) ) {
696 error_log( "Media Cleaner: Unknown error occured while trying to delete a file ($originalPath)." );
697 return false;
698 }
699 }
700 catch ( Exception $e ) {
701 return false;
702 }
703 $this->clean_dir( dirname( $originalPath ) );
704 return true;
705 }
706
707 function repair( $id ) {
708 $repair = $this->get_repair( $id );
709 if ( empty( $repair ) ) {
710 $this->log( "🚫 Repair #{$id} does not exist. Cannot repair this." );
711 return false;
712 }
713 foreach ( $repair->child_ids as $child_id ) {
714 if ( !$this->delete( $child_id ) ) {
715 $this->log( "🚫 Failed to repair the file." );
716 return false;
717 }
718 }
719 $full_path = $this->get_full_upload_path( $repair->path );
720 $filetype = wp_check_filetype( basename( $full_path ), null );
721 $wp_upload_dir = wp_upload_dir();
722 $attachment = array(
723 'guid' => $wp_upload_dir['url'] . '/' . basename( $full_path ),
724 'post_mime_type' => $filetype['type'],
725 'post_title' => preg_replace( '/\.[^.]+$/', '', basename( $full_path ) ),
726 'post_content' => '',
727 'post_status' => 'inherit'
728 );
729
730 $attach_id = wp_insert_attachment( $attachment, $full_path );
731
732 require_once( ABSPATH . 'wp-admin/includes/image.php' );
733 $attach_data = wp_generate_attachment_metadata( $attach_id, $full_path );
734 wp_update_attachment_metadata( $attach_id, $attach_data );
735
736 global $wpdb;
737 $table_name = $wpdb->prefix . "mclean_scan";
738 $wpdb->query( $wpdb->prepare( "DELETE FROM $table_name WHERE id = %d OR parentId = %d", $id, $id ) );
739 $this->log( "�
740 Repaired {$repair->path}." );
741 return true;
742 }
743
744 function ignore( $id, $ignore ) {
745 global $wpdb;
746 $table_name = $wpdb->prefix . "mclean_scan";
747 $issue = $this->get_issue( $id );
748
749 if ( empty( $issue ) ) {
750 $this->log( "🚫 Issue #{$id} does not exist. Cannot ignore this." );
751 return false;
752 }
753
754 if ( !$ignore ) {
755 $wpdb->query( $wpdb->prepare( "UPDATE $table_name SET ignored = 0 WHERE id = %d", $id ) );
756 }
757 else {
758 // If it is in trash, recover it
759 if ( $issue->deleted ) {
760 $this->recover( $id );
761 }
762 $wpdb->query( $wpdb->prepare( "UPDATE $table_name SET ignored = 1 WHERE id = %d", $id ) );
763 }
764 return true;
765 }
766
767 function endsWith( $haystack, $needle )
768 {
769 $length = strlen( $needle );
770 if ( $length == 0 )
771 return true;
772 return ( substr( $haystack, -$length ) === $needle );
773 }
774
775 function clean_dir( $dir ) {
776 if ( !file_exists( $dir ) )
777 return;
778 else if ( $this->endsWith( $dir, 'uploads' ) )
779 return;
780 $found = array_diff( scandir( $dir ), array( '.', '..' ) );
781 if ( count( $found ) < 1 ) {
782 if ( rmdir( $dir ) ) {
783 $this->clean_dir( dirname( $dir ) );
784 }
785 }
786 }
787
788 function get_issue( $id ) {
789 global $wpdb;
790 $table_name = $wpdb->prefix . "mclean_scan";
791 $issue = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $table_name WHERE id = %d", $id ), OBJECT );
792 if ( empty( $issue ) ) {
793 return false;
794 }
795 $issue->id = (int)$issue->id;
796 $issue->postId = (int)$issue->postId;
797 $issue->type = (int)$issue->type;
798 $issue->deleted = (int)$issue->deleted;
799 $issue->ignored = (int)$issue->ignored;
800 $issue->path = stripslashes( $issue->path );
801 return $issue;
802 }
803
804 function get_repair( $id ) {
805 global $wpdb;
806 $table_name = $wpdb->prefix . "mclean_scan";
807 $repair = $wpdb->get_row( $wpdb->prepare( "SELECT
808 main.id AS id,
809 main.path AS path,
810 GROUP_CONCAT(child.id) AS child_ids
811 FROM
812 $table_name AS main
813 LEFT JOIN
814 $table_name AS child ON main.id = child.parentId
815 WHERE main.id = %d", $id
816 ), OBJECT );
817 if ( empty( $repair ) ) {
818 return false;
819 }
820 $repair->id = (int)$repair->id;
821 $regex = "^(.*)(\\s\\(\\+.*)$";
822 $repair->path = preg_replace( '/' . $regex . '/i', '$1', stripslashes( $repair->path ) );
823 $repair->child_ids = $repair->child_ids ? explode( ',', $repair->child_ids ) : [];
824 return $repair;
825 }
826
827 function get_issues_to_repair( $order_by = 'id', $order = 'asc', $search = '', $skip = 0, $limit = 10 ) {
828 global $wpdb;
829 $table_name = $wpdb->prefix . "mclean_scan";
830
831 $search_clause = '';
832 if ( !empty( $search ) ) {
833 $search_clause = $wpdb->prepare("AND main.path LIKE %s", ( '%' . $search . '%' ));
834 }
835
836 $order_clause = 'ORDER BY main.id ASC';
837 if ( $order_by === 'path' ) {
838 $order_clause = 'ORDER BY main.path ' . ( $order === 'asc' ? 'ASC' : 'DESC' );
839 }
840 else if ( $order_by === 'issue' ) {
841 $order_clause = 'ORDER BY main.issue ' . ( $order === 'asc' ? 'ASC' : 'DESC' );
842 }
843 else if ( $order_by === 'size' ) {
844 $order_clause = 'ORDER BY main.size ' . ( $order === 'asc' ? 'ASC' : 'DESC' );
845 }
846
847 $result = $wpdb->get_results( $wpdb->prepare( "SELECT
848 main.id AS id,
849 main.path AS path,
850 GROUP_CONCAT(child.id) AS child_ids,
851 GROUP_CONCAT(child.path) AS child_paths,
852 main.type AS type,
853 main.postId AS postId,
854 main.size AS size,
855 main.ignored AS ignored,
856 main.deleted AS deleted,
857 main.issue AS issue
858 FROM
859 $table_name AS main
860 LEFT JOIN
861 $table_name AS child ON main.id = child.parentId
862 WHERE
863 main.path IS NOT NULL AND main.parentId IS NULL
864 AND main.deleted = 0 AND main.ignored = 0
865 AND main.type = 0
866 $search_clause
867 GROUP BY main.id
868 $order_clause
869 LIMIT %d, %d;
870 ", $skip, $limit ) );
871
872 return $result;
873 }
874
875 function get_repair_ids ( $search = '' ) {
876 global $wpdb;
877 $table_name = $wpdb->prefix . "mclean_scan";
878
879 $search_clause = '';
880 if ( !empty( $search ) ) {
881 $search_clause = $wpdb->prepare("AND main.path LIKE %s", ( '%' . $search . '%' ));
882 }
883
884 return $wpdb->get_col( "SELECT DISTINCT main.id
885 FROM
886 $table_name AS main
887 LEFT JOIN $table_name AS child ON main.id = child.parentId
888 WHERE
889 main.path IS NOT NULL
890 AND main.parentId IS NULL
891 $search_clause
892 GROUP BY
893 main.id
894 ;"
895 );
896 }
897
898 function get_stats_of_issues_to_repair( $search = '' ) {
899 global $wpdb;
900 $table_name = $wpdb->prefix . "mclean_scan";
901
902 $search_clause = '';
903 if ( !empty( $search ) ) {
904 $search_clause = $wpdb->prepare("AND main.path LIKE %s", ( '%' . $search . '%' ));
905 }
906
907 return $wpdb->get_row( "SELECT
908 COUNT(id) AS entries,
909 SUM(size) AS size
910 FROM (
911 SELECT
912 COUNT(DISTINCT main.id) as id,
913 main.size as size
914 FROM
915 $table_name AS main
916 LEFT JOIN
917 $table_name AS child ON main.id = child.parentId
918 WHERE
919 main.path IS NOT NULL AND main.parentId IS NULL AND main.deleted = 0 AND main.ignored = 0
920 $search_clause
921 GROUP BY main.id
922 ) t;
923 " );
924 }
925
926 function get_count_of_issues_to_repair( $search ) {
927 $stats = $this->get_stats_of_issues_to_repair( $search );
928 return $stats->entries;
929 }
930
931 function delete( $id ) {
932 global $wpdb;
933 $table_name = $wpdb->prefix . "mclean_scan";
934 $issue = $this->get_issue( $id );
935
936 if ( empty( $issue ) ) {
937 $this->log( "🚫 Issue #{$id} does not exist. Cannot delete this." );
938 return false;
939 }
940
941 $regex = "^(.*)(\\s\\(\\+.*)$";
942 $issue->path = preg_replace( '/' . $regex . '/i', '$1', $issue->path ); // remove " (+ 6 files)" from path
943 $skip_trash = $this->get_option( 'skip_trash' );
944
945 if ( $issue->type === 0 ) {
946
947 // Delete file from the trash
948 if ( $issue->deleted === 1 ) {
949 $trashPath = trailingslashit( $this->get_trashdir() ) . $issue->path;
950 if ( unlink( $trashPath ) ) {
951 $wpdb->query( $wpdb->prepare( "DELETE FROM $table_name WHERE id = %d", $id ) );
952 $this->clean_dir( dirname( $trashPath ) );
953 return true;
954 }
955 }
956 // Delete file without using trash
957 else if ( $skip_trash ) {
958 $originalPath = trailingslashit( $this->upload_path ) . $issue->path;
959 if ( unlink( $originalPath ) ) {
960 $wpdb->query( $wpdb->prepare( "DELETE FROM $table_name WHERE id = %d", $id ) );
961 $this->clean_dir( dirname( $originalPath ) );
962 return true;
963 }
964 }
965 // Move file to the trash
966 else if ( $this->trash_file( $issue->path ) ) {
967 $wpdb->query( $wpdb->prepare( "UPDATE $table_name SET deleted = 1, ignored = 0 WHERE id = %d", $id ) );
968 return true;
969 }
970
971 $this->log( "🚫 Failed to delete/trash the file." );
972 error_log( "Media Cleaner: Failed to delete/trash the file." );
973 }
974
975 if ( $issue->type === 1 ) {
976
977 // Trash Media definitely by recovering it (to be like a normal Media) and remove it through the
978 // standard WordPress workflow
979 if ( $issue->deleted === 1 || $skip_trash ) {
980 if ( $issue->deleted === 1 ) {
981 $this->recover( $id );
982 }
983 wp_update_post( array( 'ID' => $issue->postId, 'post_type' => 'attachment' ) );
984 wp_delete_attachment( $issue->postId, true );
985 $wpdb->query( $wpdb->prepare( "DELETE FROM $table_name WHERE id = %d", $id ) );
986 return true;
987 }
988 else {
989 // Move Media to trash
990 // Let's copy the images to the trash so that it can be recovered.
991 $paths = $this->get_paths_from_attachment( $issue->postId );
992 foreach ( $paths as $path ) {
993 if ( !$this->trash_file( $path ) ) {
994 $this->log( "🚫 Could not trash $path." );
995 error_log( "Media Cleaner: Could not trash $path." );
996 return false;
997 }
998 }
999 wp_update_post( array( 'ID' => $issue->postId, 'post_type' => 'wmpc-trash' ) );
1000 $wpdb->query( $wpdb->prepare( "UPDATE $table_name SET deleted = 1, ignored = 0 WHERE id = %d", $id ) );
1001 return true;
1002 }
1003 }
1004 return false;
1005 }
1006
1007 /**
1008 *
1009 * SCANNING / RESET
1010 *
1011 */
1012
1013 function add_reference_url( $urlOrUrls, $type, $origin = null, $extra = null ) {
1014 $urlOrUrls = !is_array( $urlOrUrls ) ? array( $urlOrUrls ) : $urlOrUrls;
1015 foreach ( $urlOrUrls as $url ) {
1016 // With files, we need both filename without resolution and filename with resolution, it's important
1017 // to make sure the original file is not deleted if a size exists for it.
1018 // With media, all URLs should be without resolution to make sure it matches Media.
1019 if ( $this->current_method == 'files' ) {
1020 $this->add_reference( null, $url, $type, $origin );
1021 $this->add_reference( 0, $this->clean_url_from_resolution( $url ), $type, $origin );
1022 }
1023 else {
1024 // 2021/11/08: I added this, the problem is that sometimes users create image filenames with the resolution
1025 // in it, even though it is the original.
1026 $this->add_reference( null, $url, $type, $origin );
1027
1028 $this->add_reference( 0, $this->clean_url_from_resolution( $url ), $type, $origin );
1029 }
1030 }
1031 }
1032
1033 function add_reference_id( $idOrIds, $type, $origin = null, $extra = null ) {
1034 $idOrIds = !is_array( $idOrIds ) ? array( $idOrIds ) : $idOrIds;
1035 foreach ( $idOrIds as $id ) {
1036 $this->add_reference( $id, "", $type, $origin );
1037 if ( $this->multilingual ) {
1038 $translatedIds = $this->get_translated_media_ids( (int)$id );
1039
1040 // Test for WPML
1041 // if ( $id === '350') {
1042 // $translatedIds = $this->get_translated_media_ids( (int)$id );
1043 // $count = count($translatedIds);
1044 // error_log( "${id} => ${count}" );
1045 // }
1046
1047 if ( !empty( $translatedIds ) ) {
1048 foreach ( $translatedIds as $translatedId ) {
1049 $this->add_reference( $translatedId, "", $type, $origin );
1050 }
1051 }
1052 }
1053 }
1054 }
1055
1056 private $cached_ids = array();
1057 private $cached_urls = array();
1058
1059 // Returns the reference with the type, origin, related to a Media ID it is referenced
1060 public function get_reference_for_media_id( $id ) {
1061 global $wpdb;
1062 $table_name = $wpdb->prefix . "mclean_refs";
1063 $refs = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM $table_name WHERE mediaId = %d", $id ), OBJECT );
1064 if ( empty( $refs ) ) {
1065 return false;
1066 }
1067 $ref = $refs[0];
1068 $ref->id = (int)$ref->id;
1069 $ref->mediaId = (int)$ref->mediaId;
1070 $ref->originType = (int)$ref->originType;
1071 $ref->origin = stripslashes( $ref->origin );
1072 $ref->parentId = empty( $ref->parentId ) ? null : (int)$ref->parentId;
1073 return $ref;
1074 }
1075
1076 // Return the references related to a Post ID
1077 public function get_references_for_post_id( $id ) {
1078 global $wpdb;
1079 $table_name = $wpdb->prefix . "mclean_refs";
1080 $refs = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM $table_name WHERE originType LIKE %s", "%[$id]" ), OBJECT );
1081 if ( empty( $refs ) ) {
1082 return [];
1083 }
1084 $fresh_refs = array();
1085 foreach ( $refs as $ref ) {
1086 $mediaId = (int)$ref->mediaId > 0 ? (int)$ref->mediaId : null;
1087 if ( !$mediaId && !empty( $ref->mediaUrl ) ) {
1088 $mediaId = $this->find_media_id_from_file( $ref->mediaUrl, false );
1089 $mediaId = !empty( $mediaId ) ? (int)$mediaId : null;
1090 }
1091 if ( !$mediaId ) {
1092 continue;
1093 }
1094 array_push( $fresh_refs, [
1095 'id' => (int)$ref->id,
1096 'mediaId' => $mediaId,
1097 'mediaUrl' => $ref->mediaUrl,
1098 'originType' => $ref->originType,
1099 'parentId' => empty( $ref->parentId ) ? null : (int)$ref->parentId,
1100 ] );
1101 }
1102 return $fresh_refs;
1103 }
1104
1105 // The references are actually not being added directly in the DB, they are being pushed
1106 // into a cache ($this->refcache).
1107 private function add_reference( $id, $url, $type, $origin = null, $extra = null ) {
1108
1109 if ( !empty( $origin ) ) {
1110 $type = $type . " [$origin]";
1111 }
1112
1113 if ( !empty( $id ) ) {
1114 if ( !in_array( $id, $this->cached_ids ) ) {
1115 array_push( $this->cached_ids, $id );
1116 array_push( $this->refcache, array( 'id' => $id, 'url' => null, 'type' => $type, 'origin' => $origin ) );
1117 }
1118 }
1119 if ( !empty( $url ) ) {
1120 // The URL shouldn't contain http, https, javascript at the beginning (and there are probably many more cases)
1121 // The URL must be cleaned before being passed as a reference.
1122 if ( substr( $url, 0, 5 ) === "http:" || substr( $url, 0, 6 ) === "https:" || substr( $url, 0, 11 ) === "javascript:" ) {
1123 return;
1124 }
1125 if ( !in_array( $url, $this->cached_urls ) ) {
1126 array_push( $this->cached_urls, $url );
1127 array_push( $this->refcache, array( 'id' => null, 'url' => $url, 'type' => $type, 'origin' => $origin ) );
1128 }
1129 }
1130 }
1131
1132 function insert_references($entries)
1133 {
1134 global $wpdb;
1135 $table = $wpdb->prefix . "mclean_refs";
1136 $values = array();
1137 $place_holders = array();
1138 $query = "INSERT INTO $table (mediaId, mediaUrl, originType, parentId) VALUES ";
1139
1140 foreach ( $entries as $value ) {
1141 if ( !is_null($value['id'] ) ) {
1142 // Media Reference
1143 array_push( $values, $value['id'], $value['type'] );
1144 $place_holders[] = "('%d', NULL, '%s', NULL)";
1145
1146 if ($this->debug_logs) {
1147 $this->log("+ Media #{$value['id']} (as ID)");
1148 }
1149 }
1150 else if ( !is_null($value['url'] ) ) {
1151 // File Reference
1152 array_push( $values, $value['url'], $value['type'] );
1153 if ( isset( $value['parentId'] ) ) {
1154 array_push( $values, $value['parentId'] );
1155 $place_holders[] = "(NULL, '%s', '%s', '%d')";
1156 if ( $this->debug_logs ) {
1157 $this->log( "{$value['url']} (as URL) (ParentID: {$value['parentId']})" );
1158 }
1159 } else {
1160 $place_holders[] = "(NULL, '%s', '%s', NULL)";
1161 if ( $this->debug_logs ) {
1162 $this->log("{$value['url']} (as URL)");
1163 }
1164 }
1165 }
1166 }
1167
1168 if ( !empty( $values ) ) {
1169 $query .= implode( ', ', $place_holders );
1170 $prepared = $wpdb->prepare( "$query ", $values );
1171 $wpdb->query( $prepared );
1172 }
1173 }
1174
1175
1176 // The cache containing the references is wrote to the DB.
1177 function write_references() {
1178 global $wpdb;
1179 $table = $wpdb->prefix . "mclean_refs";
1180
1181 $potential_parents = array();
1182 $potential_children = array();
1183
1184 foreach ( $this->refcache as $value ) {
1185 $potentialParentPath = !is_null( $value['url'] ) ? $this->clean_url_from_resolution( $value['url'] ) : null;
1186 if ( $potentialParentPath === $value['url'] ) {
1187 $potential_parents[] = $value;
1188 }
1189 else {
1190 $potential_children[] = $value;
1191 }
1192 }
1193
1194 $this->insert_references( $potential_parents );
1195
1196 // Resolve parentId for potential children
1197 foreach ( $potential_children as &$child ) {
1198 $potentialParentPath = $this->clean_url_from_resolution( $child['url'] );
1199 $parentId = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM $table WHERE mediaUrl = %s", $potentialParentPath ) );
1200 if ( !empty( $parentId ) ) {
1201 $child['parentId'] = (int)$parentId;
1202 }
1203 }
1204
1205 // Insert potential children with resolved parentIds
1206 $this->insert_references( $potential_children );
1207 $this->refcache = array();
1208 }
1209
1210 function check_is_ignore( $file ) {
1211 global $wpdb;
1212 $table_name = $wpdb->prefix . "mclean_scan";
1213 $count = $wpdb->get_var( "SELECT COUNT(*)
1214 FROM $table_name
1215 WHERE ignored = 1
1216 AND path LIKE '%". esc_sql( $wpdb->esc_like( $file ) ) . "%'" );
1217 if ( $count > 0 ) {
1218 $this->log( "🚫 Could not trash $file." );
1219 }
1220 return ($count > 0);
1221 }
1222
1223 function find_media_id_from_file( $file, $doLog ) {
1224 global $wpdb;
1225 $postmeta_table_name = $wpdb->prefix . 'postmeta';
1226 $file = $this->clean_uploaded_filename( $file );
1227 $sql = $wpdb->prepare( "SELECT post_id
1228 FROM {$postmeta_table_name}
1229 WHERE meta_key = '_wp_attached_file'
1230 AND meta_value = %s", $file
1231 );
1232 $ret = $wpdb->get_var( $sql );
1233 if ( $doLog ) {
1234 if ( empty( $ret ) )
1235 $this->log( "🚫 File $file not found as _wp_attached_file (Library)." );
1236 else {
1237 $this->log( "�
1238 File $file found as Media $ret." );
1239 }
1240 }
1241
1242 return $ret;
1243 }
1244
1245 function get_image_sizes() {
1246 $sizes = array();
1247 global $_wp_additional_image_sizes;
1248 foreach ( get_intermediate_image_sizes() as $s ) {
1249 $crop = false;
1250 if ( isset( $_wp_additional_image_sizes[$s] ) ) {
1251 $width = intval( $_wp_additional_image_sizes[$s]['width'] );
1252 $height = intval( $_wp_additional_image_sizes[$s]['height'] );
1253 $crop = $_wp_additional_image_sizes[$s]['crop'];
1254 } else {
1255 $width = get_option( $s.'_size_w' );
1256 $height = get_option( $s.'_size_h' );
1257 $crop = get_option( $s.'_crop' );
1258 }
1259 $sizes[$s] = array( 'width' => $width, 'height' => $height, 'crop' => $crop );
1260 }
1261 return $sizes;
1262 }
1263
1264 function clean_url_from_resolution( $url ) {
1265 $pattern = '/[_-]\d+x\d+(?=\.[a-z]{3,4}$)/';
1266 $url = preg_replace( $pattern, '', $url );
1267 return $url;
1268 }
1269
1270 function is_url( $url ) {
1271 return ( (
1272 !empty( $url ) ) &&
1273 is_string( $url ) &&
1274 strlen( $url ) > 4 && (
1275 strtolower( substr( $url, 0, 4) ) == 'http' || $url[0] == '/'
1276 )
1277 );
1278 }
1279
1280 function clean_url_from_resolution_ref( &$url ) {
1281 $url = $this->clean_url_from_resolution( $url );
1282 }
1283
1284 // From a url to the shortened and cleaned url (for example '2013/02/file.png')
1285 function clean_url( $url ) {
1286 // if ( is_array( $url ) ) {
1287 // error_log( print_r( $url, 1 ) );
1288 // }
1289 $dirIndex = strpos( $url, $this->upload_url );
1290 if ( empty( $url ) || $dirIndex === false ) {
1291 $finalUrl = null;
1292 }
1293 else {
1294 $finalUrl = urldecode( substr( $url, 1 + strlen( $this->upload_url ) + $dirIndex ) );
1295 }
1296 return $finalUrl;
1297 }
1298
1299 // From a fullpath to the shortened and cleaned path (for example '2013/02/file.png')
1300 // Original version by Jordy
1301 // function clean_uploaded_filename( $fullpath ) {
1302 // $basedir = $this->upload_path;
1303 // $file = str_replace( $basedir, '', $fullpath );
1304 // $file = str_replace( "./", "", $file );
1305 // $file = trim( $file, "/" );
1306 // return $file;
1307 // }
1308
1309 // From a fullpath to the shortened and cleaned path (for example '2013/02/file.png')
1310 // Faster version, more difficult to read, by Mike Meinz
1311 function clean_uploaded_filename( $fullpath ) {
1312 $dirIndex = strpos( $fullpath, $this->upload_url );
1313 if ( $dirIndex == false ) {
1314 $file = $fullpath;
1315 }
1316 else {
1317 // Remove first part of the path leaving yyyy/mm/filename.ext
1318 $file = substr( $fullpath, 1 + strlen( $this->upload_url ) + $dirIndex );
1319 }
1320 if ( substr( $file, 0, 2 ) == './' ) {
1321 $file = substr( $file, 2 );
1322 }
1323 if ( substr( $file, 0, 1 ) == '/' ) {
1324 $file = substr( $file, 1 );
1325 }
1326 return $file;
1327 }
1328
1329 /*
1330 Check if the file or the Media ID is used in the install.
1331 That file or ID will be checked against the database of references created by the plugin
1332 by the parsers.
1333 */
1334 public function reference_exists( $file, $mediaId ) {
1335 global $wpdb;
1336 $table = $wpdb->prefix . "mclean_refs";
1337 $row = null;
1338 if ( !empty( $mediaId ) ) {
1339 $row = $wpdb->get_row( $wpdb->prepare( "SELECT originType FROM $table WHERE mediaId = %d", $mediaId ) );
1340 if ( !empty( $row ) ) {
1341 $origin = $row->originType === 'MEDIA LIBRARY' ? 'Media Library' : 'content';
1342 $this->log( "�
1343 Media #{$mediaId} used by {$origin}" );
1344 return $row->originType;
1345 }
1346 }
1347 if ( !empty( $file ) ) {
1348 $row = $wpdb->get_row( $wpdb->prepare( "SELECT originType FROM $table WHERE mediaUrl = %s", $file ) );
1349 if ( !empty( $row ) ) {
1350 $origin = $row->originType === 'MEDIA LIBRARY' ? 'Media Library' : 'content';
1351 $this->log( "�
1352 File {$file} used by {$origin}" );
1353 return $row->originType;
1354 }
1355 }
1356 return false;
1357 }
1358
1359 function get_full_upload_path( $relative_path ) {
1360 $wp_upload_dir = wp_upload_dir();
1361 $full_path = trailingslashit( $wp_upload_dir['basedir'] ) . $relative_path;
1362 return $full_path;
1363 }
1364
1365 function get_paths_from_attachment( $attachmentId ) {
1366 $paths = array();
1367 $fullpath = get_attached_file( $attachmentId );
1368 if ( empty( $fullpath ) ) {
1369 error_log( 'Media Cleaner: Could not find attached file for Media ID ' . $attachmentId );
1370 return array();
1371 }
1372 $mainfile = $this->clean_uploaded_filename( $fullpath );
1373 array_push( $paths, $mainfile );
1374 $baseUp = pathinfo( $mainfile );
1375 $filespath = trailingslashit( $this->upload_path ) . trailingslashit( $baseUp['dirname'] );
1376 $meta = wp_get_attachment_metadata( $attachmentId );
1377 if ( isset( $meta['original_image'] ) ) {
1378 $original_image = $this->clean_uploaded_filename( $filespath . $meta['original_image'] );
1379 array_push( $paths, $original_image );
1380 }
1381 $isImage = isset( $meta, $meta['width'], $meta['height'] );
1382 $sizes = $this->get_image_sizes();
1383 if ( $isImage && isset( $meta['sizes'] ) ) {
1384 foreach ( $meta['sizes'] as $name => $attr ) {
1385 if ( isset( $attr['file'] ) ) {
1386 $file = $this->clean_uploaded_filename( $filespath . $attr['file'] );
1387 array_push( $paths, $file );
1388 }
1389 }
1390 }
1391 return $paths;
1392 }
1393
1394 function is_media_ignored( $attachmentId ) {
1395 global $wpdb;
1396 $table_name = $wpdb->prefix . "mclean_scan";
1397 $issue = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $table_name WHERE postId = %d", $attachmentId ), OBJECT );
1398 //error_log( $attachmentId );
1399 //error_log( print_r( $issue, 1 ) );
1400 if ( $issue && $issue->ignored )
1401 return true;
1402 return false;
1403 }
1404
1405 function check_media( $attachmentId, $checkOnly = false ) {
1406
1407 // Is Media ID ignored, consider as used.
1408 if ( $this->is_media_ignored( $attachmentId ) ) {
1409 return true;
1410 }
1411
1412 // Remove everything related to this media from the database.
1413 if ( !$checkOnly ) {
1414 $this->delete_attachment_related_data( $attachmentId );
1415 }
1416
1417 $size = 0;
1418 $countfiles = 0;
1419 $check_broken_media = !$this->check_content;
1420 $fullpath = get_attached_file( $attachmentId );
1421 $is_broken = apply_filters( 'wpmc_is_file_broken', !file_exists( $fullpath ), $attachmentId );
1422
1423 // It's a broken-only scan
1424 if ( $check_broken_media && !$is_broken ) {
1425 $is_considered_used = apply_filters( 'wpmc_check_media', true, $attachmentId, false );
1426 return $is_considered_used;
1427 }
1428
1429 // Let's analyze the usage of each path (thumbnails included) for this Media ID.
1430 $issue = 'NO_CONTENT';
1431 $paths = $this->get_paths_from_attachment( $attachmentId );
1432 foreach ( $paths as $path ) {
1433
1434 // If it's found in the content, we stop the scan right away
1435 if ( $this->check_content && $this->reference_exists( $path, $attachmentId ) ) {
1436 $is_considered_used = apply_filters( 'wpmc_check_media', true, $attachmentId, false );
1437 if ( $is_considered_used ) {
1438 return true;
1439 }
1440 }
1441
1442 // Let's count the size of the files for later, in case it's unused
1443 $filepath = trailingslashit( $this->upload_path ) . $path;
1444 if ( file_exists( $filepath ) )
1445 $size += filesize( $filepath );
1446 $countfiles++;
1447 }
1448
1449 // This Media ID seems not in used (or broken)
1450 // Let's double-check through the filter (overridable by users)
1451 $is_considered_used = apply_filters( 'wpmc_check_media', false, $attachmentId, $is_broken );
1452 if ( !$is_considered_used ) {
1453 if ( $is_broken ) {
1454 $this->log( "🚫 File {$fullpath} does not exist." );
1455 $issue = 'ORPHAN_MEDIA';
1456 }
1457 if ( !$checkOnly ) {
1458 global $wpdb;
1459 $table_name = $wpdb->prefix . "mclean_scan";
1460 $mainfile = $this->clean_uploaded_filename( $fullpath );
1461 $wpdb->insert( $table_name,
1462 array(
1463 'time' => current_time('mysql'),
1464 'type' => 1,
1465 'size' => $size,
1466 'path' => $mainfile . ( $countfiles > 0 ? ( " (+ " . $countfiles . " files)" ) : "" ),
1467 'postId' => $attachmentId,
1468 'issue' => $issue
1469 )
1470 );
1471 }
1472 }
1473 return $is_considered_used;
1474 }
1475
1476 // Delete all issues
1477 function reset_issues( $includingIgnored = false ) {
1478 global $wpdb;
1479 $table_name = $wpdb->prefix . "mclean_scan";
1480 if ( $includingIgnored ) {
1481 $wpdb->query( "DELETE FROM $table_name WHERE deleted = 0" );
1482 }
1483 else {
1484 $wpdb->query( "DELETE FROM $table_name WHERE ignored = 0 AND deleted = 0" );
1485 }
1486 if ( file_exists( WPMC_PATH . '/logs/media-cleaner.log' ) ) {
1487 file_put_contents( WPMC_PATH . '/logs/media-cleaner.log', '' );
1488 }
1489 }
1490
1491 function reset_references() {
1492 global $wpdb;
1493 $table_name = $wpdb->prefix . "mclean_refs";
1494 $wpdb->query("TRUNCATE $table_name");
1495 }
1496
1497 function get_issue_for_postId( $postId ) {
1498 global $wpdb;
1499 $table_name = $wpdb->prefix . "mclean_scan";
1500 $issue = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $table_name WHERE postId = %d", $postId ), OBJECT );
1501 return $issue;
1502 }
1503
1504 function echo_issue( $issue ) {
1505 if ( $issue == 'NO_CONTENT' ) {
1506 _e( "Not found in content", 'media-cleaner' );
1507 }
1508 else if ( $issue == 'ORPHAN_FILE' ) {
1509 _e( "Not in Library", 'media-cleaner' );
1510 }
1511 else if ( $issue == 'ORPHAN_RETINA' ) {
1512 _e( "Orphan Retina", 'media-cleaner' );
1513 }
1514 else if ( $issue == 'ORPHAN_WEBP' ) {
1515 _e( "Orphan WebP", 'media-cleaner' );
1516 }
1517 else if ( $issue == 'ORPHAN_MEDIA' ) {
1518 _e( "No attached file", 'media-cleaner' );
1519 }
1520 else {
1521 echo $issue;
1522 }
1523 }
1524
1525 function get_uploads_directory_hierarchy() {
1526 $uploads_dir = wp_upload_dir();
1527 $base_dir = $uploads_dir['basedir'];
1528 $root = '/' . wp_basename( $base_dir );
1529 $directories = array();
1530
1531 // Get all subdirectories of the base directory
1532 $dir_iterator = new RecursiveDirectoryIterator( $base_dir, FilesystemIterator::KEY_AS_PATHNAME|FilesystemIterator::CURRENT_AS_FILEINFO|FilesystemIterator::SKIP_DOTS );
1533 $iterator = new RecursiveIteratorIterator( $dir_iterator, RecursiveIteratorIterator::SELF_FIRST );
1534 foreach ( $iterator as $file ) {
1535 if ( $file->isDir() ) {
1536 // Remove base_dir from path
1537 $directory = str_replace( $base_dir, '', $file->getPathname() );
1538 if ( $directory ) {
1539 $directories[] = $root . $directory;
1540 }
1541 }
1542 }
1543
1544 // Return the hierarchy as a JSON file
1545 return json_encode( $directories );
1546 }
1547
1548 /**
1549 *
1550 * Roles & Access Rights
1551 *
1552 */
1553 public function can_access_settings() {
1554 return apply_filters( 'wpmc_allow_setup', current_user_can( 'manage_options' ) );
1555 }
1556
1557 public function can_access_features() {
1558 return apply_filters( 'wpmc_allow_usage', current_user_can( 'administrator' ) );
1559 }
1560
1561 #region Options
1562
1563 function list_options() {
1564 return array(
1565 'method' => 'media',
1566 'content' => true,
1567 'filesystem_content' => false,
1568 'media_library' => true,
1569 'live_content' => false,
1570 'debuglogs' => false,
1571 'images_only' => false,
1572 'attach_is_use' => false,
1573 'thumbnails_only' => false,
1574 'dirs_filter' => '',
1575 'files_filter' => '',
1576 'hide_thumbnails' => false,
1577 'hide_warning' => false,
1578 'skip_trash' => false,
1579 'medias_buffer' => 100,
1580 'posts_buffer' => 5,
1581 'analysis_buffer' => 100,
1582 'file_op_buffer' => 20,
1583 'delay' => 100,
1584 'shortcodes_disabled' => false,
1585 'output_buffer_cleaning_disabled' => false,
1586 'posts_per_page' => 10,
1587 'clean_uninstall' => false,
1588 'repair_mode' => false,
1589 'expert_mode' => false,
1590 );
1591 }
1592
1593 function reset_options() {
1594 delete_option( $this->option_name );
1595 }
1596
1597 function get_option( $option ) {
1598 $options = $this->get_all_options();
1599 return $options[$option];
1600 }
1601
1602 function get_all_options() {
1603 $options = get_option( $this->option_name, null );
1604 $options = $this->check_options( $options );
1605 return $options;
1606 }
1607
1608 // Let's work on this function if we need it.
1609 // Right now, it looks like the options are all updated at the same time.
1610
1611 // function update_option( $option, $value ) {
1612 // if ( !array_key_exists( $name, $options ) ) {
1613 // return new WP_REST_Response([ 'success' => false, 'message' => 'This option does not exist.' ], 200 );
1614 // }
1615 // $value = is_bool( $params['value'] ) ? ( $params['value'] ? '1' : '' ) : $params['value'];
1616 // }
1617
1618 function update_options( $options ) {
1619 if ( !update_option( $this->option_name, $options, false ) ) {
1620 return false;
1621 }
1622 $options = $this->sanitize_options();
1623 return $options;
1624 }
1625
1626 // Upgrade from the old way of storing options to the new way.
1627 function check_options( $options = [] ) {
1628 $plugin_options = $this->list_options();
1629 $options = empty( $options ) ? [] : $options;
1630 $hasChanges = false;
1631 foreach ( $plugin_options as $option => $default ) {
1632 // The option already exists
1633 if ( isset( $options[$option] ) ) {
1634 continue;
1635 }
1636 // The option does not exist, so we need to add it.
1637 // Let's use the old value if any, or the default value.
1638 $options[$option] = get_option( 'wpmc_' . $option, $default );
1639 delete_option( 'wpmc_' . $option );
1640 $hasChanges = true;
1641 }
1642 if ( $hasChanges ) {
1643 update_option( $this->option_name , $options );
1644 }
1645 return $options;
1646 }
1647
1648 // Validate and keep the options clean and logical.
1649 function sanitize_options() {
1650 $options = $this->get_all_options();
1651 $medias = $options['medias_buffer'];
1652 $posts = $options['posts_buffer'];
1653 $analysis = $options['analysis_buffer'];
1654 $fileOp = $options['file_op_buffer'];
1655 $delay = $options['delay'];
1656 $hasChanges = false;
1657 if ( $medias === '' ) {
1658 $options['medias_buffer'] = 100;
1659 $hasChanges = true;
1660 }
1661 if ( $posts === '' ) {
1662 $options['posts_buffer'] = 5;
1663 $hasChanges = true;
1664 }
1665 if ( $analysis === '' ) {
1666 $options['analysis_buffer'] = 100;
1667 $hasChanges = true;
1668 }
1669 if ( $fileOp === '' ) {
1670 $options['file_op_buffer'] = 20;
1671 $hasChanges = true;
1672 }
1673 if ( $delay === '' ) {
1674 $options['delay'] = 100;
1675 $hasChanges = true;
1676 }
1677 if ( $hasChanges ) {
1678 update_option( $this->option_name, $options, false );
1679 }
1680 return $options;
1681 }
1682
1683 #endregion
1684 }
1685
1686 // Check the DB. If does not exist, let's create it.
1687 // TODO: When PHP 7 only, let's clean this and use anonymous functions.
1688 function wpmc_check_database() {
1689 global $wpdb;
1690 static $wpmc_check_database_done = false;
1691 if ( $wpmc_check_database_done ) {
1692 return true;
1693 }
1694 $table_refs = $wpdb->prefix . "mclean_refs";
1695 $table_scan = $wpdb->prefix . "mclean_scan";
1696 $db_init = !( strtolower( $wpdb->get_var( "SHOW TABLES LIKE '$table_refs'" ) ) != strtolower( $table_refs )
1697 || strtolower( $wpdb->get_var( "SHOW TABLES LIKE '$table_scan'" ) ) != strtolower( $table_scan ) );
1698 if ( !$db_init ) {
1699 wpmc_create_database();
1700 $db_init = !( strtolower( $wpdb->get_var( "SHOW TABLES LIKE '$table_refs'" ) ) != strtolower( $table_refs )
1701 || strtolower( $wpdb->get_var( "SHOW TABLES LIKE '$table_scan'" ) ) != strtolower( $table_scan ) );
1702 }
1703
1704 // Check if parentId column exists in the table
1705 // TODO: Delete this after June 2024
1706 $parentIdExists = $wpdb->get_var( "SHOW COLUMNS FROM $table_refs LIKE 'parentId'" );
1707 if ( !$parentIdExists ) {
1708 $wpdb->query( "ALTER TABLE $table_refs ADD parentId BIGINT(20) NULL;" );
1709 $wpdb->query( "ALTER TABLE $table_scan ADD parentId BIGINT(20) NULL;" );
1710 }
1711
1712 $wpmc_check_database_done = true;
1713 }
1714
1715 function wpmc_create_database() {
1716 global $wpdb;
1717 $table_name = $wpdb->prefix . "mclean_scan";
1718 $charset_collate = $wpdb->get_charset_collate();
1719 $sql = "CREATE TABLE $table_name (
1720 id BIGINT(20) NOT NULL AUTO_INCREMENT,
1721 time DATETIME DEFAULT '0000-00-00 00:00:00' NOT NULL,
1722 type TINYINT(1) NOT NULL,
1723 postId BIGINT(20) NULL,
1724 path TINYTEXT NULL,
1725 size INT(9) NULL,
1726 ignored TINYINT(1) NOT NULL DEFAULT 0,
1727 deleted TINYINT(1) NOT NULL DEFAULT 0,
1728 issue TINYTEXT NOT NULL,
1729 parentId BIGINT(20) NULL,
1730 PRIMARY KEY (id)
1731 ) " . $charset_collate . ";" ;
1732 require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
1733 dbDelta( $sql );
1734 $sql="ALTER TABLE $table_name ADD INDEX IgnoredIndex (ignored) USING BTREE;";
1735 $wpdb->query($sql);
1736 $table_name = $wpdb->prefix . "mclean_refs";
1737 $charset_collate = $wpdb->get_charset_collate();
1738 // This key doesn't work on too many installs because of the 'Specified key was too long' issue
1739 // KEY mediaLookUp (mediaId, mediaUrl)
1740 $sql = "CREATE TABLE $table_name (
1741 id BIGINT(20) NOT NULL AUTO_INCREMENT,
1742 mediaId BIGINT(20) NULL,
1743 mediaUrl TINYTEXT NULL,
1744 originType TINYTEXT NOT NULL,
1745 parentId BIGINT(20) NULL,
1746 PRIMARY KEY (id)
1747 ) " . $charset_collate . ";";
1748 require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
1749 dbDelta( $sql );
1750 }
1751
1752 function wpmc_remove_database() {
1753 global $wpdb;
1754 $table_name1 = $wpdb->prefix . "mclean_scan";
1755 $table_name2 = $wpdb->prefix . "mclean_refs";
1756 $table_name3 = $wpdb->prefix . "wpmcleaner";
1757 $sql = "DROP TABLE IF EXISTS $table_name1, $table_name2, $table_name3;";
1758 $wpdb->query( $sql );
1759 }
1760
1761 #region Install / Uninstall
1762
1763 /*
1764 INSTALL / UNINSTALL
1765 */
1766
1767 function wpmc_init( $mainfile ) {
1768 //register_activation_hook( $mainfile, 'wpmc_install' );
1769 //register_deactivation_hook( $mainfile, 'wpmc_uninstall' );
1770 register_uninstall_hook( $mainfile, 'wpmc_uninstall' );
1771 }
1772
1773 function wpmc_install() {
1774 wpmc_create_database();
1775 }
1776
1777 function wpmc_reset () {
1778 wpmc_remove_database();
1779 wpmc_create_database();
1780 }
1781
1782 function wpmc_remove_options() {
1783 global $wpdb;
1784 $options = $wpdb->get_results( "SELECT option_name FROM $wpdb->options WHERE option_name LIKE 'wpmc_%'" );
1785 foreach( $options as $option ) {
1786 delete_option( $option->option_name );
1787 }
1788 }
1789
1790 function wpmc_uninstall () {
1791 $options = get_option( 'wpmc_options', [] );
1792 $cleanUninstall = $options['clean_uninstall'];
1793 if ($cleanUninstall) {
1794 wpmc_remove_options();
1795 wpmc_remove_database();
1796 }
1797 }
1798
1799 #endregion