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

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