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