PluginProbe ʕ •ᴥ•ʔ
Media Cleaner: Clean your WordPress! / 7.2.1
Media Cleaner: Clean your WordPress! v7.2.1
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 parsers.php 1 year ago rest.php 1 month ago runs.php 1 month ago support.php 1 month ago ui.php 1 month ago
rest.php
1955 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 $where_sql = $wpdb->prepare( 'AND run_id = %d', $run_id );
1359 if ($referenceFilter === 'mediaIds') {
1360 $where_sql .= ' AND mediaId IS NOT NULL';
1361 } else if ($referenceFilter === 'mediaUrls') {
1362 $where_sql .= ' AND mediaUrl IS NOT NULL';
1363 }
1364
1365 $order_sql = 'ORDER BY id DESC';
1366 if ( $orderBy === 'id' ) {
1367 $order_sql = 'ORDER BY ID IS NULL, ID ' . ( $order === 'asc' ? 'ASC' : 'DESC' );
1368 } elseif ( $orderBy === 'mediaId' ) {
1369 $order_sql = 'ORDER BY mediaId IS NULL, mediaId ' . ( $order === 'asc' ? 'ASC' : 'DESC' );
1370 } elseif ( $orderBy === 'mediaUrl' ) {
1371 $order_sql = 'ORDER BY mediaUrl IS NULL, mediaUrl ' . ( $order === 'asc' ? 'ASC' : 'DESC' );
1372 } elseif ( $orderBy === 'originType' ) {
1373 $order_sql = 'ORDER BY originType ' . ( $order === 'asc' ? 'ASC' : 'DESC' );
1374 }
1375
1376 if ( empty( $search ) ) {
1377 $entries = $wpdb->get_results(
1378 $wpdb->prepare( "SELECT *
1379 FROM $table_ref
1380 WHERE 1=1
1381 $where_sql
1382 $order_sql
1383 LIMIT %d, %d", $skip, $limit
1384 )
1385 );
1386 } else {
1387 $posts_table = $wpdb->posts;
1388 $entries = $wpdb->get_results(
1389 $wpdb->prepare( "SELECT r.*
1390 FROM $table_ref r
1391 LEFT JOIN $posts_table p ON r.origin = p.ID
1392 WHERE (r.mediaId LIKE '%1\$s'
1393 OR r.mediaUrl LIKE '%1\$s'
1394 OR r.originType LIKE '%1\$s'
1395 OR r.origin LIKE '%1\$s'
1396 OR p.post_title LIKE '%1\$s')
1397 $where_sql
1398 $order_sql
1399 LIMIT %2\$d, %3\$d", ( '%' . $search . '%' ), $skip, $limit
1400 )
1401 );
1402 }
1403
1404 // Prepare arrays to store IDs and data
1405 $post_ids = [];
1406 $media_ids = [];
1407 $media_urls = [];
1408
1409 // Extract post IDs and media IDs/URLs
1410 foreach ( $entries as $entry ) {
1411
1412 if( $entry->origin && is_numeric( $entry->origin ) ) {
1413 $post_ids[] = (int) $entry->origin;
1414 }
1415
1416 // Collect media IDs and URLs
1417 if ( $entry->mediaId ) {
1418 $media_ids[] = $entry->mediaId;
1419 }
1420
1421 if ( $entry->mediaUrl ) {
1422 $media_urls[] = $entry->mediaUrl;
1423 }
1424 }
1425
1426 // Remove duplicates
1427 $post_ids = array_unique( $post_ids );
1428 $media_ids = array_unique( $media_ids );
1429 $media_urls = array_unique( $media_urls );
1430
1431 // Get post titles
1432 $post_titles = [];
1433 if ( !empty( $post_ids ) ) {
1434 $posts = get_posts( array(
1435 'include' => $post_ids,
1436 'post_type' => 'any',
1437 'numberposts' => -1,
1438 ) );
1439 foreach ( $posts as $post ) {
1440 $post_titles[ $post->ID ] = $post->post_title;
1441 }
1442 }
1443
1444 // Get thumbnails for media IDs
1445 $media_thumbnails = [];
1446 foreach ( $media_ids as $media_id ) {
1447 $media = wp_get_attachment_image_src( $media_id, 'thumbnail' );
1448 if ( $media ) {
1449 $media_thumbnails[ $media_id ] = $media[0];
1450 }
1451 }
1452
1453 // Map media URLs to attachment IDs and get thumbnails
1454 $media_url_to_id = [];
1455 foreach ( $media_urls as $media_url ) {
1456 $attachment_id = attachment_url_to_postid( $media_url );
1457 if ( $attachment_id ) {
1458 $media_url_to_id[ $media_url ] = $attachment_id;
1459 $media = wp_get_attachment_image_src( $attachment_id, 'thumbnail' );
1460 if ( $media ) {
1461 $media_thumbnails[ $attachment_id ] = $media[0];
1462 }
1463 }
1464 }
1465
1466 // Get the uploads directory URL
1467 $upload_dir = wp_upload_dir();
1468 $upload_baseurl = $upload_dir['baseurl'];
1469
1470 // Assign post titles and thumbnails to entries
1471 foreach ( $entries as $entry ) {
1472 // Assign post title
1473 if ( isset( $entry->origin ) && isset( $post_titles[ $entry->origin ] ) ) {
1474 $entry->post_title = $post_titles[ $entry->origin ];
1475 } else {
1476 $entry->post_title = '';
1477 }
1478
1479
1480 // Assign thumbnail
1481 $entry->thumbnail = '';
1482
1483 if ( $entry->mediaId && isset( $media_thumbnails[ $entry->mediaId ] ) ) {
1484 $entry->thumbnail = $media_thumbnails[ $entry->mediaId ];
1485 } elseif ( $entry->mediaUrl && isset( $media_url_to_id[ $entry->mediaUrl ] ) ) {
1486 $attachment_id = $media_url_to_id[ $entry->mediaUrl ];
1487 if ( isset( $media_thumbnails[ $attachment_id ] ) ) {
1488 $entry->thumbnail = $media_thumbnails[ $attachment_id ];
1489 }
1490 }
1491
1492 // If thumbnail is still empty, use mediaUrl as thumbnail
1493 if ( empty( $entry->thumbnail ) && $entry->mediaUrl ) {
1494 // Ensure mediaUrl is absolute
1495 if ( strpos( $entry->mediaUrl, 'http' ) !== 0 ) {
1496 $entry->thumbnail = $upload_baseurl . '/' . ltrim( $entry->mediaUrl, '/' );
1497 } else {
1498 $entry->thumbnail = $entry->mediaUrl;
1499 }
1500 }
1501
1502 // Ensure thumbnail is absolute URL ( for sizes of medias )
1503 if ( !empty( $entry->thumbnail ) && strpos( $entry->thumbnail, 'http' ) !== 0 ) {
1504 $entry->thumbnail = $upload_baseurl . '/' . ltrim( $entry->thumbnail, '/' );
1505 }
1506 }
1507
1508 return new WP_REST_Response( [ 'success' => true, 'data' => $entries, 'total' => $total ], 200 );
1509 }
1510
1511 function rest_entries( $request ) {
1512 global $wpdb;
1513 $limit = max( 1, min( 100, (int) $request->get_param('limit') ) );
1514 $skip = max( 0, (int) $request->get_param('skip') );
1515 $filterBy = sanitize_text_field( $request->get_param('filterBy') );
1516 $orderBy = sanitize_text_field( $request->get_param('orderBy') );
1517 $order = sanitize_text_field( $request->get_param('order') );
1518 $search = sanitize_text_field( $request->get_param('search') );
1519 $repair_mode = rest_sanitize_boolean( $request->get_param('repairMode') );
1520 $table_scan = $wpdb->prefix . "mclean_scan";
1521 $run_id = $this->core->get_run_id();
1522 $total = 0;
1523
1524 if ( $filterBy === 'references' ) {
1525 return $this->rest_reference_entries( $request );
1526 }
1527
1528 $entries = [];
1529 if ( $repair_mode ) {
1530 $entries = $this->core->get_issues_to_repair( $orderBy, $order, $search, $skip, $limit );
1531 $total = $this->core->get_count_of_issues_to_repair( $search );
1532 }
1533 else {
1534 $filters = array(
1535 'issues' => 'ignored = 0 AND deleted = 0',
1536 'ignored' => 'ignored = 1',
1537 'trash' => 'deleted = 1',
1538 'all' => 'deleted = 0',
1539 );
1540 $filter_sql = isset( $filters[ $filterBy ] ) ? $filters[ $filterBy ] : $filters['all'];
1541 $total = $filterBy === 'issues' ? $this->count_issues( $search ) : ( $filterBy === 'ignored' ? $this->count_ignored( $search ) : ( $filterBy === 'trash' ? $this->count_trash( $search ) : 0 ) );
1542
1543 $allowed_order = array( 'id', 'type', 'postId', 'time', 'path', 'size' );
1544 $order_column = in_array( $orderBy, $allowed_order, true ) ? $orderBy : 'id';
1545 $order_direction = $order === 'asc' ? 'ASC' : 'DESC';
1546 $search_sql = empty( $search ) ? '' : $wpdb->prepare( 'AND path LIKE %s', '%' . $wpdb->esc_like( $search ) . '%' );
1547 $entries = $wpdb->get_results( $wpdb->prepare(
1548 "SELECT id, type, postId, path, size, ignored, deleted, issue, time
1549 FROM $table_scan
1550 WHERE run_id = %d AND $filter_sql $search_sql
1551 ORDER BY $order_column $order_direction
1552 LIMIT %d, %d",
1553 $run_id,
1554 $skip,
1555 $limit
1556 ) );
1557 }
1558
1559 $is_trash = $filterBy === 'trash';
1560 $base = $this->core->upload_url;
1561 foreach ( $entries as $entry ) {
1562 if ( $is_trash ) {
1563 $entry->thumbnail_url = null;
1564 $entry->image_url = null;
1565 if ( $entry->type != 0 ) {
1566 $entry->title = html_entity_decode( get_the_title( $entry->postId ) );
1567 }
1568 continue;
1569 }
1570
1571 // FILESYSTEM
1572 if ( $entry->type == 0 ) {
1573 $entry->thumbnail_url = htmlspecialchars( trailingslashit( $base ) . $entry->path, ENT_QUOTES );
1574 $entry->image_url = $entry->thumbnail_url;
1575
1576 // If the extension is not an image, we set the thumbnail to null
1577 $ext = pathinfo( $entry->path, PATHINFO_EXTENSION );
1578 if ( !$this->core->is_image_extension( $ext ) ) {
1579 $entry->thumbnail_url = null;
1580 }
1581
1582
1583
1584 }
1585 // MEDIA
1586 else {
1587 $attachment_src = wp_get_attachment_image_src( $entry->postId, 'thumbnail' );
1588 $attachment_src_large = wp_get_attachment_image_src( $entry->postId, 'large' );
1589 $thumbnail = empty( $attachment_src ) ? null : $attachment_src[0];
1590 $image = empty( $attachment_src_large ) ? null : $attachment_src_large[0];
1591 // This was working when the Post Type" was attachment"
1592 $entry->thumbnail_url = $thumbnail;
1593 $entry->image_url = $image;
1594 $entry->title = html_entity_decode( get_the_title( $entry->postId ) );
1595 }
1596 }
1597
1598 return new WP_REST_Response( [ 'success' => true, 'data' => $entries, 'total' => $total ], 200 );
1599 }
1600
1601 private function perform_item_operations( $request, $operation, $callback ) {
1602 if ( !$this->core->can_cleanup() ) {
1603 return $this->error_response( new WP_Error(
1604 'wpmc_cleanup_locked',
1605 __( 'Cleanup is available only for the last completed scan and while no other scan is running.', 'media-cleaner' ),
1606 array( 'status' => 409 )
1607 ) );
1608 }
1609
1610 $params = $this->request_json( $request );
1611 $ids = isset( $params['entryIds'] ) ? (array) $params['entryIds'] : array();
1612 if ( isset( $params['entryId'] ) ) {
1613 $ids[] = $params['entryId'];
1614 }
1615 $ids = array_values( array_unique( array_filter( array_map( 'absint', $ids ) ) ) );
1616 if ( empty( $ids ) || count( $ids ) > 100 ) {
1617 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 ) ) );
1618 }
1619 $request_key = isset( $params['requestKey'] ) ? sanitize_text_field( $params['requestKey'] ) : wp_generate_uuid4();
1620 $results = array();
1621 $succeeded = 0;
1622 $failed = 0;
1623 $attempted = 0;
1624 $yielded = false;
1625 $this->core->timeout_check_start( count( $ids ) );
1626
1627 foreach ( $ids as $index => $id ) {
1628 if ( $index > 0 && $this->core->timeout_should_yield() ) {
1629 $yielded = true;
1630 break;
1631 }
1632 $attempted = $index + 1;
1633 $issue = null;
1634 $journal = $this->core->runs->begin_operation( $id, $operation, $request_key );
1635 if ( is_wp_error( $journal ) ) {
1636 $results[] = array( 'id' => $id, 'success' => false, 'code' => $journal->get_error_code(), 'message' => $journal->get_error_message() );
1637 $failed++;
1638 continue;
1639 }
1640 if ( $journal->state === 'complete' ) {
1641 $results[] = array( 'id' => $id, 'success' => true, 'idempotent' => true );
1642 $succeeded++;
1643 continue;
1644 }
1645 $manifest = json_decode( (string) $journal->manifest, true );
1646 $manifest = is_array( $manifest ) ? $manifest : array();
1647 if ( $journal->state === 'pending' ) {
1648 $issue = $this->core->get_issue( $id );
1649 if ( !$issue ) {
1650 $error = new WP_Error( 'wpmc_issue_missing', __( 'The selected Media Cleaner result no longer exists.', 'media-cleaner' ) );
1651 $this->core->runs->update_operation( $journal->id, 'failed', $manifest, $error );
1652 $results[] = array( 'id' => $id, 'success' => false, 'code' => $error->get_error_code(), 'message' => $error->get_error_message() );
1653 $failed++;
1654 continue;
1655 }
1656 $manifest = array(
1657 'initial_deleted' => (bool) $issue->deleted,
1658 'initial_ignored' => (bool) $issue->ignored,
1659 'initial_type' => (int) $issue->type,
1660 );
1661 }
1662 $requires_identity = in_array( $operation, array( 'delete', 'recover', 'repair' ), true );
1663 if ( $requires_identity && empty( $manifest['identity_validated'] ) ) {
1664 $issue = isset( $issue ) && $issue ? $issue : $this->core->get_issue( $id );
1665 $identity = $issue ? $this->core->validate_issue_manifest( $issue ) : new WP_Error( 'wpmc_issue_missing', __( 'The selected Media Cleaner result no longer exists.', 'media-cleaner' ) );
1666 if ( is_wp_error( $identity ) ) {
1667 $this->core->runs->update_operation( $journal->id, 'failed', $manifest, $identity );
1668 $results[] = array( 'id' => $id, 'success' => false, 'code' => $identity->get_error_code(), 'message' => $identity->get_error_message() );
1669 $failed++;
1670 continue;
1671 }
1672 $manifest['identity_validated'] = true;
1673 }
1674 if ( !$this->core->runs->update_operation( $journal->id, 'running', $manifest ) ) {
1675 $results[] = array( 'id' => $id, 'success' => false, 'code' => 'wpmc_operation_journal_failed', 'message' => __( 'The cleanup journal could not be updated.', 'media-cleaner' ) );
1676 $failed++;
1677 continue;
1678 }
1679 try {
1680 $result = in_array( $operation, array( 'delete', 'recover' ), true ) ? call_user_func( $callback, $id, $manifest ) : call_user_func( $callback, $id );
1681 if ( is_wp_error( $result ) || $result !== true ) {
1682 $error = is_wp_error( $result ) ? $result : new WP_Error( 'wpmc_operation_failed', __( 'The item could not be updated.', 'media-cleaner' ) );
1683 $this->core->runs->update_operation( $journal->id, 'failed', null, $error );
1684 $results[] = array( 'id' => $id, 'success' => false, 'code' => $error->get_error_code(), 'message' => $error->get_error_message() );
1685 $failed++;
1686 }
1687 else {
1688 if ( !$this->core->runs->update_operation( $journal->id, 'complete', $manifest ) ) {
1689 $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' ) );
1690 $failed++;
1691 }
1692 else {
1693 $results[] = array( 'id' => $id, 'success' => true );
1694 $succeeded++;
1695 }
1696 }
1697 }
1698 catch ( Throwable $e ) {
1699 $error = new WP_Error( 'wpmc_operation_exception', $e->getMessage() );
1700 $this->core->runs->update_operation( $journal->id, 'failed', null, $error );
1701 $results[] = array( 'id' => $id, 'success' => false, 'code' => $error->get_error_code(), 'message' => $error->get_error_message() );
1702 $failed++;
1703 }
1704 }
1705
1706 $response = array(
1707 'success' => $failed === 0,
1708 'data' => array(
1709 'results' => $results,
1710 'succeeded' => $succeeded,
1711 'failed' => $failed,
1712 'request_key' => $request_key,
1713 'finished' => !$yielded,
1714 'remaining' => max( 0, count( $ids ) - $attempted ),
1715 ),
1716 'message' => $failed === 0 ? __( 'All requested items were updated.', 'media-cleaner' ) : sprintf( __( '%1$d item(s) succeeded and %2$d failed.', 'media-cleaner' ), $succeeded, $failed ),
1717 );
1718 $new_token = $this->verify_token();
1719 if ( $new_token ) {
1720 $response['new_token'] = $new_token;
1721 }
1722 return new WP_REST_Response( $response, $failed === 0 ? 200 : 207 );
1723 }
1724
1725 function rest_set_ignore( $request ) {
1726 $params = $this->request_json( $request );
1727 $ignore = isset( $params['ignore'] ) ? rest_sanitize_boolean( $params['ignore'] ) : true;
1728 return $this->perform_item_operations( $request, $ignore ? 'ignore' : 'unignore', function( $id ) use ( $ignore ) {
1729 return $this->core->ignore( $id, $ignore );
1730 } );
1731 }
1732
1733 function rest_delete( $request ) {
1734 return $this->perform_item_operations( $request, 'delete', array( $this->core, 'delete' ) );
1735 }
1736
1737 function rest_force_trash_all( $request ) {
1738 if ( !$this->core->can_cleanup() ) {
1739 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 ) ) );
1740 }
1741 $params = $this->request_json( $request );
1742 $initialize = isset( $params['initialize'] ) ? rest_sanitize_boolean( $params['initialize'] ) : true;
1743 $res = $this->core->force_trash( $initialize, 100 );
1744 if ( is_wp_error( $res ) ) return $this->error_response( $res );
1745 return new WP_REST_Response( array(
1746 'success' => true,
1747 'data' => $res,
1748 'message' => !empty( $res['finished'] ) ? __( 'The Media Cleaner trash has been emptied.', 'media-cleaner' ) : __( 'Media Cleaner emptied another bounded trash batch.', 'media-cleaner' ),
1749 ), 200 );
1750 }
1751
1752 function rest_recover( $request ) {
1753 return $this->perform_item_operations( $request, 'recover', array( $this->core, 'recover' ) );
1754 }
1755
1756 function rest_repair( $request ) {
1757 return $this->perform_item_operations( $request, 'repair', array( $this->core, 'repair' ) );
1758 }
1759
1760 function get_issues_ids( $search, $cursor = 0, $limit = 100 ) {
1761 global $wpdb;
1762 $whereSql = empty($search) ? '' : $wpdb->prepare( "AND path LIKE %s", '%' . $wpdb->esc_like( $search ) . '%' );
1763 $table_scan = $wpdb->prefix . "mclean_scan";
1764 $run_id = $this->core->get_run_id();
1765 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 ) );
1766 }
1767
1768 function get_ignored_ids( $search, $cursor = 0, $limit = 100 ) {
1769 global $wpdb;
1770 $whereSql = empty($search) ? '' : $wpdb->prepare( "AND path LIKE %s", '%' . $wpdb->esc_like( $search ) . '%' );
1771 $table_scan = $wpdb->prefix . "mclean_scan";
1772 $run_id = $this->core->get_run_id();
1773 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 ) );
1774 }
1775
1776 function get_trash_ids( $search, $cursor = 0, $limit = 100 ) {
1777 global $wpdb;
1778 $whereSql = empty($search) ? '' : $wpdb->prepare( "AND path LIKE %s", '%' . $wpdb->esc_like( $search ) . '%' );
1779 $table_scan = $wpdb->prefix . "mclean_scan";
1780 $run_id = $this->core->get_run_id();
1781 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 ) );
1782 }
1783
1784 function count_issues($search) {
1785 global $wpdb;
1786 $whereSql = empty($search) ? '' : $wpdb->prepare("AND path LIKE %s", ( '%' . $search . '%' ));
1787 $table_scan = $wpdb->prefix . "mclean_scan";
1788 $run_id = $this->core->get_run_id();
1789 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 ) );
1790 }
1791
1792 function count_ignored($search) {
1793 global $wpdb;
1794 $whereSql = empty($search) ? '' : $wpdb->prepare("AND path LIKE %s", ( '%' . $search . '%' ));
1795 $table_scan = $wpdb->prefix . "mclean_scan";
1796 $run_id = $this->core->get_run_id();
1797 return (int)$wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $table_scan WHERE run_id = %d AND ignored = 1 $whereSql", $run_id ) );
1798 }
1799
1800 function count_trash($search) {
1801 global $wpdb;
1802 $whereSql = empty($search) ? '' : $wpdb->prepare("AND path LIKE %s", ( '%' . $search . '%' ));
1803 $table_scan = $wpdb->prefix . "mclean_scan";
1804 $run_id = $this->core->get_run_id();
1805 return (int)$wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $table_scan WHERE run_id = %d AND deleted = 1 $whereSql", $run_id ) );
1806 }
1807
1808 function count_references($search, $referenceFilter) {
1809 global $wpdb;
1810 $table_ref = $wpdb->prefix . "mclean_refs";
1811 $run_id = $this->core->get_run_id();
1812 $posts_table = $wpdb->posts;
1813 $where_sqls = [];
1814 $join_sql = '';
1815 if (! empty($search) ) {
1816 $search_like = '%' . $wpdb->esc_like( $search ) . '%';
1817 $where_sqls[] = $wpdb->prepare("AND (r.mediaId LIKE '%1\$s' OR r.mediaUrl LIKE '%1\$s' OR r.originType LIKE '%1\$s' OR r.origin LIKE '%1\$s' OR p.post_title LIKE '%1\$s')", $search_like);
1818 $join_sql = "LEFT JOIN $posts_table p ON r.origin = p.ID";
1819 }
1820 if ( $referenceFilter !== 'showAll' ) {
1821 if ($referenceFilter === 'mediaIds') {
1822 $where_sqls[] = 'AND r.mediaId IS NOT NULL';
1823 } else if ($referenceFilter === 'mediaUrls') {
1824 $where_sqls[] = 'AND r.mediaUrl IS NOT NULL';
1825 }
1826 }
1827 $where_sql = implode(' ', $where_sqls);
1828 return (int)$wpdb->get_var( $wpdb->prepare( "SELECT COUNT(r.id) FROM $table_ref r $join_sql WHERE r.run_id = %d $where_sql", $run_id ) );
1829 }
1830
1831 function rest_get_stats( $request ) {
1832 $search = sanitize_text_field( $request->get_param('search') );
1833 $reference_filter = sanitize_text_field( $request->get_param('referenceFilter') );
1834 $repair_mode = rest_sanitize_boolean( $request->get_param('repairMode') );
1835
1836 global $wpdb;
1837 $whereSql = empty($search) ? '' : $wpdb->prepare("AND path LIKE %s", ( '%' . $search . '%' ));
1838 $table_scan = $wpdb->prefix . "mclean_scan";
1839 $run_id = $this->core->get_run_id();
1840 $issues = $repair_mode
1841 ? $this->core->get_stats_of_issues_to_repair( $search )
1842 : $wpdb->get_row( $wpdb->prepare( "SELECT COUNT(*) as entries, SUM(size) as size
1843 FROM $table_scan WHERE run_id = %d AND ignored = 0 AND deleted = 0 $whereSql", $run_id ) );
1844 $ignored = (int)$wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*)
1845 FROM $table_scan WHERE run_id = %d AND ignored = 1 $whereSql", $run_id ) );
1846 $trash = $wpdb->get_row( $wpdb->prepare( "SELECT COUNT(*) as entries, SUM(size) as size
1847 FROM $table_scan WHERE run_id = %d AND deleted = 1 $whereSql", $run_id ) );
1848 $references = $this->count_references($search, $reference_filter);
1849
1850 return new WP_REST_Response( [ 'success' => true, 'data' => array(
1851 'issues' => $issues->entries,
1852 'issues_size' => $issues->size,
1853 'ignored' => $ignored,
1854 'trash' => $trash->entries,
1855 'trash_size' => $trash->size,
1856 'references' => $references,
1857 ) ], 200 );
1858 }
1859
1860 function rest_uploads_directory_hierarchy( $request ) {
1861 if ( !$this->admin->is_pro_user() ) {
1862 return $this->error_response( new WP_Error(
1863 'wpmc_pro_required',
1864 __( 'This feature is available to Pro users.', 'media-cleaner' ),
1865 array( 'status' => 403 )
1866 ) );
1867 }
1868
1869 $force = rest_sanitize_boolean( $request->get_param('force') );
1870 $transientKey = 'wpmc_uploads_directory_hierarchy_' . get_current_blog_id();
1871 if ( $force ) {
1872 delete_transient( $transientKey );
1873 }
1874
1875 $data = get_transient( $transientKey );
1876 if ( !$data ) {
1877 $data = $this->core->get_uploads_directory_hierarchy();
1878 set_transient( $transientKey, $data, HOUR_IN_SECONDS );
1879 }
1880
1881 $uploads_dir = wp_upload_dir();
1882 $root = wp_normalize_path( '/' . wp_basename( $uploads_dir['basedir'] ) );
1883
1884 return new WP_REST_Response( [ 'success' => true, 'data' => [
1885 'root' => $root,
1886 'hierarchy' => $data,
1887 ] ] , 200 );
1888 }
1889
1890 function rest_get_progress() {
1891 $progress = $this->core->get_progress();
1892 return new WP_REST_Response( [ 'success' => true, 'data' => $progress ], 200 );
1893 }
1894
1895 function rest_clear_progress() {
1896 $this->core->clear_step_progress();
1897 return new WP_REST_Response( [ 'success' => true, 'message' => __( 'Progress cleared.', 'media-cleaner' ) ], 200 );
1898 }
1899
1900 function rest_export( $request ) {
1901 global $wpdb;
1902 $table_scan = $wpdb->prefix . "mclean_scan";
1903 $table_ref = $wpdb->prefix . "mclean_refs";
1904 $run_id = $this->core->get_run_id();
1905 $section = sanitize_key( (string) $request->get_param( 'section' ) );
1906 $section = $section ?: 'issues';
1907 $cursor = max( 0, (int) $request->get_param( 'cursor' ) );
1908 $limit_param = (int) $request->get_param( 'limit' );
1909 $limit = $limit_param > 0 ? max( 1, min( 500, $limit_param ) ) : 500;
1910 $sections = array( 'issues', 'ignored', 'trash', 'references' );
1911 if ( !in_array( $section, $sections, true ) ) $section = 'issues';
1912
1913 $rows = array();
1914 if ( $section === 'references' ) {
1915 $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 ) );
1916 }
1917 else {
1918 $condition = $section === 'issues' ? 'ignored = 0 AND deleted = 0' : ( $section === 'ignored' ? 'ignored = 1' : 'deleted = 1' );
1919 $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 ) );
1920 }
1921
1922 $csv = $section === 'issues' && $cursor === 0 ? "Tab,ID,Path/Url,Size,Issue/Origin,Time,PostId,MediaId\n" : '';
1923 foreach ( $rows as $row ) {
1924 if ( $section === 'references' ) {
1925 $post_id = preg_match( '/\[(\d+)\]/', $row->originType, $matches ) ? $matches[1] : '';
1926 $csv .= implode( ',', array_map( array( $this, 'csv_cell' ), array( 'Found In Use Medias', $row->id, $row->mediaUrl, '', $row->originType, '', $post_id, $row->mediaId ) ) ) . "\n";
1927 }
1928 else {
1929 $label = $section === 'issues' ? 'Issues' : ( $section === 'ignored' ? 'Ignored' : 'Trash' );
1930 $csv .= implode( ',', array_map( array( $this, 'csv_cell' ), array( $label, $row->id, $row->path, $row->size, $row->issue, $row->time, $row->postId, '' ) ) ) . "\n";
1931 }
1932 }
1933
1934 $next_cursor = !empty( $rows ) ? (int) end( $rows )->id : $cursor;
1935 $section_finished = count( $rows ) < $limit;
1936 $section_index = array_search( $section, $sections, true );
1937 $finished = $section_finished && $section_index === count( $sections ) - 1;
1938 $next_section = $section_finished && !$finished ? $sections[ $section_index + 1 ] : $section;
1939 if ( $section_finished && !$finished ) $next_cursor = 0;
1940
1941 return new WP_REST_Response( array( 'success' => true, 'data' => array(
1942 'chunk' => $csv,
1943 'section' => $next_section,
1944 'cursor' => $next_cursor,
1945 'finished' => $finished,
1946 ) ), 200 );
1947 }
1948
1949 public function csv_cell( $value ) {
1950 $value = (string) $value;
1951 if ( preg_match( '/^[=+\-@]/', $value ) ) $value = "'" . $value;
1952 return '"' . str_replace( '"', '""', $value ) . '"';
1953 }
1954 }
1955