PluginProbe ʕ •ᴥ•ʔ
Media Cleaner: Clean your WordPress! / 7.2.7
Media Cleaner: Clean your WordPress! v7.2.7
7.2.7 7.2.6 7.2.5 7.2.4 7.2.3 7.2.2 7.2.1 7.2.0 7.1.1 7.1.0 7.0.9 7.0.8 trunk 3.6.8 3.6.9 3.7.0 3.8.0 3.9.0 4.0.0 4.0.2 4.0.4 4.0.6 4.0.7 4.1.0 4.2.0 4.2.2 4.2.3 4.2.4 4.2.5 4.4.0 4.4.2 4.4.4 4.4.6 4.4.7 4.4.8 4.5.0 4.5.4 4.5.6 4.5.7 4.5.8 4.6.2 4.6.3 4.8.0 4.8.4 5.0.0 5.0.1 5.1.0 5.1.1 5.1.3 5.2.0 5.2.1 5.2.4 5.4.0 5.4.1 5.4.2 5.4.3 5.4.4 5.4.5 5.4.6 5.4.9 5.5.0 5.5.1 5.5.2 5.5.3 5.5.4 5.5.7 5.5.8 5.6.1 5.6.2 5.6.3 5.6.4 6.0.1 6.0.2 6.0.3 6.0.4 6.0.5 6.0.6 6.0.7 6.0.8 6.0.9 6.1.2 6.1.3 6.1.4 6.1.5 6.1.6 6.1.7 6.1.8 6.1.9 6.2.0 6.2.1 6.2.3 6.2.4 6.2.5 6.2.6 6.2.7 6.2.8 6.3.0 6.3.1 6.3.2 6.3.4 6.3.5 6.3.7 6.3.8 6.3.9 6.4.0 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.4.7 6.4.8 6.4.9 6.5.0 6.5.1 6.5.2 6.5.3 6.5.4 6.5.5 6.5.6 6.5.7 6.5.8 6.5.9 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.6.6 6.6.7 6.6.8 6.6.9 6.7.0 6.7.1 6.7.2 6.7.3 6.7.4 6.7.5 6.7.6 6.7.7 6.7.8 6.7.9 6.8.0 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.8.6 6.8.7 6.8.8 6.8.9 6.9.0 6.9.1 6.9.2 6.9.3 6.9.4 6.9.5 6.9.6 6.9.7 6.9.8 6.9.9 7.0.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.0.6 7.0.7
media-cleaner / classes / runs.php
media-cleaner / classes Last commit date
parsers 1 day ago admin.php 1 month ago buffers.php 3 weeks ago core.php 1 day ago engine.php 1 day ago init.php 9 months ago mcp.php 1 month ago parsers.php 1 month ago rest.php 1 day ago runs.php 1 day ago support.php 1 day ago ui.php 1 month ago
runs.php
1012 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 escape hatch behind the Unlock Cleanup button: the results on screen are
614 * declared current by hand. It clears the two things cleanup_allowed() refuses on
615 * — a staged run nobody stopped, and a version stamp from another release — and
616 * never invents a completed run: if there is none, a scan is still required.
617 */
618 public function force_cleanup_allowed() {
619 global $wpdb;
620 $resumable = $this->get_resumable();
621 while ( $resumable ) {
622 $cancelled = $this->cancel( (int) $resumable->id );
623 if ( is_wp_error( $cancelled ) ) return $cancelled;
624 $next = $this->get_resumable();
625 // Guard against a row that refuses to leave 'running': the loop must end.
626 if ( $next && (int) $next->id === (int) $resumable->id ) {
627 return new WP_Error( 'wpmc_run_cancel_failed', __( 'Media Cleaner could not stop the staged scan.', 'media-cleaner' ) );
628 }
629 $resumable = $next;
630 }
631 $run = $this->get( $this->get_active_id() );
632 if ( !$run || $run->status !== 'completed' ) {
633 return new WP_Error( 'wpmc_no_completed_run', __( 'There are no published results to unlock. Run a scan first.', 'media-cleaner' ), array( 'status' => 409 ) );
634 }
635 $counters = $this->decode_json( $run->counters );
636 $counters['version'] = WPMC_VERSION;
637 $updated = $wpdb->update(
638 $this->table( 'runs' ),
639 array( 'counters' => wp_json_encode( $counters ), 'updated_at' => current_time( 'mysql', true ) ),
640 array( 'id' => (int) $run->id ),
641 array( '%s', '%s' ),
642 array( '%d' )
643 );
644 if ( $updated === false ) {
645 return new WP_Error( 'wpmc_run_unlock_failed', __( 'Media Cleaner could not unlock the results.', 'media-cleaner' ), array( 'database_error' => $wpdb->last_error ) );
646 }
647 return true;
648 }
649
650 /**
651 * The same answer as cleanup_allowed(), with the run behind it, so the screen can
652 * say which scan it is waiting on instead of only that it is waiting.
653 */
654 public function cleanup_status() {
655 $summary = function( $r ) {
656 if ( !$r ) return null;
657 $counters = $this->decode_json( $r->counters );
658 return array(
659 'id' => (int) $r->id,
660 // Which version analysed the site: a completed run that is still refused
661 // was made by another one, and the screen has to be able to say so.
662 'version' => isset( $counters['version'] ) ? (string) $counters['version'] : '',
663 'method' => $r->method,
664 'status' => $r->status,
665 'created_at' => $r->created_at,
666 'finished_at' => $r->finished_at,
667 'published_at' => $r->published_at,
668 );
669 };
670 return array(
671 // Asked, never re-implemented: two copies of this rule would drift apart.
672 'allowed' => $this->cleanup_allowed(),
673 'run' => $summary( $this->get( $this->get_active_id() ) ),
674 'resumable' => $summary( $this->get_resumable() ),
675 );
676 }
677
678 public function garbage_collect( $limit = 500 ) {
679 global $wpdb;
680 $active_id = $this->get_active_id();
681 $candidates = $wpdb->get_col( $wpdb->prepare(
682 "SELECT id FROM {$this->table( 'runs' )}
683 WHERE status IN ('completed', 'failed', 'cancelled') AND id != %d
684 ORDER BY id DESC LIMIT 20",
685 $active_id
686 ) );
687 if ( count( $candidates ) <= 3 ) return true;
688 $run_id = (int) $candidates[3];
689 $limit = max( 10, min( 2000, (int) $limit ) );
690 $tables = array(
691 $this->table( 'scan' ),
692 $this->table( 'refs' ),
693 $this->table( 'duplicates' ),
694 $this->table( 'work' ),
695 $this->table( 'operations' ),
696 );
697 foreach ( $tables as $table ) {
698 $deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM `$table` WHERE run_id = %d LIMIT %d", $run_id, $limit ) );
699 if ( $deleted === false ) return false;
700 $remaining = (int) $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM `$table` WHERE run_id = %d LIMIT 1", $run_id ) );
701 if ( $remaining ) return true;
702 }
703 return $wpdb->delete( $this->table( 'runs' ), array( 'id' => $run_id ), array( '%d' ) ) !== false;
704 }
705
706 public function begin_operation( $issue_id, $operation, $request_key ) {
707 global $wpdb;
708 $run_id = $this->get_active_id();
709 $operation = sanitize_key( $operation );
710 $request_key = sanitize_text_field( $request_key );
711 $table = $this->table( 'operations' );
712 $existing = $wpdb->get_row( $wpdb->prepare(
713 "SELECT * FROM $table WHERE request_key = %s AND issue_id = %d AND operation = %s",
714 $request_key,
715 (int) $issue_id,
716 $operation
717 ) );
718 if ( $existing ) {
719 return $existing;
720 }
721 $now = current_time( 'mysql', true );
722 $inserted = $wpdb->insert( $table, array(
723 'run_id' => $run_id,
724 'issue_id' => (int) $issue_id,
725 'operation' => $operation,
726 'state' => 'pending',
727 'request_key' => $request_key,
728 'manifest' => wp_json_encode( array() ),
729 'created_at' => $now,
730 'updated_at' => $now,
731 ), array( '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s' ) );
732 if ( !$inserted ) {
733 return new WP_Error( 'wpmc_operation_create_failed', __( 'The cleanup operation could not be recorded.', 'media-cleaner' ), array( 'database_error' => $wpdb->last_error ) );
734 }
735 return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $table WHERE id = %d", $wpdb->insert_id ) );
736 }
737
738 public function update_operation( $operation_id, $state, $manifest = null, $error = null ) {
739 global $wpdb;
740 $data = array(
741 'state' => sanitize_key( $state ),
742 'updated_at' => current_time( 'mysql', true ),
743 );
744 $formats = array( '%s', '%s' );
745 if ( in_array( $state, array( 'running', 'complete' ), true ) ) {
746 $data['error_code'] = null;
747 $data['error_message'] = null;
748 $formats[] = '%s';
749 $formats[] = '%s';
750 }
751 if ( is_array( $manifest ) ) {
752 $data['manifest'] = wp_json_encode( $manifest );
753 $formats[] = '%s';
754 }
755 if ( $error ) {
756 $data['error_code'] = $error instanceof WP_Error ? $error->get_error_code() : 'operation_failed';
757 $data['error_message'] = $error instanceof WP_Error ? $error->get_error_message() : (string) $error;
758 $formats[] = '%s';
759 $formats[] = '%s';
760 }
761 return $wpdb->update( $this->table( 'operations' ), $data, array( 'id' => (int) $operation_id ), $formats, array( '%d' ) ) !== false;
762 }
763
764 public function enqueue_work( $run_id, $phase, $target_type, $target_key, $cursor = 0 ) {
765 global $wpdb;
766 $target_key = (string) $target_key;
767 $inserted = $wpdb->query( $wpdb->prepare(
768 "INSERT IGNORE INTO {$this->table( 'work' )}
769 (run_id, phase, target_type, target_key, target_hash, cursor_value, status, attempts, updated_at)
770 VALUES (%d, %s, %s, %s, %s, %d, 'pending', 0, %s)",
771 (int) $run_id,
772 sanitize_key( $phase ),
773 sanitize_key( $target_type ),
774 $target_key,
775 hash( 'sha256', $target_key ),
776 max( 0, (int) $cursor ),
777 current_time( 'mysql', true )
778 ) );
779 return $inserted !== false;
780 }
781
782 public function next_work( $run_id, $phase ) {
783 global $wpdb;
784 $table = $this->table( 'work' );
785 return $wpdb->get_row( $wpdb->prepare(
786 "SELECT * FROM $table
787 WHERE run_id = %d AND phase = %s AND status IN ('pending', 'running')
788 ORDER BY id ASC LIMIT 1",
789 (int) $run_id,
790 sanitize_key( $phase )
791 ) );
792 }
793
794 public function get_work( $run_id, $phase, $target_key ) {
795 global $wpdb;
796 return $wpdb->get_row( $wpdb->prepare(
797 "SELECT * FROM {$this->table( 'work' )}
798 WHERE run_id = %d AND phase = %s AND target_hash = %s LIMIT 1",
799 (int) $run_id,
800 sanitize_key( $phase ),
801 hash( 'sha256', (string) $target_key )
802 ) );
803 }
804
805 public function update_work( $work_id, $status, $cursor, $error = null ) {
806 global $wpdb;
807 $data = array(
808 'status' => sanitize_key( $status ),
809 'cursor_value' => max( 0, (int) $cursor ),
810 'updated_at' => current_time( 'mysql', true ),
811 );
812 $formats = array( '%s', '%d', '%s' );
813 if ( $error ) {
814 $data['last_error'] = $error instanceof WP_Error
815 ? $error->get_error_message()
816 : ( $error instanceof Throwable ? $error->getMessage() : (string) $error );
817 $formats[] = '%s';
818 }
819 return $wpdb->update( $this->table( 'work' ), $data, array( 'id' => (int) $work_id ), $formats, array( '%d' ) ) !== false;
820 }
821
822 public function retry_work( $work_id, $error = null ) {
823 global $wpdb;
824 $message = $error instanceof WP_Error
825 ? $error->get_error_message()
826 : ( $error instanceof Throwable ? $error->getMessage() : (string) $error );
827 $table = $this->table( 'work' );
828 return $wpdb->query( $wpdb->prepare(
829 "UPDATE $table SET status = 'pending', attempts = attempts + 1, last_error = %s, updated_at = %s WHERE id = %d",
830 $message,
831 current_time( 'mysql', true ),
832 (int) $work_id
833 ) ) !== false;
834 }
835
836 public function set_work_snapshot( $work_id, $snapshot_token ) {
837 global $wpdb;
838 return $wpdb->update(
839 $this->table( 'work' ),
840 array( 'snapshot_token' => substr( (string) $snapshot_token, 0, 64 ), 'updated_at' => current_time( 'mysql', true ) ),
841 array( 'id' => (int) $work_id ),
842 array( '%s', '%s' ),
843 array( '%d' )
844 ) !== false;
845 }
846
847 public function pending_work_count( $run_id, $phase ) {
848 global $wpdb;
849 return (int) $wpdb->get_var( $wpdb->prepare(
850 "SELECT COUNT(*) FROM {$this->table( 'work' )} WHERE run_id = %d AND phase = %s AND status IN ('pending', 'running')",
851 (int) $run_id,
852 sanitize_key( $phase )
853 ) );
854 }
855
856 public function failed_work_count( $run_id, $phase ) {
857 global $wpdb;
858 return (int) $wpdb->get_var( $wpdb->prepare(
859 "SELECT COUNT(*) FROM {$this->table( 'work' )} WHERE run_id = %d AND phase = %s AND status = 'failed'",
860 (int) $run_id,
861 sanitize_key( $phase )
862 ) );
863 }
864
865 public function clear_work( $run_id, $phase ) {
866 global $wpdb;
867 return $wpdb->query( $wpdb->prepare(
868 "DELETE FROM {$this->table( 'work' )} WHERE run_id = %d AND phase = %s",
869 (int) $run_id,
870 sanitize_key( $phase )
871 ) ) !== false;
872 }
873
874 public function wake_work( $run_id, $phase, $target_key ) {
875 global $wpdb;
876 return $wpdb->update(
877 $this->table( 'work' ),
878 array( 'status' => 'pending', 'updated_at' => current_time( 'mysql', true ) ),
879 array(
880 'run_id' => (int) $run_id,
881 'phase' => sanitize_key( $phase ),
882 'target_hash' => hash( 'sha256', (string) $target_key ),
883 'status' => 'waiting',
884 ),
885 array( '%s', '%s' ),
886 array( '%d', '%s', '%s', '%s' )
887 ) !== false;
888 }
889
890 public function to_array( $run ) {
891 if ( !$run ) {
892 return null;
893 }
894 return array(
895 'id' => (int) $run->id,
896 'owner_id' => (int) $run->owner_id,
897 'method' => $run->method,
898 'status' => $run->status,
899 'phase' => $run->phase,
900 'config' => $this->decode_json( $run->config ),
901 'checkpoint' => $this->decode_json( $run->checkpoint ),
902 'counters' => $this->decode_json( $run->counters ),
903 'errors' => $this->decode_json( $run->errors ),
904 'error_count' => (int) $run->error_count,
905 'created_at' => $run->created_at,
906 'updated_at' => $run->updated_at,
907 'heartbeat_at' => $run->heartbeat_at,
908 'finished_at' => $run->finished_at,
909 'published_at' => $run->published_at,
910 );
911 }
912
913 private function decode_json( $value ) {
914 $decoded = json_decode( (string) $value, true );
915 return is_array( $decoded ) ? $decoded : array();
916 }
917
918 private function refresh_lock( $run_id ) {
919 $lock = get_option( self::LOCK_OPTION, null );
920 if ( is_array( $lock ) && isset( $lock['run_id'] ) && (int) $lock['run_id'] === (int) $run_id ) {
921 $lock['expires_at'] = time() + self::LOCK_TTL;
922 update_option( self::LOCK_OPTION, $lock, false );
923 }
924 }
925
926 private function release_lock( $run_id ) {
927 $lock = get_option( self::LOCK_OPTION, null );
928 if ( is_array( $lock ) && isset( $lock['run_id'] ) && (int) $lock['run_id'] === (int) $run_id ) {
929 delete_option( self::LOCK_OPTION );
930 }
931 }
932
933 private function mark_stale( $run_id ) {
934 global $wpdb;
935 $run = $this->get( $run_id );
936 if ( !$run || !in_array( $run->status, array( 'running', 'paused' ), true ) ) {
937 return;
938 }
939 $wpdb->update(
940 $this->table( 'runs' ),
941 array( 'status' => 'failed', 'updated_at' => current_time( 'mysql', true ), 'finished_at' => current_time( 'mysql', true ) ),
942 array( 'id' => (int) $run_id ),
943 array( '%s', '%s', '%s' ),
944 array( '%d' )
945 );
946 }
947
948
949 private function migrate_reference_indexes() {
950 global $wpdb;
951 $table = $wpdb->prefix . 'mclean_refs';
952 $old_index = $wpdb->get_var( "SHOW INDEX FROM $table WHERE Key_name = 'ref_hash_unique'" );
953 if ( $old_index ) {
954 if ( $wpdb->query( "ALTER TABLE $table DROP INDEX ref_hash_unique" ) === false ) return false;
955 }
956 $new_index = $wpdb->get_var( "SHOW INDEX FROM $table WHERE Key_name = 'run_ref_hash_unique'" );
957 if ( !$new_index ) {
958 if ( $wpdb->query( "ALTER TABLE $table ADD UNIQUE KEY run_ref_hash_unique (run_id, ref_hash)" ) === false ) return false;
959 }
960 return true;
961 }
962
963 private function copy_persistent_results( $run_id ) {
964 global $wpdb;
965 $active_id = $this->get_active_id();
966 $scan_table = $wpdb->prefix . 'mclean_scan';
967 $result = $wpdb->query( $wpdb->prepare(
968 "INSERT INTO $scan_table
969 (run_id, time, type, postId, path, path_hash, manifest, size, ignored, deleted, issue, parentId)
970 SELECT %d, source.time, source.type, source.postId, source.path,
971 COALESCE(source.path_hash, SHA2(source.path, 256)), source.manifest, source.size,
972 source.ignored, source.deleted, source.issue, NULL
973 FROM $scan_table AS source
974 WHERE source.run_id = %d AND (source.ignored = 1 OR source.deleted = 1)
975 AND NOT EXISTS (
976 SELECT 1 FROM $scan_table AS target
977 WHERE target.run_id = %d
978 AND target.type = source.type
979 AND target.postId <=> source.postId
980 AND target.path_hash <=> COALESCE(source.path_hash, SHA2(source.path, 256))
981 AND target.issue = source.issue
982 AND target.ignored = source.ignored
983 AND target.deleted = source.deleted
984 )",
985 (int) $run_id,
986 (int) $active_id,
987 (int) $run_id
988 ) );
989 if ( $result === false ) {
990 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 ) );
991 }
992 return true;
993 }
994
995 private function initialize_started_run( $run_id ) {
996 global $wpdb;
997 $copied = $this->copy_persistent_results( $run_id );
998 if ( is_wp_error( $copied ) ) return $copied;
999 $updated = $wpdb->update(
1000 $this->table( 'runs' ),
1001 array( 'phase' => 'ready', 'updated_at' => current_time( 'mysql', true ), 'heartbeat_at' => current_time( 'mysql', true ) ),
1002 array( 'id' => (int) $run_id, 'phase' => 'starting' ),
1003 array( '%s', '%s', '%s' ),
1004 array( '%d', '%s' )
1005 );
1006 if ( $updated === false ) {
1007 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 ) );
1008 }
1009 return true;
1010 }
1011 }
1012