PluginProbe ʕ •ᴥ•ʔ
Media Cleaner: Clean your WordPress! / 7.2.2
Media Cleaner: Clean your WordPress! v7.2.2
7.2.6 7.2.5 7.2.4 7.2.3 7.2.2 7.2.1 7.2.0 7.1.1 7.1.0 7.0.9 7.0.8 trunk 3.6.8 3.6.9 3.7.0 3.8.0 3.9.0 4.0.0 4.0.2 4.0.4 4.0.6 4.0.7 4.1.0 4.2.0 4.2.2 4.2.3 4.2.4 4.2.5 4.4.0 4.4.2 4.4.4 4.4.6 4.4.7 4.4.8 4.5.0 4.5.4 4.5.6 4.5.7 4.5.8 4.6.2 4.6.3 4.8.0 4.8.4 5.0.0 5.0.1 5.1.0 5.1.1 5.1.3 5.2.0 5.2.1 5.2.4 5.4.0 5.4.1 5.4.2 5.4.3 5.4.4 5.4.5 5.4.6 5.4.9 5.5.0 5.5.1 5.5.2 5.5.3 5.5.4 5.5.7 5.5.8 5.6.1 5.6.2 5.6.3 5.6.4 6.0.1 6.0.2 6.0.3 6.0.4 6.0.5 6.0.6 6.0.7 6.0.8 6.0.9 6.1.2 6.1.3 6.1.4 6.1.5 6.1.6 6.1.7 6.1.8 6.1.9 6.2.0 6.2.1 6.2.3 6.2.4 6.2.5 6.2.6 6.2.7 6.2.8 6.3.0 6.3.1 6.3.2 6.3.4 6.3.5 6.3.7 6.3.8 6.3.9 6.4.0 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.4.7 6.4.8 6.4.9 6.5.0 6.5.1 6.5.2 6.5.3 6.5.4 6.5.5 6.5.6 6.5.7 6.5.8 6.5.9 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.6.6 6.6.7 6.6.8 6.6.9 6.7.0 6.7.1 6.7.2 6.7.3 6.7.4 6.7.5 6.7.6 6.7.7 6.7.8 6.7.9 6.8.0 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.8.6 6.8.7 6.8.8 6.8.9 6.9.0 6.9.1 6.9.2 6.9.3 6.9.4 6.9.5 6.9.6 6.9.7 6.9.8 6.9.9 7.0.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.0.6 7.0.7
media-cleaner / classes / runs.php
media-cleaner / classes Last commit date
parsers 1 month ago admin.php 1 month ago core.php 1 month ago engine.php 1 month ago init.php 9 months ago mcp.php 1 month ago parsers.php 1 month ago rest.php 1 month ago runs.php 1 month ago support.php 1 month ago ui.php 1 month ago
runs.php
920 lines
1 <?php
2
3 class Meow_WPMC_Runs {
4
5 const SCHEMA_VERSION = 5;
6 const LOCK_OPTION = 'wpmc_scan_lock';
7 const ACTIVE_RUN_OPTION = 'wpmc_active_run_id';
8 const SCHEMA_OPTION = 'wpmc_schema_version';
9 const SCHEMA_LOCK_OPTION = 'wpmc_schema_upgrade_lock';
10 const LOCK_TTL = 1800;
11
12 private $core;
13 private $schema_ready = null;
14
15 public function __construct( $core ) {
16 $this->core = $core;
17 }
18
19 public function maybe_upgrade() {
20 $version = (int) get_option( self::SCHEMA_OPTION, 0 );
21 if ( $version >= self::SCHEMA_VERSION && $this->tables_exist() ) {
22 return true;
23 }
24 $lock = get_option( self::SCHEMA_LOCK_OPTION, null );
25 if ( is_array( $lock ) && !empty( $lock['expires_at'] ) && (int) $lock['expires_at'] > time() ) return false;
26 if ( $lock !== null ) delete_option( self::SCHEMA_LOCK_OPTION );
27 $token = wp_generate_uuid4();
28 if ( !add_option( self::SCHEMA_LOCK_OPTION, array( 'token' => $token, 'expires_at' => time() + 600 ), '', false ) ) return false;
29
30 try {
31 wpmc_create_database();
32 if ( !$this->migrate_reference_indexes() ) return false;
33 if ( !$this->tables_exist( true ) ) return false;
34 if ( $version < 2 ) {
35 delete_transient( 'wpmc_progress' );
36 }
37 // Schema 2 to 4 force-disabled shortcode analysis on upgrade, which silently
38 // removed references produced by rendered shortcodes (Foo Gallery and others)
39 // and caused false positives. Undo that overwrite once; users who prefer it
40 // disabled can turn it off again in the settings.
41 if ( $version >= 2 && $version < 5 ) {
42 $options = get_option( 'wpmc_options', array() );
43 if ( is_array( $options ) && !empty( $options['shortcodes_disabled'] ) ) {
44 $options['shortcodes_disabled'] = false;
45 update_option( 'wpmc_options', $options, false );
46 }
47 }
48 update_option( self::SCHEMA_OPTION, self::SCHEMA_VERSION, false );
49 return true;
50 }
51 finally {
52 $stored = get_option( self::SCHEMA_LOCK_OPTION, null );
53 if ( is_array( $stored ) && isset( $stored['token'] ) && hash_equals( $token, (string) $stored['token'] ) ) delete_option( self::SCHEMA_LOCK_OPTION );
54 }
55 }
56
57 public function table( $name ) {
58 global $wpdb;
59 $tables = array(
60 'scan' => $wpdb->prefix . 'mclean_scan',
61 'refs' => $wpdb->prefix . 'mclean_refs',
62 'runs' => $wpdb->prefix . 'mclean_runs',
63 'work' => $wpdb->prefix . 'mclean_work',
64 'operations' => $wpdb->prefix . 'mclean_operations',
65 'duplicates' => $wpdb->prefix . 'mclean_duplicates',
66 );
67 return isset( $tables[ $name ] ) ? $tables[ $name ] : null;
68 }
69
70 public function tables_exist( $refresh = false ) {
71 global $wpdb;
72 if ( !$refresh && $this->schema_ready !== null ) return $this->schema_ready;
73 $this->schema_ready = false;
74 $requirements = array(
75 'scan' => array( 'run_id', 'path_hash', 'manifest', 'deleted', 'ignored' ),
76 'refs' => array( 'run_id', 'mediaUrl_hash', 'ref_hash' ),
77 'runs' => array( 'status', 'phase', 'checkpoint', 'heartbeat_at' ),
78 'work' => array( 'run_id', 'target_hash', 'snapshot_token', 'cursor_value', 'status' ),
79 'operations' => array( 'run_id', 'request_key', 'state', 'manifest' ),
80 'duplicates' => array( 'run_id', 'media_id', 'content_hash' ),
81 );
82 foreach ( $requirements as $name => $columns ) {
83 $table = $this->table( $name );
84 if ( !$table || $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) !== $table ) {
85 return false;
86 }
87 foreach ( $columns as $column ) {
88 $found = $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM `$table` LIKE %s", $column ) );
89 if ( $found !== $column ) return false;
90 }
91 }
92 $critical_indexes = array(
93 array( $this->table( 'scan' ), 'run_state_index' ),
94 array( $this->table( 'scan' ), 'run_path_index' ),
95 array( $this->table( 'refs' ), 'run_ref_hash_unique' ),
96 array( $this->table( 'work' ), 'run_target_unique' ),
97 array( $this->table( 'operations' ), 'request_issue_unique' ),
98 );
99 foreach ( $critical_indexes as $index ) {
100 if ( !$wpdb->get_var( $wpdb->prepare( "SHOW INDEX FROM `{$index[0]}` WHERE Key_name = %s", $index[1] ) ) ) return false;
101 }
102 $refs_table = $this->table( 'refs' );
103 if ( $wpdb->get_var( "SHOW INDEX FROM `$refs_table` WHERE Key_name = 'ref_hash_unique'" ) ) return false;
104 $this->schema_ready = true;
105 return true;
106 }
107
108 public function get_active_id() {
109 return max( 0, (int) get_option( self::ACTIVE_RUN_OPTION, 0 ) );
110 }
111
112 public function get( $run_id ) {
113 global $wpdb;
114 $run_id = (int) $run_id;
115 if ( $run_id < 1 || !$this->tables_exist() ) {
116 return null;
117 }
118 return $wpdb->get_row( $wpdb->prepare(
119 'SELECT * FROM ' . $this->table( 'runs' ) . ' WHERE id = %d',
120 $run_id
121 ) );
122 }
123
124 public function get_resumable() {
125 global $wpdb;
126 if ( !$this->tables_exist() ) {
127 return null;
128 }
129 return $wpdb->get_row(
130 "SELECT * FROM {$this->table( 'runs' )}
131 WHERE status IN ('running', 'paused')
132 ORDER BY id DESC LIMIT 1"
133 );
134 }
135
136 public function start( $method, $config = array(), $request_key = '' ) {
137 global $wpdb;
138 if ( !$this->maybe_upgrade() ) {
139 return new WP_Error( 'wpmc_schema_unavailable', __( 'Media Cleaner could not prepare its database tables.', 'media-cleaner' ) );
140 }
141
142 $method = sanitize_key( $method );
143 if ( !in_array( $method, array( 'media', 'files', 'duplicates', 'optimize_thumbnails' ), true ) ) {
144 return new WP_Error( 'wpmc_invalid_method', __( 'The selected scan method is invalid.', 'media-cleaner' ) );
145 }
146
147 $request_key = substr( sanitize_text_field( $request_key ), 0, 64 );
148 if ( $request_key === '' ) {
149 $request_key = wp_generate_uuid4();
150 }
151 $token = wp_generate_uuid4();
152 $lock = get_option( self::LOCK_OPTION, null );
153 if ( is_array( $lock ) && isset( $lock['request_key'] ) && hash_equals( (string) $lock['request_key'], $request_key ) ) {
154 $locked_run = !empty( $lock['run_id'] ) ? $this->get( (int) $lock['run_id'] ) : null;
155 if ( $locked_run && in_array( $locked_run->status, array( 'running', 'paused' ), true ) ) {
156 $locked_config = $this->decode_json( $locked_run->config );
157 if ( $locked_run->method !== $method || $locked_config !== $config ) {
158 return new WP_Error( 'wpmc_start_request_conflict', __( 'This repeated start request no longer matches the staged scan configuration.', 'media-cleaner' ), array( 'status' => 409, 'run_id' => (int) $locked_run->id ) );
159 }
160 if ( $locked_run->phase === 'starting' ) {
161 $initialized = $this->initialize_started_run( (int) $locked_run->id );
162 if ( is_wp_error( $initialized ) ) return $initialized;
163 }
164 $this->refresh_lock( (int) $locked_run->id );
165 return $this->get( (int) $locked_run->id );
166 }
167 // A matching temporary lock means the first attempt stopped before a run
168 // became visible. Repeating the same start request is safe.
169 delete_option( self::LOCK_OPTION );
170 $lock = null;
171 }
172 if ( is_array( $lock ) && !empty( $lock['run_id'] ) ) {
173 $locked_run = $this->get( (int) $lock['run_id'] );
174 $is_fresh = !empty( $lock['expires_at'] ) && (int) $lock['expires_at'] > time();
175 if ( $locked_run && in_array( $locked_run->status, array( 'running', 'paused' ), true ) ) {
176 if ( !$is_fresh ) {
177 $lock['expires_at'] = time() + self::LOCK_TTL;
178 update_option( self::LOCK_OPTION, $lock, false );
179 }
180 return new WP_Error(
181 'wpmc_scan_locked',
182 __( 'Another Media Cleaner scan is already running. Resume or stop that scan first.', 'media-cleaner' ),
183 array( 'status' => 409, 'run_id' => (int) $locked_run->id )
184 );
185 }
186 $this->mark_stale( (int) $lock['run_id'] );
187 delete_option( self::LOCK_OPTION );
188 }
189 else if ( is_array( $lock ) ) {
190 $is_fresh = !empty( $lock['expires_at'] ) && (int) $lock['expires_at'] > time();
191 if ( $is_fresh ) {
192 return new WP_Error( 'wpmc_scan_initializing', __( 'Another Media Cleaner request is still initializing a scan. Retry shortly.', 'media-cleaner' ), array( 'status' => 409 ) );
193 }
194 delete_option( self::LOCK_OPTION );
195 }
196 else if ( $lock !== null && $lock !== false ) {
197 delete_option( self::LOCK_OPTION );
198 }
199
200 // A missing or damaged option lock must never permit a second staged run.
201 // The database row is the durable source of truth.
202 $resumable = $this->get_resumable();
203 if ( $resumable ) {
204 update_option( self::LOCK_OPTION, array(
205 'token' => wp_generate_uuid4(),
206 'request_key' => '',
207 'run_id' => (int) $resumable->id,
208 'expires_at' => time() + self::LOCK_TTL,
209 ), false );
210 return new WP_Error(
211 'wpmc_scan_locked',
212 __( 'A staged Media Cleaner scan already exists. Resume or stop that scan first.', 'media-cleaner' ),
213 array( 'status' => 409, 'run_id' => (int) $resumable->id )
214 );
215 }
216
217 $temporary_lock = array(
218 'token' => $token,
219 'request_key' => $request_key,
220 'run_id' => 0,
221 'expires_at' => time() + self::LOCK_TTL,
222 );
223 if ( !add_option( self::LOCK_OPTION, $temporary_lock, '', false ) ) {
224 return new WP_Error( 'wpmc_scan_locked', __( 'Another scan started at the same time. Please resume that scan.', 'media-cleaner' ), array( 'status' => 409 ) );
225 }
226
227 $now = current_time( 'mysql', true );
228 $inserted = $wpdb->insert(
229 $this->table( 'runs' ),
230 array(
231 'owner_id' => get_current_user_id(),
232 'method' => $method,
233 'status' => 'running',
234 'phase' => 'starting',
235 'config' => wp_json_encode( $config ),
236 'checkpoint' => wp_json_encode( array() ),
237 'counters' => wp_json_encode( array() ),
238 'errors' => wp_json_encode( array() ),
239 'error_count' => 0,
240 'created_at' => $now,
241 'updated_at' => $now,
242 'heartbeat_at' => $now,
243 ),
244 array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s' )
245 );
246
247 if ( !$inserted ) {
248 delete_option( self::LOCK_OPTION );
249 return new WP_Error( 'wpmc_run_create_failed', __( 'Media Cleaner could not create a scan run.', 'media-cleaner' ), array( 'database_error' => $wpdb->last_error ) );
250 }
251
252 $run_id = (int) $wpdb->insert_id;
253 update_option( self::LOCK_OPTION, array(
254 'token' => $token,
255 'request_key' => $request_key,
256 'run_id' => $run_id,
257 'expires_at' => time() + self::LOCK_TTL,
258 ), false );
259 $stored_lock = get_option( self::LOCK_OPTION, null );
260 if ( !is_array( $stored_lock ) || !isset( $stored_lock['run_id'], $stored_lock['request_key'] ) || (int) $stored_lock['run_id'] !== $run_id || !hash_equals( $request_key, (string) $stored_lock['request_key'] ) ) {
261 $wpdb->update( $this->table( 'runs' ), array( 'status' => 'failed', 'updated_at' => $now, 'finished_at' => $now ), array( 'id' => $run_id ), array( '%s', '%s', '%s' ), array( '%d' ) );
262 delete_option( self::LOCK_OPTION );
263 return new WP_Error( 'wpmc_scan_lock_failed', __( 'Media Cleaner could not persist the scan lock.', 'media-cleaner' ) );
264 }
265 $initialized = $this->initialize_started_run( $run_id );
266 if ( is_wp_error( $initialized ) ) {
267 $this->fail( $run_id, $initialized->get_error_code(), $initialized->get_error_message() );
268 return $initialized;
269 }
270
271 return $this->get( $run_id );
272 }
273
274 public function assert_writable( $run_id ) {
275 $run = $this->get( $run_id );
276 if ( !$run ) {
277 return new WP_Error( 'wpmc_run_not_found', __( 'This Media Cleaner scan no longer exists.', 'media-cleaner' ), array( 'status' => 404 ) );
278 }
279 if ( !in_array( $run->status, array( 'running', 'paused' ), true ) ) {
280 return new WP_Error( 'wpmc_run_not_writable', __( 'This Media Cleaner scan is no longer writable.', 'media-cleaner' ), array( 'status' => 409, 'run_status' => $run->status ) );
281 }
282 return $run;
283 }
284
285 public function checkpoint( $run_id, $phase, $checkpoint = array(), $counters = null ) {
286 global $wpdb;
287 $run = $this->assert_writable( $run_id );
288 if ( is_wp_error( $run ) ) {
289 return $run;
290 }
291
292 $stored_counters = $this->decode_json( $run->counters );
293 $coverage = isset( $stored_counters['coverage'] ) && is_array( $stored_counters['coverage'] ) ? $stored_counters['coverage'] : array();
294 $coverage_steps = array(
295 'resetIssuesAndReferences' => 'reset',
296 'extractReferencesFromContent_finished' => 'content',
297 'extractReferencesFromLibrary_finished' => 'library',
298 'extractReferencesFromMedia_finished' => 'library',
299 'extractReferencesFromDuplicates_finished' => 'duplicates',
300 'extractReferencesFromThumbnails_finished' => 'thumbnails',
301 'retrieveMedia_finished' => 'targets',
302 'retrieveFiles_finished' => 'targets',
303 'retrieveDuplicates_finished' => 'targets',
304 );
305 if ( isset( $coverage_steps[ $phase ] ) ) {
306 $coverage[ $coverage_steps[ $phase ] ] = true;
307 }
308 $stored_counters['coverage'] = $coverage;
309 if ( is_array( $counters ) ) {
310 $stored_counters = array_merge( $stored_counters, $counters );
311 $stored_counters['coverage'] = $coverage;
312 }
313
314 $phase_name = preg_replace( '/[^A-Za-z0-9_]/', '', (string) $phase );
315 $data = array(
316 'phase' => $phase_name,
317 'checkpoint' => wp_json_encode( $checkpoint ),
318 'counters' => wp_json_encode( $stored_counters ),
319 'updated_at' => current_time( 'mysql', true ),
320 'heartbeat_at' => current_time( 'mysql', true ),
321 );
322 $formats = array( '%s', '%s', '%s', '%s', '%s' );
323
324 $updated = $wpdb->update(
325 $this->table( 'runs' ),
326 $data,
327 array( 'id' => (int) $run_id, 'status' => $run->status ),
328 $formats,
329 array( '%d', '%s' )
330 );
331 if ( $updated === false ) return false;
332 if ( $updated === 0 ) {
333 $current = $this->get( $run_id );
334 if ( !$current || $current->status !== $run->status ) return false;
335 }
336 $this->refresh_lock( $run_id );
337 return true;
338 }
339
340 public function fail( $run_id, $code, $message, $details = array() ) {
341 global $wpdb;
342 $run = $this->get( $run_id );
343 if ( $run && $run->status === 'failed' ) return true;
344 if ( !$run || !in_array( $run->status, array( 'running', 'paused' ), true ) ) {
345 return false;
346 }
347 $errors = json_decode( $run->errors, true );
348 $errors = is_array( $errors ) ? $errors : array();
349 $errors[] = array(
350 'code' => sanitize_key( $code ),
351 'message' => sanitize_text_field( $message ),
352 'details' => $details,
353 'time' => time(),
354 );
355 $updated = $wpdb->update(
356 $this->table( 'runs' ),
357 array(
358 'status' => 'failed',
359 'errors' => wp_json_encode( $errors ),
360 'error_count' => count( $errors ),
361 'updated_at' => current_time( 'mysql', true ),
362 'finished_at' => current_time( 'mysql', true ),
363 ),
364 array( 'id' => (int) $run_id, 'status' => $run->status ),
365 array( '%s', '%s', '%d', '%s', '%s' ),
366 array( '%d', '%s' )
367 );
368 if ( $updated === false ) {
369 return new WP_Error( 'wpmc_run_fail_failed', __( 'Media Cleaner could not persist the failed scan state.', 'media-cleaner' ), array( 'database_error' => $wpdb->last_error ) );
370 }
371 if ( $updated !== 1 ) return false;
372 $this->release_lock( $run_id );
373 return true;
374 }
375
376 public function pause( $run_id, $code, $message, $details = array() ) {
377 global $wpdb;
378 $run = $this->get( $run_id );
379 if ( !$run || !in_array( $run->status, array( 'running', 'paused' ), true ) ) return false;
380 $errors = $this->decode_json( $run->errors );
381 $errors[] = array(
382 'code' => sanitize_key( $code ),
383 'message' => sanitize_text_field( $message ),
384 'details' => array_merge( is_array( $details ) ? $details : array(), array( 'transient' => true ) ),
385 'time' => time(),
386 );
387 $errors = array_slice( $errors, -20 );
388 $now = current_time( 'mysql', true );
389 $updated = $wpdb->update(
390 $this->table( 'runs' ),
391 array( 'status' => 'paused', 'errors' => wp_json_encode( $errors ), 'updated_at' => $now, 'heartbeat_at' => $now ),
392 array( 'id' => (int) $run_id, 'status' => $run->status ),
393 array( '%s', '%s', '%s', '%s' ),
394 array( '%d', '%s' )
395 );
396 if ( $updated === 0 ) {
397 $current = $this->get( $run_id );
398 if ( !$current || $current->status !== 'paused' ) return false;
399 }
400 else if ( $updated === false ) {
401 return new WP_Error( 'wpmc_run_pause_failed', __( 'Media Cleaner could not persist the paused scan state.', 'media-cleaner' ), array( 'database_error' => $wpdb->last_error ) );
402 }
403 $this->refresh_lock( $run_id );
404 return true;
405 }
406
407 public function resume( $run_id ) {
408 global $wpdb;
409 $run = $this->assert_writable( $run_id );
410 if ( is_wp_error( $run ) ) return $run;
411 if ( $run->status === 'running' ) {
412 $this->refresh_lock( $run_id );
413 return $run;
414 }
415 $now = current_time( 'mysql', true );
416 $updated = $wpdb->update(
417 $this->table( 'runs' ),
418 array( 'status' => 'running', 'updated_at' => $now, 'heartbeat_at' => $now ),
419 array( 'id' => (int) $run_id, 'status' => 'paused' ),
420 array( '%s', '%s', '%s' ),
421 array( '%d', '%s' )
422 );
423 if ( $updated !== 1 ) {
424 return new WP_Error(
425 'wpmc_run_resume_failed',
426 __( 'Media Cleaner could not resume the staged scan.', 'media-cleaner' ),
427 array( 'status' => 409, 'database_error' => $wpdb->last_error )
428 );
429 }
430 $this->refresh_lock( $run_id );
431 return $this->get( $run_id );
432 }
433
434 public function complete( $run_id ) {
435 global $wpdb;
436 $existing = $this->get( $run_id );
437 if ( $existing && $existing->status === 'completed' ) {
438 $active_id = $this->get_active_id();
439 if ( $active_id === (int) $run_id ) return $existing;
440 if ( $active_id > (int) $run_id || $this->get_resumable() ) {
441 return new WP_Error( 'wpmc_run_publish_conflict', __( 'A newer scan prevents this completed scan from being activated.', 'media-cleaner' ), array( 'status' => 409 ) );
442 }
443 update_option( self::ACTIVE_RUN_OPTION, (int) $run_id, false );
444 if ( $this->get_active_id() !== (int) $run_id ) {
445 return new WP_Error( 'wpmc_active_run_publish_failed', __( 'The completed scan exists, but Media Cleaner could not activate it. Previous results remain active.', 'media-cleaner' ) );
446 }
447 $this->release_lock( $run_id );
448 return $existing;
449 }
450 $run = $this->assert_writable( $run_id );
451 if ( is_wp_error( $run ) ) {
452 return $run;
453 }
454 if ( (int) $run->error_count > 0 ) {
455 return new WP_Error( 'wpmc_run_has_errors', __( 'This scan has errors and cannot replace the last successful results.', 'media-cleaner' ), array( 'status' => 409 ) );
456 }
457 $required_final_phases = array(
458 'media' => 'retrieveMedia_finished',
459 'files' => 'retrieveFiles_finished',
460 'duplicates' => 'retrieveDuplicates_finished',
461 'optimize_thumbnails' => 'retrieveFiles_finished',
462 );
463 $required_phase = isset( $required_final_phases[ $run->method ] ) ? $required_final_phases[ $run->method ] : null;
464 if ( !$required_phase || $run->phase !== $required_phase ) {
465 return new WP_Error(
466 'wpmc_run_incomplete',
467 __( 'This scan has not completed every required phase and cannot be published.', 'media-cleaner' ),
468 array( 'status' => 409, 'phase' => $run->phase, 'required_phase' => $required_phase )
469 );
470 }
471 $config = $this->decode_json( $run->config );
472 $counters = $this->decode_json( $run->counters );
473 $coverage = isset( $counters['coverage'] ) && is_array( $counters['coverage'] ) ? $counters['coverage'] : array();
474 $required_coverage = array( 'reset', 'targets' );
475 if ( $run->method === 'media' && !empty( $config['content'] ) ) $required_coverage[] = 'content';
476 if ( $run->method === 'files' && !empty( $config['filesystem_content'] ) ) $required_coverage[] = 'content';
477 if ( $run->method === 'files' && !empty( $config['media_library'] ) ) $required_coverage[] = 'library';
478 if ( $run->method === 'duplicates' ) {
479 $required_coverage[] = 'duplicates';
480 if ( !empty( $config['content'] ) ) $required_coverage[] = 'content';
481 }
482 if ( $run->method === 'optimize_thumbnails' ) $required_coverage[] = 'thumbnails';
483 $missing_coverage = array_values( array_filter( $required_coverage, function( $name ) use ( $coverage ) {
484 return empty( $coverage[ $name ] );
485 } ) );
486 if ( !empty( $missing_coverage ) ) {
487 return new WP_Error(
488 'wpmc_run_coverage_incomplete',
489 __( 'This scan is missing required analysis coverage and cannot be published.', 'media-cleaner' ),
490 array( 'status' => 409, 'missing' => $missing_coverage )
491 );
492 }
493
494 $now = current_time( 'mysql', true );
495 $table = $this->table( 'runs' );
496 $updated = $wpdb->query( $wpdb->prepare(
497 "UPDATE $table SET status = 'completed', phase = 'completed', updated_at = %s,
498 heartbeat_at = %s, finished_at = %s, published_at = %s
499 WHERE id = %d AND status IN ('running', 'paused')",
500 $now,
501 $now,
502 $now,
503 $now,
504 (int) $run_id
505 ) );
506 if ( $updated !== 1 ) {
507 return new WP_Error( 'wpmc_run_publish_failed', __( 'Media Cleaner could not publish the completed scan.', 'media-cleaner' ), array( 'database_error' => $wpdb->last_error ) );
508 }
509
510 update_option( self::ACTIVE_RUN_OPTION, (int) $run_id, false );
511 if ( (int) get_option( self::ACTIVE_RUN_OPTION, 0 ) !== (int) $run_id ) {
512 $wpdb->query( $wpdb->prepare(
513 "UPDATE $table SET status = 'paused', phase = %s, updated_at = %s,
514 finished_at = NULL, published_at = NULL WHERE id = %d AND status = 'completed'",
515 $run->phase,
516 current_time( 'mysql', true ),
517 (int) $run_id
518 ) );
519 $this->refresh_lock( $run_id );
520 return new WP_Error( 'wpmc_active_run_publish_failed', __( 'Media Cleaner could not activate the completed scan, so it remains staged and resumable. Previous results remain active.', 'media-cleaner' ) );
521 }
522 $this->release_lock( $run_id );
523 return $this->get( $run_id );
524 }
525
526 public function cancel( $run_id ) {
527 global $wpdb;
528 $run = $this->get( $run_id );
529 if ( $run && $run->status === 'cancelled' ) return true;
530 if ( !$run || !in_array( $run->status, array( 'running', 'paused' ), true ) ) {
531 return false;
532 }
533 $updated = $wpdb->update(
534 $this->table( 'runs' ),
535 array( 'status' => 'cancelled', 'updated_at' => current_time( 'mysql', true ), 'finished_at' => current_time( 'mysql', true ) ),
536 array( 'id' => (int) $run_id, 'status' => $run->status ),
537 array( '%s', '%s', '%s' ),
538 array( '%d', '%s' )
539 );
540 if ( $updated === false ) {
541 return new WP_Error( 'wpmc_run_cancel_failed', __( 'Media Cleaner could not cancel the staged scan.', 'media-cleaner' ), array( 'database_error' => $wpdb->last_error ) );
542 }
543 if ( $updated !== 1 ) {
544 $current = $this->get( $run_id );
545 if ( !$current || $current->status !== 'cancelled' ) return false;
546 }
547 $this->release_lock( $run_id );
548 return true;
549 }
550
551 public function discard( $run_id ) {
552 global $wpdb;
553 $run = $this->get( $run_id );
554 if ( !$run || !in_array( $run->status, array( 'running', 'paused', 'failed', 'cancelled' ), true ) ) {
555 return false;
556 }
557 if ( in_array( $run->status, array( 'running', 'paused' ), true ) ) {
558 $cancelled = $this->cancel( $run_id );
559 if ( is_wp_error( $cancelled ) ) return $cancelled;
560 if ( !$cancelled ) return false;
561 }
562 $tables = array(
563 $this->table( 'scan' ),
564 $this->table( 'refs' ),
565 $this->table( 'duplicates' ),
566 $this->table( 'work' ),
567 $this->table( 'operations' ),
568 );
569 foreach ( $tables as $table ) {
570 if ( $wpdb->delete( $table, array( 'run_id' => (int) $run_id ), array( '%d' ) ) === false ) {
571 return new WP_Error(
572 'wpmc_run_discard_failed',
573 __( 'The staged scan was cancelled, but some of its temporary data could not be removed.', 'media-cleaner' ),
574 array( 'database_error' => $wpdb->last_error )
575 );
576 }
577 }
578 return true;
579 }
580
581 public function cleanup_allowed() {
582 $active_id = $this->get_active_id();
583 $run = $this->get( $active_id );
584 return $run && $run->status === 'completed' && !$this->get_resumable();
585 }
586
587 public function garbage_collect( $limit = 500 ) {
588 global $wpdb;
589 $active_id = $this->get_active_id();
590 $candidates = $wpdb->get_col( $wpdb->prepare(
591 "SELECT id FROM {$this->table( 'runs' )}
592 WHERE status IN ('completed', 'failed', 'cancelled') AND id != %d
593 ORDER BY id DESC LIMIT 20",
594 $active_id
595 ) );
596 if ( count( $candidates ) <= 3 ) return true;
597 $run_id = (int) $candidates[3];
598 $limit = max( 10, min( 2000, (int) $limit ) );
599 $tables = array(
600 $this->table( 'scan' ),
601 $this->table( 'refs' ),
602 $this->table( 'duplicates' ),
603 $this->table( 'work' ),
604 $this->table( 'operations' ),
605 );
606 foreach ( $tables as $table ) {
607 $deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM `$table` WHERE run_id = %d LIMIT %d", $run_id, $limit ) );
608 if ( $deleted === false ) return false;
609 $remaining = (int) $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM `$table` WHERE run_id = %d LIMIT 1", $run_id ) );
610 if ( $remaining ) return true;
611 }
612 return $wpdb->delete( $this->table( 'runs' ), array( 'id' => $run_id ), array( '%d' ) ) !== false;
613 }
614
615 public function begin_operation( $issue_id, $operation, $request_key ) {
616 global $wpdb;
617 $run_id = $this->get_active_id();
618 $operation = sanitize_key( $operation );
619 $request_key = sanitize_text_field( $request_key );
620 $table = $this->table( 'operations' );
621 $existing = $wpdb->get_row( $wpdb->prepare(
622 "SELECT * FROM $table WHERE request_key = %s AND issue_id = %d AND operation = %s",
623 $request_key,
624 (int) $issue_id,
625 $operation
626 ) );
627 if ( $existing ) {
628 return $existing;
629 }
630 $now = current_time( 'mysql', true );
631 $inserted = $wpdb->insert( $table, array(
632 'run_id' => $run_id,
633 'issue_id' => (int) $issue_id,
634 'operation' => $operation,
635 'state' => 'pending',
636 'request_key' => $request_key,
637 'manifest' => wp_json_encode( array() ),
638 'created_at' => $now,
639 'updated_at' => $now,
640 ), array( '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s' ) );
641 if ( !$inserted ) {
642 return new WP_Error( 'wpmc_operation_create_failed', __( 'The cleanup operation could not be recorded.', 'media-cleaner' ), array( 'database_error' => $wpdb->last_error ) );
643 }
644 return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $table WHERE id = %d", $wpdb->insert_id ) );
645 }
646
647 public function update_operation( $operation_id, $state, $manifest = null, $error = null ) {
648 global $wpdb;
649 $data = array(
650 'state' => sanitize_key( $state ),
651 'updated_at' => current_time( 'mysql', true ),
652 );
653 $formats = array( '%s', '%s' );
654 if ( in_array( $state, array( 'running', 'complete' ), true ) ) {
655 $data['error_code'] = null;
656 $data['error_message'] = null;
657 $formats[] = '%s';
658 $formats[] = '%s';
659 }
660 if ( is_array( $manifest ) ) {
661 $data['manifest'] = wp_json_encode( $manifest );
662 $formats[] = '%s';
663 }
664 if ( $error ) {
665 $data['error_code'] = $error instanceof WP_Error ? $error->get_error_code() : 'operation_failed';
666 $data['error_message'] = $error instanceof WP_Error ? $error->get_error_message() : (string) $error;
667 $formats[] = '%s';
668 $formats[] = '%s';
669 }
670 return $wpdb->update( $this->table( 'operations' ), $data, array( 'id' => (int) $operation_id ), $formats, array( '%d' ) ) !== false;
671 }
672
673 public function enqueue_work( $run_id, $phase, $target_type, $target_key, $cursor = 0 ) {
674 global $wpdb;
675 $target_key = (string) $target_key;
676 $inserted = $wpdb->query( $wpdb->prepare(
677 "INSERT IGNORE INTO {$this->table( 'work' )}
678 (run_id, phase, target_type, target_key, target_hash, cursor_value, status, attempts, updated_at)
679 VALUES (%d, %s, %s, %s, %s, %d, 'pending', 0, %s)",
680 (int) $run_id,
681 sanitize_key( $phase ),
682 sanitize_key( $target_type ),
683 $target_key,
684 hash( 'sha256', $target_key ),
685 max( 0, (int) $cursor ),
686 current_time( 'mysql', true )
687 ) );
688 return $inserted !== false;
689 }
690
691 public function next_work( $run_id, $phase ) {
692 global $wpdb;
693 $table = $this->table( 'work' );
694 return $wpdb->get_row( $wpdb->prepare(
695 "SELECT * FROM $table
696 WHERE run_id = %d AND phase = %s AND status IN ('pending', 'running')
697 ORDER BY id ASC LIMIT 1",
698 (int) $run_id,
699 sanitize_key( $phase )
700 ) );
701 }
702
703 public function get_work( $run_id, $phase, $target_key ) {
704 global $wpdb;
705 return $wpdb->get_row( $wpdb->prepare(
706 "SELECT * FROM {$this->table( 'work' )}
707 WHERE run_id = %d AND phase = %s AND target_hash = %s LIMIT 1",
708 (int) $run_id,
709 sanitize_key( $phase ),
710 hash( 'sha256', (string) $target_key )
711 ) );
712 }
713
714 public function update_work( $work_id, $status, $cursor, $error = null ) {
715 global $wpdb;
716 $data = array(
717 'status' => sanitize_key( $status ),
718 'cursor_value' => max( 0, (int) $cursor ),
719 'updated_at' => current_time( 'mysql', true ),
720 );
721 $formats = array( '%s', '%d', '%s' );
722 if ( $error ) {
723 $data['last_error'] = $error instanceof WP_Error
724 ? $error->get_error_message()
725 : ( $error instanceof Throwable ? $error->getMessage() : (string) $error );
726 $formats[] = '%s';
727 }
728 return $wpdb->update( $this->table( 'work' ), $data, array( 'id' => (int) $work_id ), $formats, array( '%d' ) ) !== false;
729 }
730
731 public function retry_work( $work_id, $error = null ) {
732 global $wpdb;
733 $message = $error instanceof WP_Error
734 ? $error->get_error_message()
735 : ( $error instanceof Throwable ? $error->getMessage() : (string) $error );
736 $table = $this->table( 'work' );
737 return $wpdb->query( $wpdb->prepare(
738 "UPDATE $table SET status = 'pending', attempts = attempts + 1, last_error = %s, updated_at = %s WHERE id = %d",
739 $message,
740 current_time( 'mysql', true ),
741 (int) $work_id
742 ) ) !== false;
743 }
744
745 public function set_work_snapshot( $work_id, $snapshot_token ) {
746 global $wpdb;
747 return $wpdb->update(
748 $this->table( 'work' ),
749 array( 'snapshot_token' => substr( (string) $snapshot_token, 0, 64 ), 'updated_at' => current_time( 'mysql', true ) ),
750 array( 'id' => (int) $work_id ),
751 array( '%s', '%s' ),
752 array( '%d' )
753 ) !== false;
754 }
755
756 public function pending_work_count( $run_id, $phase ) {
757 global $wpdb;
758 return (int) $wpdb->get_var( $wpdb->prepare(
759 "SELECT COUNT(*) FROM {$this->table( 'work' )} WHERE run_id = %d AND phase = %s AND status IN ('pending', 'running')",
760 (int) $run_id,
761 sanitize_key( $phase )
762 ) );
763 }
764
765 public function failed_work_count( $run_id, $phase ) {
766 global $wpdb;
767 return (int) $wpdb->get_var( $wpdb->prepare(
768 "SELECT COUNT(*) FROM {$this->table( 'work' )} WHERE run_id = %d AND phase = %s AND status = 'failed'",
769 (int) $run_id,
770 sanitize_key( $phase )
771 ) );
772 }
773
774 public function clear_work( $run_id, $phase ) {
775 global $wpdb;
776 return $wpdb->query( $wpdb->prepare(
777 "DELETE FROM {$this->table( 'work' )} WHERE run_id = %d AND phase = %s",
778 (int) $run_id,
779 sanitize_key( $phase )
780 ) ) !== false;
781 }
782
783 public function wake_work( $run_id, $phase, $target_key ) {
784 global $wpdb;
785 return $wpdb->update(
786 $this->table( 'work' ),
787 array( 'status' => 'pending', 'updated_at' => current_time( 'mysql', true ) ),
788 array(
789 'run_id' => (int) $run_id,
790 'phase' => sanitize_key( $phase ),
791 'target_hash' => hash( 'sha256', (string) $target_key ),
792 'status' => 'waiting',
793 ),
794 array( '%s', '%s' ),
795 array( '%d', '%s', '%s', '%s' )
796 ) !== false;
797 }
798
799 public function to_array( $run ) {
800 if ( !$run ) {
801 return null;
802 }
803 return array(
804 'id' => (int) $run->id,
805 'owner_id' => (int) $run->owner_id,
806 'method' => $run->method,
807 'status' => $run->status,
808 'phase' => $run->phase,
809 'config' => $this->decode_json( $run->config ),
810 'checkpoint' => $this->decode_json( $run->checkpoint ),
811 'counters' => $this->decode_json( $run->counters ),
812 'errors' => $this->decode_json( $run->errors ),
813 'error_count' => (int) $run->error_count,
814 'created_at' => $run->created_at,
815 'updated_at' => $run->updated_at,
816 'heartbeat_at' => $run->heartbeat_at,
817 'finished_at' => $run->finished_at,
818 'published_at' => $run->published_at,
819 );
820 }
821
822 private function decode_json( $value ) {
823 $decoded = json_decode( (string) $value, true );
824 return is_array( $decoded ) ? $decoded : array();
825 }
826
827 private function refresh_lock( $run_id ) {
828 $lock = get_option( self::LOCK_OPTION, null );
829 if ( is_array( $lock ) && isset( $lock['run_id'] ) && (int) $lock['run_id'] === (int) $run_id ) {
830 $lock['expires_at'] = time() + self::LOCK_TTL;
831 update_option( self::LOCK_OPTION, $lock, false );
832 }
833 }
834
835 private function release_lock( $run_id ) {
836 $lock = get_option( self::LOCK_OPTION, null );
837 if ( is_array( $lock ) && isset( $lock['run_id'] ) && (int) $lock['run_id'] === (int) $run_id ) {
838 delete_option( self::LOCK_OPTION );
839 }
840 }
841
842 private function mark_stale( $run_id ) {
843 global $wpdb;
844 $run = $this->get( $run_id );
845 if ( !$run || !in_array( $run->status, array( 'running', 'paused' ), true ) ) {
846 return;
847 }
848 $wpdb->update(
849 $this->table( 'runs' ),
850 array( 'status' => 'failed', 'updated_at' => current_time( 'mysql', true ), 'finished_at' => current_time( 'mysql', true ) ),
851 array( 'id' => (int) $run_id ),
852 array( '%s', '%s', '%s' ),
853 array( '%d' )
854 );
855 }
856
857 private function migrate_reference_indexes() {
858 global $wpdb;
859 $table = $wpdb->prefix . 'mclean_refs';
860 $old_index = $wpdb->get_var( "SHOW INDEX FROM $table WHERE Key_name = 'ref_hash_unique'" );
861 if ( $old_index ) {
862 if ( $wpdb->query( "ALTER TABLE $table DROP INDEX ref_hash_unique" ) === false ) return false;
863 }
864 $new_index = $wpdb->get_var( "SHOW INDEX FROM $table WHERE Key_name = 'run_ref_hash_unique'" );
865 if ( !$new_index ) {
866 if ( $wpdb->query( "ALTER TABLE $table ADD UNIQUE KEY run_ref_hash_unique (run_id, ref_hash)" ) === false ) return false;
867 }
868 return true;
869 }
870
871 private function copy_persistent_results( $run_id ) {
872 global $wpdb;
873 $active_id = $this->get_active_id();
874 $scan_table = $wpdb->prefix . 'mclean_scan';
875 $result = $wpdb->query( $wpdb->prepare(
876 "INSERT INTO $scan_table
877 (run_id, time, type, postId, path, path_hash, manifest, size, ignored, deleted, issue, parentId)
878 SELECT %d, source.time, source.type, source.postId, source.path,
879 COALESCE(source.path_hash, SHA2(source.path, 256)), source.manifest, source.size,
880 source.ignored, source.deleted, source.issue, NULL
881 FROM $scan_table AS source
882 WHERE source.run_id = %d AND (source.ignored = 1 OR source.deleted = 1)
883 AND NOT EXISTS (
884 SELECT 1 FROM $scan_table AS target
885 WHERE target.run_id = %d
886 AND target.type = source.type
887 AND target.postId <=> source.postId
888 AND target.path_hash <=> COALESCE(source.path_hash, SHA2(source.path, 256))
889 AND target.issue = source.issue
890 AND target.ignored = source.ignored
891 AND target.deleted = source.deleted
892 )",
893 (int) $run_id,
894 (int) $active_id,
895 (int) $run_id
896 ) );
897 if ( $result === false ) {
898 return new WP_Error( 'wpmc_result_copy_failed', __( 'Media Cleaner could not preserve ignored and trashed results.', 'media-cleaner' ), array( 'database_error' => $wpdb->last_error ) );
899 }
900 return true;
901 }
902
903 private function initialize_started_run( $run_id ) {
904 global $wpdb;
905 $copied = $this->copy_persistent_results( $run_id );
906 if ( is_wp_error( $copied ) ) return $copied;
907 $updated = $wpdb->update(
908 $this->table( 'runs' ),
909 array( 'phase' => 'ready', 'updated_at' => current_time( 'mysql', true ), 'heartbeat_at' => current_time( 'mysql', true ) ),
910 array( 'id' => (int) $run_id, 'phase' => 'starting' ),
911 array( '%s', '%s', '%s' ),
912 array( '%d', '%s' )
913 );
914 if ( $updated === false ) {
915 return new WP_Error( 'wpmc_run_initialize_failed', __( 'Media Cleaner could not finish initializing the scan run.', 'media-cleaner' ), array( 'database_error' => $wpdb->last_error ) );
916 }
917 return true;
918 }
919 }
920