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