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

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