PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.5
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.5
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 / store.php

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

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