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