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

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