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 / stored-files-store.php

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

641 lines 20.5 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 $id = openstation_stored_files_locked(
168 static function () use ( $owner_id, $args ) {
169 return openstation_stored_files_create_locked( $owner_id, $args );
170 }
171 );
172 if ( ! is_wp_error( $id ) ) {
173 /**
174 * Fires after a stored-file row is created (bytes are already
175 * on disk at this point).
176 *
177 * @param int $id Stored-file id.
178 * @param int $owner_id Owner.
179 */
180 do_action( 'openstation_stored_file_created', $id, (int) $owner_id );
181
182 }
183 return $id;
184 }
185
186 /**
187 * Insert a stored-file row while holding the reconciliation lock.
188 *
189 * @internal
190 * @param int $owner_id Owner.
191 * @param array $args Stored-file attributes.
192 * @return int|WP_Error
193 */
194 function openstation_stored_files_create_locked( $owner_id, $args ) {
195 global $wpdb;
196 $owner_id = (int) $owner_id;
197 if ( $owner_id <= 0 ) {
198 return new WP_Error( 'openstation_stored_files_invalid_user', __( 'A user id is required.', 'desktop-mode' ), array( 'status' => 400 ) );
199 }
200 $args = wp_parse_args(
201 $args,
202 array(
203 'display_name' => '',
204 'disk_name' => '',
205 'size_bytes' => 0,
206 'mime' => '',
207 )
208 );
209 if ( ! openstation_stored_files_valid_disk_name( $args['disk_name'] ) ) {
210 return new WP_Error( 'openstation_stored_files_bad_disk_name', __( 'Invalid storage name.', 'desktop-mode' ), array( 'status' => 400 ) );
211 }
212 $display = sanitize_file_name( wp_strip_all_tags( (string) $args['display_name'] ) );
213 if ( '' === $display ) {
214 $display = __( 'file', 'desktop-mode' );
215 }
216
217 $tables = openstation_files_table_names();
218 $now = openstation_files_now_ms();
219 $ok = $wpdb->insert(
220 $tables['stored_files'],
221 array(
222 'owner_id' => $owner_id,
223 'display_name' => $display,
224 'disk_name' => (string) $args['disk_name'],
225 'size_bytes' => max( 0, (int) $args['size_bytes'] ),
226 'mime' => sanitize_mime_type( (string) $args['mime'] ),
227 'created_at_ms' => $now,
228 'updated_at_ms' => $now,
229 ),
230 array( '%d', '%s', '%s', '%d', '%s', '%d', '%d' )
231 );
232 if ( false === $ok ) {
233 return new WP_Error( 'openstation_stored_files_insert_failed', __( 'Failed to record the uploaded file.', 'desktop-mode' ), array( 'status' => 500 ) );
234 }
235 $id = (int) $wpdb->insert_id;
236
237 return $id;
238 }
239
240 /**
241 * Read one stored-file row.
242 *
243 * @param int $file_id Row id.
244 * @return array|null
245 */
246 function openstation_stored_files_get( $file_id ) {
247 global $wpdb;
248 $file_id = (int) $file_id;
249 if ( $file_id <= 0 ) {
250 return null;
251 }
252 $tables = openstation_files_table_names();
253 $row = $wpdb->get_row(
254 $wpdb->prepare( "SELECT * FROM {$tables['stored_files']} WHERE id = %d", $file_id ),
255 ARRAY_A
256 );
257 if ( ! $row ) {
258 return null;
259 }
260 return openstation_stored_files_normalize_row( $row );
261 }
262
263 /**
264 * Coerce wpdb's stringly-typed row. Internal helper.
265 *
266 * @internal
267 *
268 * @param array $row Raw wpdb row.
269 * @return array
270 */
271 function openstation_stored_files_normalize_row( $row ) {
272 return array(
273 'id' => (int) $row['id'],
274 'owner_id' => (int) $row['owner_id'],
275 'display_name' => (string) $row['display_name'],
276 'disk_name' => (string) $row['disk_name'],
277 'size_bytes' => (int) $row['size_bytes'],
278 'mime' => (string) $row['mime'],
279 'created_at_ms' => (int) $row['created_at_ms'],
280 'updated_at_ms' => (int) $row['updated_at_ms'],
281 );
282 }
283
284 /**
285 * Rename the display name. The caller enforces WHO may rename
286 * (owner-only — see the store gate in `store.php`); this only
287 * validates and writes.
288 *
289 * @param int $file_id Row id.
290 * @param string $name New display name.
291 * @return true|WP_Error
292 */
293 function openstation_stored_files_rename( $file_id, $name ) {
294 global $wpdb;
295 $row = openstation_stored_files_get( $file_id );
296 if ( ! $row ) {
297 return new WP_Error( 'openstation_stored_files_not_found', __( 'Stored file not found.', 'desktop-mode' ), array( 'status' => 404 ) );
298 }
299 $name = sanitize_file_name( wp_strip_all_tags( (string) $name ) );
300 if ( '' === $name ) {
301 return new WP_Error( 'openstation_stored_files_bad_name', __( 'A file name is required.', 'desktop-mode' ), array( 'status' => 400 ) );
302 }
303 $tables = openstation_files_table_names();
304 $now = openstation_files_now_ms();
305 $wpdb->update(
306 $tables['stored_files'],
307 array(
308 'display_name' => $name,
309 'updated_at_ms' => $now,
310 ),
311 array( 'id' => (int) $row['id'] ),
312 array( '%s', '%d' ),
313 array( '%d' )
314 );
315
316 // Bump every placement pointing at the file so the heartbeat
317 // re-delivers each with a fresh `file.title` — same lock-step
318 // rule the folder rename uses (tile titles are captured on the
319 // placement shape, not read live).
320 $wpdb->query(
321 $wpdb->prepare(
322 "UPDATE {$tables['placements']} SET updated_at_ms = %d
323 WHERE file_type = %s AND file_ref = %s",
324 $now,
325 'upload',
326 (string) $row['id']
327 )
328 );
329
330 /**
331 * Fires after a stored file is renamed.
332 *
333 * @param int $file_id Stored-file id.
334 * @param string $new_name New display name.
335 * @param string $old_name Previous display name.
336 */
337 do_action( 'openstation_stored_file_renamed', (int) $row['id'], $name, (string) $row['display_name'] );
338
339 return true;
340 }
341
342 /**
343 * Trash-gate filter (priority 20 — after the folder-share gate):
344 * an `upload` placement is trashable ONLY by the stored file's
345 * owner. Folder write-collaborators are read + download on
346 * uploads; the `canTrash` shape flag, the trash flow, and the
347 * recycle-bin drop target all consult this same filter.
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 openstation_stored_files_gate_trash( $can, $user_id, $row ) {
355 if ( ! is_array( $row ) || 'upload' !== (string) ( $row['file_type'] ?? '' ) ) {
356 return $can;
357 }
358 $stored = openstation_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( 'openstation_files_user_can_trash_placement', 'openstation_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 openstation_stored_files_purge()}.
370 *
371 * @param int $file_id Row id.
372 * @return true|WP_Error
373 */
374 function openstation_stored_files_delete( $file_id ) {
375 global $wpdb;
376 $row = openstation_stored_files_get( $file_id );
377 if ( ! $row ) {
378 return new WP_Error( 'openstation_stored_files_not_found', __( 'Stored file not found.', 'desktop-mode' ), array( 'status' => 404 ) );
379 }
380 $path = openstation_stored_file_path( $row );
381 if ( $path && file_exists( $path ) ) {
382 wp_delete_file( $path );
383 }
384 $tables = openstation_files_table_names();
385 $wpdb->delete( $tables['stored_files'], array( 'id' => (int) $row['id'] ), array( '%d' ) );
386
387 /**
388 * Fires after a stored file (row + bytes) is deleted.
389 *
390 * @param int $file_id Stored-file id.
391 * @param array $row The row as it was before deletion.
392 */
393 do_action( 'openstation_stored_file_deleted', (int) $row['id'], $row );
394
395 return true;
396 }
397
398 /**
399 * Full purge: delete the bytes, the row, every remaining placement
400 * of the file (each with a tombstone so heartbeat scrubs recipient
401 * tiles live), and every `target_type='file'` share row.
402 *
403 * @param int $file_id Row id.
404 * @return true|WP_Error
405 */
406 function openstation_stored_files_purge( $file_id ) {
407 global $wpdb;
408 $file_id = (int) $file_id;
409 $row = openstation_stored_files_get( $file_id );
410 if ( ! $row ) {
411 return new WP_Error( 'openstation_stored_files_not_found', __( 'Stored file not found.', 'desktop-mode' ), array( 'status' => 404 ) );
412 }
413 $tables = openstation_files_table_names();
414
415 // Remaining placements (trashed included — the file is going
416 // away for good, a recycle-bin restore must not resurrect a
417 // tile pointing at deleted bytes).
418 $placement_ids = $wpdb->get_col(
419 $wpdb->prepare(
420 "SELECT id FROM {$tables['placements']}
421 WHERE file_type = %s AND file_ref = %s",
422 'upload',
423 (string) $file_id
424 )
425 );
426 foreach ( (array) $placement_ids as $pid ) {
427 $wpdb->delete( $tables['placements'], array( 'id' => (int) $pid ), array( '%d' ) );
428 openstation_files_write_tombstone( 'placement', (int) $pid );
429 }
430
431 // File shares (target_type='file'). The shares table keys the
432 // target id on the historically-named `folder_id` column.
433 $wpdb->delete(
434 $tables['shares'],
435 array(
436 'target_type' => 'file',
437 'folder_id' => $file_id,
438 ),
439 array( '%s', '%d' )
440 );
441
442 return openstation_stored_files_delete( $file_id );
443 }
444
445 /**
446 * Sum of stored bytes for one owner.
447 *
448 * @param int $owner_id Owner.
449 * @return int
450 */
451 function openstation_stored_files_total_bytes( $owner_id ) {
452 global $wpdb;
453 $tables = openstation_files_table_names();
454 return (int) $wpdb->get_var(
455 $wpdb->prepare(
456 "SELECT COALESCE( SUM( size_bytes ), 0 ) FROM {$tables['stored_files']} WHERE owner_id = %d",
457 (int) $owner_id
458 )
459 );
460 }
461
462 /**
463 * Per-user quota in bytes. 0 = unlimited (the default). Sites
464 * enforce a cap via the filter; the REST intake consults this
465 * before accepting a new file.
466 *
467 * @param int $user_id User.
468 * @return int
469 */
470 function openstation_stored_files_user_quota_bytes( $user_id ) {
471 /**
472 * Filters the per-user storage quota in bytes. Return 0 for
473 * unlimited.
474 *
475 * @param int $quota Quota in bytes. Default 0 (unlimited).
476 * @param int $user_id User being checked.
477 */
478 return max( 0, (int) apply_filters( 'openstation_stored_files_user_quota_bytes', 0, (int) $user_id ) );
479 }
480
481 /**
482 * Capability required to upload. Defaults to WordPress's own
483 * `upload_files`; sites that want desktop storage for lower-cap
484 * roles loosen via the filter.
485 *
486 * @return string
487 */
488 function openstation_stored_files_upload_capability() {
489 /**
490 * Filters the capability required to upload desktop files.
491 *
492 * @param string $capability Default 'upload_files'.
493 */
494 return (string) apply_filters( 'openstation_stored_files_upload_capability', 'upload_files' );
495 }
496
497 /**
498 * Access resolver: can `$user_id` read (view / download) this
499 * stored file?
500 *
501 * - The owner always can.
502 * - A user with an accepted `target_type='file'` share can.
503 * - A user with at least read capability on any folder that
504 * contains a live placement of the file can (shared-folder
505 * contents are visible to the folder's audience).
506 *
507 * @param int $file_id Stored-file id.
508 * @param int $user_id Viewer.
509 * @return bool
510 */
511 function openstation_stored_file_user_can_read( $file_id, $user_id ) {
512 global $wpdb;
513 $file_id = (int) $file_id;
514 $user_id = (int) $user_id;
515 if ( $file_id <= 0 || $user_id <= 0 ) {
516 return false;
517 }
518 $row = openstation_stored_files_get( $file_id );
519 if ( ! $row ) {
520 return false;
521 }
522 if ( (int) $row['owner_id'] === $user_id ) {
523 return true;
524 }
525
526 // Accepted direct file share.
527 if ( function_exists( 'openstation_stored_file_share_state' ) ) {
528 if ( 'accepted' === openstation_stored_file_share_state( $file_id, $user_id ) ) {
529 return true;
530 }
531 }
532
533 // Read+ capability on a folder containing a live placement.
534 $tables = openstation_files_table_names();
535 $parents = $wpdb->get_col(
536 $wpdb->prepare(
537 "SELECT DISTINCT parent_id FROM {$tables['placements']}
538 WHERE file_type = %s AND file_ref = %s
539 AND parent_id > 0
540 AND trashed_at_ms IS NULL",
541 'upload',
542 (string) $file_id
543 )
544 );
545 if ( function_exists( 'openstation_folder_share_user_capability' ) ) {
546 foreach ( (array) $parents as $parent_id ) {
547 if ( 'none' !== openstation_folder_share_user_capability( (int) $parent_id, $user_id ) ) {
548 return true;
549 }
550 }
551 }
552
553 /**
554 * Last-mile override for stored-file read access. Plugins with
555 * their own sharing concepts can widen (or veto) here.
556 *
557 * @param bool $can Resolved decision so far (false).
558 * @param int $file_id Stored-file id.
559 * @param int $user_id Viewer.
560 * @param array $row Stored-file row.
561 */
562 return (bool) apply_filters( 'openstation_stored_file_can_read', false, $file_id, $user_id, $row );
563 }
564
565 /**
566 * Placement-removal listener — the deletion contract.
567 *
568 * When an `upload` placement is PERMANENTLY removed and the row's
569 * owner is the stored file's owner, check whether the owner has any
570 * placement of the file left (trashed ones count — they can be
571 * restored). If none remain, the file is unreachable for its owner:
572 * purge bytes, row, shares, and every recipient placement.
573 *
574 * Recipient placements going away never delete bytes.
575 *
576 * @param int $placement_id Removed placement id.
577 * @param array $row The removed row.
578 */
579 function openstation_stored_files_handle_unplaced( $placement_id, $row ) {
580 global $wpdb;
581 if ( ! is_array( $row ) || 'upload' !== (string) ( $row['file_type'] ?? '' ) ) {
582 return;
583 }
584 $file_id = (int) $row['file_ref'];
585 $file = openstation_stored_files_get( $file_id );
586 if ( ! $file ) {
587 return;
588 }
589 if ( (int) $row['owner_id'] !== (int) $file['owner_id'] ) {
590 return; // A recipient's tile went away; bytes stay.
591 }
592 $tables = openstation_files_table_names();
593 $remaining = (int) $wpdb->get_var(
594 $wpdb->prepare(
595 "SELECT COUNT(*) FROM {$tables['placements']}
596 WHERE file_type = %s AND file_ref = %s AND owner_id = %d",
597 'upload',
598 (string) $file_id,
599 (int) $file['owner_id']
600 )
601 );
602 if ( $remaining > 0 ) {
603 return;
604 }
605 openstation_stored_files_purge( $file_id );
606 }
607 add_action( 'openstation_file_unplaced', 'openstation_stored_files_handle_unplaced', 10, 2 );
608
609
610
611 /**
612 * When a WordPress user is deleted, purge their stored files (rows,
613 * bytes, shares, recipient placements) and remove their directory.
614 *
615 * @param int $user_id Deleted user id.
616 */
617 function openstation_stored_files_handle_deleted_user( $user_id ) {
618 global $wpdb;
619 $user_id = (int) $user_id;
620 if ( $user_id <= 0 ) {
621 return;
622 }
623 $tables = openstation_files_table_names();
624 $ids = $wpdb->get_col(
625 $wpdb->prepare( "SELECT id FROM {$tables['stored_files']} WHERE owner_id = %d", $user_id )
626 );
627 foreach ( (array) $ids as $file_id ) {
628 openstation_stored_files_purge( (int) $file_id );
629 }
630 $dir = openstation_stored_files_dir( $user_id );
631 if ( is_dir( $dir ) ) {
632 $index = $dir . '/index.php';
633 if ( file_exists( $index ) ) {
634 wp_delete_file( $index );
635 }
636 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
637 @rmdir( $dir ); // Only succeeds when empty — leftovers are the sweep's job.
638 }
639 }
640 add_action( 'deleted_user', 'openstation_stored_files_handle_deleted_user' );
641