PluginProbe ʕ •ᴥ•ʔ
Media Cleaner: Clean your WordPress! / 7.2.5
Media Cleaner: Clean your WordPress! v7.2.5
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 / rest.php
media-cleaner / classes Last commit date
parsers 1 month ago admin.php 1 month ago core.php 1 month ago engine.php 1 month ago init.php 9 months ago mcp.php 1 month ago parsers.php 1 month ago rest.php 1 month ago runs.php 1 month ago support.php 1 month ago ui.php 1 month ago
rest.php
2105 lines
1 <?php
2
3 class Meow_WPMC_Rest
4 {
5 private $core = null;
6 private $admin = null;
7 private $engine = null;
8 private $namespace = 'media-cleaner/v1';
9 private $shutdown_run_id = 0;
10 private $shutdown_phase = null;
11 private $shutdown_reserve = null;
12
13 public function __construct( $core, $admin ) {
14 $this->core = $core;
15 $this->admin = $admin;
16 $this->engine = $core->engine;
17 $this->shutdown_reserve = str_repeat( 'x', 256 * 1024 );
18 register_shutdown_function( array( $this, 'capture_fatal_shutdown' ) );
19 add_action( 'rest_api_init', array( $this, 'rest_api_init' ) );
20 }
21
22 function rest_api_init() {
23 try {
24 // SETTINGS
25 register_rest_route( $this->namespace, '/update_options', array(
26 'methods' => 'POST',
27 'permission_callback' => array( $this->core, 'can_access_settings' ),
28 'callback' => array( $this, 'rest_update_options' )
29 ) );
30 register_rest_route( $this->namespace, '/reset_options', array(
31 'methods' => 'POST',
32 'permission_callback' => array( $this->core, 'can_access_settings' ),
33 'callback' => array( $this, 'rest_reset_options' )
34 ) );
35 register_rest_route( $this->namespace, '/all_settings', array(
36 'methods' => 'GET',
37 'permission_callback' => array( $this->core, 'can_access_features' ),
38 'callback' => array( $this, 'rest_all_settings' ),
39 ) );
40
41 // STATS & LISTING
42 register_rest_route( $this->namespace, '/count', array(
43 'methods' => 'POST',
44 'permission_callback' => array( $this->core, 'can_access_features' ),
45 'callback' => array( $this, 'rest_count' )
46 ) );
47 register_rest_route( $this->namespace, '/all_ids', array(
48 'methods' => 'POST',
49 'permission_callback' => array( $this->core, 'can_access_features' ),
50 'callback' => array( $this, 'rest_all_ids' ),
51 ) );
52 register_rest_route( $this->namespace, '/stats', array(
53 'methods' => 'GET',
54 'permission_callback' => array( $this->core, 'can_access_features' ),
55 'callback' => array( $this, 'rest_get_stats' ),
56 'args' => array(
57 'search' => array( 'required' => false ),
58 )
59 ) );
60 register_rest_route( $this->namespace, '/entries', array(
61 'methods' => 'GET',
62 'permission_callback' => array( $this->core, 'can_access_features' ),
63 'callback' => array( $this, 'rest_entries' ),
64 'args' => array(
65 'limit' => array( 'required' => false, 'default' => 10 ),
66 'skip' => array( 'required' => false, 'default' => 20 ),
67 'filterBy' => array( 'required' => false, 'default' => 'all' ),
68 'orderBy' => array( 'required' => false, 'default' => 'id' ),
69 'order' => array( 'required' => false, 'default' => 'desc' ),
70 'search' => array( 'required' => false ),
71 'repairMode' => array( 'required' => false, 'default' => false ),
72 )
73 ) );
74
75 // ACTIONS
76 register_rest_route( $this->namespace, '/set_ignore', array(
77 'methods' => 'POST',
78 'permission_callback' => array( $this->core, 'can_access_features' ),
79 'callback' => array( $this, 'rest_set_ignore' )
80 ) );
81 register_rest_route( $this->namespace, '/delete', array(
82 'methods' => 'POST',
83 'permission_callback' => array( $this->core, 'can_access_features' ),
84 'callback' => array( $this, 'rest_delete' )
85 ) );
86 register_rest_route( $this->namespace, '/force_trash_all', array(
87 'methods' => 'POST',
88 'permission_callback' => array( $this->core, 'can_access_features' ),
89 'callback' => array( $this, 'rest_force_trash_all' )
90 ) );
91 register_rest_route( $this->namespace, '/force_clean_trash', array(
92 'methods' => 'POST',
93 'permission_callback' => array( $this->core, 'can_access_features' ),
94 'callback' => array( $this, 'rest_force_clean_trash' )
95 ) );
96 register_rest_route( $this->namespace, '/recover', array(
97 'methods' => 'POST',
98 'permission_callback' => array( $this->core, 'can_access_features' ),
99 'callback' => array( $this, 'rest_recover' )
100 ) );
101 register_rest_route( $this->namespace, '/reset_db', array(
102 'methods' => 'POST',
103 'permission_callback' => array( $this->core, 'can_access_settings' ),
104 'callback' => array( $this, 'rest_reset_db' )
105 ) );
106 register_rest_route( $this->namespace, '/repair', array(
107 'methods' => 'POST',
108 'permission_callback' => array( $this->core, 'can_access_features' ),
109 'callback' => array( $this, 'rest_repair' )
110 ) );
111
112 // SCAN
113 register_rest_route( $this->namespace, '/reset_issues', array(
114 'methods' => 'POST',
115 'permission_callback' => array( $this->core, 'can_access_features' ),
116 'callback' => array( $this, 'rest_reset_issues' )
117 ) );
118 register_rest_route( $this->namespace, '/reset_issues_and_references', array(
119 'methods' => 'POST',
120 'permission_callback' => array( $this->core, 'can_access_features' ),
121 'callback' => array( $this, 'rest_reset_issues_and_references' )
122 ) );
123 register_rest_route( $this->namespace, '/reset_references', array(
124 'methods' => 'POST',
125 'permission_callback' => array( $this->core, 'can_access_features' ),
126 'callback' => array( $this, 'rest_reset_references' )
127 ) );
128 register_rest_route( $this->namespace, '/extract_references', array(
129 'methods' => 'POST',
130 'permission_callback' => array( $this->core, 'can_access_features' ),
131 'callback' => array( $this, 'rest_extract_references' )
132 ) );
133 register_rest_route( $this->namespace, '/retrieve_medias', array(
134 'methods' => 'POST',
135 'permission_callback' => array( $this->core, 'can_access_features' ),
136 'callback' => array( $this, 'rest_retrieve_medias' )
137 ) );
138 register_rest_route( $this->namespace, '/retrieve_files', array(
139 'methods' => 'POST',
140 'permission_callback' => array( $this->core, 'can_access_features' ),
141 'callback' => array( $this, 'rest_retrieve_files' )
142 ) );
143 register_rest_route( $this->namespace, '/retrieve_hash_duplicates', array(
144 'methods' => 'POST',
145 'permission_callback' => array( $this->core, 'can_access_features' ),
146 'callback' => array( $this, 'rest_retrieve_hash_duplicates' )
147 ) );
148 register_rest_route( $this->namespace, '/check_targets', array(
149 'methods' => 'POST',
150 'permission_callback' => array( $this->core, 'can_access_features' ),
151 'callback' => array( $this, 'rest_check_targets' )
152 ) );
153 register_rest_route( $this->namespace, '/uploads_directory_hierarchy', array(
154 'methods' => 'GET',
155 'permission_callback' => array( $this->core, 'can_access_features' ),
156 'callback' => array( $this, 'rest_uploads_directory_hierarchy' ),
157 'args' => array(
158 'force' => array( 'required' => false, 'default' => false ),
159 )
160 ) );
161
162 // PROGRESS
163 register_rest_route( $this->namespace, '/get_progress', array(
164 'methods' => 'GET',
165 'permission_callback' => array( $this->core, 'can_access_features' ),
166 'callback' => array( $this, 'rest_get_progress' )
167 ) );
168 register_rest_route( $this->namespace, '/clear_progress', array(
169 'methods' => 'POST',
170 'permission_callback' => array( $this->core, 'can_access_features' ),
171 'callback' => array( $this, 'rest_clear_progress' )
172 ) );
173 register_rest_route( $this->namespace, '/preflight', array(
174 'methods' => 'GET',
175 'permission_callback' => array( $this->core, 'can_access_features' ),
176 'callback' => array( $this, 'rest_preflight' )
177 ) );
178 register_rest_route( $this->namespace, '/run/start', array(
179 'methods' => 'POST',
180 'permission_callback' => array( $this->core, 'can_access_features' ),
181 'callback' => array( $this, 'rest_run_start' )
182 ) );
183 register_rest_route( $this->namespace, '/run/status', array(
184 'methods' => 'GET',
185 'permission_callback' => array( $this->core, 'can_access_features' ),
186 'callback' => array( $this, 'rest_run_status' )
187 ) );
188 register_rest_route( $this->namespace, '/run/complete', array(
189 'methods' => 'POST',
190 'permission_callback' => array( $this->core, 'can_access_features' ),
191 'callback' => array( $this, 'rest_run_complete' )
192 ) );
193 register_rest_route( $this->namespace, '/run/fail', array(
194 'methods' => 'POST',
195 'permission_callback' => array( $this->core, 'can_access_features' ),
196 'callback' => array( $this, 'rest_run_fail' )
197 ) );
198 register_rest_route( $this->namespace, '/run/pause', array(
199 'methods' => 'POST',
200 'permission_callback' => array( $this->core, 'can_access_features' ),
201 'callback' => array( $this, 'rest_run_pause' )
202 ) );
203 register_rest_route( $this->namespace, '/run/cancel', array(
204 'methods' => 'POST',
205 'permission_callback' => array( $this->core, 'can_access_features' ),
206 'callback' => array( $this, 'rest_run_cancel' )
207 ) );
208
209 register_rest_route( $this->namespace, '/trash_preview', array(
210 'methods' => 'GET',
211 'permission_callback' => array( $this->core, 'can_access_features' ),
212 'callback' => array( $this, 'rest_trash_preview' )
213 ) );
214
215 // LOGS
216 register_rest_route( $this->namespace, '/refresh_logs', array(
217 'methods' => 'POST',
218 'permission_callback' => array( $this->core, 'can_access_features' ),
219 'callback' => array( $this, 'rest_refresh_logs' )
220 ) );
221 register_rest_route( $this->namespace, '/clear_logs', array(
222 'methods' => 'POST',
223 'permission_callback' => array( $this->core, 'can_access_features' ),
224 'callback' => array( $this, 'rest_clear_logs' )
225 ) );
226 register_rest_route( $this->namespace, '/export', array(
227 'methods' => 'GET',
228 'permission_callback' => array( $this->core, 'can_access_features' ),
229 'callback' => array( $this, 'rest_export' )
230 ) );
231 }
232 catch ( Throwable $e ) {
233 error_log( '[Media Cleaner] REST route registration failed: ' . $e->getMessage() );
234 }
235 }
236
237 private function request_json( $request ) {
238 $params = $request->get_json_params();
239 return is_array( $params ) ? $params : array();
240 }
241
242 private function validate_regex_options( $options ) {
243 foreach ( array( 'dirs_filter', 'files_filter' ) as $name ) {
244 if ( !isset( $options[ $name ] ) || $options[ $name ] === '' ) continue;
245 if ( !is_string( $options[ $name ] ) || @preg_match( $options[ $name ], '' ) === false ) {
246 return new WP_Error( 'wpmc_invalid_regex', sprintf( __( 'The %s regular expression is invalid.', 'media-cleaner' ), $name ), array( 'status' => 400, 'option' => $name ) );
247 }
248 }
249 return true;
250 }
251
252 private function request_run_id( $request ) {
253 $params = $this->request_json( $request );
254 $value = isset( $params['runId'] ) ? $params['runId'] : $request->get_param( 'runId' );
255 return max( 0, (int) $value );
256 }
257
258 private function directory_snapshot( $relative_path ) {
259 $directory = $this->core->resolve_upload_path( $relative_path, true );
260 if ( is_wp_error( $directory ) ) return $directory;
261 $stat = @lstat( $directory );
262 if ( !$stat || !is_dir( $directory ) || is_link( $directory ) ) {
263 return new WP_Error( 'wpmc_directory_snapshot_failed', __( 'A filesystem directory became unavailable or unsafe during the scan.', 'media-cleaner' ) );
264 }
265 return hash( 'sha256', wp_json_encode( array(
266 'dev' => isset( $stat['dev'] ) ? (int) $stat['dev'] : 0,
267 'ino' => isset( $stat['ino'] ) ? (int) $stat['ino'] : 0,
268 'mtime' => isset( $stat['mtime'] ) ? (int) $stat['mtime'] : 0,
269 'ctime' => isset( $stat['ctime'] ) ? (int) $stat['ctime'] : 0,
270 ) ) );
271 }
272
273 private function activate_request_run( $request ) {
274 $run_id = $this->request_run_id( $request );
275 if ( $run_id < 1 ) {
276 return new WP_Error( 'wpmc_run_required', __( 'A valid scan run is required for this request.', 'media-cleaner' ), array( 'status' => 400 ) );
277 }
278 $run = $this->core->set_run_context( $run_id );
279 if ( !is_wp_error( $run ) ) {
280 $this->shutdown_run_id = (int) $run->id;
281 $this->shutdown_phase = sanitize_key( basename( (string) $request->get_route() ) );
282 }
283 return $run;
284 }
285
286 public function capture_fatal_shutdown() {
287 $this->shutdown_reserve = null;
288 if ( $this->shutdown_run_id < 1 || !$this->core->runs ) return;
289 $error = error_get_last();
290 $fatal_types = array( E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR );
291 if ( !$error || !in_array( $error['type'], $fatal_types, true ) ) return;
292 $run = $this->core->runs->get( $this->shutdown_run_id );
293 if ( !$run || !in_array( $run->status, array( 'running', 'paused' ), true ) ) return;
294 $message = isset( $error['message'] ) ? $error['message'] : __( 'The PHP worker stopped unexpectedly.', 'media-cleaner' );
295 $details = array(
296 'phase' => $this->shutdown_phase,
297 'file' => isset( $error['file'] ) ? $error['file'] : null,
298 'line' => isset( $error['line'] ) ? (int) $error['line'] : null,
299 'memory' => memory_get_peak_usage( true ),
300 );
301 if ( preg_match( '/maximum execution time|allowed memory size|out of memory/i', $message ) ) {
302 $this->core->runs->pause( $this->shutdown_run_id, 'wpmc_resource_exhausted', $message, $details );
303 }
304 else {
305 $this->core->runs->fail( $this->shutdown_run_id, 'wpmc_fatal_error', $message, $details );
306 }
307 }
308
309 private function transient_error_details( $error ) {
310 if ( $error instanceof Meow_WPMC_Transient_Exception ) {
311 return array( 'retryable' => true, 'retry_after_ms' => $error->get_retry_after_ms() );
312 }
313 if ( $error instanceof WP_Error ) {
314 $data = $error->get_error_data();
315 $status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 0;
316 if ( is_array( $data ) && !empty( $data['retryable'] ) ) {
317 return array( 'retryable' => true, 'retry_after_ms' => isset( $data['retry_after_ms'] ) ? (int) $data['retry_after_ms'] : 2000 );
318 }
319 if ( in_array( $status, array( 408, 429, 502, 503, 504 ), true ) ) {
320 return array( 'retryable' => true, 'retry_after_ms' => isset( $data['retry_after_ms'] ) ? (int) $data['retry_after_ms'] : 2000 );
321 }
322 }
323 $message = $error instanceof WP_Error
324 ? $error->get_error_message()
325 : ( $error instanceof Throwable ? $error->getMessage() : (string) $error );
326 if ( preg_match( '/too many connections|server has gone away|lost connection|connection (?:refused|reset)|deadlock|lock wait timeout|resource temporarily unavailable|temporarily unavailable/i', $message ) ) {
327 return array( 'retryable' => true, 'retry_after_ms' => 5000 );
328 }
329 return array( 'retryable' => false, 'retry_after_ms' => 0 );
330 }
331
332 private function error_response( $error, $run_id = 0, $phase = null, $commit_state = 'not_committed' ) {
333 if ( !$error instanceof WP_Error ) {
334 $error = new WP_Error( 'wpmc_unknown_error', $error instanceof Throwable ? $error->getMessage() : (string) $error );
335 }
336 $data = $error->get_error_data();
337 $status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 500;
338 $code = $error->get_error_code();
339 $message = $error->get_error_message();
340 $transient = $this->transient_error_details( $error );
341 $retry_after_ms = is_array( $data ) && isset( $data['retry_after_ms'] )
342 ? max( 250, min( 60000, (int) $data['retry_after_ms'] ) )
343 : max( 0, (int) $transient['retry_after_ms'] );
344 $retryable_commit = in_array( $commit_state, array( 'not_committed', 'safe_to_retry' ), true );
345 $retryable_status = in_array( $status, array( 408, 429, 502, 503, 504 ), true );
346 $response = new WP_REST_Response( array(
347 'success' => false,
348 'error' => array(
349 'request_id' => wp_generate_uuid4(),
350 'run_id' => (int) $run_id,
351 'phase' => $phase,
352 'code' => $code,
353 'retryable' => $retryable_commit && ( $retryable_status || !empty( $transient['retryable'] ) ),
354 'retry_after_ms' => $retry_after_ms,
355 'commit_state' => $commit_state,
356 'message' => $message,
357 'details' => is_array( $data ) ? $data : array(),
358 ),
359 'message' => $message,
360 ), $status );
361 if ( $retry_after_ms > 0 ) {
362 $response->header( 'Retry-After', (string) max( 1, (int) ceil( $retry_after_ms / 1000 ) ) );
363 }
364 return $response;
365 }
366
367 private function fail_run_response( $throwable, $run_id, $phase ) {
368 $code = $throwable instanceof WP_Error ? $throwable->get_error_code() : 'wpmc_' . sanitize_key( $phase ) . '_failed';
369 $message = $throwable instanceof WP_Error ? $throwable->get_error_message() : $throwable->getMessage();
370 $transient = $this->transient_error_details( $throwable );
371 if ( !empty( $transient['retryable'] ) ) {
372 if ( $run_id > 0 && $this->core->runs ) {
373 $paused = $this->core->runs->pause( $run_id, $code, $message, array( 'phase' => $phase, 'retry_after_ms' => $transient['retry_after_ms'] ) );
374 if ( is_wp_error( $paused ) ) {
375 return $this->error_response( $paused, $run_id, $phase, 'unknown' );
376 }
377 if ( !$paused ) {
378 return $this->error_response( new WP_Error(
379 'wpmc_run_state_changed',
380 __( 'The scan state changed while Media Cleaner was handling a temporary server error.', 'media-cleaner' ),
381 array( 'status' => 409 )
382 ), $run_id, $phase, 'unknown' );
383 }
384 }
385 $error = new WP_Error( $code, $message, array(
386 'status' => 503,
387 'retryable' => true,
388 'retry_after_ms' => $transient['retry_after_ms'],
389 ) );
390 return $this->error_response( $error, $run_id, $phase, 'safe_to_retry' );
391 }
392 if ( $run_id > 0 && $this->core->runs ) {
393 $failed = $this->core->runs->fail( $run_id, $code, $message, array( 'phase' => $phase ) );
394 if ( is_wp_error( $failed ) ) {
395 return $this->error_response( $failed, $run_id, $phase, 'unknown' );
396 }
397 if ( !$failed ) {
398 return $this->error_response( new WP_Error(
399 'wpmc_run_state_changed',
400 __( 'The scan state changed while Media Cleaner was recording an error.', 'media-cleaner' ),
401 array( 'status' => 409 )
402 ), $run_id, $phase, 'unknown' );
403 }
404 }
405 $error = $throwable instanceof WP_Error ? $throwable : new WP_Error( $code, $message, array( 'status' => 500 ) );
406 return $this->error_response( $error, $run_id, $phase, 'staged_run_failed' );
407 }
408
409 function rest_run_start( $request ) {
410 $params = $this->request_json( $request );
411 $method = isset( $params['method'] ) ? sanitize_key( $params['method'] ) : $this->core->get_option( 'method' );
412 $config = isset( $params['config'] ) && is_array( $params['config'] ) ? $params['config'] : array();
413 $valid_regex = $this->validate_regex_options( array_merge( $this->core->get_all_options(), $config ) );
414 if ( is_wp_error( $valid_regex ) ) return $this->error_response( $valid_regex );
415 $config = $this->core->sanitize_scan_config( $config );
416 $uploads = wp_upload_dir();
417 $basedir = empty( $uploads['error'] ) && !empty( $uploads['basedir'] ) ? $uploads['basedir'] : null;
418 if ( !$basedir || !is_dir( $basedir ) || !is_readable( $basedir ) || !is_writable( $basedir ) ) {
419 return $this->error_response( new WP_Error(
420 'wpmc_storage_unavailable',
421 __( 'Uploads storage must be readable and writable before a safe scan can start.', 'media-cleaner' ),
422 array( 'status' => 412, 'storage_error' => isset( $uploads['error'] ) ? $uploads['error'] : null )
423 ) );
424 }
425 $private_storage = $this->core->prepare_private_storage();
426 if ( is_wp_error( $private_storage ) ) {
427 $private_storage->add_data( array( 'status' => 412 ) );
428 return $this->error_response( $private_storage );
429 }
430 $roundtrip = $this->core->test_quarantine_roundtrip();
431 if ( is_wp_error( $roundtrip ) ) {
432 $roundtrip->add_data( array( 'status' => 412 ) );
433 return $this->error_response( $roundtrip );
434 }
435 $needs_dom = ( $method === 'media' && !empty( $config['content'] ) ) || ( $method === 'files' && !empty( $config['filesystem_content'] ) );
436 if ( $needs_dom && !class_exists( 'DOMDocument' ) ) {
437 return $this->error_response( new WP_Error(
438 'wpmc_dom_unavailable',
439 __( 'The PHP DOM extension is required for the selected content analysis.', 'media-cleaner' ),
440 array( 'status' => 412 )
441 ) );
442 }
443 $request_key = isset( $params['requestKey'] ) ? sanitize_text_field( $params['requestKey'] ) : '';
444 $run = $this->core->runs->start( $method, $config, $request_key );
445 if ( is_wp_error( $run ) ) {
446 return $this->error_response( $run );
447 }
448 $this->core->set_run_context( $run->id );
449 return new WP_REST_Response( array(
450 'success' => true,
451 'data' => array(
452 'run' => $this->core->runs->to_array( $run ),
453 'cleanup_allowed' => false,
454 ),
455 ), 201 );
456 }
457
458 function rest_run_status( $request ) {
459 $run_id = $this->request_run_id( $request );
460 $run = $run_id > 0 ? $this->core->runs->get( $run_id ) : $this->core->runs->get_resumable();
461 if ( !$run && $run_id > 0 ) {
462 return $this->error_response( new WP_Error( 'wpmc_run_not_found', __( 'This scan run was not found.', 'media-cleaner' ), array( 'status' => 404 ) ), $run_id );
463 }
464 return new WP_REST_Response( array(
465 'success' => true,
466 'data' => array(
467 'run' => $this->core->runs->to_array( $run ),
468 'active_run_id' => $this->core->runs->get_active_id(),
469 'cleanup_allowed' => $this->core->runs->cleanup_allowed(),
470 ),
471 ), 200 );
472 }
473
474 function rest_run_complete( $request ) {
475 $run_id = $this->request_run_id( $request );
476 $run = $this->core->runs->complete( $run_id );
477 if ( is_wp_error( $run ) ) {
478 return $this->error_response( $run, $run_id, 'complete' );
479 }
480 $this->core->clear_run_context();
481 // Asked rather than assumed: a scan started by an older version can still be
482 // resumed and published here, and its results are not the current one's.
483 return new WP_REST_Response( array( 'success' => true, 'data' => array(
484 'run' => $this->core->runs->to_array( $run ),
485 'cleanup_allowed' => $this->core->runs->cleanup_allowed(),
486 ) ), 200 );
487 }
488
489 function rest_run_fail( $request ) {
490 $params = $this->request_json( $request );
491 $run_id = $this->request_run_id( $request );
492 $code = isset( $params['code'] ) ? sanitize_key( $params['code'] ) : 'client_failure';
493 $message = isset( $params['message'] ) ? sanitize_text_field( $params['message'] ) : __( 'The scan stopped after an error.', 'media-cleaner' );
494 $failed = $this->core->runs->fail( $run_id, $code, $message );
495 if ( is_wp_error( $failed ) ) {
496 return $this->error_response( $failed, $run_id, 'fail', 'unknown' );
497 }
498 if ( !$failed ) {
499 return $this->error_response( new WP_Error(
500 'wpmc_run_not_failed',
501 __( 'The scan could not be marked as failed because its state changed.', 'media-cleaner' ),
502 array( 'status' => 409 )
503 ), $run_id, 'fail' );
504 }
505 return new WP_REST_Response( array( 'success' => true, 'data' => array( 'run' => $this->core->runs->to_array( $this->core->runs->get( $run_id ) ) ) ), 200 );
506 }
507
508 function rest_run_pause( $request ) {
509 $params = $this->request_json( $request );
510 $run_id = $this->request_run_id( $request );
511 $code = isset( $params['code'] ) ? sanitize_key( $params['code'] ) : 'client_pause';
512 $message = isset( $params['message'] )
513 ? sanitize_text_field( $params['message'] )
514 : __( 'The scan was paused by the user.', 'media-cleaner' );
515 $paused = $this->core->runs->pause( $run_id, $code, $message, array( 'source' => 'client' ) );
516 if ( is_wp_error( $paused ) ) {
517 return $this->error_response( $paused, $run_id, 'pause', 'unknown' );
518 }
519 if ( !$paused ) {
520 return $this->error_response( new WP_Error(
521 'wpmc_run_not_paused',
522 __( 'The scan could not be paused because it is no longer running.', 'media-cleaner' ),
523 array( 'status' => 409 )
524 ), $run_id, 'pause' );
525 }
526 return new WP_REST_Response( array(
527 'success' => true,
528 'data' => array( 'run' => $this->core->runs->to_array( $this->core->runs->get( $run_id ) ) ),
529 ), 200 );
530 }
531
532 function rest_run_cancel( $request ) {
533 $params = $this->request_json( $request );
534 $run_id = $this->request_run_id( $request );
535 $discard = isset( $params['discard'] ) && rest_sanitize_boolean( $params['discard'] );
536 $result = $discard ? $this->core->runs->discard( $run_id ) : $this->core->runs->cancel( $run_id );
537 if ( is_wp_error( $result ) ) {
538 return $this->error_response( $result, $run_id, $discard ? 'discard' : 'cancel' );
539 }
540 if ( !$result ) {
541 return $this->error_response( new WP_Error( 'wpmc_run_not_cancelled', __( 'The scan could not be cancelled because it is no longer running.', 'media-cleaner' ), array( 'status' => 409 ) ), $run_id );
542 }
543 return new WP_REST_Response( array( 'success' => true, 'data' => array( 'run' => $this->core->runs->to_array( $this->core->runs->get( $run_id ) ) ) ), 200 );
544 }
545
546 function rest_preflight() {
547 $checks = array();
548 $blocked = false;
549 $constrained = false;
550
551 $add_check = function( $id, $status, $message, $details = array() ) use ( &$checks, &$blocked, &$constrained ) {
552 $checks[] = array( 'id' => $id, 'status' => $status, 'message' => $message, 'details' => $details );
553 $blocked = $blocked || $status === 'blocked';
554 $constrained = $constrained || $status === 'constrained';
555 };
556
557 $schema_ok = $this->core->runs && $this->core->runs->maybe_upgrade();
558 $add_check( 'database', $schema_ok ? 'ready' : 'blocked', $schema_ok ? __( 'Database tables are ready.', 'media-cleaner' ) : __( 'Database tables could not be created or upgraded.', 'media-cleaner' ) );
559 if ( $schema_ok ) {
560 $gc_ok = $this->core->runs->garbage_collect( 500 );
561 $add_check( 'database_retention', $gc_ok ? 'ready' : 'constrained', $gc_ok ? __( 'Old scan data is within the bounded retention process.', 'media-cleaner' ) : __( 'Old scan data could not be pruned during this request.', 'media-cleaner' ) );
562 $resumable = $this->core->runs->get_resumable();
563 $add_check( 'scan_lock', $resumable ? 'blocked' : 'ready', $resumable ? __( 'A staged scan already exists. Resume or stop it before starting another scan.', 'media-cleaner' ) : __( 'No competing scan is running.', 'media-cleaner' ), $resumable ? array( 'run_id' => (int) $resumable->id ) : array() );
564 }
565
566 $uploads = wp_upload_dir();
567 $upload_error = isset( $uploads['error'] ) ? $uploads['error'] : null;
568 $basedir = isset( $uploads['basedir'] ) ? wp_normalize_path( $uploads['basedir'] ) : '';
569 $upload_ready = !$upload_error && $basedir && is_dir( $basedir ) && is_readable( $basedir ) && is_writable( $basedir );
570 $add_check( 'storage', $upload_ready ? 'ready' : 'blocked', $upload_ready ? __( 'Uploads storage is readable and writable.', 'media-cleaner' ) : __( 'Uploads storage is unavailable or not writable.', 'media-cleaner' ), array( 'error' => $upload_error ) );
571
572 if ( $upload_ready ) {
573 $source = tempnam( $basedir, '.wpmc-' );
574 $destination = $source ? $source . '.moved' : null;
575 $storage_ops = $source && file_put_contents( $source, 'wpmc' ) === 4 && @rename( $source, $destination ) && @unlink( $destination );
576 if ( $source && file_exists( $source ) ) {
577 @unlink( $source );
578 }
579 if ( $destination && file_exists( $destination ) ) {
580 @unlink( $destination );
581 }
582 $add_check( 'storage_operations', $storage_ops ? 'ready' : 'blocked', $storage_ops ? __( 'Storage move and delete operations work.', 'media-cleaner' ) : __( 'Storage cannot reliably move and delete files.', 'media-cleaner' ) );
583 }
584
585 $private_storage = $this->core->prepare_private_storage();
586 $private_ready = !is_wp_error( $private_storage );
587 $add_check(
588 'private_storage',
589 $private_ready ? 'ready' : 'blocked',
590 $private_ready ? __( 'Private quarantine storage is ready.', 'media-cleaner' ) : $private_storage->get_error_message(),
591 $private_ready ? array() : array( 'code' => $private_storage->get_error_code() )
592 );
593 if ( $private_ready ) {
594 $roundtrip = $this->core->test_quarantine_roundtrip();
595 $roundtrip_ready = !is_wp_error( $roundtrip );
596 $add_check( 'quarantine_roundtrip', $roundtrip_ready ? 'ready' : 'blocked', $roundtrip_ready ? __( 'Quarantine move and recovery operations work.', 'media-cleaner' ) : $roundtrip->get_error_message() );
597 }
598
599 $memory_bytes = $this->core->parse_ini_bytes( ini_get( 'memory_limit' ) );
600 $memory_status = $memory_bytes < 0 || $memory_bytes >= 128 * 1024 * 1024 ? 'ready' : 'constrained';
601 $add_check( 'memory', $memory_status, sprintf( __( 'PHP memory limit: %s.', 'media-cleaner' ), ini_get( 'memory_limit' ) ), array( 'bytes' => $memory_bytes ) );
602
603 $execution_time = (int) ini_get( 'max_execution_time' );
604 $request_budget = $this->core->get_request_time_budget();
605 $time_status = $request_budget >= 10 ? 'ready' : 'constrained';
606 $add_check( 'execution_time', $time_status, sprintf( __( 'PHP execution limit: %d seconds; Media Cleaner work budget: %.1f seconds.', 'media-cleaner' ), $execution_time, $request_budget ), array( 'seconds' => $execution_time, 'work_budget_seconds' => $request_budget ) );
607
608 global $wpdb;
609 $db_started = microtime( true );
610 $db_probe = $wpdb->get_var( 'SELECT 1' );
611 $db_latency_ms = (int) round( ( microtime( true ) - $db_started ) * 1000 );
612 $db_ready = (int) $db_probe === 1 && empty( $wpdb->last_error );
613 $db_status = !$db_ready ? 'blocked' : ( $db_latency_ms > 250 ? 'constrained' : 'ready' );
614 $add_check(
615 'database_connection',
616 $db_status,
617 $db_ready ? sprintf( __( 'Database round trip: %d ms.', 'media-cleaner' ), $db_latency_ms ) : __( 'The database connection did not answer a health check.', 'media-cleaner' ),
618 array( 'latency_ms' => $db_latency_ms, 'error' => $wpdb->last_error )
619 );
620
621 $dom_ready = class_exists( 'DOMDocument' );
622 $method = $this->core->get_option( 'method' );
623 $needs_dom = ( $method === 'media' && $this->core->get_option( 'content' ) ) || ( $method === 'files' && $this->core->get_option( 'filesystem_content' ) );
624 $dom_status = $dom_ready ? 'ready' : ( $needs_dom ? 'blocked' : 'constrained' );
625 $add_check( 'dom', $dom_status, $dom_ready ? __( 'The PHP DOM extension is available.', 'media-cleaner' ) : __( 'The PHP DOM extension is unavailable; content analysis cannot run.', 'media-cleaner' ) );
626
627 $disk_free = $basedir && function_exists( 'disk_free_space' ) ? @disk_free_space( $basedir ) : false;
628 $disk_status = $disk_free === false || $disk_free >= 256 * 1024 * 1024 ? 'ready' : 'constrained';
629 $add_check( 'disk', $disk_status, $disk_free === false ? __( 'Free disk space could not be measured.', 'media-cleaner' ) : sprintf( __( 'Free upload storage: %s.', 'media-cleaner' ), size_format( $disk_free ) ), array( 'bytes' => $disk_free ) );
630
631 $status = $blocked ? 'blocked' : ( $constrained ? 'constrained' : 'ready' );
632 $severely_constrained = $request_budget < 10 || ( $memory_bytes > 0 && $memory_bytes < 96 * 1024 * 1024 ) || $db_latency_ms > 750;
633 $profile_constrained = $status === 'constrained';
634 $base_delay_ms = $severely_constrained ? 1000 : ( $profile_constrained || $db_latency_ms > 250 ? 500 : 150 );
635 return new WP_REST_Response( array(
636 'success' => true,
637 'data' => array(
638 'status' => $status,
639 'checks' => $checks,
640 'profile' => array(
641 'posts_buffer' => $severely_constrained ? 1 : ( $profile_constrained ? 2 : 5 ),
642 'medias_buffer' => $severely_constrained ? 10 : ( $profile_constrained ? 25 : 100 ),
643 'analysis_buffer' => $severely_constrained ? 10 : ( $profile_constrained ? 25 : 100 ),
644 'file_buffer' => $severely_constrained ? 25 : ( $profile_constrained ? 100 : 500 ),
645 'cleanup_buffer' => $severely_constrained ? 5 : ( $profile_constrained ? 10 : 20 ),
646 'base_delay_ms' => $base_delay_ms,
647 'max_retries' => 4,
648 'request_timeout_ms' => max( 30000, min( 90000, (int) ceil( ( $request_budget + 15 ) * 1000 ) ) ),
649 ),
650 'cleanup_allowed' => $this->core->runs->cleanup_allowed(),
651 ),
652 ), 200 );
653 }
654
655 /**
656 * Validates certain option values
657 * @param string $option Option name
658 * @param mixed $value Option value
659 * @return mixed|WP_Error Validated value if no problem
660 */
661 function validate_option( $option, $value ) {
662 switch ( $option ) {
663 case 'wpmc_dirs_filter':
664 case 'wpmc_files_filter':
665 if ( $value && @preg_match( $value, '' ) === false ) return new WP_Error( 'invalid_option', __( "Invalid Regular-Expression", 'media-cleaner' ) );
666 break;
667 }
668 return $value;
669 }
670
671 function rest_reset_issues( $request ) {
672 $run = $this->activate_request_run( $request );
673 if ( is_wp_error( $run ) ) return $this->error_response( $run );
674 try {
675 $this->core->reset_issues();
676 $this->core->save_progress( 'resetIssues' );
677 return new WP_REST_Response( [ 'success' => true, 'message' => __( 'Issues were reset.', 'media-cleaner' ) ], 200 );
678 }
679 catch ( Throwable $e ) {
680 return $this->fail_run_response( $e, $run->id, 'resetIssues' );
681 }
682 }
683
684 function rest_reset_issues_and_references( $request ) {
685 $run = $this->activate_request_run( $request );
686 if ( is_wp_error( $run ) ) return $this->error_response( $run );
687 try {
688 $this->core->reset_issues();
689 $this->core->reset_references();
690 $this->core->save_progress( 'resetIssuesAndReferences' );
691 return new WP_REST_Response( [ 'success' => true, 'message' => __( 'Issues and References were reset.', 'media-cleaner' ) ], 200 );
692 }
693 catch ( Throwable $e ) {
694 return $this->fail_run_response( $e, $run->id, 'resetIssuesAndReferences' );
695 }
696 }
697
698 function rest_reset_references( $request ) {
699 $run = $this->activate_request_run( $request );
700 if ( is_wp_error( $run ) ) return $this->error_response( $run );
701 try {
702 $this->core->reset_references();
703 $this->core->save_progress( 'resetReferences' );
704 return new WP_REST_Response( [ 'success' => true, 'message' => __( 'References were reset.', 'media-cleaner' ) ], 200 );
705 }
706 catch ( Throwable $e ) {
707 return $this->fail_run_response( $e, $run->id, 'resetReferences' );
708 }
709 }
710
711 function rest_count( $request ) {
712 $run = $this->activate_request_run( $request );
713 if ( is_wp_error( $run ) ) {
714 return $this->error_response( $run );
715 }
716 $params = $request->get_json_params();
717 $src = isset( $params['source'] ) ? $params['source'] : null;
718 $num = 0;
719 if ( $src === 'posts' ) {
720 $num = $this->engine->count_posts_to_check();
721 }
722 else if ( $src === 'medias' ) {
723 $num = $this->engine->count_media_entries( $this->core->get_option( 'attach_is_use' ) );
724 }
725 else {
726 return $this->fail_run_response( new WP_Error(
727 'wpmc_count_source_invalid',
728 __( 'No valid source was provided for the scan count.', 'media-cleaner' ),
729 array( 'status' => 400 )
730 ), $run->id, 'count' );
731 }
732 return new WP_REST_Response( [ 'success' => true, 'data' => $num ], 200 );
733 }
734
735 function rest_all_ids( $request ) {
736 $params = $this->request_json( $request );
737 $src = isset( $params['source'] ) ? $params['source'] : null;
738 $search = isset( $params['search'] ) ? sanitize_text_field( $params['search'] ) : null;
739 $repair_mode = isset( $params['repairMode'] ) ? rest_sanitize_boolean( $params['repairMode'] ) : false;
740 $cursor = isset( $params['cursor'] ) ? absint( $params['cursor'] ) : 0;
741 $limit = isset( $params['limit'] ) ? absint( $params['limit'] ) : 100;
742 $limit = max( 1, min( 100, $limit ) );
743 $ids = [];
744 if ( $src === 'issues' ) {
745 $ids = $repair_mode ? $this->core->get_repair_ids( $search, $cursor, $limit ) : $this->get_issues_ids( $search, $cursor, $limit );
746 }
747 else if ( $src === 'ignored' ) {
748 $ids = $this->get_ignored_ids( $search, $cursor, $limit );
749 }
750 else if ( $src === 'trash' ) {
751 $ids = $this->get_trash_ids( $search, $cursor, $limit );
752 }
753 else {
754 return $this->error_response( new WP_Error(
755 'wpmc_id_source_invalid',
756 __( 'No valid source was provided for the requested IDs.', 'media-cleaner' ),
757 array( 'status' => 400 )
758 ) );
759 }
760 $next_cursor = empty( $ids ) ? $cursor : (int) end( $ids );
761 return new WP_REST_Response( array(
762 'success' => true,
763 'data' => array_map( 'intval', $ids ),
764 'pagination' => array(
765 'cursor' => $next_cursor,
766 'finished' => count( $ids ) < $limit,
767 ),
768 ), 200 );
769 }
770
771 function verify_token() {
772 // Check if token needs refresh
773 $current_nonce = $this->core->get_nonce( true );
774 $request_nonce = isset( $_SERVER['HTTP_X_WP_NONCE'] ) ? $_SERVER['HTTP_X_WP_NONCE'] : null;
775
776 $should_refresh = false;
777 if ( $request_nonce ) {
778 $verify = wp_verify_nonce( $request_nonce, 'wp_rest' );
779 if ( $verify === 2 ) {
780 // Nonce is valid but was generated 12-24 hours ago
781 $should_refresh = true;
782 }
783 }
784
785 if ( $should_refresh || ( $request_nonce && $current_nonce !== $request_nonce ) ) {
786 return $current_nonce;
787 }
788
789 return false;
790 }
791
792 function rest_extract_references( $request ) {
793 $run = $this->activate_request_run( $request );
794 if ( is_wp_error( $run ) ) {
795 return $this->error_response( $run );
796 }
797 try {
798
799 //DEBUG: Simulate a service unavailable error
800 // $error_chance = rand( 0, 4 ) === 0; // 25% chance to simulate an error
801 // if ( $error_chance ) {
802 // return new WP_REST_Response( [ 'success' => false, 'message' => 'Test Service Unavailable!' ], 503 );
803 // }
804
805 $params = $request->get_json_params();
806 $limit = isset( $params['limit'] ) ? max( 0, (int) $params['limit'] ) : 0;
807 $source = isset( $params['source'] ) ? $params['source'] : null;
808 $post_id = isset( $params['postId'] ) ? $params['postId'] : null;
809 $limitsize = $this->core->get_option( 'posts_buffer' );
810 $finished = false;
811 $processed = 0;
812 $message = ""; // will be filled by extractRefsFrom...
813
814 // Randomly throw an exception timeout
815 // if ( rand( 0, 1 ) !== 1 ) {
816 // //throw a 408 error
817 // $this->core->deepsleep(10); header("HTTP/1.0 408 Request Timeout"); exit;
818 // }
819
820 if ( $post_id !== null && ( !is_numeric( $post_id ) || !is_int( (int) $post_id ) ) ) {
821 return $this->fail_run_response( new WP_Error(
822 'wpmc_post_id_invalid',
823 __( 'The postId parameter must be null or an integer.', 'media-cleaner' ),
824 array( 'status' => 400 )
825 ), $run->id, 'extractReferences' );
826 }
827
828 if ( $source === 'content' ) {
829 $finished = $this->engine->extractRefsFromContent( $limit, $limitsize, $message, $post_id, $processed );
830 }
831 else if ( $source === 'media' ) {
832 $finished = $this->engine->extractRefsFromLibrary( $limit, $limitsize, $message, $post_id, $processed );
833 }else if ( $source === 'duplicates' ) {
834 $finished = $this->engine->extractRefsFromDuplicates( $limit, $limitsize, $message, $post_id, $processed );
835 } else if( $source === 'thumbnails' ) {
836 $finished = $this->engine->extractRefsFromThumbnails( $limit, $limitsize, $message, $post_id, $processed );
837 }
838 else {
839 return $this->fail_run_response( new WP_Error(
840 'wpmc_reference_source_invalid',
841 __( 'No valid source was provided for reference extraction.', 'media-cleaner' ),
842 array( 'status' => 400 )
843 ), $run->id, 'extractReferences' );
844 }
845
846 $this->core->clean_ob();
847
848 $response = [
849 'success' => true,
850 'message' => $message,
851 'data' => [
852 'limit' => $limit + $processed,
853 'finished' => $finished,
854 'checked' => $processed,
855 'yielded' => !$finished && $processed < $limitsize,
856 ]
857 ];
858
859 $new_token = $this->verify_token();
860 if( $new_token ) {
861 $response['new_token'] = $new_token;
862 }
863
864 return new WP_REST_Response( $response, 200 );
865 }
866 catch ( Throwable $e ) {
867 return $this->fail_run_response( $e, $run->id, 'extractReferences' );
868 }
869 }
870
871 function rest_retrieve_hash_duplicates( $request ) {
872 $run = $this->activate_request_run( $request );
873 if ( is_wp_error( $run ) ) {
874 return $this->error_response( $run );
875 }
876 try {
877
878 $params = $this->request_json( $request );
879 $offset = isset( $params['offset'] ) ? max( 0, (int) $params['offset'] ) : 0;
880 $limit = min( 500, max( 1, (int) $this->core->get_option( 'analysis_buffer' ) ) );
881 $hashes = $this->engine->get_hash_duplicates( $offset, $limit );
882 $finished = count( $hashes ) < $limit;
883 $processed = 0;
884 $yielded = false;
885 $this->core->timeout_check_start( count( $hashes ) );
886 foreach ( $hashes as $hash ) {
887 if ( $this->core->timeout_should_yield() ) {
888 $yielded = true;
889 break;
890 }
891 $this->core->timeout_check();
892 $this->engine->check_duplicates( $hash );
893 $this->core->timeout_check_additem();
894 $processed++;
895 }
896 $finished = !$yielded && $processed === count( $hashes ) && $finished;
897
898 $response = [
899 'success' => true,
900 'message' => sprintf( __( "Retrieved %d hash duplicates.", 'media-cleaner' ), $processed ),
901 'data' => [
902 'results' => array(),
903 'checked' => $processed,
904 'offset' => $offset + $processed,
905 'finished' => $finished,
906 'yielded' => $yielded,
907 ],
908 ];
909
910 $this->core->save_progress( $finished ? 'retrieveDuplicates_finished' : 'retrieveDuplicates', array(
911 'type' => 'duplicates',
912 'groups' => $processed,
913 'offset' => $offset,
914 'next' => $offset + $processed,
915 ) );
916 $new_token = $this->verify_token();
917 if ( $new_token ) {
918 $response['new_token'] = $new_token;
919 }
920
921 return new WP_REST_Response( $response, 200 );
922 }
923 catch ( Throwable $e ) {
924 return $this->fail_run_response( $e, $run->id, 'retrieveDuplicates' );
925 }
926 }
927
928 function rest_save_progress( $request ) {
929 $run = $this->activate_request_run( $request );
930 if ( is_wp_error( $run ) ) {
931 return $this->error_response( $run );
932 }
933 $params = $request->get_json_params();
934
935 $save = isset( $params['data'] ) ? $params['data'] : null;
936 $step = isset( $params['step'] ) ? $params['step'] : null;
937
938 if( !is_array( $save ) || !$step ) {
939 return $this->fail_run_response( new WP_Error(
940 'wpmc_progress_invalid',
941 __( 'Invalid parameters were provided for saving scan progress.', 'media-cleaner' ),
942 array( 'status' => 400 )
943 ), $run->id, 'saveProgress' );
944 }
945
946 $this->core->save_progress( $step, $save );
947
948 $response = [
949 'success' => true,
950 'message' => __( 'Progress saved successfully.', 'media-cleaner' ),
951 ];
952
953 $new_token = $this->verify_token();
954 if( $new_token ) {
955 $response['new_token'] = $new_token;
956 }
957
958 return new WP_REST_Response( $response, 200 );
959 }
960
961 function rest_retrieve_files( $request ) {
962 $run = $this->activate_request_run( $request );
963 if ( is_wp_error( $run ) ) {
964 return $this->error_response( $run );
965 }
966 $work = null;
967 try {
968 $params = $this->request_json( $request );
969 if ( !empty( $params['initialize'] ) ) {
970 $root = isset( $params['root'] ) ? $this->core->normalize_upload_relative_path( $params['root'] ) : '';
971 if ( is_wp_error( $root ) ) return $this->fail_run_response( $root, $run->id, 'retrieveFiles' );
972 $resolved_root = $this->core->resolve_upload_path( $root, true );
973 if ( is_wp_error( $resolved_root ) || !is_dir( $resolved_root ) ) {
974 $error = is_wp_error( $resolved_root ) ? $resolved_root : new WP_Error( 'wpmc_not_directory', __( 'The selected uploads path is not a directory.', 'media-cleaner' ), array( 'status' => 400 ) );
975 return $this->fail_run_response( $error, $run->id, 'retrieveFiles' );
976 }
977 if ( !$this->core->runs->enqueue_work( $run->id, 'retrieveFiles', 'directory', $root ) ) {
978 throw new RuntimeException( __( 'Media Cleaner could not queue the uploads directory.', 'media-cleaner' ) );
979 }
980 }
981
982 $work = $this->core->runs->next_work( $run->id, 'retrieveFiles' );
983 if ( !$work ) {
984 $this->core->save_progress( 'retrieveFiles_finished', array( 'pending_directories' => 0 ) );
985 return new WP_REST_Response( array( 'success' => true, 'message' => __( 'Filesystem discovery completed.', 'media-cleaner' ), 'data' => array( 'results' => array(), 'finished' => true, 'pending_directories' => 0 ) ), 200 );
986 }
987
988 $snapshot = $this->directory_snapshot( $work->target_key );
989 if ( is_wp_error( $snapshot ) ) throw new RuntimeException( $snapshot->get_error_message() );
990 if ( !empty( $work->snapshot_token ) && !hash_equals( (string) $work->snapshot_token, $snapshot ) ) {
991 throw new RuntimeException( __( 'An uploads directory changed while it was being paged. Start a new scan so no files are skipped.', 'media-cleaner' ) );
992 }
993 if ( empty( $work->snapshot_token ) && !$this->core->runs->set_work_snapshot( $work->id, $snapshot ) ) {
994 throw new RuntimeException( __( 'Media Cleaner could not checkpoint the uploads directory.', 'media-cleaner' ) );
995 }
996 if ( !$this->core->runs->update_work( $work->id, 'running', $work->cursor_value ) ) {
997 throw new RuntimeException( __( 'Media Cleaner could not lease the uploads directory batch.', 'media-cleaner' ) );
998 }
999 $limitsize = $this->core->get_option( 'uploads_file_buffer' );
1000 $entries = $this->engine->get_files( $work->target_key, (int) $work->cursor_value, $limitsize );
1001 $page_info = $this->engine->get_file_page_info( count( $entries ), $limitsize );
1002 $scanned_count = isset( $page_info['scanned'] ) ? (int) $page_info['scanned'] : count( $entries );
1003 $directory_finished = !empty( $page_info['finished'] );
1004 $has_files = !empty( array_filter( $entries, function( $entry ) { return isset( $entry['type'] ) && $entry['type'] === 'file'; } ) );
1005 if ( $has_files ) {
1006 $this->core->safe_do_action( 'wpmc_check_file_init' );
1007 }
1008 $this->core->timeout_check_start( count( $entries ) );
1009 $processed = 0;
1010 $checked = 0;
1011 $yielded = false;
1012 $next_cursor = (int) $work->cursor_value;
1013 foreach ( $entries as $entry ) {
1014 if ( $this->core->timeout_should_yield() ) {
1015 $yielded = true;
1016 break;
1017 }
1018 $this->core->timeout_check();
1019 if ( $entry['type'] === 'dir' ) {
1020 if ( !$this->core->runs->enqueue_work( $run->id, 'retrieveFiles', 'directory', $entry['path'] ) ) {
1021 throw new RuntimeException( __( 'Media Cleaner could not queue an uploads subdirectory.', 'media-cleaner' ) );
1022 }
1023 }
1024 else if ( $entry['type'] === 'file' ) {
1025 $this->engine->check_file( $entry['path'] );
1026 $checked++;
1027 }
1028 $processed++;
1029 $next_cursor = isset( $entry['cursor'] ) ? max( $next_cursor, (int) $entry['cursor'] ) : $next_cursor + 1;
1030 $this->core->timeout_check_additem();
1031 if ( $processed % 10 === 0 && !$this->core->runs->update_work( $work->id, 'running', $next_cursor ) ) {
1032 throw new RuntimeException( __( 'Media Cleaner could not checkpoint a filesystem sub-batch.', 'media-cleaner' ) );
1033 }
1034 }
1035 $snapshot_after = $this->directory_snapshot( $work->target_key );
1036 if ( is_wp_error( $snapshot_after ) || !hash_equals( $snapshot, (string) $snapshot_after ) ) {
1037 throw new RuntimeException( __( 'An uploads directory changed during analysis. Start a new scan so no files are skipped.', 'media-cleaner' ) );
1038 }
1039 if ( !$yielded && $processed === count( $entries ) ) {
1040 $next_cursor = isset( $page_info['next_cursor'] )
1041 ? max( $next_cursor, (int) $page_info['next_cursor'] )
1042 : max( $next_cursor, (int) $work->cursor_value + $scanned_count );
1043 }
1044 $work_status = !$yielded && $processed === count( $entries ) && $directory_finished ? 'complete' : 'pending';
1045 if ( !$this->core->runs->update_work( $work->id, $work_status, $next_cursor ) ) {
1046 throw new RuntimeException( __( 'Media Cleaner could not checkpoint the uploads directory batch.', 'media-cleaner' ) );
1047 }
1048 $pending = $this->core->runs->pending_work_count( $run->id, 'retrieveFiles' );
1049 $finished = $pending === 0;
1050 $this->core->save_progress( $finished ? 'retrieveFiles_finished' : 'retrieveFiles', array(
1051 'work_id' => (int) $work->id,
1052 'path' => $work->target_key,
1053 'cursor' => $next_cursor,
1054 'pending_directories' => $pending,
1055 'skipped' => isset( $page_info['skipped'] ) ? $page_info['skipped'] : array(),
1056 ) );
1057 $response = array(
1058 'success' => true,
1059 'message' => sprintf( __( 'Retrieved %d filesystem targets.', 'media-cleaner' ), $checked ),
1060 'data' => array(
1061 'results' => array(),
1062 'checked' => $checked,
1063 'finished' => $finished,
1064 'yielded' => $yielded,
1065 'work_id' => (int) $work->id,
1066 'cursor' => $next_cursor,
1067 'pending_directories' => $pending,
1068 'scanned' => $scanned_count,
1069 'skipped' => isset( $page_info['skipped'] ) ? $page_info['skipped'] : array(),
1070 ),
1071 );
1072 $new_token = $this->verify_token();
1073 if ( $new_token ) $response['new_token'] = $new_token;
1074 return new WP_REST_Response( $response, 200 );
1075 }
1076 catch ( Throwable $e ) {
1077 if ( $work ) {
1078 $transient = $this->transient_error_details( $e );
1079 if ( !empty( $transient['retryable'] ) ) $this->core->runs->retry_work( $work->id, $e );
1080 else $this->core->runs->update_work( $work->id, 'failed', $work->cursor_value, $e );
1081 }
1082 return $this->fail_run_response( $e, $run->id, 'retrieveFiles' );
1083 }
1084 }
1085
1086 function rest_retrieve_medias( $request ) {
1087 $run = $this->activate_request_run( $request );
1088 if ( is_wp_error( $run ) ) {
1089 return $this->error_response( $run );
1090 }
1091 try {
1092
1093 //DEBUG: Simulate a service unavailable error
1094 // $error_chance = rand( 0, 4 ) === 0; // 25% chance to simulate an error
1095 // if ( $error_chance ) {
1096 // return new WP_REST_Response( [ 'success' => false, 'message' => 'Test Service Unavailable!' ], 503 );
1097 // }
1098
1099 $params = $request->get_json_params();
1100 $limit = isset( $params['limit'] ) ? max( 0, (int) $params['limit'] ) : 0;
1101 $limitsize = $this->core->get_option( 'medias_buffer' );
1102 $unattachedOnly = $this->core->get_option( 'attach_is_use' );
1103
1104 // Save step progress at the beginning of media retrieval
1105 if ( $limit === 0 ) {
1106 $this->core->save_progress( 'retrieveMedia' );
1107 }
1108
1109 $results = $this->engine->get_media_entries( $limit, $limitsize, $unattachedOnly );
1110 $finished = count( $results ) < $limitsize;
1111 $processed = 0;
1112 $yielded = false;
1113 $this->core->timeout_check_start( count( $results ) );
1114 foreach ( $results as $media_id ) {
1115 if ( $this->core->timeout_should_yield() ) {
1116 $yielded = true;
1117 break;
1118 }
1119 $this->core->timeout_check();
1120 $this->engine->check_media( $media_id );
1121 $this->core->timeout_check_additem();
1122 $processed++;
1123 }
1124 $finished = !$yielded && $processed === count( $results ) && $finished;
1125 $next_offset = $limit + $processed;
1126 $message = sprintf( __( "Retrieved %d targets.", 'media-cleaner' ), $processed );
1127
1128 $this->core->save_progress( $finished ? 'retrieveMedia_finished' : 'retrieveMedia', array( 'limit' => $limit, 'limitSize' => $limitsize, 'next' => $next_offset, 'processed' => $processed ) );
1129
1130 $this->core->clean_ob();
1131
1132 $response = [
1133 'success' => true,
1134 'message' => $message,
1135 'data' => [
1136 'limit' => $next_offset,
1137 'finished' => $finished,
1138 'results' => array(),
1139 'checked' => $processed,
1140 'yielded' => $yielded,
1141 ]
1142 ];
1143
1144 $new_token = $this->verify_token();
1145 if( $new_token ) {
1146 $response['new_token'] = $new_token;
1147 }
1148
1149 return new WP_REST_Response( $response, 200 );
1150 }
1151 catch ( Throwable $e ) {
1152 return $this->fail_run_response( $e, $run->id, 'retrieveMedia' );
1153 }
1154 }
1155
1156 function rest_check_targets( $request ) {
1157 $run = $this->activate_request_run( $request );
1158 if ( is_wp_error( $run ) ) {
1159 return $this->error_response( $run );
1160 }
1161 try {
1162 //DEBUG: Simulate a service unavailable error
1163 // $error_chance = rand( 0, 4 ) === 0; // 25% chance to simulate an error
1164 // if ( $error_chance ) {
1165 // return new WP_REST_Response( [ 'success' => false, 'message' => 'Test Service Unavailable!' ], 503 );
1166 // }
1167
1168 $params = $request->get_json_params();
1169 // DEBUG: Simulate a timeout
1170 //$this->core->deepsleep(10); header("HTTP/1.0 408 Request Timeout by Nyao"); exit;
1171
1172 //ob_start();
1173 $data = isset( $params['targets'] ) && is_array( $params['targets'] ) ? array_values( $params['targets'] ) : array();
1174 $method = $this->core->current_method;
1175
1176 if ( empty( $data ) || count( $data ) > 500 ) {
1177 return $this->fail_run_response( new WP_Error(
1178 'wpmc_invalid_targets',
1179 __( 'The scan target batch must contain between 1 and 500 items.', 'media-cleaner' ),
1180 array( 'status' => 400 )
1181 ), $run->id, 'checkTargets' );
1182 }
1183 if ( $method === 'media' ) {
1184 $data = array_values( array_filter( array_map( 'absint', $data ) ) );
1185 }
1186 else if ( $method === 'files' || $method === 'optimize_thumbnails' ) {
1187 $normalized = array();
1188 foreach ( $data as $path ) {
1189 $path = $this->core->normalize_upload_relative_path( $path );
1190 if ( is_wp_error( $path ) ) return $this->fail_run_response( $path, $run->id, 'checkTargets' );
1191 $normalized[] = $path;
1192 }
1193 $data = $normalized;
1194 }
1195 else if ( $method === 'duplicates' ) {
1196 $data = array_values( array_filter( array_map( 'sanitize_text_field', $data ), function( $hash ) {
1197 return preg_match( '/^HASH:[a-f0-9]{64}$/', $hash ) === 1;
1198 } ) );
1199 }
1200 if ( empty( $data ) ) {
1201 return $this->fail_run_response( new WP_Error( 'wpmc_invalid_targets', __( 'The scan target batch is invalid.', 'media-cleaner' ), array( 'status' => 400 ) ), $run->id, 'checkTargets' );
1202 }
1203
1204 $this->core->timeout_check_start( count( $data ) );
1205 $success = 0;
1206 if ( $method == 'files' ) {
1207 $this->core->safe_do_action( 'wpmc_check_file_init' ); // Build_CroppedFile_Cache() in pro core.php
1208 }
1209 foreach ( $data as $piece ) {
1210 $this->core->timeout_check();
1211 if ( $method == 'files' ) {
1212 $this->core->log( "🔎 Checking File: {$piece}..." );
1213 $result = ( $this->engine->check_file( $piece ) ? 1 : 0 );
1214 if ( $result ) {
1215 $success += $result;
1216 }
1217 // else {
1218 // $this->core->log( "👻 Nothing found." );
1219 // }
1220 }
1221 else if ( $method == 'media' ) {
1222 $this->core->log( "🔎 Checking Media #{$piece}..." );
1223 $result = ( $this->engine->check_media( $piece ) ? 1 : 0 );
1224 if ( $result ) {
1225 $success += $result;
1226 }
1227 // else {
1228 // $this->core->log( "👻 Nothing found." );
1229 // }
1230 } else if( $method == 'duplicates' ) {
1231 $this->core->log( "🔎 Checking Duplicate #{$piece}..." );
1232 $result = ( $this->engine->check_duplicates( $piece ) ? 1 : 0 );
1233 if ( $result ) {
1234 $success += $result;
1235 }
1236 }
1237 else if ( $method == 'optimize_thumbnails' ) {
1238 $this->core->log( "🔎 Checking Thumbnail File: {$piece}..." );
1239 $result = ( $this->engine->check_file( $piece ) ? 1 : 0 );
1240 if ( $result ) {
1241 $success += $result;
1242 }
1243 }
1244 //$this->core->log();
1245 $this->core->timeout_check_additem();
1246 }
1247 //ob_end_clean();
1248 $elapsed = $this->core->timeout_get_elapsed();
1249 $issues_found = count( $data ) - $success;
1250 $message = sprintf(
1251 // translators: %1$d is a number of targets, %2$d is a number of issues, %3$s is elapsed time in milliseconds
1252 __( 'Checked %1$d targets and found %2$d issues in %3$s.', 'media-cleaner' ),
1253 count( $data ), $issues_found, $elapsed
1254 );
1255
1256 $response = [
1257 'success' => true,
1258 'message' => $message,
1259 'data' => [
1260 'results' => $success
1261 ]
1262 ];
1263
1264 $this->core->save_progress( 'checkTargets', array( 'last_batch_size' => count( $data ) ) );
1265
1266
1267 $new_token = $this->verify_token();
1268 if( $new_token ) {
1269 $response['new_token'] = $new_token;
1270 }
1271
1272 return new WP_REST_Response( $response, 200 );
1273 }
1274 catch ( Throwable $e ) {
1275 return $this->fail_run_response( $e, $run->id, 'checkTargets' );
1276 }
1277 }
1278
1279 /**
1280 * Streams the preview of a trashed item. The trash is private storage with no
1281 * public URL, so the file is served here, and only if it really is an image
1282 * sitting inside the trash.
1283 */
1284 function rest_trash_preview( $request ) {
1285 $id = (int) $request->get_param( 'id' );
1286 $issue = $this->core->get_issue( $id );
1287 if ( !$issue || (int) $issue->deleted !== 1 ) {
1288 return new WP_Error( 'wpmc_preview_not_found', __( 'This trashed item does not exist.', 'media-cleaner' ), array( 'status' => 404 ) );
1289 }
1290
1291 $path = $this->trash_preview_path( $issue );
1292 if ( !$path ) {
1293 return new WP_Error( 'wpmc_preview_unavailable', __( 'This item cannot be previewed.', 'media-cleaner' ), array( 'status' => 404 ) );
1294 }
1295 $resolved = $this->core->resolve_trash_path( $path, true );
1296 if ( is_wp_error( $resolved ) || !is_file( $resolved ) || is_link( $resolved ) ) {
1297 return new WP_Error( 'wpmc_preview_unavailable', __( 'This item cannot be previewed.', 'media-cleaner' ), array( 'status' => 404 ) );
1298 }
1299
1300 // Raster formats only, and never anything derived from the extension alone.
1301 // SVG is an image that can carry scripts: served inline from here, opening one
1302 // directly would run it under the site's own origin.
1303 $filetype = wp_check_filetype( basename( $resolved ) );
1304 $mime = isset( $filetype['type'] ) ? $filetype['type'] : '';
1305 $previewable = apply_filters( 'wpmc_previewable_trash_mimes', array(
1306 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/bmp',
1307 ) );
1308 if ( !in_array( (string) $mime, (array) $previewable, true ) ) {
1309 return new WP_Error( 'wpmc_preview_not_an_image', __( 'Only images can be previewed.', 'media-cleaner' ), array( 'status' => 415 ) );
1310 }
1311 $size = (int) @filesize( $resolved );
1312 $limit = (int) apply_filters( 'wpmc_max_trash_preview_bytes', 8 * 1024 * 1024 );
1313 if ( $size < 1 || $size > $limit ) {
1314 return new WP_Error( 'wpmc_preview_too_large', __( 'This image is too large to be previewed.', 'media-cleaner' ), array( 'status' => 413 ) );
1315 }
1316
1317 $this->core->clean_ob();
1318 header( 'Content-Type: ' . $mime );
1319 header( 'Content-Length: ' . $size );
1320 header( 'X-Content-Type-Options: nosniff' );
1321 header( 'Content-Disposition: inline; filename="' . basename( $resolved ) . '"' );
1322 header( 'Cache-Control: private, max-age=300' );
1323 readfile( $resolved );
1324 exit;
1325 }
1326
1327 /**
1328 * The smallest file of a trashed item, so previewing never streams a huge
1329 * original when a thumbnail was trashed along with it.
1330 */
1331 private function trash_preview_path( $issue ) {
1332 if ( (int) $issue->type === 1 && $issue->postId ) {
1333 $meta = wp_get_attachment_metadata( $issue->postId );
1334 $main = $this->core->clean_uploaded_filename( get_attached_file( $issue->postId ) );
1335 if ( !empty( $meta['sizes']['thumbnail']['file'] ) && $main !== '' ) {
1336 $directory = dirname( $main );
1337 $directory = $directory === '.' ? '' : trailingslashit( $directory );
1338 return $directory . $meta['sizes']['thumbnail']['file'];
1339 }
1340 return $main !== '' ? $main : null;
1341 }
1342 // Filesystem items keep the display suffix, e.g. "file.jpg (+ 3 files)".
1343 $path = preg_replace( '/\s\(\+.*$/', '', (string) $issue->path );
1344 return $path !== '' ? $path : null;
1345 }
1346
1347 function rest_refresh_logs() {
1348 return new WP_REST_Response( [ 'success' => true, 'data' => $this->core->get_logs() ], 200 );
1349 }
1350
1351 function rest_clear_logs() {
1352 $this->core->clear_logs();
1353 return new WP_REST_Response( [ 'success' => true ], 200 );
1354 }
1355
1356 private function settings_options_payload( $options = null, $include_progress = true ) {
1357 $options = is_array( $options ) ? $options : $this->core->get_all_options();
1358 $payload = array_merge( $options, array(
1359 'incompatible_plugins' => Meow_WPMC_Support::get_issues(),
1360 'native_plugins' => Meow_WPMC_Support::get_natives(),
1361 ) );
1362 if ( $include_progress ) {
1363 $payload['scan_progress'] = $this->core->get_progress();
1364 }
1365 return $payload;
1366 }
1367
1368 function rest_all_settings() {
1369 return new WP_REST_Response( [
1370 'success' => true,
1371 'data' => $this->settings_options_payload( null, false ),
1372 ], 200 );
1373 }
1374
1375 function rest_update_options( $request ) {
1376 try {
1377 $params = $this->request_json( $request );
1378 if ( !isset( $params['options'] ) || !is_array( $params['options'] ) ) {
1379 return $this->error_response( new WP_Error( 'wpmc_invalid_options', __( 'A valid options object is required.', 'media-cleaner' ), array( 'status' => 400 ) ) );
1380 }
1381 unset( $params['options']['logs_path'] );
1382 $valid_regex = $this->validate_regex_options( array_merge( $this->core->get_all_options(), $params['options'] ) );
1383 if ( is_wp_error( $valid_regex ) ) return $this->error_response( $valid_regex );
1384
1385 if ( count( $params['options'] ) === 1 ) {
1386 $this->core->log( 'Updating the Media Cleaner option: ' . sanitize_key( key( $params['options'] ) ) );
1387
1388 $options = $this->core->get_all_options();
1389 $options[ key( $params['options'] ) ] = $params['options'][ key( $params['options'] ) ];
1390 $params['options'] = $options;
1391 }
1392
1393 $value = $params['options'];
1394
1395 $options = $this->core->update_options( $value );
1396 return new WP_REST_Response([ 'success' => true, 'message' => 'OK', 'options' => $this->settings_options_payload( $options ) ], 200 );
1397 }
1398 catch ( Throwable $e ) {
1399 return $this->error_response( $e );
1400 }
1401 }
1402
1403 function rest_reset_options() {
1404 $this->core->reset_options();
1405 return new WP_REST_Response( [ 'success' => true, 'options' => $this->settings_options_payload() ], 200 );
1406 }
1407
1408 function rest_reset_db() {
1409 if ( !current_user_can( 'manage_options' ) ) {
1410 return $this->error_response( new WP_Error( 'wpmc_reset_forbidden', __( 'Only an administrator can reset the Media Cleaner database.', 'media-cleaner' ), array( 'status' => 403 ) ) );
1411 }
1412 if ( $this->core->runs->get_resumable() ) {
1413 return $this->error_response( new WP_Error( 'wpmc_reset_scan_running', __( 'The database cannot be reset while a scan is resumable.', 'media-cleaner' ), array( 'status' => 409 ) ) );
1414 }
1415 global $wpdb;
1416 $table_scan = $wpdb->prefix . 'mclean_scan';
1417 $active_run_id = $this->core->runs->get_active_id();
1418 $trashed = (int) $wpdb->get_var( $wpdb->prepare(
1419 "SELECT COUNT(*) FROM $table_scan WHERE run_id = %d AND deleted = 1",
1420 $active_run_id
1421 ) );
1422 if ( $trashed > 0 ) {
1423 return $this->error_response( new WP_Error( 'wpmc_reset_trash_not_empty', __( 'Recover or permanently delete every quarantined item before resetting the database.', 'media-cleaner' ), array( 'status' => 409, 'trash_count' => $trashed ) ) );
1424 }
1425 wpmc_reset();
1426 return new WP_REST_Response( [ 'success' => true ], 200 );
1427 }
1428
1429 function rest_reference_entries( $request ) {
1430 global $wpdb;
1431 $limit = max( 1, min( 100, (int) $request->get_param('limit') ) );
1432 $skip = max( 0, (int) $request->get_param('skip') );
1433 $orderBy = sanitize_text_field( $request->get_param('orderBy') );
1434 $order = sanitize_text_field( $request->get_param('order') );
1435 $search = sanitize_text_field( $request->get_param('search') );
1436 $referenceFilter = sanitize_text_field( $request->get_param('referenceFilter') );
1437 $table_ref = $wpdb->prefix . "mclean_refs";
1438 $run_id = $this->core->get_run_id();
1439
1440 $total = $this->count_references($search, $referenceFilter);
1441
1442 // Every column is qualified with the r alias. Searching joins the posts table,
1443 // and an unqualified id exists on both sides, which makes the query ambiguous
1444 // and returns no reference at all.
1445 $where_sql = $wpdb->prepare( 'AND r.run_id = %d', $run_id );
1446 if ($referenceFilter === 'mediaIds') {
1447 $where_sql .= ' AND r.mediaId IS NOT NULL';
1448 } else if ($referenceFilter === 'mediaUrls') {
1449 $where_sql .= ' AND r.mediaUrl IS NOT NULL';
1450 }
1451
1452 $order_sql = 'ORDER BY r.id DESC';
1453 if ( $orderBy === 'id' ) {
1454 $order_sql = 'ORDER BY r.id IS NULL, r.id ' . ( $order === 'asc' ? 'ASC' : 'DESC' );
1455 } elseif ( $orderBy === 'mediaId' ) {
1456 $order_sql = 'ORDER BY r.mediaId IS NULL, r.mediaId ' . ( $order === 'asc' ? 'ASC' : 'DESC' );
1457 } elseif ( $orderBy === 'mediaUrl' ) {
1458 $order_sql = 'ORDER BY r.mediaUrl IS NULL, r.mediaUrl ' . ( $order === 'asc' ? 'ASC' : 'DESC' );
1459 } elseif ( $orderBy === 'originType' ) {
1460 $order_sql = 'ORDER BY r.originType ' . ( $order === 'asc' ? 'ASC' : 'DESC' );
1461 }
1462
1463 if ( empty( $search ) ) {
1464 $entries = $wpdb->get_results(
1465 $wpdb->prepare( "SELECT r.*
1466 FROM $table_ref r
1467 WHERE 1=1
1468 $where_sql
1469 $order_sql
1470 LIMIT %d, %d", $skip, $limit
1471 )
1472 );
1473 } else {
1474 $posts_table = $wpdb->posts;
1475 $search_like = '%' . $wpdb->esc_like( $search ) . '%';
1476 $entries = $wpdb->get_results(
1477 $wpdb->prepare( "SELECT r.*
1478 FROM $table_ref r
1479 LEFT JOIN $posts_table p ON r.origin = p.ID
1480 WHERE (r.mediaId LIKE %s
1481 OR r.mediaUrl LIKE %s
1482 OR r.originType LIKE %s
1483 OR r.origin LIKE %s
1484 OR p.post_title LIKE %s)
1485 $where_sql
1486 $order_sql
1487 LIMIT %d, %d", $search_like, $search_like, $search_like, $search_like, $search_like, $skip, $limit
1488 )
1489 );
1490 }
1491
1492 // Prepare arrays to store IDs and data
1493 $post_ids = [];
1494 $media_ids = [];
1495 $media_urls = [];
1496
1497 // Extract post IDs and media IDs/URLs
1498 foreach ( $entries as $entry ) {
1499
1500 if( $entry->origin && is_numeric( $entry->origin ) ) {
1501 $post_ids[] = (int) $entry->origin;
1502 }
1503
1504 // Collect media IDs and URLs
1505 if ( $entry->mediaId ) {
1506 $media_ids[] = $entry->mediaId;
1507 }
1508
1509 if ( $entry->mediaUrl ) {
1510 $media_urls[] = $entry->mediaUrl;
1511 }
1512 }
1513
1514 // Remove duplicates
1515 $post_ids = array_unique( $post_ids );
1516 $media_ids = array_unique( $media_ids );
1517 $media_urls = array_unique( $media_urls );
1518
1519 // Get post titles. get_posts() would silently drop the post types excluded
1520 // from search (many builders and galleries use such types) and anything not
1521 // published, so the posts are read directly instead.
1522 $post_titles = [];
1523 if ( !empty( $post_ids ) ) {
1524 _prime_post_caches( $post_ids, false, false );
1525 foreach ( $post_ids as $post_id ) {
1526 $post = get_post( $post_id );
1527 if ( $post ) {
1528 $post_titles[ $post->ID ] = $post->post_title;
1529 }
1530 }
1531 }
1532
1533 // Get thumbnails and titles for media IDs
1534 $media_thumbnails = [];
1535 $media_titles = [];
1536 if ( !empty( $media_ids ) ) {
1537 _prime_post_caches( $media_ids, false, true );
1538 }
1539 foreach ( $media_ids as $media_id ) {
1540 $media = wp_get_attachment_image_src( $media_id, 'thumbnail' );
1541 if ( $media ) {
1542 $media_thumbnails[ $media_id ] = $media[0];
1543 }
1544 $media_post = get_post( $media_id );
1545 if ( $media_post ) {
1546 $media_titles[ $media_id ] = $media_post->post_title;
1547 }
1548 }
1549
1550 // Get the uploads directory URL
1551 $upload_dir = wp_upload_dir();
1552 $upload_baseurl = $upload_dir['baseurl'];
1553
1554 // Map media URLs to attachment IDs and get thumbnails. The references store a
1555 // path relative to the uploads folder, so it has to be made absolute first,
1556 // otherwise nothing is ever resolved and the full size image ends up being
1557 // used as a thumbnail. A resolution suffix is dropped to find the original.
1558 $media_url_to_id = [];
1559 foreach ( $media_urls as $media_url ) {
1560 $absolute = strpos( $media_url, 'http' ) === 0
1561 ? $media_url
1562 : trailingslashit( $upload_baseurl ) . ltrim( $media_url, '/' );
1563 $attachment_id = attachment_url_to_postid( $absolute );
1564 if ( !$attachment_id ) {
1565 $original = preg_replace( '/-\d+x\d+(\.[A-Za-z0-9]+)$/', '$1', $absolute );
1566 if ( $original !== $absolute ) {
1567 $attachment_id = attachment_url_to_postid( $original );
1568 }
1569 }
1570 if ( $attachment_id ) {
1571 $media_url_to_id[ $media_url ] = $attachment_id;
1572 $media = wp_get_attachment_image_src( $attachment_id, 'thumbnail' );
1573 if ( $media ) {
1574 $media_thumbnails[ $attachment_id ] = $media[0];
1575 }
1576 }
1577 }
1578
1579 // Assign post titles and thumbnails to entries
1580 foreach ( $entries as $entry ) {
1581 // Assign post title
1582 if ( isset( $entry->origin ) && isset( $post_titles[ $entry->origin ] ) ) {
1583 $entry->post_title = $post_titles[ $entry->origin ];
1584 } else {
1585 $entry->post_title = '';
1586 }
1587
1588
1589 // Assign the media title, so a reference by ID is readable without
1590 // having to open the media.
1591 $entry->media_title = isset( $entry->mediaId ) && isset( $media_titles[ $entry->mediaId ] )
1592 ? $media_titles[ $entry->mediaId ]
1593 : '';
1594 $entry->media_exists = !$entry->mediaId || isset( $media_titles[ $entry->mediaId ] );
1595
1596 // Assign thumbnail
1597 $entry->thumbnail = '';
1598
1599 if ( $entry->mediaId && isset( $media_thumbnails[ $entry->mediaId ] ) ) {
1600 $entry->thumbnail = $media_thumbnails[ $entry->mediaId ];
1601 } elseif ( $entry->mediaUrl && isset( $media_url_to_id[ $entry->mediaUrl ] ) ) {
1602 $attachment_id = $media_url_to_id[ $entry->mediaUrl ];
1603 if ( isset( $media_thumbnails[ $attachment_id ] ) ) {
1604 $entry->thumbnail = $media_thumbnails[ $attachment_id ];
1605 }
1606 }
1607
1608 // If thumbnail is still empty, use mediaUrl as thumbnail
1609 if ( empty( $entry->thumbnail ) && $entry->mediaUrl ) {
1610 // Ensure mediaUrl is absolute
1611 if ( strpos( $entry->mediaUrl, 'http' ) !== 0 ) {
1612 $entry->thumbnail = $upload_baseurl . '/' . ltrim( $entry->mediaUrl, '/' );
1613 } else {
1614 $entry->thumbnail = $entry->mediaUrl;
1615 }
1616 }
1617
1618 // Ensure thumbnail is absolute URL ( for sizes of medias )
1619 if ( !empty( $entry->thumbnail ) && strpos( $entry->thumbnail, 'http' ) !== 0 ) {
1620 $entry->thumbnail = $upload_baseurl . '/' . ltrim( $entry->thumbnail, '/' );
1621 }
1622 }
1623
1624 return new WP_REST_Response( [ 'success' => true, 'data' => $entries, 'total' => $total ], 200 );
1625 }
1626
1627 function rest_entries( $request ) {
1628 global $wpdb;
1629 $limit = max( 1, min( 100, (int) $request->get_param('limit') ) );
1630 $skip = max( 0, (int) $request->get_param('skip') );
1631 $filterBy = sanitize_text_field( $request->get_param('filterBy') );
1632 $orderBy = sanitize_text_field( $request->get_param('orderBy') );
1633 $order = sanitize_text_field( $request->get_param('order') );
1634 $search = sanitize_text_field( $request->get_param('search') );
1635 $repair_mode = rest_sanitize_boolean( $request->get_param('repairMode') );
1636 $table_scan = $wpdb->prefix . "mclean_scan";
1637 $run_id = $this->core->get_run_id();
1638 $total = 0;
1639
1640 if ( $filterBy === 'references' ) {
1641 return $this->rest_reference_entries( $request );
1642 }
1643
1644 $entries = [];
1645 if ( $repair_mode ) {
1646 $entries = $this->core->get_issues_to_repair( $orderBy, $order, $search, $skip, $limit );
1647 $total = $this->core->get_count_of_issues_to_repair( $search );
1648 }
1649 else {
1650 $filters = array(
1651 'issues' => 'ignored = 0 AND deleted = 0',
1652 'ignored' => 'ignored = 1',
1653 'trash' => 'deleted = 1',
1654 'all' => 'deleted = 0',
1655 );
1656 $filter_sql = isset( $filters[ $filterBy ] ) ? $filters[ $filterBy ] : $filters['all'];
1657 $total = $filterBy === 'issues' ? $this->count_issues( $search ) : ( $filterBy === 'ignored' ? $this->count_ignored( $search ) : ( $filterBy === 'trash' ? $this->count_trash( $search ) : 0 ) );
1658
1659 $allowed_order = array( 'id', 'type', 'postId', 'time', 'path', 'size' );
1660 $order_column = in_array( $orderBy, $allowed_order, true ) ? $orderBy : 'id';
1661 $order_direction = $order === 'asc' ? 'ASC' : 'DESC';
1662 $search_sql = empty( $search ) ? '' : $wpdb->prepare( 'AND path LIKE %s', '%' . $wpdb->esc_like( $search ) . '%' );
1663 $entries = $wpdb->get_results( $wpdb->prepare(
1664 "SELECT id, type, postId, path, size, ignored, deleted, issue, time, manifest
1665 FROM $table_scan
1666 WHERE run_id = %d AND $filter_sql $search_sql
1667 ORDER BY $order_column $order_direction
1668 LIMIT %d, %d",
1669 $run_id,
1670 $skip,
1671 $limit
1672 ) );
1673 }
1674
1675 $is_trash = $filterBy === 'trash';
1676 $base = $this->core->upload_url;
1677 foreach ( $entries as $entry ) {
1678 // An item that includes a file marked unsafe during the scan can never be
1679 // cleaned (delete() refuses it), so the dashboard flags it and locks its
1680 // selection. The manifest itself is internal and is not sent to the client.
1681 if ( property_exists( $entry, 'manifest' ) ) {
1682 $manifest = json_decode( (string) $entry->manifest, true );
1683 $entry->unsafe = is_array( $manifest ) && in_array( Meow_WPMC_Core::FINGERPRINT_UNSAFE, $manifest, true );
1684 unset( $entry->manifest );
1685 }
1686 if ( $is_trash ) {
1687 // The trash lives outside the uploads folder, so it has no public URL.
1688 // The preview is streamed by the plugin instead of being linked.
1689 $preview = add_query_arg( array(
1690 'id' => (int) $entry->id,
1691 '_wpnonce' => wp_create_nonce( 'wp_rest' ),
1692 ), rest_url( $this->namespace . '/trash_preview' ) );
1693 $entry->thumbnail_url = $preview;
1694 $entry->image_url = $preview;
1695 if ( $entry->type != 0 ) {
1696 $entry->title = html_entity_decode( get_the_title( $entry->postId ) );
1697 }
1698 continue;
1699 }
1700
1701 // FILESYSTEM
1702 if ( $entry->type == 0 ) {
1703 $entry->thumbnail_url = htmlspecialchars( trailingslashit( $base ) . $entry->path, ENT_QUOTES );
1704 $entry->image_url = $entry->thumbnail_url;
1705
1706 // If the extension is not an image, we set the thumbnail to null
1707 $ext = pathinfo( $entry->path, PATHINFO_EXTENSION );
1708 if ( !$this->core->is_image_extension( $ext ) ) {
1709 $entry->thumbnail_url = null;
1710 }
1711
1712
1713
1714 }
1715 // MEDIA
1716 else {
1717 $attachment_src = wp_get_attachment_image_src( $entry->postId, 'thumbnail' );
1718 $attachment_src_large = wp_get_attachment_image_src( $entry->postId, 'large' );
1719 $thumbnail = empty( $attachment_src ) ? null : $attachment_src[0];
1720 $image = empty( $attachment_src_large ) ? null : $attachment_src_large[0];
1721 // This was working when the Post Type" was attachment"
1722 $entry->thumbnail_url = $thumbnail;
1723 $entry->image_url = $image;
1724 $entry->title = html_entity_decode( get_the_title( $entry->postId ) );
1725 }
1726 }
1727
1728 return new WP_REST_Response( [ 'success' => true, 'data' => $entries, 'total' => $total ], 200 );
1729 }
1730
1731 // Nothing here waits for a scan. Only trashing something new does, and delete()
1732 // refuses that on its own. Recovering, emptying the trash and ignoring are the
1733 // user acting on decisions they already made.
1734 private function perform_item_operations( $request, $operation, $callback ) {
1735 $params = $this->request_json( $request );
1736 $ids = isset( $params['entryIds'] ) ? (array) $params['entryIds'] : array();
1737 if ( isset( $params['entryId'] ) ) {
1738 $ids[] = $params['entryId'];
1739 }
1740 $ids = array_values( array_unique( array_filter( array_map( 'absint', $ids ) ) ) );
1741 if ( empty( $ids ) || count( $ids ) > 100 ) {
1742 return $this->error_response( new WP_Error( 'wpmc_invalid_operation_items', __( 'Cleanup requests must contain between 1 and 100 valid items.', 'media-cleaner' ), array( 'status' => 400 ) ) );
1743 }
1744 $request_key = isset( $params['requestKey'] ) ? sanitize_text_field( $params['requestKey'] ) : wp_generate_uuid4();
1745 $results = array();
1746 $succeeded = 0;
1747 $failed = 0;
1748 $attempted = 0;
1749 $yielded = false;
1750 $this->core->timeout_check_start( count( $ids ) );
1751
1752 foreach ( $ids as $index => $id ) {
1753 if ( $index > 0 && $this->core->timeout_should_yield() ) {
1754 $yielded = true;
1755 break;
1756 }
1757 $attempted = $index + 1;
1758 $issue = null;
1759 $journal = $this->core->runs->begin_operation( $id, $operation, $request_key );
1760 if ( is_wp_error( $journal ) ) {
1761 $results[] = array( 'id' => $id, 'success' => false, 'code' => $journal->get_error_code(), 'message' => $journal->get_error_message() );
1762 $failed++;
1763 continue;
1764 }
1765 if ( $journal->state === 'complete' ) {
1766 $results[] = array( 'id' => $id, 'success' => true, 'idempotent' => true );
1767 $succeeded++;
1768 continue;
1769 }
1770 $manifest = json_decode( (string) $journal->manifest, true );
1771 $manifest = is_array( $manifest ) ? $manifest : array();
1772 if ( $journal->state === 'pending' ) {
1773 $issue = $this->core->get_issue( $id );
1774 if ( !$issue ) {
1775 $error = new WP_Error( 'wpmc_issue_missing', __( 'The selected Media Cleaner result no longer exists.', 'media-cleaner' ) );
1776 $this->core->runs->update_operation( $journal->id, 'failed', $manifest, $error );
1777 $results[] = array( 'id' => $id, 'success' => false, 'code' => $error->get_error_code(), 'message' => $error->get_error_message() );
1778 $failed++;
1779 continue;
1780 }
1781 $manifest = array(
1782 'initial_deleted' => (bool) $issue->deleted,
1783 'initial_ignored' => (bool) $issue->ignored,
1784 'initial_type' => (int) $issue->type,
1785 );
1786 }
1787 $requires_identity = in_array( $operation, array( 'delete', 'recover', 'repair' ), true );
1788 if ( $requires_identity && empty( $manifest['identity_validated'] ) ) {
1789 $issue = isset( $issue ) && $issue ? $issue : $this->core->get_issue( $id );
1790 $identity = $issue ? $this->core->validate_issue_manifest( $issue ) : new WP_Error( 'wpmc_issue_missing', __( 'The selected Media Cleaner result no longer exists.', 'media-cleaner' ) );
1791 if ( is_wp_error( $identity ) ) {
1792 $this->core->runs->update_operation( $journal->id, 'failed', $manifest, $identity );
1793 $results[] = array( 'id' => $id, 'success' => false, 'code' => $identity->get_error_code(), 'message' => $identity->get_error_message() );
1794 $failed++;
1795 continue;
1796 }
1797 $manifest['identity_validated'] = true;
1798 }
1799 if ( !$this->core->runs->update_operation( $journal->id, 'running', $manifest ) ) {
1800 $results[] = array( 'id' => $id, 'success' => false, 'code' => 'wpmc_operation_journal_failed', 'message' => __( 'The cleanup journal could not be updated.', 'media-cleaner' ) );
1801 $failed++;
1802 continue;
1803 }
1804 try {
1805 $result = in_array( $operation, array( 'delete', 'recover' ), true ) ? call_user_func( $callback, $id, $manifest ) : call_user_func( $callback, $id );
1806 if ( is_wp_error( $result ) || $result !== true ) {
1807 $error = is_wp_error( $result ) ? $result : new WP_Error( 'wpmc_operation_failed', __( 'The item could not be updated.', 'media-cleaner' ) );
1808 $this->core->runs->update_operation( $journal->id, 'failed', null, $error );
1809 $results[] = array( 'id' => $id, 'success' => false, 'code' => $error->get_error_code(), 'message' => $error->get_error_message() );
1810 $failed++;
1811 }
1812 else {
1813 if ( !$this->core->runs->update_operation( $journal->id, 'complete', $manifest ) ) {
1814 $results[] = array( 'id' => $id, 'success' => false, 'code' => 'wpmc_operation_journal_failed', 'message' => __( 'The item was updated, but the cleanup journal could not be finalized.', 'media-cleaner' ) );
1815 $failed++;
1816 }
1817 else {
1818 $results[] = array( 'id' => $id, 'success' => true );
1819 $succeeded++;
1820 }
1821 }
1822 }
1823 catch ( Throwable $e ) {
1824 $error = new WP_Error( 'wpmc_operation_exception', $e->getMessage() );
1825 $this->core->runs->update_operation( $journal->id, 'failed', null, $error );
1826 $results[] = array( 'id' => $id, 'success' => false, 'code' => $error->get_error_code(), 'message' => $error->get_error_message() );
1827 $failed++;
1828 }
1829 }
1830
1831 $response = array(
1832 'success' => $failed === 0,
1833 'data' => array(
1834 'results' => $results,
1835 'succeeded' => $succeeded,
1836 'failed' => $failed,
1837 'request_key' => $request_key,
1838 'finished' => !$yielded,
1839 'remaining' => max( 0, count( $ids ) - $attempted ),
1840 ),
1841 'message' => $failed === 0 ? __( 'All requested items were updated.', 'media-cleaner' ) : sprintf( __( '%1$d item(s) succeeded and %2$d failed.', 'media-cleaner' ), $succeeded, $failed ),
1842 );
1843 $new_token = $this->verify_token();
1844 if ( $new_token ) {
1845 $response['new_token'] = $new_token;
1846 }
1847 return new WP_REST_Response( $response, $failed === 0 ? 200 : 207 );
1848 }
1849
1850 function rest_set_ignore( $request ) {
1851 $params = $this->request_json( $request );
1852 $ignore = isset( $params['ignore'] ) ? rest_sanitize_boolean( $params['ignore'] ) : true;
1853 return $this->perform_item_operations( $request, $ignore ? 'ignore' : 'unignore', function( $id ) use ( $ignore ) {
1854 return $this->core->ignore( $id, $ignore );
1855 } );
1856 }
1857
1858 function rest_delete( $request ) {
1859 return $this->perform_item_operations( $request, 'delete', array( $this->core, 'delete' ) );
1860 }
1861
1862 // Emptying the trash needs no scan: those files were set aside on purpose.
1863 function rest_force_trash_all( $request ) {
1864 $params = $this->request_json( $request );
1865 $initialize = isset( $params['initialize'] ) ? rest_sanitize_boolean( $params['initialize'] ) : true;
1866 $res = $this->core->force_trash( $initialize, 100 );
1867 if ( is_wp_error( $res ) ) return $this->error_response( $res );
1868 return new WP_REST_Response( array(
1869 'success' => true,
1870 'data' => $res,
1871 'message' => !empty( $res['finished'] ) ? __( 'The Media Cleaner trash has been emptied.', 'media-cleaner' ) : __( 'Media Cleaner emptied another bounded trash batch.', 'media-cleaner' ),
1872 ), 200 );
1873 }
1874
1875 // A last resort for stuck trash rows whose file has vanished, changed, or become
1876 // unsafe since the scan, so the normal Empty Trash refuses them. It removes only
1877 // those broken rows (and any file left in quarantine); healthy, still recoverable
1878 // trash is left untouched. Emptying the trash needs no scan.
1879 function rest_force_clean_trash() {
1880 $deleted = $this->core->force_clean_trash();
1881 if ( is_wp_error( $deleted ) ) return $this->error_response( $deleted );
1882 return new WP_REST_Response( array(
1883 'success' => true,
1884 'data' => array( 'deleted' => $deleted ),
1885 'message' => $deleted > 0
1886 ? sprintf(
1887 /* translators: %d is the number of broken trash items that were removed. */
1888 _n( '%d stuck trash item was removed.', '%d stuck trash items were removed.', $deleted, 'media-cleaner' ),
1889 $deleted
1890 )
1891 : __( 'No stuck trash items were found. Your trash is already clean.', 'media-cleaner' ),
1892 ), 200 );
1893 }
1894
1895 function rest_recover( $request ) {
1896 return $this->perform_item_operations( $request, 'recover', array( $this->core, 'recover' ) );
1897 }
1898
1899 function rest_repair( $request ) {
1900 return $this->perform_item_operations( $request, 'repair', array( $this->core, 'repair' ) );
1901 }
1902
1903 function get_issues_ids( $search, $cursor = 0, $limit = 100 ) {
1904 global $wpdb;
1905 $whereSql = empty($search) ? '' : $wpdb->prepare( "AND path LIKE %s", '%' . $wpdb->esc_like( $search ) . '%' );
1906 $table_scan = $wpdb->prefix . "mclean_scan";
1907 $run_id = $this->core->get_run_id();
1908 return $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM $table_scan WHERE run_id = %d AND ID > %d AND ignored = 0 AND deleted = 0 $whereSql ORDER BY ID ASC LIMIT %d", $run_id, $cursor, $limit ) );
1909 }
1910
1911 function get_ignored_ids( $search, $cursor = 0, $limit = 100 ) {
1912 global $wpdb;
1913 $whereSql = empty($search) ? '' : $wpdb->prepare( "AND path LIKE %s", '%' . $wpdb->esc_like( $search ) . '%' );
1914 $table_scan = $wpdb->prefix . "mclean_scan";
1915 $run_id = $this->core->get_run_id();
1916 return $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM $table_scan WHERE run_id = %d AND ID > %d AND ignored = 1 $whereSql ORDER BY ID ASC LIMIT %d", $run_id, $cursor, $limit ) );
1917 }
1918
1919 function get_trash_ids( $search, $cursor = 0, $limit = 100 ) {
1920 global $wpdb;
1921 $whereSql = empty($search) ? '' : $wpdb->prepare( "AND path LIKE %s", '%' . $wpdb->esc_like( $search ) . '%' );
1922 $table_scan = $wpdb->prefix . "mclean_scan";
1923 $run_id = $this->core->get_run_id();
1924 return $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM $table_scan WHERE run_id = %d AND ID > %d AND deleted = 1 $whereSql ORDER BY ID ASC LIMIT %d", $run_id, $cursor, $limit ) );
1925 }
1926
1927 function count_issues($search) {
1928 global $wpdb;
1929 $whereSql = empty($search) ? '' : $wpdb->prepare("AND path LIKE %s", ( '%' . $search . '%' ));
1930 $table_scan = $wpdb->prefix . "mclean_scan";
1931 $run_id = $this->core->get_run_id();
1932 return (int)$wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $table_scan WHERE run_id = %d AND ignored = 0 AND deleted = 0 $whereSql", $run_id ) );
1933 }
1934
1935 function count_ignored($search) {
1936 global $wpdb;
1937 $whereSql = empty($search) ? '' : $wpdb->prepare("AND path LIKE %s", ( '%' . $search . '%' ));
1938 $table_scan = $wpdb->prefix . "mclean_scan";
1939 $run_id = $this->core->get_run_id();
1940 return (int)$wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $table_scan WHERE run_id = %d AND ignored = 1 $whereSql", $run_id ) );
1941 }
1942
1943 function count_trash($search) {
1944 global $wpdb;
1945 $whereSql = empty($search) ? '' : $wpdb->prepare("AND path LIKE %s", ( '%' . $search . '%' ));
1946 $table_scan = $wpdb->prefix . "mclean_scan";
1947 $run_id = $this->core->get_run_id();
1948 return (int)$wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $table_scan WHERE run_id = %d AND deleted = 1 $whereSql", $run_id ) );
1949 }
1950
1951 function count_references($search, $referenceFilter) {
1952 global $wpdb;
1953 $table_ref = $wpdb->prefix . "mclean_refs";
1954 $run_id = $this->core->get_run_id();
1955 $posts_table = $wpdb->posts;
1956 $filter_sql = '';
1957 if ($referenceFilter === 'mediaIds') {
1958 $filter_sql = ' AND r.mediaId IS NOT NULL';
1959 } else if ($referenceFilter === 'mediaUrls') {
1960 $filter_sql = ' AND r.mediaUrl IS NOT NULL';
1961 }
1962 // The whole statement is prepared once: feeding an already prepared clause
1963 // back into prepare() would treat the escaped search value as placeholders.
1964 if ( empty( $search ) ) {
1965 return (int)$wpdb->get_var( $wpdb->prepare(
1966 "SELECT COUNT(r.id) FROM $table_ref r WHERE r.run_id = %d $filter_sql",
1967 $run_id
1968 ) );
1969 }
1970 $search_like = '%' . $wpdb->esc_like( $search ) . '%';
1971 return (int)$wpdb->get_var( $wpdb->prepare(
1972 "SELECT COUNT(r.id) FROM $table_ref r
1973 LEFT JOIN $posts_table p ON r.origin = p.ID
1974 WHERE r.run_id = %d $filter_sql
1975 AND (r.mediaId LIKE %s OR r.mediaUrl LIKE %s OR r.originType LIKE %s
1976 OR r.origin LIKE %s OR p.post_title LIKE %s)",
1977 $run_id, $search_like, $search_like, $search_like, $search_like, $search_like
1978 ) );
1979 }
1980
1981 function rest_get_stats( $request ) {
1982 $search = sanitize_text_field( $request->get_param('search') );
1983 $reference_filter = sanitize_text_field( $request->get_param('referenceFilter') );
1984 $repair_mode = rest_sanitize_boolean( $request->get_param('repairMode') );
1985
1986 global $wpdb;
1987 $whereSql = empty($search) ? '' : $wpdb->prepare("AND path LIKE %s", ( '%' . $search . '%' ));
1988 $table_scan = $wpdb->prefix . "mclean_scan";
1989 $run_id = $this->core->get_run_id();
1990 $issues = $repair_mode
1991 ? $this->core->get_stats_of_issues_to_repair( $search )
1992 : $wpdb->get_row( $wpdb->prepare( "SELECT COUNT(*) as entries, SUM(size) as size
1993 FROM $table_scan WHERE run_id = %d AND ignored = 0 AND deleted = 0 $whereSql", $run_id ) );
1994 $ignored = (int)$wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*)
1995 FROM $table_scan WHERE run_id = %d AND ignored = 1 $whereSql", $run_id ) );
1996 $trash = $wpdb->get_row( $wpdb->prepare( "SELECT COUNT(*) as entries, SUM(size) as size
1997 FROM $table_scan WHERE run_id = %d AND deleted = 1 $whereSql", $run_id ) );
1998 $references = $this->count_references($search, $reference_filter);
1999
2000 return new WP_REST_Response( [ 'success' => true, 'data' => array(
2001 'issues' => $issues->entries,
2002 'issues_size' => $issues->size,
2003 'ignored' => $ignored,
2004 'trash' => $trash->entries,
2005 'trash_size' => $trash->size,
2006 'references' => $references,
2007 ) ], 200 );
2008 }
2009
2010 function rest_uploads_directory_hierarchy( $request ) {
2011 if ( !$this->admin->is_pro_user() ) {
2012 return $this->error_response( new WP_Error(
2013 'wpmc_pro_required',
2014 __( 'This feature is available to Pro users.', 'media-cleaner' ),
2015 array( 'status' => 403 )
2016 ) );
2017 }
2018
2019 $force = rest_sanitize_boolean( $request->get_param('force') );
2020 $transientKey = 'wpmc_uploads_directory_hierarchy_' . get_current_blog_id();
2021 if ( $force ) {
2022 delete_transient( $transientKey );
2023 }
2024
2025 $data = get_transient( $transientKey );
2026 if ( !$data ) {
2027 $data = $this->core->get_uploads_directory_hierarchy();
2028 set_transient( $transientKey, $data, HOUR_IN_SECONDS );
2029 }
2030
2031 $uploads_dir = wp_upload_dir();
2032 $root = wp_normalize_path( '/' . wp_basename( $uploads_dir['basedir'] ) );
2033
2034 return new WP_REST_Response( [ 'success' => true, 'data' => [
2035 'root' => $root,
2036 'hierarchy' => $data,
2037 ] ] , 200 );
2038 }
2039
2040 function rest_get_progress() {
2041 $progress = $this->core->get_progress();
2042 return new WP_REST_Response( [ 'success' => true, 'data' => $progress ], 200 );
2043 }
2044
2045 function rest_clear_progress() {
2046 $this->core->clear_step_progress();
2047 return new WP_REST_Response( [ 'success' => true, 'message' => __( 'Progress cleared.', 'media-cleaner' ) ], 200 );
2048 }
2049
2050 function rest_export( $request ) {
2051 global $wpdb;
2052 $table_scan = $wpdb->prefix . "mclean_scan";
2053 $table_ref = $wpdb->prefix . "mclean_refs";
2054 $run_id = $this->core->get_run_id();
2055 $section = sanitize_key( (string) $request->get_param( 'section' ) );
2056 $section = $section ?: 'issues';
2057 $cursor = max( 0, (int) $request->get_param( 'cursor' ) );
2058 $limit_param = (int) $request->get_param( 'limit' );
2059 $limit = $limit_param > 0 ? max( 1, min( 500, $limit_param ) ) : 500;
2060 $sections = array( 'issues', 'ignored', 'trash', 'references' );
2061 if ( !in_array( $section, $sections, true ) ) $section = 'issues';
2062
2063 $rows = array();
2064 if ( $section === 'references' ) {
2065 $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, mediaId, mediaUrl, originType, origin FROM $table_ref WHERE run_id = %d AND id > %d ORDER BY id ASC LIMIT %d", $run_id, $cursor, $limit ) );
2066 }
2067 else {
2068 $condition = $section === 'issues' ? 'ignored = 0 AND deleted = 0' : ( $section === 'ignored' ? 'ignored = 1' : 'deleted = 1' );
2069 $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, path, size, issue, time, postId FROM $table_scan WHERE run_id = %d AND id > %d AND $condition ORDER BY id ASC LIMIT %d", $run_id, $cursor, $limit ) );
2070 }
2071
2072 $csv = $section === 'issues' && $cursor === 0 ? "Tab,ID,Path/Url,Size,Issue/Origin,Time,PostId,MediaId\n" : '';
2073 foreach ( $rows as $row ) {
2074 if ( $section === 'references' ) {
2075 $post_id = preg_match( '/\[(\d+)\]/', $row->originType, $matches ) ? $matches[1] : '';
2076 $csv .= implode( ',', array_map( array( $this, 'csv_cell' ), array( 'Found In Use Medias', $row->id, $row->mediaUrl, '', $row->originType, '', $post_id, $row->mediaId ) ) ) . "\n";
2077 }
2078 else {
2079 $label = $section === 'issues' ? 'Issues' : ( $section === 'ignored' ? 'Ignored' : 'Trash' );
2080 $csv .= implode( ',', array_map( array( $this, 'csv_cell' ), array( $label, $row->id, $row->path, $row->size, $row->issue, $row->time, $row->postId, '' ) ) ) . "\n";
2081 }
2082 }
2083
2084 $next_cursor = !empty( $rows ) ? (int) end( $rows )->id : $cursor;
2085 $section_finished = count( $rows ) < $limit;
2086 $section_index = array_search( $section, $sections, true );
2087 $finished = $section_finished && $section_index === count( $sections ) - 1;
2088 $next_section = $section_finished && !$finished ? $sections[ $section_index + 1 ] : $section;
2089 if ( $section_finished && !$finished ) $next_cursor = 0;
2090
2091 return new WP_REST_Response( array( 'success' => true, 'data' => array(
2092 'chunk' => $csv,
2093 'section' => $next_section,
2094 'cursor' => $next_cursor,
2095 'finished' => $finished,
2096 ) ), 200 );
2097 }
2098
2099 public function csv_cell( $value ) {
2100 $value = (string) $value;
2101 if ( preg_match( '/^[=+\-@]/', $value ) ) $value = "'" . $value;
2102 return '"' . str_replace( '"', '""', $value ) . '"';
2103 }
2104 }
2105