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