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