PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.13
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.13
1.1.13 1.1.12 1.1.11 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 All 37 releases
desktop-mode / includes / desktop-files / store.php

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

982 lines 33.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Files placement store.
4 *
5 * CRUD primitives for the `_desktop_mode_file_placements` table.
6 * Every read goes through `openstation_files_query_args` so
7 * plugins can scope what's visible (mirror of the recycle-bin's
8 * filter pattern). Every write fires before / after actions so
9 * other plugins can react and so Phase 6's Heartbeat sync has a
10 * single subscription point.
11 *
12 * Capability gate is per-call: callers pass the `$user_id` they
13 * intend to act for; the function consults
14 * `openstation_files_can_place` (filter) and the file's
15 * `OpenStation_File::can_read()` before writing.
16 *
17 * Tombstones are written only for permanent removals (hard
18 * deletes); soft-trash and moves are surfaced to clients via
19 * `updated_at_ms` / `trashed_at_ms` in the Heartbeat delta — see
20 * openstation_files_write_tombstone() for the invariant.
21 *
22 * @package OpenStation
23 */
24
25 defined( 'ABSPATH' ) || exit;
26
27 /**
28 * Insert a placement.
29 *
30 * @param int $user_id Owner of the placement (the user the
31 * tile lives on).
32 * @param int $parent_id Folder id, or 0 for the desktop root.
33 * @param string $type File-type slug.
34 * @param string $ref Entity reference.
35 * @param array $args Optional. `x`, `y`, `sort_order`, `meta`.
36 * @return int|WP_Error Placement id on success, `WP_Error` otherwise.
37 */
38 function openstation_files_place( $user_id, $parent_id, $type, $ref, $args = array() ) {
39 global $wpdb;
40
41 $user_id = (int) $user_id;
42 $parent_id = (int) $parent_id;
43 $type = (string) $type;
44 $ref = (string) $ref;
45
46 if ( $user_id <= 0 ) {
47 return new WP_Error( 'openstation_files_invalid_user', __( 'A user id is required.', 'desktop-mode' ), array( 'status' => 400 ) );
48 }
49 $entry = openstation_get_file_type( $type );
50 if ( ! $entry ) {
51 return new WP_Error( 'openstation_files_unknown_type', __( 'Unknown file type.', 'desktop-mode' ), array( 'status' => 400 ) );
52 }
53
54 /**
55 * Gate placement creation. Defaults to allowing the user to
56 * place any type they can read; plugins use this to enforce
57 * stricter rules (e.g. only admins may place users).
58 *
59 * @param bool $can Default: file's `can_read( $user_id )`.
60 * @param int $user_id Owner.
61 * @param string $type File-type slug.
62 * @param string $ref Entity reference.
63 */
64 $file = openstation_resolve_file( $type, $ref );
65 $can = $file ? $file->can_read( $user_id ) : false;
66 $can = (bool) apply_filters( 'openstation_files_can_place', $can, $user_id, $type, $ref );
67 if ( ! $can ) {
68 return new WP_Error( 'openstation_files_forbidden', __( 'You are not allowed to place this file.', 'desktop-mode' ), array( 'status' => 403 ) );
69 }
70
71 // Write-gate: placing INTO a non-owned folder requires the
72 // folder's `write` cap. Owner / desktop-root placements are
73 // always allowed.
74 if ( (int) $parent_id > 0 ) {
75 $target_folder = openstation_files_get_folder( (int) $parent_id );
76 if ( $target_folder && (int) $target_folder['owner_id'] !== $user_id ) {
77 $cap = function_exists( 'openstation_folder_share_user_capability' )
78 ? openstation_folder_share_user_capability( (int) $parent_id, $user_id )
79 : 'none';
80 if ( 'write' !== $cap ) {
81 return new WP_Error(
82 'openstation_files_no_write_in_shared_folder',
83 __( 'You only have read access to that folder.', 'desktop-mode' ),
84 array( 'status' => 403 )
85 );
86 }
87 }
88 }
89
90 $args = wp_parse_args(
91 $args,
92 array(
93 'x' => 0,
94 'y' => 0,
95 'sort_order' => 0,
96 'meta' => null,
97 )
98 );
99
100 $tables = openstation_files_table_names();
101 $now = openstation_files_now_ms();
102 $row = array(
103 'owner_id' => $user_id,
104 'updated_by' => $user_id,
105 'parent_id' => max( 0, $parent_id ),
106 'file_type' => $type,
107 'file_ref' => $ref,
108 'x' => (int) $args['x'],
109 'y' => (int) $args['y'],
110 'sort_order' => (int) $args['sort_order'],
111 'updated_at_ms' => $now,
112 'meta' => null === $args['meta'] ? null : wp_json_encode( $args['meta'] ),
113 );
114
115 // Silence wpdb's HTML error block around the insert: a unique-key
116 // collision is an expected (and recovered) outcome below, and the
117 // default `WP_DEBUG_DISPLAY` behavior would otherwise prepend a
118 // `<div class="wpdberror">…</div>` to the REST response body and
119 // break `await response.json()` on the client. `$wpdb->last_error`
120 // still holds the message, so genuine DB failures surface via the
121 // `WP_Error` we return when no existing row is found.
122 $insert = static function () use ( $tables, $row ) {
123 global $wpdb;
124 $prev_suppress = $wpdb->suppress_errors( true );
125 $ok = $wpdb->insert( $tables['placements'], $row, array( '%d', '%d', '%d', '%s', '%s', '%d', '%d', '%d', '%d', '%s' ) );
126 $wpdb->suppress_errors( $prev_suppress );
127 return array( $ok, (int) $wpdb->insert_id );
128 };
129 $result = 'upload' === $type ? openstation_stored_files_place_insert( $ref, $insert ) : $insert();
130 if ( is_wp_error( $result ) ) {
131 return $result;
132 }
133 list( $ok, $id ) = $result;
134 if ( false === $ok ) {
135 // Disambiguate the two cases hidden behind a generic `false`:
136 // (a) The `placement_unique` index collided
137 // with an existing row for this (user, parent, type,
138 // ref). The collider may be active (the orphan placer
139 // won a race against this caller, or a stale duplicate
140 // client request) or soft-trashed (the user removed a
141 // link tile and is now recreating the same URL).
142 // (b) Any other DB failure — connection, deadlock, bad
143 // column. The error must surface to the caller as-is.
144 // We treat (a) idempotently: restore if trashed, then apply
145 // the caller's coords / meta so the new placement lands
146 // where the user clicked. Reported as #167.
147 $existing = $wpdb->get_row(
148 $wpdb->prepare(
149 "SELECT * FROM {$tables['placements']}
150 WHERE owner_id = %d
151 AND parent_id = %d
152 AND file_type = %s
153 AND file_ref = %s
154 LIMIT 1",
155 $user_id,
156 max( 0, $parent_id ),
157 $type,
158 $ref
159 ),
160 ARRAY_A
161 );
162 if ( ! $existing ) {
163 return new WP_Error( 'openstation_files_insert_failed', __( 'Failed to write placement.', 'desktop-mode' ), array( 'status' => 500 ) );
164 }
165
166 $existing_id = (int) $existing['id'];
167
168 if ( ! empty( $existing['trashed_at_ms'] ) ) {
169 $restore = openstation_files_restore_placement( $user_id, $existing_id );
170 if ( is_wp_error( $restore ) ) {
171 return $restore;
172 }
173 }
174
175 // Belt-and-suspenders: even on the non-trashed-revival branch
176 // (caller re-placed an already-active row at new coords), any
177 // stale tombstones for this id should be cleared so a fresh
178 // heartbeat tick can't surface "alive + removed" together.
179 // `restore_placement` already clears its own tombstones, so
180 // this is a no-op in the soft-trashed branch above.
181 openstation_files_clear_tombstones_for( 'placement', $existing_id );
182
183 $move = openstation_files_move(
184 $existing_id,
185 $user_id,
186 array(
187 'parent_id' => max( 0, $parent_id ),
188 'x' => (int) $args['x'],
189 'y' => (int) $args['y'],
190 'sort_order' => (int) $args['sort_order'],
191 'meta' => $args['meta'],
192 )
193 );
194 if ( is_wp_error( $move ) ) {
195 return $move;
196 }
197
198 return $existing_id;
199 }
200 $row['id'] = $id;
201
202 /**
203 * Fires after a placement is created.
204 *
205 * @param int $id Placement id.
206 * @param array $row Inserted row.
207 */
208 do_action( 'openstation_file_placed', $id, $row );
209
210 return $id;
211 }
212
213 /**
214 * Move / mutate a placement. Omit keys that should stay untouched.
215 * For `parent_id`, `x`, `y`, `sort_order` a `null` value is treated
216 * the same as omitting the key; for `meta`, an explicit
217 * `meta => null` CLEARS the column (keyed on array_key_exists) —
218 * omit the key to preserve it.
219 *
220 * @param int $placement_id Placement id.
221 * @param int $user_id Acting user (for capability gate).
222 * @param array $changes `parent_id`, `x`, `y`, `sort_order`, `meta`.
223 * @return true|WP_Error
224 */
225 function openstation_files_move( $placement_id, $user_id, $changes = array() ) {
226 global $wpdb;
227
228 $placement_id = (int) $placement_id;
229 $user_id = (int) $user_id;
230 if ( $placement_id <= 0 || $user_id <= 0 ) {
231 return new WP_Error( 'openstation_files_bad_request', __( 'Invalid arguments.', 'desktop-mode' ), array( 'status' => 400 ) );
232 }
233
234 $row = openstation_files_get_placement( $placement_id );
235 if ( ! $row ) {
236 return new WP_Error( 'openstation_files_not_found', __( 'Placement not found.', 'desktop-mode' ), array( 'status' => 404 ) );
237 }
238
239 // Owner-lock for `upload` placements: only the stored file's
240 // owner may move them — folder-share write capability does NOT
241 // extend to uploaded files (recipients are read + download
242 // only; see stored-files-store.php).
243 $upload_lock = openstation_files_upload_owner_lock( $row, $user_id );
244 if ( is_wp_error( $upload_lock ) ) {
245 return $upload_lock;
246 }
247
248 // Permission check. Owner of the row is always allowed. For
249 // rows inside a shared folder, the FOLDER's write cap is the
250 // gate — anyone with write on the folder can move/rearrange
251 // every icon in it, regardless of which user originally placed
252 // the row (shared-namespace semantics).
253 $is_row_owner = (int) $row['owner_id'] === $user_id;
254 if ( (int) $row['parent_id'] > 0 ) {
255 $source_folder = openstation_files_get_folder( (int) $row['parent_id'] );
256 if ( $source_folder ) {
257 $is_folder_owner = (int) $source_folder['owner_id'] === $user_id;
258 $source_cap = function_exists( 'openstation_folder_share_user_capability' )
259 ? openstation_folder_share_user_capability( (int) $row['parent_id'], $user_id )
260 : 'none';
261 if ( ! $is_row_owner && ! $is_folder_owner && 'write' !== $source_cap ) {
262 return new WP_Error(
263 'openstation_files_no_write_in_shared_folder',
264 __( 'You only have read access to this folder.', 'desktop-mode' ),
265 array( 'status' => 403 )
266 );
267 }
268 // Folder reader on their own row inside the folder — still no.
269 if ( $is_row_owner && ! $is_folder_owner && 'write' !== $source_cap ) {
270 return new WP_Error(
271 'openstation_files_no_write_in_shared_folder',
272 __( 'You only have read access to this folder.', 'desktop-mode' ),
273 array( 'status' => 403 )
274 );
275 }
276 }
277 } elseif ( ! $is_row_owner ) {
278 // Row at root, viewer doesn't own it.
279 return new WP_Error( 'openstation_files_forbidden', __( 'You cannot edit this placement.', 'desktop-mode' ), array( 'status' => 403 ) );
280 }
281 if ( isset( $changes['parent_id'] ) ) {
282 $target_parent = max( 0, (int) $changes['parent_id'] );
283 if ( $target_parent > 0 ) {
284 $target = openstation_files_get_folder( $target_parent );
285 if ( $target && (int) $target['owner_id'] !== $user_id ) {
286 $cap = function_exists( 'openstation_folder_share_user_capability' )
287 ? openstation_folder_share_user_capability( $target_parent, $user_id )
288 : 'none';
289 if ( 'write' !== $cap ) {
290 return new WP_Error(
291 'openstation_files_no_write_in_shared_folder',
292 __( 'You only have read access to that folder.', 'desktop-mode' ),
293 array( 'status' => 403 )
294 );
295 }
296 }
297 }
298 // Folder-cycle guard. When the row being moved is itself a
299 // folder placement, the new parent must not be the folder
300 // itself OR any of the folder's descendants — otherwise we
301 // commit `X.parent_id = Y` while `Y.parent_id` still leads
302 // back through `X`, producing an unreachable cycle that
303 // strands every descendant outside the desktop root. Walk
304 // the ancestry of `$target_parent` upward; bail if we hit
305 // the moving folder's id or detect a pre-existing cycle.
306 if ( 'folder' === (string) $row['file_type'] && $target_parent > 0 ) {
307 $moving_folder_id = (int) $row['file_ref'];
308 if ( $moving_folder_id > 0 ) {
309 if (
310 openstation_files_would_create_folder_cycle(
311 $user_id,
312 $moving_folder_id,
313 $target_parent
314 )
315 ) {
316 return new WP_Error(
317 'openstation_files_folder_cycle',
318 __( 'A folder cannot be placed inside itself or one of its descendants.', 'desktop-mode' ),
319 array( 'status' => 409 )
320 );
321 }
322 }
323 }
324 }
325
326 $tables = openstation_files_table_names();
327 $set = array();
328 $fmt = array();
329
330 if ( isset( $changes['parent_id'] ) ) {
331 $set['parent_id'] = max( 0, (int) $changes['parent_id'] );
332 $fmt[] = '%d';
333 }
334 foreach ( array( 'x', 'y', 'sort_order' ) as $col ) {
335 if ( isset( $changes[ $col ] ) ) {
336 $set[ $col ] = (int) $changes[ $col ];
337 $fmt[] = '%d';
338 }
339 }
340 if ( array_key_exists( 'meta', $changes ) ) {
341 $set['meta'] = null === $changes['meta'] ? null : wp_json_encode( $changes['meta'] );
342 $fmt[] = '%s';
343 }
344 if ( empty( $set ) ) {
345 return true; // No-op.
346 }
347
348 $set['updated_at_ms'] = openstation_files_now_ms();
349 $fmt[] = '%d';
350 // Track who actually fired this mutation so a future
351 // `If-Match` 409 can name the session that won the race,
352 // not just whoever happens to own the row.
353 $set['updated_by'] = $user_id;
354 $fmt[] = '%d';
355
356 $ok = $wpdb->update( $tables['placements'], $set, array( 'id' => $placement_id ), $fmt, array( '%d' ) );
357 if ( false === $ok ) {
358 return new WP_Error( 'openstation_files_update_failed', __( 'Failed to update placement.', 'desktop-mode' ), array( 'status' => 500 ) );
359 }
360
361 $next = openstation_files_get_placement( $placement_id );
362
363 /**
364 * Fires after a placement is moved / mutated.
365 *
366 * @param int $id Placement id.
367 * @param array $next Row after the change.
368 * @param array $prev Row before the change.
369 */
370 do_action( 'openstation_file_moved', $placement_id, $next, $row );
371
372 return true;
373 }
374
375 /**
376 * Remove a placement. Writes a tombstone for Phase-6 sync.
377 *
378 * @param int $placement_id Placement id.
379 * @param int $user_id Acting user.
380 * @return true|WP_Error
381 */
382 function openstation_files_remove( $placement_id, $user_id ) {
383 global $wpdb;
384
385 $placement_id = (int) $placement_id;
386 $user_id = (int) $user_id;
387 $row = openstation_files_get_placement( $placement_id );
388 if ( ! $row ) {
389 return new WP_Error( 'openstation_files_not_found', __( 'Placement not found.', 'desktop-mode' ), array( 'status' => 404 ) );
390 }
391 // Owner-lock for `upload` placements — removal is destructive
392 // for real bytes, so only the stored file's owner may do it.
393 $upload_lock = openstation_files_upload_owner_lock( $row, $user_id );
394 if ( is_wp_error( $upload_lock ) ) {
395 return $upload_lock;
396 }
397 // Same shared-namespace rule as the trash gate: owner of the
398 // row OR write cap on the parent folder.
399 $is_row_owner = (int) $row['owner_id'] === $user_id;
400 $allowed = $is_row_owner;
401 if ( ! $allowed && (int) $row['parent_id'] > 0 ) {
402 $cap = function_exists( 'openstation_folder_share_user_capability' )
403 ? openstation_folder_share_user_capability( (int) $row['parent_id'], $user_id )
404 : 'none';
405 $allowed = 'write' === $cap;
406 }
407 if ( ! $allowed ) {
408 return new WP_Error( 'openstation_files_forbidden', __( 'You cannot remove this placement.', 'desktop-mode' ), array( 'status' => 403 ) );
409 }
410
411 $tables = openstation_files_table_names();
412 $ok = $wpdb->delete( $tables['placements'], array( 'id' => $placement_id ), array( '%d' ) );
413 if ( false === $ok ) {
414 return new WP_Error( 'openstation_files_delete_failed', __( 'Failed to remove placement.', 'desktop-mode' ), array( 'status' => 500 ) );
415 }
416
417 openstation_files_write_tombstone( 'placement', $placement_id );
418
419 /**
420 * Fires after a placement is removed.
421 *
422 * @param int $id Placement id.
423 * @param array $row Removed row.
424 */
425 do_action( 'openstation_file_unplaced', $placement_id, $row );
426
427 return true;
428 }
429
430 /**
431 * Owner-lock gate for `upload` placements. Returns a `WP_Error`
432 * when `$user_id` is NOT the underlying stored file's owner —
433 * uploaded files are immutable to everyone else, including folder
434 * write-collaborators (the deliberate divergence from the shared-
435 * namespace rule; recipients are read + download only). Returns
436 * `true` for every other file type, and falls back to the normal
437 * rules when the stored-file row is gone (dangling tiles must stay
438 * cleanable).
439 *
440 * @param array $row Placement row.
441 * @param int $user_id Acting user.
442 * @return true|WP_Error
443 */
444 function openstation_files_upload_owner_lock( $row, $user_id ) {
445 if ( ! is_array( $row ) || 'upload' !== (string) ( $row['file_type'] ?? '' ) ) {
446 return true;
447 }
448 if ( ! function_exists( 'openstation_stored_files_get' ) ) {
449 return true;
450 }
451 $stored = openstation_stored_files_get( (int) $row['file_ref'] );
452 if ( ! $stored ) {
453 return true;
454 }
455 if ( (int) $stored['owner_id'] === (int) $user_id ) {
456 return true;
457 }
458 return new WP_Error(
459 'openstation_files_upload_owner_locked',
460 __( 'Only the file’s owner can move or delete an uploaded file.', 'desktop-mode' ),
461 array( 'status' => 403 )
462 );
463 }
464
465 /**
466 * Read a single placement row by id.
467 *
468 * @param int $placement_id Placement id.
469 * @return array|null
470 */
471 function openstation_files_get_placement( $placement_id ) {
472 global $wpdb;
473 $tables = openstation_files_table_names();
474 $row = $wpdb->get_row(
475 $wpdb->prepare( "SELECT * FROM {$tables['placements']} WHERE id = %d", (int) $placement_id ),
476 ARRAY_A
477 );
478 if ( ! $row ) {
479 return null;
480 }
481 return openstation_files_normalize_placement_row( $row );
482 }
483
484 /**
485 * List placements for a user under a given folder (0 = desktop
486 * root). Honors the `openstation_files_query_args` filter and
487 * applies the file-type's `can_read()` per row.
488 *
489 * @param int $user_id Viewer.
490 * @param int $parent_id Folder id (0 for desktop root).
491 * @return array[]
492 */
493 function openstation_files_get_for_user_folder( $user_id, $parent_id = 0 ) {
494 global $wpdb;
495 $user_id = (int) $user_id;
496 $parent_id = max( 0, (int) $parent_id );
497 if ( $user_id <= 0 ) {
498 return array();
499 }
500
501 $tables = openstation_files_table_names();
502
503 // Access gate + shared-namespace decision for non-root folders.
504 // Desktop root (parent_id = 0) is always per-user. For sub-
505 // folders, the contents of a SHARED folder are visible to every
506 // user who has at least 'read' on it — the icons inside belong
507 // to the folder, not to the user who originally placed them.
508 $share_view = false;
509 if ( $parent_id > 0 ) {
510 $folder = openstation_files_get_folder( $parent_id );
511 if ( ! $folder ) {
512 return array();
513 }
514 if ( (int) $folder['owner_id'] !== $user_id ) {
515 $cap = function_exists( 'openstation_folder_share_user_capability' )
516 ? openstation_folder_share_user_capability( $parent_id, $user_id )
517 : 'none';
518 if ( 'none' === $cap ) {
519 return array();
520 }
521 $share_view = true;
522 }
523 }
524
525 $args = array(
526 'user_id' => $user_id,
527 'parent_id' => $parent_id,
528 'share_view' => $share_view,
529 );
530 /**
531 * Filter the args used to read placements.
532 *
533 * @param array $args Defaults: `{ user_id, parent_id, share_view }`.
534 * @param int $user_id Viewer.
535 * @param int $parent_id Folder id.
536 */
537 $args = (array) apply_filters( 'openstation_files_query_args', $args, $user_id, $parent_id );
538
539 // Active queries always exclude trashed rows. Recycle-bin
540 // callers reach for the dedicated trash store.
541 if ( ! empty( $args['share_view'] ) || (int) $args['parent_id'] > 0 ) {
542 // A sub-folder, the viewer's own or shared with them: return
543 // every placement in it regardless of which user placed it,
544 // so the owner of a shared folder sees what a writer added.
545 // The icons are part of the folder; the owner_id column is
546 // audit info, not a permission gate.
547 $rows = $wpdb->get_results(
548 $wpdb->prepare(
549 "SELECT * FROM {$tables['placements']}
550 WHERE parent_id = %d
551 AND trashed_at_ms IS NULL
552 ORDER BY sort_order ASC, id ASC",
553 (int) $args['parent_id']
554 ),
555 ARRAY_A
556 );
557 } else {
558 $rows = $wpdb->get_results(
559 $wpdb->prepare(
560 "SELECT * FROM {$tables['placements']}
561 WHERE owner_id = %d
562 AND parent_id = %d
563 AND trashed_at_ms IS NULL
564 ORDER BY sort_order ASC, id ASC",
565 (int) $args['user_id'],
566 (int) $args['parent_id']
567 ),
568 ARRAY_A
569 );
570 }
571 if ( ! is_array( $rows ) ) {
572 return array();
573 }
574 $out = array();
575 foreach ( $rows as $row ) {
576 $normalized = openstation_files_normalize_placement_row( $row );
577 $file = openstation_resolve_file( $normalized['file_type'], $normalized['file_ref'] );
578 if ( empty( $args['share_view'] ) ) {
579 // The viewer's own folder or desktop root: keep the
580 // per-row read filter so stale or inaccessible entities,
581 // a writer's included, don't clutter the user's own view.
582 if ( $file && ! $file->can_read( $user_id ) ) {
583 continue;
584 }
585 } elseif ( $file && ! $file->can_read( $user_id ) ) {
586 // Shared folder view — every placement the OWNER chose
587 // to include is surfaced to the recipient. When the
588 // recipient lacks read on the underlying entity, we
589 // mark the row as `access_gated` so the tile renderer
590 // can paint a lock overlay + tooltip + intercept the
591 // open. Entity-level access enforcement still happens
592 // at open time in each opener — this flag is just the
593 // pre-emptive visual cue.
594 $normalized['access_gated'] = true;
595 }
596 $out[] = $normalized;
597 }
598 return $out;
599 }
600
601 /**
602 * Self-healing backfill. Surfaces two kinds of orphans on the
603 * desktop root:
604 *
605 * 1. Folders the viewer owns that have no placement anywhere.
606 * (Pre-fix folder-create flow could leak these; new flow
607 * writes the placement atomically.)
608 *
609 * 2. Plugin shortcuts (`openstation_register_icon()`) the
610 * viewer hasn't placed yet. The unified-rail merge means
611 * every registered icon shows up as a `shortcut` placement
612 * on first hydrate so plugin shortcuts behave like any
613 * other tile (drag, sort, right-click, clean up).
614 *
615 * Idempotent on both axes: a folder/shortcut that already has
616 * any placement is left alone. Coordinates come from
617 * `includes/desktop-files/grid.php`, which mirrors
618 * `src/desktop-files/grid.ts` — pitch, reading order, and the
619 * assumed canvas the scan wraps at.
620 *
621 * Called by the placements list endpoint when the requested
622 * folder is the root (`parent_id=0`).
623 *
624 * @param int $user_id Viewer.
625 * @return int Total number of orphans that were auto-placed.
626 */
627 function openstation_files_auto_place_orphans( $user_id ) {
628 global $wpdb;
629 $user_id = (int) $user_id;
630 if ( $user_id <= 0 ) {
631 return 0;
632 }
633
634 $tables = openstation_files_table_names();
635
636 // 1) Owned folders without any placement. Skip trashed folders
637 // and trashed placement rows so a recycled folder doesn't get
638 // auto-placed back on the desktop on next hydrate.
639 $folder_rows = $wpdb->get_results(
640 $wpdb->prepare(
641 "SELECT f.id FROM {$tables['folders']} f
642 LEFT JOIN {$tables['placements']} p
643 ON p.file_type = 'folder'
644 AND p.file_ref = CAST( f.id AS CHAR )
645 AND p.trashed_at_ms IS NULL
646 WHERE f.owner_id = %d
647 AND f.trashed_at_ms IS NULL
648 AND p.id IS NULL",
649 $user_id
650 ),
651 ARRAY_A
652 );
653
654 // 2) Registered plugin shortcuts the viewer hasn't placed yet.
655 // Pull the registered ids first, then ask the placements
656 // table which the viewer already has — set difference
657 // yields the orphans without a heavy join.
658 $shortcut_ids = array();
659 $registry = function_exists( 'openstation_desktop_icon_registry' )
660 ? openstation_desktop_icon_registry()
661 : array();
662 if ( is_array( $registry ) ) {
663 // Run through the same `openstation_icons` filter the
664 // build-payload path uses so plugins (and tests) can inject
665 // virtual entries.
666 $registry = (array) apply_filters( 'openstation_icons', $registry );
667 }
668 if ( is_array( $registry ) && ! empty( $registry ) ) {
669 $registered_ids = array_map( 'strval', array_keys( $registry ) );
670 $placeholders = implode( ',', array_fill( 0, count( $registered_ids ), '%s' ) );
671 $args = array_merge( array( $user_id ), $registered_ids );
672 $placed_ids = $wpdb->get_col(
673 $wpdb->prepare(
674 "SELECT file_ref FROM {$tables['placements']}
675 WHERE owner_id = %d
676 AND file_type = 'shortcut'
677 AND trashed_at_ms IS NULL
678 AND file_ref IN ($placeholders)",
679 $args
680 )
681 );
682 $placed_set = array_flip( array_map( 'strval', (array) $placed_ids ) );
683 foreach ( $registered_ids as $id ) {
684 if ( ! isset( $placed_set[ $id ] ) ) {
685 $shortcut_ids[] = $id;
686 }
687 }
688 }
689
690 if ( empty( $folder_rows ) && empty( $shortcut_ids ) ) {
691 return 0;
692 }
693
694 // Build an occupied set from EXISTING root placements so
695 // we never drop an orphan on top of a tile the user
696 // already has. Cell math lives in
697 // `includes/desktop-files/grid.php`, the mirror of
698 // `src/desktop-files/grid.ts`.
699 $existing = $wpdb->get_results(
700 $wpdb->prepare(
701 "SELECT x, y FROM {$tables['placements']}
702 WHERE owner_id = %d
703 AND parent_id = 0
704 AND trashed_at_ms IS NULL",
705 $user_id
706 ),
707 ARRAY_A
708 );
709 $occupied = openstation_files_grid_occupied( $existing );
710
711 // The desktop reads in columns, and the scan wraps to the next one
712 // at the assumed canvas height rather than running a column 999
713 // cells deep. The server has no viewport; a slot it invents below
714 // the fold is a tile the user cannot reach, because the layer that
715 // renders it does not scroll.
716 $order = openstation_files_grid_order( 0 );
717
718 $placed = 0;
719 $emit_at = function ( $type, $ref, $col, $row ) use ( $user_id, &$occupied, &$placed ) {
720 $occupied[ "$col,$row" ] = true;
721 $result = openstation_files_place(
722 $user_id,
723 0,
724 $type,
725 (string) $ref,
726 openstation_files_grid_cell_to_point( $col, $row )
727 );
728 if ( ! is_wp_error( $result ) ) {
729 ++$placed;
730 }
731 };
732 $emit_next = function ( $type, $ref ) use ( &$occupied, $order, $emit_at ) {
733 list( $col, $row ) = openstation_files_grid_next_free( $occupied, $order );
734 $emit_at( $type, $ref, $col, $row );
735 };
736
737 // Pinned shortcuts get reserved top-left slots. Anchored to
738 // column 0 (x=16) so the JS layer's pinned-slot math
739 // (`GRID_PADDING + n*GRID_CELL_H`) lines up with the row the
740 // server picks. Mark the slot occupied BEFORE other orphans
741 // flow in so a draggable tile never lands on top of the
742 // anchored "My WordPress" icon.
743 $pinned_ids = array();
744 foreach ( $shortcut_ids as $id ) {
745 $entry = is_array( $registry ) && isset( $registry[ $id ] ) ? $registry[ $id ] : null;
746 if ( is_array( $entry ) && ! empty( $entry['pinned'] ) ) {
747 $pinned_ids[] = $id;
748 }
749 }
750 $pinned_set = array_flip( $pinned_ids );
751 $pinned_idx = 0;
752 foreach ( $pinned_ids as $id ) {
753 // Force the slot at (col=0, row=$pinned_idx). Any pre-
754 // existing occupant on that slot is left alone — the layer
755 // re-renders the pinned tile on top via the
756 // client-side override anyway, but a future cleanup pass
757 // can compact the column.
758 $occupied[ "0,$pinned_idx" ] = true;
759 $emit_at( 'shortcut', $id, 0, $pinned_idx );
760 ++$pinned_idx;
761 }
762
763 foreach ( $folder_rows as $row ) {
764 $emit_next( 'folder', $row['id'] );
765 }
766 foreach ( $shortcut_ids as $id ) {
767 if ( isset( $pinned_set[ $id ] ) ) {
768 continue;
769 }
770 $emit_next( 'shortcut', $id );
771 }
772 return $placed;
773 }
774
775 /**
776 * Backwards-compat alias for the older folder-only name.
777 *
778 * @deprecated Use {@see openstation_files_auto_place_orphans}.
779 *
780 * @param int $user_id Viewer.
781 * @return int
782 */
783 function openstation_files_auto_place_orphan_folders( $user_id ) {
784 return openstation_files_auto_place_orphans( $user_id );
785 }
786
787 /**
788 * Coerce wpdb's stringly-typed row into typed values + decoded
789 * meta. Internal helper.
790 *
791 * @internal
792 *
793 * @param array $row Raw wpdb row.
794 * @return array
795 */
796 function openstation_files_normalize_placement_row( $row ) {
797 $meta_raw = isset( $row['meta'] ) ? (string) $row['meta'] : '';
798 $meta = '' !== $meta_raw ? json_decode( $meta_raw, true ) : null;
799 return array(
800 'id' => (int) $row['id'],
801 'owner_id' => (int) $row['owner_id'],
802 // `updated_by` is v10. Null on legacy rows — callers that
803 // need the actor (e.g. `openstation_files_check_if_match`)
804 // fall back to `owner_id` when this is null/missing.
805 'updated_by' => isset( $row['updated_by'] ) ? (int) $row['updated_by'] : null,
806 'parent_id' => (int) $row['parent_id'],
807 'file_type' => (string) $row['file_type'],
808 'file_ref' => (string) $row['file_ref'],
809 'x' => (int) $row['x'],
810 'y' => (int) $row['y'],
811 'sort_order' => (int) $row['sort_order'],
812 'updated_at_ms' => (int) $row['updated_at_ms'],
813 'meta' => is_array( $meta ) ? $meta : null,
814 );
815 }
816
817 /**
818 * Write a tombstone row.
819 *
820 * Invariant (enforced by callers): tombstones may exist only for
821 * ids of PERMANENTLY-DELETED rows. Never write one for a soft-
822 * trashed row — soft-trash is reversible and the heartbeat already
823 * surfaces it via the `trashed_at_ms IS NOT NULL` query in
824 * `openstation_files_compute_heartbeat_delta`. A tombstone on a
825 * soft-trashed row lingers past restore and tells clients the row
826 * is gone while it is in fact alive — see the "shared folder
827 * disappears on refresh" bug.
828 *
829 * Pair every revival path (`openstation_files_restore_placement`,
830 * `openstation_files_restore_folder`, and the duplicate-key
831 * revival branch in `openstation_files_place`) with
832 * {@see openstation_files_clear_tombstones_for} so a row coming
833 * back to life never carries lingering tombstones from a previous
834 * removal that turned out to be reversible.
835 *
836 * @param string $kind 'placement' | 'folder'.
837 * @param int $ref Removed id.
838 */
839 function openstation_files_write_tombstone( $kind, $ref ) {
840 global $wpdb;
841 $tables = openstation_files_table_names();
842 $wpdb->insert(
843 $tables['tombstones'],
844 array(
845 'kind' => (string) $kind,
846 'ref_id' => (int) $ref,
847 'removed_at_ms' => openstation_files_now_ms(),
848 ),
849 array( '%s', '%d', '%d' )
850 );
851 }
852
853 /**
854 * Drop every tombstone referring to `($kind, $ref_id)`. Called from
855 * the row-revival paths so a placement/folder coming back to life
856 * never carries lingering "this is gone" tombstones from a
857 * previous removal that turned out to be reversible.
858 *
859 * Idempotent — running it on a ref with no tombstones is a no-op.
860 *
861 * @param string $kind 'placement' | 'folder'.
862 * @param int $ref_id Row id whose tombstones should be dropped.
863 */
864 function openstation_files_clear_tombstones_for( $kind, $ref_id ) {
865 global $wpdb;
866 $ref_id = (int) $ref_id;
867 if ( $ref_id <= 0 ) {
868 return;
869 }
870 $tables = openstation_files_table_names();
871 $wpdb->delete(
872 $tables['tombstones'],
873 array(
874 'kind' => (string) $kind,
875 'ref_id' => $ref_id,
876 ),
877 array( '%s', '%d' )
878 );
879 }
880
881 /**
882 * Daily prune of tombstones older than 7 days. Phase 6 may tune
883 * the retention window when the Heartbeat sync lands; for now 7d
884 * is plenty since a client that's been offline that long will
885 * always need a full REST resync anyway.
886 */
887 function openstation_files_prune_tombstones() {
888 global $wpdb;
889 $tables = openstation_files_table_names();
890 $cutoff = openstation_files_now_ms() - ( 7 * DAY_IN_SECONDS * 1000 );
891 $wpdb->query( $wpdb->prepare( "DELETE FROM {$tables['tombstones']} WHERE removed_at_ms < %d", $cutoff ) );
892 }
893 add_action( 'desktop_mode_files_daily_prune', 'openstation_files_prune_tombstones' );
894
895 /**
896 * Schedule the daily prune. Hooked on `init` and idempotent via
897 * wp_next_scheduled(), so a manual file-copy install (no activation
898 * hook) still gets the cron event.
899 */
900 function openstation_files_schedule_prune() {
901 if ( ! wp_next_scheduled( 'desktop_mode_files_daily_prune' ) ) {
902 wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'desktop_mode_files_daily_prune' );
903 }
904 }
905 add_action( 'init', 'openstation_files_schedule_prune' );
906
907 /**
908 * Walk the folder-parentage chain upward from `$target_parent_id` and
909 * return `true` when `$moving_folder_id` appears anywhere in it —
910 * meaning a move that sets `moving_folder.parent_id = target_parent`
911 * would produce an unreachable cycle (folder placed inside itself or
912 * inside one of its own descendants).
913 *
914 * Folder-parentage is determined by the parent_id of the folder's
915 * placement row, not by anything on the `folders` table. We look up
916 * one live placement per cursor (`LIMIT 1`) — folders with multiple
917 * placements (rare; shared semantics) are still safely covered
918 * because any one upward chain hitting the moving folder is enough
919 * to flag the cycle.
920 *
921 * Defends against pre-existing cycles in the data: if we re-visit a
922 * cursor we've already seen, we treat it as a cycle and reject, so a
923 * corrupted history can't drive this function into an infinite loop.
924 *
925 * @param int $user_id Acting user.
926 * @param int $moving_folder_id Folder being moved (its `folders.id`).
927 * @param int $target_parent_id New container folder id (0 = desktop root).
928 * @return bool True when the move would create a cycle.
929 */
930 function openstation_files_would_create_folder_cycle( $user_id, $moving_folder_id, $target_parent_id ) {
931 $moving_folder_id = (int) $moving_folder_id;
932 $target_parent_id = (int) $target_parent_id;
933 $user_id = (int) $user_id;
934 if ( $moving_folder_id <= 0 || $target_parent_id <= 0 || $user_id <= 0 ) {
935 return false;
936 }
937 if ( $moving_folder_id === $target_parent_id ) {
938 return true;
939 }
940 global $wpdb;
941 $tables = openstation_files_table_names();
942 $visited = array();
943 $cursor = $target_parent_id;
944 // Hard cap to defend against catastrophically deep trees too —
945 // real installs won't approach 256.
946 $max_depth = 256;
947 while ( $cursor > 0 && $max_depth-- > 0 ) {
948 if ( $cursor === $moving_folder_id ) {
949 return true;
950 }
951 if ( isset( $visited[ $cursor ] ) ) {
952 // Pre-existing cycle in the data — bail safe by treating
953 // the move as cycle-creating too. Better to refuse a
954 // suspicious move than to deepen the damage.
955 return true;
956 }
957 $visited[ $cursor ] = true;
958 // `LIMIT 1` is enough — any upward chain that reaches the
959 // moving folder flags the cycle. Trashed rows excluded so a
960 // recycled-then-recovered ancestor doesn't poison the check.
961 $parent_of_cursor = $wpdb->get_var(
962 $wpdb->prepare(
963 "SELECT parent_id FROM {$tables['placements']}
964 WHERE owner_id = %d
965 AND file_type = 'folder'
966 AND file_ref = %s
967 AND trashed_at_ms IS NULL
968 LIMIT 1",
969 $user_id,
970 (string) $cursor
971 )
972 );
973 if ( null === $parent_of_cursor ) {
974 // Folder has no live placement under this user — chain
975 // ends here. No cycle.
976 return false;
977 }
978 $cursor = (int) $parent_of_cursor;
979 }
980 return false;
981 }
982