PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.8
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.8, at includes/desktop-files/stored-files-store.php

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