PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.10
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.10
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / desktop-files / reconcile.php

reconcile.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.10, at includes/desktop-files/reconcile.php

264 lines 8.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Failure-safe reconciliation of stored rows and upload bytes.
4 *
5 * @package OpenStation
6 */
7 defined( 'ABSPATH' ) || exit;
8
9 require_once dirname( __DIR__ ) . '/storage-primary.php';
10
11 /**
12 * Serialize upload registration, placement creation and each cleanup candidate.
13 *
14 * Connection-scoped MySQL locks work without committing a caller's transaction.
15 * The name includes the database and site prefix; unrelated sites never wait.
16 * Nested calls on this connection are balanced by MySQL's lock reference count.
17 * No filesystem traversal is performed while the lock is held.
18 *
19 * @internal
20 * @param callable $callback Operation to protect.
21 * @param bool $cleanup Require a real advisory lock for destructive reconciliation.
22 * @return mixed|WP_Error Callback result, or a retryable lock error.
23 */
24 function openstation_stored_files_locked( $callback, $cleanup = false ) {
25 global $wpdb;
26 openstation_storage_use_primary();
27 $name = 'os-files-' . md5( $wpdb->dbname . ':' . $wpdb->prefix );
28 $lock = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 5)', $name ) );
29 // SQLite translates GET_LOCK to a successful no-op. Keep intake working,
30 // but never run destructive reconciliation without mutual exclusion.
31 if ( '1=1' === $lock && ! $cleanup ) {
32 return $callback();
33 }
34 if ( '1' !== (string) $lock ) {
35 return new WP_Error( 'openstation_storage_busy', __( 'File storage is busy. Please try again.', 'desktop-mode' ), array( 'status' => 503 ) );
36 }
37 try {
38 return $callback();
39 } finally {
40 $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $name ) );
41 }
42 }
43
44 /**
45 * Protect only the current reference check and placement insert.
46 * Authorization, collision recovery and extension callbacks run outside the lock.
47 *
48 * @internal
49 * @param string $ref Stored-file reference.
50 * @param callable $insert Placement insert.
51 * @return array|WP_Error
52 */
53 function openstation_stored_files_place_insert( $ref, $insert ) {
54 return openstation_stored_files_locked(
55 static function () use ( $ref, $insert ) {
56 global $wpdb;
57 $tables = openstation_files_table_names();
58 $exists = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$tables['stored_files']} WHERE id = %d FOR UPDATE", (int) $ref ) );
59 if ( '' !== $wpdb->last_error ) {
60 return new WP_Error( 'openstation_storage_unavailable', __( 'File storage is unavailable. Please try again.', 'desktop-mode' ), array( 'status' => 503 ) );
61 }
62 if ( ! $exists ) {
63 return new WP_Error( 'openstation_stored_files_not_found', __( 'Stored file not found.', 'desktop-mode' ), array( 'status' => 404 ) );
64 }
65 return $insert();
66 }
67 );
68 }
69
70 /**
71 * Report a failed cleanup operation without exposing SQL or filesystem paths.
72 *
73 * @internal
74 * @param string $stage Failed operation.
75 * @return void
76 */
77 function openstation_stored_files_reconcile_failed( $stage ) {
78 /**
79 * Fires when reconciliation skips bytes or stops after a failed safety check.
80 *
81 * @param WP_Error $error Error whose data contains the failing stage.
82 */
83 do_action(
84 'openstation_stored_files_reconcile_failed',
85 new WP_Error( 'openstation_reconcile_failed', __( 'A file cleanup operation could not be completed.', 'desktop-mode' ), array( 'stage' => $stage ) )
86 );
87 }
88
89 /**
90 * Remove one old placement-less row, rechecking under the upload writer lock.
91 *
92 * Delete the database row before bytes. A failed DELETE leaves bytes untouched;
93 * a failed unlink leaves row-less bytes for a later sweep. Trashed placements
94 * count as references, so cleanup never consumes the recycle bin's files.
95 *
96 * @internal
97 * @param int $id Candidate id.
98 * @param int $cutoff_ms Oldest retained creation timestamp.
99 * @return bool Whether the check and any required deletion succeeded.
100 */
101 function openstation_stored_files_reconcile_row( $id, $cutoff_ms ) {
102 global $wpdb;
103 $tables = openstation_files_table_names();
104 $row = $wpdb->get_row(
105 $wpdb->prepare(
106 "SELECT sf.* FROM {$tables['stored_files']} sf
107 WHERE sf.id = %d AND sf.created_at_ms < %d
108 AND NOT EXISTS (SELECT 1 FROM {$tables['placements']} p
109 WHERE p.file_type = 'upload' AND p.file_ref = CAST(sf.id AS CHAR))",
110 $id,
111 $cutoff_ms
112 ),
113 ARRAY_A
114 );
115 if ( '' !== $wpdb->last_error ) {
116 return false;
117 }
118 if ( ! $row ) {
119 return true;
120 }
121 $deleted = $wpdb->query(
122 $wpdb->prepare(
123 "DELETE sf FROM {$tables['stored_files']} sf
124 WHERE sf.id = %d AND sf.created_at_ms < %d
125 AND NOT EXISTS (SELECT 1 FROM {$tables['placements']} p
126 WHERE p.file_type = 'upload' AND p.file_ref = CAST(sf.id AS CHAR))",
127 $id,
128 $cutoff_ms
129 )
130 );
131 if ( false === $deleted ) {
132 return false;
133 }
134 if ( 1 === $deleted ) {
135 $row = openstation_stored_files_normalize_row( $row );
136 $path = openstation_stored_file_path( $row );
137 if ( $path && is_file( $path ) ) {
138 wp_delete_file( $path );
139 clearstatcache( true, $path );
140 if ( is_file( $path ) ) {
141 openstation_stored_files_reconcile_failed( 'unlink_row_bytes' );
142 }
143 }
144 /** This action is documented in includes/desktop-files/stored-files-store.php. */
145 do_action( 'openstation_stored_file_deleted', (int) $row['id'], $row );
146 }
147 return true;
148 }
149
150 /**
151 * Revalidate one row-less disk file against current DB state before unlinking.
152 *
153 * @internal
154 * @param int $owner_id Owner directory.
155 * @param string $entry Candidate path.
156 * @param int $cutoff Unix seconds cutoff.
157 * @return bool Whether the check succeeded.
158 */
159 function openstation_stored_files_reconcile_bytes( $owner_id, $entry, $cutoff ) {
160 global $wpdb;
161 $tables = openstation_files_table_names();
162 // A locking read sees current committed rows even inside a caller's
163 // REPEATABLE READ transaction, rather than an earlier empty snapshot.
164 // Engines that reject the changed snapshot take the failure path below.
165 $known = $wpdb->get_var(
166 $wpdb->prepare(
167 "SELECT id FROM {$tables['stored_files']} WHERE owner_id = %d AND disk_name = %s LIMIT 1 FOR UPDATE",
168 $owner_id,
169 basename( $entry )
170 )
171 );
172 if ( '' !== $wpdb->last_error ) {
173 return false;
174 }
175 clearstatcache( true, $entry );
176 if ( null === $known && is_file( $entry ) && ! is_link( $entry ) ) {
177 $mtime = filemtime( $entry );
178 if ( false !== $mtime && $mtime > 0 && $mtime < $cutoff ) {
179 wp_delete_file( $entry );
180 clearstatcache( true, $entry );
181 if ( is_file( $entry ) ) {
182 openstation_stored_files_reconcile_failed( 'unlink_bytes' );
183 }
184 return true;
185 }
186 }
187 return true;
188 }
189
190 /**
191 * Daily reconciliation. Any failed lookup aborts the remaining sweep.
192 *
193 * The one-day grace period protects uploads between their filesystem move and
194 * row insertion. Missing bytes retain their row, allowing manual recovery.
195 *
196 * @return void
197 */
198 function openstation_stored_files_reconcile() {
199 global $wpdb;
200 $tables = openstation_files_table_names();
201 $cutoff_ms = openstation_files_now_ms() - DAY_IN_SECONDS * 1000;
202 $orphans = $wpdb->get_col(
203 $wpdb->prepare(
204 "SELECT sf.id FROM {$tables['stored_files']} sf
205 LEFT JOIN {$tables['placements']} p ON p.file_type = 'upload' AND p.file_ref = CAST(sf.id AS CHAR)
206 WHERE p.id IS NULL AND sf.created_at_ms < %d",
207 $cutoff_ms
208 )
209 );
210 if ( '' !== $wpdb->last_error ) {
211 openstation_stored_files_reconcile_failed( 'orphan_rows' );
212 return;
213 }
214 foreach ( $orphans as $id ) {
215 $result = openstation_stored_files_locked(
216 static function () use ( $id, $cutoff_ms ) {
217 return openstation_stored_files_reconcile_row( (int) $id, $cutoff_ms );
218 },
219 true
220 );
221 if ( true !== $result ) {
222 openstation_stored_files_reconcile_failed( 'delete_row' );
223 return;
224 }
225 }
226
227 $base = openstation_stored_files_dir();
228 if ( ! is_dir( $base ) || is_link( $base ) ) {
229 return;
230 }
231 foreach ( (array) glob( $base . '/*', GLOB_ONLYDIR ) as $user_dir ) {
232 $owner_id = (int) basename( $user_dir );
233 if ( $owner_id <= 0 || basename( $user_dir ) !== (string) $owner_id || is_link( $user_dir ) ) {
234 continue;
235 }
236 $known = $wpdb->get_col( $wpdb->prepare( "SELECT disk_name FROM {$tables['stored_files']} WHERE owner_id = %d", $owner_id ) );
237 if ( '' !== $wpdb->last_error ) {
238 openstation_stored_files_reconcile_failed( 'known_bytes' );
239 return;
240 }
241 $known_set = array_fill_keys( $known, true );
242 foreach ( (array) glob( $user_dir . '/*' ) as $entry ) {
243 if ( isset( $known_set[ basename( $entry ) ] ) || ! openstation_stored_files_valid_disk_name( basename( $entry ) ) ) {
244 continue;
245 }
246 clearstatcache( true, $entry );
247 if ( ! is_file( $entry ) || is_link( $entry ) || filemtime( $entry ) >= time() - DAY_IN_SECONDS ) {
248 continue;
249 }
250 $result = openstation_stored_files_locked(
251 static function () use ( $owner_id, $entry ) {
252 return openstation_stored_files_reconcile_bytes( $owner_id, $entry, time() - DAY_IN_SECONDS );
253 },
254 true
255 );
256 if ( true !== $result ) {
257 openstation_stored_files_reconcile_failed( 'delete_bytes' );
258 return;
259 }
260 }
261 }
262 }
263 add_action( 'desktop_mode_files_daily_prune', 'openstation_stored_files_reconcile' );
264