PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.6
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.6
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 / stored-files-store.php

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

738 lines 22.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — stored-files store (real per-user file storage).
4 *
5 * Backs the `upload` file type. One row per uploaded file in
6 * `{$wpdb->prefix}desktop_mode_stored_files`; the bytes live flat on
7 * disk under `uploads/desktop-mode-files/<owner_id>/<disk_name>`.
8 *
9 * Layout invariants (the security model depends on all three):
10 *
11 * 1. `disk_name` is a server-generated UUID with NO extension —
12 * user input never composes a disk path, and a direct hit on
13 * an unprotected server yields opaque bytes, not something a
14 * PHP handler would execute.
15 * 2. The storage base is protected by `.htaccess` (both Apache
16 * 2.2 and 2.4 syntaxes) + an empty `index.php`. nginx ignores
17 * `.htaccess`; the documented `deny all` location snippet plus
18 * invariants 1 and 3 are the floor there.
19 * 3. Bytes are only ever served through the authenticated
20 * download endpoint (`includes/desktop-files/downloads.php`)
21 * with `Content-Disposition: attachment` + nosniff.
22 *
23 * Deletion contract — the deliberate exception to the desktop-files
24 * "references, not copies" rule: for `upload` placements the
25 * placement OWNS the entity. Soft-trash keeps the bytes; when the
26 * owner's last placement of a file is permanently removed, the row,
27 * the bytes, and every recipient placement are purged (see
28 * {@see desktop_mode_stored_files_handle_unplaced()}).
29 *
30 * @package WPDesktopMode
31 * @since 0.9.6
32 */
33
34 defined( 'ABSPATH' ) || exit;
35
36 /**
37 * Absolute path of the storage base dir (no trailing slash), or of
38 * a user's subdirectory when `$user_id` is given. Purely a path
39 * computation — nothing is created; see
40 * {@see desktop_mode_stored_files_ensure_dir()}.
41 *
42 * @since 0.9.6
43 *
44 * @param int $user_id Optional. Owner whose subdirectory to return.
45 * @return string
46 */
47 function desktop_mode_stored_files_dir( $user_id = 0 ) {
48 $uploads = wp_get_upload_dir();
49 $base = trailingslashit( $uploads['basedir'] ) . 'desktop-mode-files';
50 /**
51 * Filters the storage base directory. Sites that can write
52 * outside the webroot can point this somewhere safer entirely.
53 *
54 * @since 0.9.6
55 *
56 * @param string $base Absolute path, no trailing slash.
57 */
58 $base = (string) apply_filters( 'desktop_mode_stored_files_base_dir', $base );
59 if ( (int) $user_id > 0 ) {
60 return $base . '/' . (int) $user_id;
61 }
62 return $base;
63 }
64
65 /**
66 * Create (idempotently) the storage base + per-user dir and drop
67 * the protection files into the base. Returns the user dir path or
68 * a `WP_Error` when the filesystem refuses.
69 *
70 * @since 0.9.6
71 *
72 * @param int $user_id Owner.
73 * @return string|WP_Error
74 */
75 function desktop_mode_stored_files_ensure_dir( $user_id ) {
76 $user_id = (int) $user_id;
77 if ( $user_id <= 0 ) {
78 return new WP_Error( 'desktop_mode_stored_files_invalid_user', __( 'A user id is required.', 'desktop-mode' ), array( 'status' => 400 ) );
79 }
80 $base = desktop_mode_stored_files_dir();
81 $dir = desktop_mode_stored_files_dir( $user_id );
82 if ( ! wp_mkdir_p( $dir ) ) {
83 return new WP_Error( 'desktop_mode_stored_files_mkdir_failed', __( 'Could not create the storage directory.', 'desktop-mode' ), array( 'status' => 500 ) );
84 }
85
86 // Protection files in the base. `Require all denied` alone 500s
87 // on Apache 2.2 and `Deny from all` alone is ignored on pure
88 // 2.4 — the IfModule guards make one file serve both. nginx
89 // ignores all of this; extensionless UUID names + PHP-gated
90 // serving are the floor there (documented in
91 // docs/files-on-desktop.md along with a `deny all` snippet).
92 $htaccess = $base . '/.htaccess';
93 if ( ! file_exists( $htaccess ) ) {
94 $rules = "Options -Indexes\n"
95 . "<IfModule mod_authz_core.c>\n"
96 . "\tRequire all denied\n"
97 . "</IfModule>\n"
98 . "<IfModule !mod_authz_core.c>\n"
99 . "\tOrder deny,allow\n"
100 . "\tDeny from all\n"
101 . "</IfModule>\n";
102 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
103 file_put_contents( $htaccess, $rules );
104 }
105 foreach ( array( $base . '/index.php', $dir . '/index.php' ) as $index ) {
106 if ( ! file_exists( $index ) ) {
107 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
108 file_put_contents( $index, "<?php // Silence is golden.\n" );
109 }
110 }
111 return $dir;
112 }
113
114 /**
115 * Whether `$disk_name` is a well-formed server-generated name
116 * (UUID v4, dashes allowed, no dots or separators — nothing a
117 * traversal could ride on).
118 *
119 * @since 0.9.6
120 *
121 * @param string $disk_name Candidate.
122 * @return bool
123 */
124 function desktop_mode_stored_files_valid_disk_name( $disk_name ) {
125 return (bool) preg_match( '/^[a-f0-9-]{16,64}$/', (string) $disk_name );
126 }
127
128 /**
129 * Absolute path of a stored file's bytes, with containment guard.
130 * Returns `null` when the row/disk name is malformed or the
131 * resolved path escapes the storage base (defense in depth — the
132 * disk-name regex should already make escape impossible).
133 *
134 * @since 0.9.6
135 *
136 * @param array $row Normalized stored-file row.
137 * @return string|null
138 */
139 function desktop_mode_stored_file_path( $row ) {
140 if ( ! is_array( $row ) || empty( $row['owner_id'] ) || empty( $row['disk_name'] ) ) {
141 return null;
142 }
143 if ( ! desktop_mode_stored_files_valid_disk_name( $row['disk_name'] ) ) {
144 return null;
145 }
146 $base = desktop_mode_stored_files_dir();
147 $path = desktop_mode_stored_files_dir( (int) $row['owner_id'] ) . '/' . $row['disk_name'];
148
149 // realpath() fails on not-yet-existing leaves; canonicalize the
150 // parent instead and re-attach the (regex-validated) leaf.
151 $real_parent = realpath( dirname( $path ) );
152 $real_base = realpath( $base );
153 if ( false === $real_parent || false === $real_base ) {
154 // Parent doesn't exist yet (nothing uploaded) — path is
155 // structurally safe per the regex; return as computed.
156 return $path;
157 }
158 if ( 0 !== strpos( $real_parent . '/', $real_base . '/' ) ) {
159 return null;
160 }
161 return $real_parent . '/' . $row['disk_name'];
162 }
163
164 /**
165 * Insert a stored-file row for bytes ALREADY on disk (the REST
166 * intake moves them there via `wp_handle_upload()` first).
167 *
168 * @since 0.9.6
169 *
170 * @param int $owner_id Owner.
171 * @param array $args `display_name`, `disk_name`, `size_bytes`, `mime`.
172 * @return int|WP_Error Row id.
173 */
174 function desktop_mode_stored_files_create( $owner_id, $args ) {
175 global $wpdb;
176 $owner_id = (int) $owner_id;
177 if ( $owner_id <= 0 ) {
178 return new WP_Error( 'desktop_mode_stored_files_invalid_user', __( 'A user id is required.', 'desktop-mode' ), array( 'status' => 400 ) );
179 }
180 $args = wp_parse_args(
181 $args,
182 array(
183 'display_name' => '',
184 'disk_name' => '',
185 'size_bytes' => 0,
186 'mime' => '',
187 )
188 );
189 if ( ! desktop_mode_stored_files_valid_disk_name( $args['disk_name'] ) ) {
190 return new WP_Error( 'desktop_mode_stored_files_bad_disk_name', __( 'Invalid storage name.', 'desktop-mode' ), array( 'status' => 400 ) );
191 }
192 $display = sanitize_file_name( wp_strip_all_tags( (string) $args['display_name'] ) );
193 if ( '' === $display ) {
194 $display = __( 'file', 'desktop-mode' );
195 }
196
197 $tables = desktop_mode_files_table_names();
198 $now = desktop_mode_files_now_ms();
199 $ok = $wpdb->insert(
200 $tables['stored_files'],
201 array(
202 'owner_id' => $owner_id,
203 'display_name' => $display,
204 'disk_name' => (string) $args['disk_name'],
205 'size_bytes' => max( 0, (int) $args['size_bytes'] ),
206 'mime' => sanitize_mime_type( (string) $args['mime'] ),
207 'created_at_ms' => $now,
208 'updated_at_ms' => $now,
209 ),
210 array( '%d', '%s', '%s', '%d', '%s', '%d', '%d' )
211 );
212 if ( false === $ok ) {
213 return new WP_Error( 'desktop_mode_stored_files_insert_failed', __( 'Failed to record the uploaded file.', 'desktop-mode' ), array( 'status' => 500 ) );
214 }
215 $id = (int) $wpdb->insert_id;
216
217 /**
218 * Fires after a stored-file row is created (bytes are already
219 * on disk at this point).
220 *
221 * @since 0.9.6
222 *
223 * @param int $id Stored-file id.
224 * @param int $owner_id Owner.
225 */
226 do_action( 'desktop_mode_stored_file_created', $id, $owner_id );
227
228 return $id;
229 }
230
231 /**
232 * Read one stored-file row.
233 *
234 * @since 0.9.6
235 *
236 * @param int $file_id Row id.
237 * @return array|null
238 */
239 function desktop_mode_stored_files_get( $file_id ) {
240 global $wpdb;
241 $file_id = (int) $file_id;
242 if ( $file_id <= 0 ) {
243 return null;
244 }
245 $tables = desktop_mode_files_table_names();
246 $row = $wpdb->get_row(
247 $wpdb->prepare( "SELECT * FROM {$tables['stored_files']} WHERE id = %d", $file_id ),
248 ARRAY_A
249 );
250 if ( ! $row ) {
251 return null;
252 }
253 return desktop_mode_stored_files_normalize_row( $row );
254 }
255
256 /**
257 * Coerce wpdb's stringly-typed row. Internal helper.
258 *
259 * @since 0.9.6
260 * @internal
261 *
262 * @param array $row Raw wpdb row.
263 * @return array
264 */
265 function desktop_mode_stored_files_normalize_row( $row ) {
266 return array(
267 'id' => (int) $row['id'],
268 'owner_id' => (int) $row['owner_id'],
269 'display_name' => (string) $row['display_name'],
270 'disk_name' => (string) $row['disk_name'],
271 'size_bytes' => (int) $row['size_bytes'],
272 'mime' => (string) $row['mime'],
273 'created_at_ms' => (int) $row['created_at_ms'],
274 'updated_at_ms' => (int) $row['updated_at_ms'],
275 );
276 }
277
278 /**
279 * Rename the display name. The caller enforces WHO may rename
280 * (owner-only — see the store gate in `store.php`); this only
281 * validates and writes.
282 *
283 * @since 0.9.6
284 *
285 * @param int $file_id Row id.
286 * @param string $name New display name.
287 * @return true|WP_Error
288 */
289 function desktop_mode_stored_files_rename( $file_id, $name ) {
290 global $wpdb;
291 $row = desktop_mode_stored_files_get( $file_id );
292 if ( ! $row ) {
293 return new WP_Error( 'desktop_mode_stored_files_not_found', __( 'Stored file not found.', 'desktop-mode' ), array( 'status' => 404 ) );
294 }
295 $name = sanitize_file_name( wp_strip_all_tags( (string) $name ) );
296 if ( '' === $name ) {
297 return new WP_Error( 'desktop_mode_stored_files_bad_name', __( 'A file name is required.', 'desktop-mode' ), array( 'status' => 400 ) );
298 }
299 $tables = desktop_mode_files_table_names();
300 $now = desktop_mode_files_now_ms();
301 $wpdb->update(
302 $tables['stored_files'],
303 array(
304 'display_name' => $name,
305 'updated_at_ms' => $now,
306 ),
307 array( 'id' => (int) $row['id'] ),
308 array( '%s', '%d' ),
309 array( '%d' )
310 );
311
312 // Bump every placement pointing at the file so the heartbeat
313 // re-delivers each with a fresh `file.title` — same lock-step
314 // rule the folder rename uses (tile titles are captured on the
315 // placement shape, not read live).
316 $wpdb->query(
317 $wpdb->prepare(
318 "UPDATE {$tables['placements']} SET updated_at_ms = %d
319 WHERE file_type = %s AND file_ref = %s",
320 $now,
321 'upload',
322 (string) $row['id']
323 )
324 );
325
326 /**
327 * Fires after a stored file is renamed.
328 *
329 * @since 0.9.6
330 *
331 * @param int $file_id Stored-file id.
332 * @param string $new_name New display name.
333 * @param string $old_name Previous display name.
334 */
335 do_action( 'desktop_mode_stored_file_renamed', (int) $row['id'], $name, (string) $row['display_name'] );
336
337 return true;
338 }
339
340 /**
341 * Trash-gate filter (priority 20 — after the folder-share gate):
342 * an `upload` placement is trashable ONLY by the stored file's
343 * owner. Folder write-collaborators are read + download on
344 * uploads; the `canTrash` shape flag, the trash flow, and the
345 * recycle-bin drop target all consult this same filter.
346 *
347 * @since 0.9.6
348 *
349 * @param bool $can Decision so far.
350 * @param int $user_id Acting user.
351 * @param array $row Placement row.
352 * @return bool
353 */
354 function desktop_mode_stored_files_gate_trash( $can, $user_id, $row ) {
355 if ( ! is_array( $row ) || 'upload' !== (string) ( $row['file_type'] ?? '' ) ) {
356 return $can;
357 }
358 $stored = desktop_mode_stored_files_get( (int) $row['file_ref'] );
359 if ( ! $stored ) {
360 return $can; // Dangling tile — normal rules, so it stays cleanable.
361 }
362 return (int) $stored['owner_id'] === (int) $user_id;
363 }
364 add_filter( 'desktop_mode_files_user_can_trash_placement', 'desktop_mode_stored_files_gate_trash', 20, 3 );
365
366 /**
367 * Delete a stored file: bytes first, then the row. Does NOT touch
368 * placements — callers that need the full cascade go through
369 * {@see desktop_mode_stored_files_purge()}.
370 *
371 * @since 0.9.6
372 *
373 * @param int $file_id Row id.
374 * @return true|WP_Error
375 */
376 function desktop_mode_stored_files_delete( $file_id ) {
377 global $wpdb;
378 $row = desktop_mode_stored_files_get( $file_id );
379 if ( ! $row ) {
380 return new WP_Error( 'desktop_mode_stored_files_not_found', __( 'Stored file not found.', 'desktop-mode' ), array( 'status' => 404 ) );
381 }
382 $path = desktop_mode_stored_file_path( $row );
383 if ( $path && file_exists( $path ) ) {
384 wp_delete_file( $path );
385 }
386 $tables = desktop_mode_files_table_names();
387 $wpdb->delete( $tables['stored_files'], array( 'id' => (int) $row['id'] ), array( '%d' ) );
388
389 /**
390 * Fires after a stored file (row + bytes) is deleted.
391 *
392 * @since 0.9.6
393 *
394 * @param int $file_id Stored-file id.
395 * @param array $row The row as it was before deletion.
396 */
397 do_action( 'desktop_mode_stored_file_deleted', (int) $row['id'], $row );
398
399 return true;
400 }
401
402 /**
403 * Full purge: delete the bytes, the row, every remaining placement
404 * of the file (each with a tombstone so heartbeat scrubs recipient
405 * tiles live), and every `target_type='file'` share row.
406 *
407 * @since 0.9.6
408 *
409 * @param int $file_id Row id.
410 * @return true|WP_Error
411 */
412 function desktop_mode_stored_files_purge( $file_id ) {
413 global $wpdb;
414 $file_id = (int) $file_id;
415 $row = desktop_mode_stored_files_get( $file_id );
416 if ( ! $row ) {
417 return new WP_Error( 'desktop_mode_stored_files_not_found', __( 'Stored file not found.', 'desktop-mode' ), array( 'status' => 404 ) );
418 }
419 $tables = desktop_mode_files_table_names();
420
421 // Remaining placements (trashed included — the file is going
422 // away for good, a recycle-bin restore must not resurrect a
423 // tile pointing at deleted bytes).
424 $placement_ids = $wpdb->get_col(
425 $wpdb->prepare(
426 "SELECT id FROM {$tables['placements']}
427 WHERE file_type = %s AND file_ref = %s",
428 'upload',
429 (string) $file_id
430 )
431 );
432 foreach ( (array) $placement_ids as $pid ) {
433 $wpdb->delete( $tables['placements'], array( 'id' => (int) $pid ), array( '%d' ) );
434 desktop_mode_files_write_tombstone( 'placement', (int) $pid );
435 }
436
437 // File shares (target_type='file'). The shares table keys the
438 // target id on the historically-named `folder_id` column.
439 $wpdb->delete(
440 $tables['shares'],
441 array(
442 'target_type' => 'file',
443 'folder_id' => $file_id,
444 ),
445 array( '%s', '%d' )
446 );
447
448 return desktop_mode_stored_files_delete( $file_id );
449 }
450
451 /**
452 * Sum of stored bytes for one owner.
453 *
454 * @since 0.9.6
455 *
456 * @param int $owner_id Owner.
457 * @return int
458 */
459 function desktop_mode_stored_files_total_bytes( $owner_id ) {
460 global $wpdb;
461 $tables = desktop_mode_files_table_names();
462 return (int) $wpdb->get_var(
463 $wpdb->prepare(
464 "SELECT COALESCE( SUM( size_bytes ), 0 ) FROM {$tables['stored_files']} WHERE owner_id = %d",
465 (int) $owner_id
466 )
467 );
468 }
469
470 /**
471 * Per-user quota in bytes. 0 = unlimited (the default). Sites
472 * enforce a cap via the filter; the REST intake consults this
473 * before accepting a new file.
474 *
475 * @since 0.9.6
476 *
477 * @param int $user_id User.
478 * @return int
479 */
480 function desktop_mode_stored_files_user_quota_bytes( $user_id ) {
481 /**
482 * Filters the per-user storage quota in bytes. Return 0 for
483 * unlimited.
484 *
485 * @since 0.9.6
486 *
487 * @param int $quota Quota in bytes. Default 0 (unlimited).
488 * @param int $user_id User being checked.
489 */
490 return max( 0, (int) apply_filters( 'desktop_mode_stored_files_user_quota_bytes', 0, (int) $user_id ) );
491 }
492
493 /**
494 * Capability required to upload. Defaults to WordPress's own
495 * `upload_files`; sites that want desktop storage for lower-cap
496 * roles loosen via the filter.
497 *
498 * @since 0.9.6
499 *
500 * @return string
501 */
502 function desktop_mode_stored_files_upload_capability() {
503 /**
504 * Filters the capability required to upload desktop files.
505 *
506 * @since 0.9.6
507 *
508 * @param string $capability Default 'upload_files'.
509 */
510 return (string) apply_filters( 'desktop_mode_stored_files_upload_capability', 'upload_files' );
511 }
512
513 /**
514 * Access resolver: can `$user_id` read (view / download) this
515 * stored file?
516 *
517 * - The owner always can.
518 * - A user with an accepted `target_type='file'` share can.
519 * - A user with at least read capability on any folder that
520 * contains a live placement of the file can (shared-folder
521 * contents are visible to the folder's audience).
522 *
523 * @since 0.9.6
524 *
525 * @param int $file_id Stored-file id.
526 * @param int $user_id Viewer.
527 * @return bool
528 */
529 function desktop_mode_stored_file_user_can_read( $file_id, $user_id ) {
530 global $wpdb;
531 $file_id = (int) $file_id;
532 $user_id = (int) $user_id;
533 if ( $file_id <= 0 || $user_id <= 0 ) {
534 return false;
535 }
536 $row = desktop_mode_stored_files_get( $file_id );
537 if ( ! $row ) {
538 return false;
539 }
540 if ( (int) $row['owner_id'] === $user_id ) {
541 return true;
542 }
543
544 // Accepted direct file share.
545 if ( function_exists( 'desktop_mode_stored_file_share_state' ) ) {
546 if ( 'accepted' === desktop_mode_stored_file_share_state( $file_id, $user_id ) ) {
547 return true;
548 }
549 }
550
551 // Read+ capability on a folder containing a live placement.
552 $tables = desktop_mode_files_table_names();
553 $parents = $wpdb->get_col(
554 $wpdb->prepare(
555 "SELECT DISTINCT parent_id FROM {$tables['placements']}
556 WHERE file_type = %s AND file_ref = %s
557 AND parent_id > 0
558 AND trashed_at_ms IS NULL",
559 'upload',
560 (string) $file_id
561 )
562 );
563 if ( function_exists( 'desktop_mode_folder_share_user_capability' ) ) {
564 foreach ( (array) $parents as $parent_id ) {
565 if ( 'none' !== desktop_mode_folder_share_user_capability( (int) $parent_id, $user_id ) ) {
566 return true;
567 }
568 }
569 }
570
571 /**
572 * Last-mile override for stored-file read access. Plugins with
573 * their own sharing concepts can widen (or veto) here.
574 *
575 * @since 0.9.6
576 *
577 * @param bool $can Resolved decision so far (false).
578 * @param int $file_id Stored-file id.
579 * @param int $user_id Viewer.
580 * @param array $row Stored-file row.
581 */
582 return (bool) apply_filters( 'desktop_mode_stored_file_can_read', false, $file_id, $user_id, $row );
583 }
584
585 /**
586 * Placement-removal listener — the deletion contract.
587 *
588 * When an `upload` placement is PERMANENTLY removed and the row's
589 * owner is the stored file's owner, check whether the owner has any
590 * placement of the file left (trashed ones count — they can be
591 * restored). If none remain, the file is unreachable for its owner:
592 * purge bytes, row, shares, and every recipient placement.
593 *
594 * Recipient placements going away never delete bytes.
595 *
596 * @since 0.9.6
597 *
598 * @param int $placement_id Removed placement id.
599 * @param array $row The removed row.
600 */
601 function desktop_mode_stored_files_handle_unplaced( $placement_id, $row ) {
602 global $wpdb;
603 if ( ! is_array( $row ) || 'upload' !== (string) ( $row['file_type'] ?? '' ) ) {
604 return;
605 }
606 $file_id = (int) $row['file_ref'];
607 $file = desktop_mode_stored_files_get( $file_id );
608 if ( ! $file ) {
609 return;
610 }
611 if ( (int) $row['owner_id'] !== (int) $file['owner_id'] ) {
612 return; // A recipient's tile went away; bytes stay.
613 }
614 $tables = desktop_mode_files_table_names();
615 $remaining = (int) $wpdb->get_var(
616 $wpdb->prepare(
617 "SELECT COUNT(*) FROM {$tables['placements']}
618 WHERE file_type = %s AND file_ref = %s AND owner_id = %d",
619 'upload',
620 (string) $file_id,
621 (int) $file['owner_id']
622 )
623 );
624 if ( $remaining > 0 ) {
625 return;
626 }
627 desktop_mode_stored_files_purge( $file_id );
628 }
629 add_action( 'desktop_mode_file_unplaced', 'desktop_mode_stored_files_handle_unplaced', 10, 2 );
630
631 /**
632 * Daily reconciliation sweep, both directions:
633 *
634 * a) Rows with no placement at all (crashed uploads, interrupted
635 * purges) older than the grace period → delete row + bytes.
636 * b) Bytes on disk with no matching row (interrupted deletes)
637 * whose mtime is older than the grace period → delete bytes.
638 *
639 * Rows whose bytes are missing are left alone — `exists()` still
640 * renders the tile so the user can see and remove it.
641 *
642 * @since 0.9.6
643 */
644 function desktop_mode_stored_files_reconcile() {
645 global $wpdb;
646 $tables = desktop_mode_files_table_names();
647 $grace = DAY_IN_SECONDS;
648
649 // a) Placement-less rows past grace.
650 $cutoff_ms = desktop_mode_files_now_ms() - ( $grace * 1000 );
651 $orphans = $wpdb->get_col(
652 $wpdb->prepare(
653 "SELECT sf.id FROM {$tables['stored_files']} sf
654 LEFT JOIN {$tables['placements']} p
655 ON p.file_type = 'upload'
656 AND p.file_ref = CAST( sf.id AS CHAR )
657 WHERE p.id IS NULL
658 AND sf.created_at_ms < %d",
659 $cutoff_ms
660 )
661 );
662 foreach ( (array) $orphans as $orphan_id ) {
663 desktop_mode_stored_files_delete( (int) $orphan_id );
664 }
665
666 // b) Row-less bytes past grace. The flat layout makes this a
667 // two-level scan: <base>/<user_id>/<disk_name>.
668 $base = desktop_mode_stored_files_dir();
669 if ( ! is_dir( $base ) ) {
670 return;
671 }
672 $user_dirs = glob( $base . '/*', GLOB_ONLYDIR );
673 foreach ( (array) $user_dirs as $user_dir ) {
674 $owner_id = (int) basename( $user_dir );
675 if ( $owner_id <= 0 ) {
676 continue;
677 }
678 $known = $wpdb->get_col(
679 $wpdb->prepare(
680 "SELECT disk_name FROM {$tables['stored_files']} WHERE owner_id = %d",
681 $owner_id
682 )
683 );
684 $known_set = array_flip( array_map( 'strval', (array) $known ) );
685 $entries = glob( $user_dir . '/*' );
686 foreach ( (array) $entries as $entry ) {
687 $name = basename( $entry );
688 if ( 'index.php' === $name || ! is_file( $entry ) ) {
689 continue;
690 }
691 if ( isset( $known_set[ $name ] ) ) {
692 continue;
693 }
694 if ( ! desktop_mode_stored_files_valid_disk_name( $name ) ) {
695 continue; // Not ours — leave foreign files alone.
696 }
697 $mtime = (int) filemtime( $entry );
698 if ( $mtime > 0 && ( time() - $mtime ) > $grace ) {
699 wp_delete_file( $entry );
700 }
701 }
702 }
703 }
704 add_action( 'desktop_mode_files_daily_prune', 'desktop_mode_stored_files_reconcile' );
705
706 /**
707 * When a WordPress user is deleted, purge their stored files (rows,
708 * bytes, shares, recipient placements) and remove their directory.
709 *
710 * @since 0.9.6
711 *
712 * @param int $user_id Deleted user id.
713 */
714 function desktop_mode_stored_files_handle_deleted_user( $user_id ) {
715 global $wpdb;
716 $user_id = (int) $user_id;
717 if ( $user_id <= 0 ) {
718 return;
719 }
720 $tables = desktop_mode_files_table_names();
721 $ids = $wpdb->get_col(
722 $wpdb->prepare( "SELECT id FROM {$tables['stored_files']} WHERE owner_id = %d", $user_id )
723 );
724 foreach ( (array) $ids as $file_id ) {
725 desktop_mode_stored_files_purge( (int) $file_id );
726 }
727 $dir = desktop_mode_stored_files_dir( $user_id );
728 if ( is_dir( $dir ) ) {
729 $index = $dir . '/index.php';
730 if ( file_exists( $index ) ) {
731 wp_delete_file( $index );
732 }
733 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
734 @rmdir( $dir ); // Only succeeds when empty — leftovers are the sweep's job.
735 }
736 }
737 add_action( 'deleted_user', 'desktop_mode_stored_files_handle_deleted_user' );
738