PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.8
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 0.8.6 All 33 releases
desktop-mode / includes / desktop-files / store.php

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

981 lines 33.5 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'] ) ) {
542 // Shared sub-folder — return every placement in the folder
543 // regardless of which user originally placed it. The icons
544 // are part of the folder; the owner_id column is audit info,
545 // not a permission gate.
546 $rows = $wpdb->get_results(
547 $wpdb->prepare(
548 "SELECT * FROM {$tables['placements']}
549 WHERE parent_id = %d
550 AND trashed_at_ms IS NULL
551 ORDER BY sort_order ASC, id ASC",
552 (int) $args['parent_id']
553 ),
554 ARRAY_A
555 );
556 } else {
557 $rows = $wpdb->get_results(
558 $wpdb->prepare(
559 "SELECT * FROM {$tables['placements']}
560 WHERE owner_id = %d
561 AND parent_id = %d
562 AND trashed_at_ms IS NULL
563 ORDER BY sort_order ASC, id ASC",
564 (int) $args['user_id'],
565 (int) $args['parent_id']
566 ),
567 ARRAY_A
568 );
569 }
570 if ( ! is_array( $rows ) ) {
571 return array();
572 }
573 $out = array();
574 foreach ( $rows as $row ) {
575 $normalized = openstation_files_normalize_placement_row( $row );
576 $file = openstation_resolve_file( $normalized['file_type'], $normalized['file_ref'] );
577 if ( empty( $args['share_view'] ) ) {
578 // Private folder / desktop root — keep the existing
579 // per-row read filter so stale/inaccessible entities
580 // don't clutter the user's own view.
581 if ( $file && ! $file->can_read( $user_id ) ) {
582 continue;
583 }
584 } elseif ( $file && ! $file->can_read( $user_id ) ) {
585 // Shared folder view — every placement the OWNER chose
586 // to include is surfaced to the recipient. When the
587 // recipient lacks read on the underlying entity, we
588 // mark the row as `access_gated` so the tile renderer
589 // can paint a lock overlay + tooltip + intercept the
590 // open. Entity-level access enforcement still happens
591 // at open time in each opener — this flag is just the
592 // pre-emptive visual cue.
593 $normalized['access_gated'] = true;
594 }
595 $out[] = $normalized;
596 }
597 return $out;
598 }
599
600 /**
601 * Self-healing backfill. Surfaces two kinds of orphans on the
602 * desktop root:
603 *
604 * 1. Folders the viewer owns that have no placement anywhere.
605 * (Pre-fix folder-create flow could leak these; new flow
606 * writes the placement atomically.)
607 *
608 * 2. Plugin shortcuts (`openstation_register_icon()`) the
609 * viewer hasn't placed yet. The unified-rail merge means
610 * every registered icon shows up as a `shortcut` placement
611 * on first hydrate so plugin shortcuts behave like any
612 * other tile (drag, sort, right-click, clean up).
613 *
614 * Idempotent on both axes: a folder/shortcut that already has
615 * any placement is left alone. Coordinates come from
616 * `includes/desktop-files/grid.php`, which mirrors
617 * `src/desktop-files/grid.ts` — pitch, reading order, and the
618 * assumed canvas the scan wraps at.
619 *
620 * Called by the placements list endpoint when the requested
621 * folder is the root (`parent_id=0`).
622 *
623 * @param int $user_id Viewer.
624 * @return int Total number of orphans that were auto-placed.
625 */
626 function openstation_files_auto_place_orphans( $user_id ) {
627 global $wpdb;
628 $user_id = (int) $user_id;
629 if ( $user_id <= 0 ) {
630 return 0;
631 }
632
633 $tables = openstation_files_table_names();
634
635 // 1) Owned folders without any placement. Skip trashed folders
636 // and trashed placement rows so a recycled folder doesn't get
637 // auto-placed back on the desktop on next hydrate.
638 $folder_rows = $wpdb->get_results(
639 $wpdb->prepare(
640 "SELECT f.id FROM {$tables['folders']} f
641 LEFT JOIN {$tables['placements']} p
642 ON p.file_type = 'folder'
643 AND p.file_ref = CAST( f.id AS CHAR )
644 AND p.trashed_at_ms IS NULL
645 WHERE f.owner_id = %d
646 AND f.trashed_at_ms IS NULL
647 AND p.id IS NULL",
648 $user_id
649 ),
650 ARRAY_A
651 );
652
653 // 2) Registered plugin shortcuts the viewer hasn't placed yet.
654 // Pull the registered ids first, then ask the placements
655 // table which the viewer already has — set difference
656 // yields the orphans without a heavy join.
657 $shortcut_ids = array();
658 $registry = function_exists( 'openstation_desktop_icon_registry' )
659 ? openstation_desktop_icon_registry()
660 : array();
661 if ( is_array( $registry ) ) {
662 // Run through the same `openstation_icons` filter the
663 // build-payload path uses so plugins (and tests) can inject
664 // virtual entries.
665 $registry = (array) apply_filters( 'openstation_icons', $registry );
666 }
667 if ( is_array( $registry ) && ! empty( $registry ) ) {
668 $registered_ids = array_map( 'strval', array_keys( $registry ) );
669 $placeholders = implode( ',', array_fill( 0, count( $registered_ids ), '%s' ) );
670 $args = array_merge( array( $user_id ), $registered_ids );
671 $placed_ids = $wpdb->get_col(
672 $wpdb->prepare(
673 "SELECT file_ref FROM {$tables['placements']}
674 WHERE owner_id = %d
675 AND file_type = 'shortcut'
676 AND trashed_at_ms IS NULL
677 AND file_ref IN ($placeholders)",
678 $args
679 )
680 );
681 $placed_set = array_flip( array_map( 'strval', (array) $placed_ids ) );
682 foreach ( $registered_ids as $id ) {
683 if ( ! isset( $placed_set[ $id ] ) ) {
684 $shortcut_ids[] = $id;
685 }
686 }
687 }
688
689 if ( empty( $folder_rows ) && empty( $shortcut_ids ) ) {
690 return 0;
691 }
692
693 // Build an occupied set from EXISTING root placements so
694 // we never drop an orphan on top of a tile the user
695 // already has. Cell math lives in
696 // `includes/desktop-files/grid.php`, the mirror of
697 // `src/desktop-files/grid.ts`.
698 $existing = $wpdb->get_results(
699 $wpdb->prepare(
700 "SELECT x, y FROM {$tables['placements']}
701 WHERE owner_id = %d
702 AND parent_id = 0
703 AND trashed_at_ms IS NULL",
704 $user_id
705 ),
706 ARRAY_A
707 );
708 $occupied = openstation_files_grid_occupied( $existing );
709
710 // The desktop reads in columns, and the scan wraps to the next one
711 // at the assumed canvas height rather than running a column 999
712 // cells deep. The server has no viewport; a slot it invents below
713 // the fold is a tile the user cannot reach, because the layer that
714 // renders it does not scroll.
715 $order = openstation_files_grid_order( 0 );
716
717 $placed = 0;
718 $emit_at = function ( $type, $ref, $col, $row ) use ( $user_id, &$occupied, &$placed ) {
719 $occupied[ "$col,$row" ] = true;
720 $result = openstation_files_place(
721 $user_id,
722 0,
723 $type,
724 (string) $ref,
725 openstation_files_grid_cell_to_point( $col, $row )
726 );
727 if ( ! is_wp_error( $result ) ) {
728 ++$placed;
729 }
730 };
731 $emit_next = function ( $type, $ref ) use ( &$occupied, $order, $emit_at ) {
732 list( $col, $row ) = openstation_files_grid_next_free( $occupied, $order );
733 $emit_at( $type, $ref, $col, $row );
734 };
735
736 // Pinned shortcuts get reserved top-left slots. Anchored to
737 // column 0 (x=16) so the JS layer's pinned-slot math
738 // (`GRID_PADDING + n*GRID_CELL_H`) lines up with the row the
739 // server picks. Mark the slot occupied BEFORE other orphans
740 // flow in so a draggable tile never lands on top of the
741 // anchored "My WordPress" icon.
742 $pinned_ids = array();
743 foreach ( $shortcut_ids as $id ) {
744 $entry = is_array( $registry ) && isset( $registry[ $id ] ) ? $registry[ $id ] : null;
745 if ( is_array( $entry ) && ! empty( $entry['pinned'] ) ) {
746 $pinned_ids[] = $id;
747 }
748 }
749 $pinned_set = array_flip( $pinned_ids );
750 $pinned_idx = 0;
751 foreach ( $pinned_ids as $id ) {
752 // Force the slot at (col=0, row=$pinned_idx). Any pre-
753 // existing occupant on that slot is left alone — the layer
754 // re-renders the pinned tile on top via the
755 // client-side override anyway, but a future cleanup pass
756 // can compact the column.
757 $occupied[ "0,$pinned_idx" ] = true;
758 $emit_at( 'shortcut', $id, 0, $pinned_idx );
759 ++$pinned_idx;
760 }
761
762 foreach ( $folder_rows as $row ) {
763 $emit_next( 'folder', $row['id'] );
764 }
765 foreach ( $shortcut_ids as $id ) {
766 if ( isset( $pinned_set[ $id ] ) ) {
767 continue;
768 }
769 $emit_next( 'shortcut', $id );
770 }
771 return $placed;
772 }
773
774 /**
775 * Backwards-compat alias for the older folder-only name.
776 *
777 * @deprecated Use {@see openstation_files_auto_place_orphans}.
778 *
779 * @param int $user_id Viewer.
780 * @return int
781 */
782 function openstation_files_auto_place_orphan_folders( $user_id ) {
783 return openstation_files_auto_place_orphans( $user_id );
784 }
785
786 /**
787 * Coerce wpdb's stringly-typed row into typed values + decoded
788 * meta. Internal helper.
789 *
790 * @internal
791 *
792 * @param array $row Raw wpdb row.
793 * @return array
794 */
795 function openstation_files_normalize_placement_row( $row ) {
796 $meta_raw = isset( $row['meta'] ) ? (string) $row['meta'] : '';
797 $meta = '' !== $meta_raw ? json_decode( $meta_raw, true ) : null;
798 return array(
799 'id' => (int) $row['id'],
800 'owner_id' => (int) $row['owner_id'],
801 // `updated_by` is v10. Null on legacy rows — callers that
802 // need the actor (e.g. `openstation_files_check_if_match`)
803 // fall back to `owner_id` when this is null/missing.
804 'updated_by' => isset( $row['updated_by'] ) ? (int) $row['updated_by'] : null,
805 'parent_id' => (int) $row['parent_id'],
806 'file_type' => (string) $row['file_type'],
807 'file_ref' => (string) $row['file_ref'],
808 'x' => (int) $row['x'],
809 'y' => (int) $row['y'],
810 'sort_order' => (int) $row['sort_order'],
811 'updated_at_ms' => (int) $row['updated_at_ms'],
812 'meta' => is_array( $meta ) ? $meta : null,
813 );
814 }
815
816 /**
817 * Write a tombstone row.
818 *
819 * Invariant (enforced by callers): tombstones may exist only for
820 * ids of PERMANENTLY-DELETED rows. Never write one for a soft-
821 * trashed row — soft-trash is reversible and the heartbeat already
822 * surfaces it via the `trashed_at_ms IS NOT NULL` query in
823 * `openstation_files_compute_heartbeat_delta`. A tombstone on a
824 * soft-trashed row lingers past restore and tells clients the row
825 * is gone while it is in fact alive — see the "shared folder
826 * disappears on refresh" bug.
827 *
828 * Pair every revival path (`openstation_files_restore_placement`,
829 * `openstation_files_restore_folder`, and the duplicate-key
830 * revival branch in `openstation_files_place`) with
831 * {@see openstation_files_clear_tombstones_for} so a row coming
832 * back to life never carries lingering tombstones from a previous
833 * removal that turned out to be reversible.
834 *
835 * @param string $kind 'placement' | 'folder'.
836 * @param int $ref Removed id.
837 */
838 function openstation_files_write_tombstone( $kind, $ref ) {
839 global $wpdb;
840 $tables = openstation_files_table_names();
841 $wpdb->insert(
842 $tables['tombstones'],
843 array(
844 'kind' => (string) $kind,
845 'ref_id' => (int) $ref,
846 'removed_at_ms' => openstation_files_now_ms(),
847 ),
848 array( '%s', '%d', '%d' )
849 );
850 }
851
852 /**
853 * Drop every tombstone referring to `($kind, $ref_id)`. Called from
854 * the row-revival paths so a placement/folder coming back to life
855 * never carries lingering "this is gone" tombstones from a
856 * previous removal that turned out to be reversible.
857 *
858 * Idempotent — running it on a ref with no tombstones is a no-op.
859 *
860 * @param string $kind 'placement' | 'folder'.
861 * @param int $ref_id Row id whose tombstones should be dropped.
862 */
863 function openstation_files_clear_tombstones_for( $kind, $ref_id ) {
864 global $wpdb;
865 $ref_id = (int) $ref_id;
866 if ( $ref_id <= 0 ) {
867 return;
868 }
869 $tables = openstation_files_table_names();
870 $wpdb->delete(
871 $tables['tombstones'],
872 array(
873 'kind' => (string) $kind,
874 'ref_id' => $ref_id,
875 ),
876 array( '%s', '%d' )
877 );
878 }
879
880 /**
881 * Daily prune of tombstones older than 7 days. Phase 6 may tune
882 * the retention window when the Heartbeat sync lands; for now 7d
883 * is plenty since a client that's been offline that long will
884 * always need a full REST resync anyway.
885 */
886 function openstation_files_prune_tombstones() {
887 global $wpdb;
888 $tables = openstation_files_table_names();
889 $cutoff = openstation_files_now_ms() - ( 7 * DAY_IN_SECONDS * 1000 );
890 $wpdb->query( $wpdb->prepare( "DELETE FROM {$tables['tombstones']} WHERE removed_at_ms < %d", $cutoff ) );
891 }
892 add_action( 'desktop_mode_files_daily_prune', 'openstation_files_prune_tombstones' );
893
894 /**
895 * Schedule the daily prune. Hooked on `init` and idempotent via
896 * wp_next_scheduled(), so a manual file-copy install (no activation
897 * hook) still gets the cron event.
898 */
899 function openstation_files_schedule_prune() {
900 if ( ! wp_next_scheduled( 'desktop_mode_files_daily_prune' ) ) {
901 wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'desktop_mode_files_daily_prune' );
902 }
903 }
904 add_action( 'init', 'openstation_files_schedule_prune' );
905
906 /**
907 * Walk the folder-parentage chain upward from `$target_parent_id` and
908 * return `true` when `$moving_folder_id` appears anywhere in it —
909 * meaning a move that sets `moving_folder.parent_id = target_parent`
910 * would produce an unreachable cycle (folder placed inside itself or
911 * inside one of its own descendants).
912 *
913 * Folder-parentage is determined by the parent_id of the folder's
914 * placement row, not by anything on the `folders` table. We look up
915 * one live placement per cursor (`LIMIT 1`) — folders with multiple
916 * placements (rare; shared semantics) are still safely covered
917 * because any one upward chain hitting the moving folder is enough
918 * to flag the cycle.
919 *
920 * Defends against pre-existing cycles in the data: if we re-visit a
921 * cursor we've already seen, we treat it as a cycle and reject, so a
922 * corrupted history can't drive this function into an infinite loop.
923 *
924 * @param int $user_id Acting user.
925 * @param int $moving_folder_id Folder being moved (its `folders.id`).
926 * @param int $target_parent_id New container folder id (0 = desktop root).
927 * @return bool True when the move would create a cycle.
928 */
929 function openstation_files_would_create_folder_cycle( $user_id, $moving_folder_id, $target_parent_id ) {
930 $moving_folder_id = (int) $moving_folder_id;
931 $target_parent_id = (int) $target_parent_id;
932 $user_id = (int) $user_id;
933 if ( $moving_folder_id <= 0 || $target_parent_id <= 0 || $user_id <= 0 ) {
934 return false;
935 }
936 if ( $moving_folder_id === $target_parent_id ) {
937 return true;
938 }
939 global $wpdb;
940 $tables = openstation_files_table_names();
941 $visited = array();
942 $cursor = $target_parent_id;
943 // Hard cap to defend against catastrophically deep trees too —
944 // real installs won't approach 256.
945 $max_depth = 256;
946 while ( $cursor > 0 && $max_depth-- > 0 ) {
947 if ( $cursor === $moving_folder_id ) {
948 return true;
949 }
950 if ( isset( $visited[ $cursor ] ) ) {
951 // Pre-existing cycle in the data — bail safe by treating
952 // the move as cycle-creating too. Better to refuse a
953 // suspicious move than to deepen the damage.
954 return true;
955 }
956 $visited[ $cursor ] = true;
957 // `LIMIT 1` is enough — any upward chain that reaches the
958 // moving folder flags the cycle. Trashed rows excluded so a
959 // recycled-then-recovered ancestor doesn't poison the check.
960 $parent_of_cursor = $wpdb->get_var(
961 $wpdb->prepare(
962 "SELECT parent_id FROM {$tables['placements']}
963 WHERE owner_id = %d
964 AND file_type = 'folder'
965 AND file_ref = %s
966 AND trashed_at_ms IS NULL
967 LIMIT 1",
968 $user_id,
969 (string) $cursor
970 )
971 );
972 if ( null === $parent_of_cursor ) {
973 // Folder has no live placement under this user — chain
974 // ends here. No cycle.
975 return false;
976 }
977 $cursor = (int) $parent_of_cursor;
978 }
979 return false;
980 }
981