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

971 lines 32.0 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 // 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 = desktop_mode_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( 'desktop_mode_folder_share_user_capability' )
252 ? desktop_mode_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 'desktop_mode_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 'desktop_mode_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( 'desktop_mode_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 = desktop_mode_files_get_folder( $target_parent );
278 if ( $target && (int) $target['owner_id'] !== $user_id ) {
279 $cap = function_exists( 'desktop_mode_folder_share_user_capability' )
280 ? desktop_mode_folder_share_user_capability( $target_parent, $user_id )
281 : 'none';
282 if ( 'write' !== $cap ) {
283 return new WP_Error(
284 'desktop_mode_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 desktop_mode_files_would_create_folder_cycle(
304 $user_id,
305 $moving_folder_id,
306 $target_parent
307 )
308 ) {
309 return new WP_Error(
310 'desktop_mode_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 = desktop_mode_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'] = desktop_mode_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( 'desktop_mode_files_update_failed', __( 'Failed to update placement.', 'desktop-mode' ), array( 'status' => 500 ) );
352 }
353
354 $next = desktop_mode_files_get_placement( $placement_id );
355
356 /**
357 * Fires after a placement is moved / mutated.
358 *
359 * @since 0.9.0
360 *
361 * @param int $id Placement id.
362 * @param array $next Row after the change.
363 * @param array $prev Row before the change.
364 */
365 do_action( 'desktop_mode_file_moved', $placement_id, $next, $row );
366
367 return true;
368 }
369
370 /**
371 * Remove a placement. Writes a tombstone for Phase-6 sync.
372 *
373 * @since 0.9.0
374 *
375 * @param int $placement_id Placement id.
376 * @param int $user_id Acting user.
377 * @return true|WP_Error
378 */
379 function desktop_mode_files_remove( $placement_id, $user_id ) {
380 global $wpdb;
381
382 $placement_id = (int) $placement_id;
383 $user_id = (int) $user_id;
384 $row = desktop_mode_files_get_placement( $placement_id );
385 if ( ! $row ) {
386 return new WP_Error( 'desktop_mode_files_not_found', __( 'Placement not found.', 'desktop-mode' ), array( 'status' => 404 ) );
387 }
388 // Same shared-namespace rule as the trash gate: owner of the
389 // row OR write cap on the parent folder.
390 $is_row_owner = (int) $row['owner_id'] === $user_id;
391 $allowed = $is_row_owner;
392 if ( ! $allowed && (int) $row['parent_id'] > 0 ) {
393 $cap = function_exists( 'desktop_mode_folder_share_user_capability' )
394 ? desktop_mode_folder_share_user_capability( (int) $row['parent_id'], $user_id )
395 : 'none';
396 $allowed = 'write' === $cap;
397 }
398 if ( ! $allowed ) {
399 return new WP_Error( 'desktop_mode_files_forbidden', __( 'You cannot remove this placement.', 'desktop-mode' ), array( 'status' => 403 ) );
400 }
401
402 $tables = desktop_mode_files_table_names();
403 $ok = $wpdb->delete( $tables['placements'], array( 'id' => $placement_id ), array( '%d' ) );
404 if ( false === $ok ) {
405 return new WP_Error( 'desktop_mode_files_delete_failed', __( 'Failed to remove placement.', 'desktop-mode' ), array( 'status' => 500 ) );
406 }
407
408 desktop_mode_files_write_tombstone( 'placement', $placement_id );
409
410 /**
411 * Fires after a placement is removed.
412 *
413 * @since 0.9.0
414 *
415 * @param int $id Placement id.
416 * @param array $row Removed row.
417 */
418 do_action( 'desktop_mode_file_unplaced', $placement_id, $row );
419
420 return true;
421 }
422
423 /**
424 * Read a single placement row by id.
425 *
426 * @since 0.9.0
427 *
428 * @param int $placement_id Placement id.
429 * @return array|null
430 */
431 function desktop_mode_files_get_placement( $placement_id ) {
432 global $wpdb;
433 $tables = desktop_mode_files_table_names();
434 $row = $wpdb->get_row(
435 $wpdb->prepare( "SELECT * FROM {$tables['placements']} WHERE id = %d", (int) $placement_id ),
436 ARRAY_A
437 );
438 if ( ! $row ) {
439 return null;
440 }
441 return desktop_mode_files_normalize_placement_row( $row );
442 }
443
444 /**
445 * List placements for a user under a given folder (0 = desktop
446 * root). Honors the `desktop_mode_files_query_args` filter and
447 * applies the file-type's `can_read()` per row.
448 *
449 * @since 0.9.0
450 *
451 * @param int $user_id Viewer.
452 * @param int $parent_id Folder id (0 for desktop root).
453 * @return array[]
454 */
455 function desktop_mode_files_get_for_user_folder( $user_id, $parent_id = 0 ) {
456 global $wpdb;
457 $user_id = (int) $user_id;
458 $parent_id = max( 0, (int) $parent_id );
459 if ( $user_id <= 0 ) {
460 return array();
461 }
462
463 $tables = desktop_mode_files_table_names();
464
465 // Access gate + shared-namespace decision for non-root folders.
466 // Desktop root (parent_id = 0) is always per-user. For sub-
467 // folders, the contents of a SHARED folder are visible to every
468 // user who has at least 'read' on it — the icons inside belong
469 // to the folder, not to the user who originally placed them.
470 $share_view = false;
471 if ( $parent_id > 0 ) {
472 $folder = desktop_mode_files_get_folder( $parent_id );
473 if ( ! $folder ) {
474 return array();
475 }
476 if ( (int) $folder['owner_id'] !== $user_id ) {
477 $cap = function_exists( 'desktop_mode_folder_share_user_capability' )
478 ? desktop_mode_folder_share_user_capability( $parent_id, $user_id )
479 : 'none';
480 if ( 'none' === $cap ) {
481 return array();
482 }
483 $share_view = true;
484 }
485 }
486
487 $args = array(
488 'user_id' => $user_id,
489 'parent_id' => $parent_id,
490 'share_view' => $share_view,
491 );
492 /**
493 * Filter the args used to read placements.
494 *
495 * @since 0.9.0
496 *
497 * @param array $args Defaults: `{ user_id, parent_id, share_view }`.
498 * @param int $user_id Viewer.
499 * @param int $parent_id Folder id.
500 */
501 $args = (array) apply_filters( 'desktop_mode_files_query_args', $args, $user_id, $parent_id );
502
503 // Active queries always exclude trashed rows. Recycle-bin
504 // callers reach for the dedicated trash store.
505 if ( ! empty( $args['share_view'] ) ) {
506 // Shared sub-folder — return every placement in the folder
507 // regardless of which user originally placed it. The icons
508 // are part of the folder; the owner_id column is audit info,
509 // not a permission gate.
510 $rows = $wpdb->get_results(
511 $wpdb->prepare(
512 "SELECT * FROM {$tables['placements']}
513 WHERE parent_id = %d
514 AND trashed_at_ms IS NULL
515 ORDER BY sort_order ASC, id ASC",
516 (int) $args['parent_id']
517 ),
518 ARRAY_A
519 );
520 } else {
521 $rows = $wpdb->get_results(
522 $wpdb->prepare(
523 "SELECT * FROM {$tables['placements']}
524 WHERE owner_id = %d
525 AND parent_id = %d
526 AND trashed_at_ms IS NULL
527 ORDER BY sort_order ASC, id ASC",
528 (int) $args['user_id'],
529 (int) $args['parent_id']
530 ),
531 ARRAY_A
532 );
533 }
534 if ( ! is_array( $rows ) ) {
535 return array();
536 }
537 $out = array();
538 foreach ( $rows as $row ) {
539 $normalized = desktop_mode_files_normalize_placement_row( $row );
540 $file = desktop_mode_resolve_file( $normalized['file_type'], $normalized['file_ref'] );
541 if ( empty( $args['share_view'] ) ) {
542 // Private folder / desktop root — keep the existing
543 // per-row read filter so stale/inaccessible entities
544 // don't clutter the user's own view.
545 if ( $file && ! $file->can_read( $user_id ) ) {
546 continue;
547 }
548 } else {
549 // Shared folder view — every placement the OWNER chose
550 // to include is surfaced to the recipient. When the
551 // recipient lacks read on the underlying entity, we
552 // mark the row as `access_gated` so the tile renderer
553 // can paint a lock overlay + tooltip + intercept the
554 // open. Entity-level access enforcement still happens
555 // at open time in each opener — this flag is just the
556 // pre-emptive visual cue.
557 if ( $file && ! $file->can_read( $user_id ) ) {
558 $normalized['access_gated'] = true;
559 }
560 }
561 $out[] = $normalized;
562 }
563 return $out;
564 }
565
566 /**
567 * Self-healing backfill. Surfaces two kinds of orphans on the
568 * desktop root:
569 *
570 * 1. Folders the viewer owns that have no placement anywhere.
571 * (Pre-fix folder-create flow could leak these; new flow
572 * writes the placement atomically.)
573 *
574 * 2. Plugin shortcuts (`desktop_mode_register_icon()`) the
575 * viewer hasn't placed yet. The unified-rail merge means
576 * every registered icon shows up as a `shortcut` placement
577 * on first hydrate so plugin shortcuts behave like any
578 * other tile (drag, sort, right-click, clean up).
579 *
580 * Idempotent on both axes: a folder/shortcut that already has
581 * any placement is left alone. Coordinates use the column-major
582 * grid that `src/desktop-files/grid.ts` mirrors on the JS side.
583 *
584 * Called by the placements list endpoint when the requested
585 * folder is the root (`parent_id=0`).
586 *
587 * @since 0.9.0
588 *
589 * @param int $user_id Viewer.
590 * @return int Total number of orphans that were auto-placed.
591 */
592 function desktop_mode_files_auto_place_orphans( $user_id ) {
593 global $wpdb;
594 $user_id = (int) $user_id;
595 if ( $user_id <= 0 ) {
596 return 0;
597 }
598
599 $tables = desktop_mode_files_table_names();
600
601 // 1) Owned folders without any placement. Skip trashed folders
602 // and trashed placement rows so a recycled folder doesn't get
603 // auto-placed back on the desktop on next hydrate.
604 $folder_rows = $wpdb->get_results(
605 $wpdb->prepare(
606 "SELECT f.id FROM {$tables['folders']} f
607 LEFT JOIN {$tables['placements']} p
608 ON p.file_type = 'folder'
609 AND p.file_ref = CAST( f.id AS CHAR )
610 AND p.trashed_at_ms IS NULL
611 WHERE f.owner_id = %d
612 AND f.trashed_at_ms IS NULL
613 AND p.id IS NULL",
614 $user_id
615 ),
616 ARRAY_A
617 );
618
619 // 2) Registered plugin shortcuts the viewer hasn't placed yet.
620 // Pull the registered ids first, then ask the placements
621 // table which the viewer already has — set difference
622 // yields the orphans without a heavy join.
623 $shortcut_ids = array();
624 $registry = function_exists( 'desktop_mode_desktop_icon_registry' )
625 ? desktop_mode_desktop_icon_registry()
626 : array();
627 if ( is_array( $registry ) ) {
628 // Run through the same `desktop_mode_icons` filter the
629 // build-payload path uses so plugins (and tests) can inject
630 // virtual entries.
631 $registry = (array) apply_filters( 'desktop_mode_icons', $registry );
632 }
633 if ( is_array( $registry ) && ! empty( $registry ) ) {
634 $registered_ids = array_map( 'strval', array_keys( $registry ) );
635 $placeholders = implode( ',', array_fill( 0, count( $registered_ids ), '%s' ) );
636 $args = array_merge( array( $user_id ), $registered_ids );
637 $placed_ids = $wpdb->get_col(
638 $wpdb->prepare(
639 "SELECT file_ref FROM {$tables['placements']}
640 WHERE owner_id = %d
641 AND file_type = 'shortcut'
642 AND trashed_at_ms IS NULL
643 AND file_ref IN ($placeholders)",
644 $args
645 )
646 );
647 $placed_set = array_flip( array_map( 'strval', (array) $placed_ids ) );
648 foreach ( $registered_ids as $id ) {
649 if ( ! isset( $placed_set[ $id ] ) ) {
650 $shortcut_ids[] = $id;
651 }
652 }
653 }
654
655 if ( empty( $folder_rows ) && empty( $shortcut_ids ) ) {
656 return 0;
657 }
658
659 // Build an occupied set from EXISTING root placements so
660 // we never drop an orphan on top of a tile the user
661 // already has. Cell math mirrors `src/desktop-files/grid.ts`
662 // (padding 16 + col 96 + row 110).
663 $existing = $wpdb->get_results(
664 $wpdb->prepare(
665 "SELECT x, y FROM {$tables['placements']}
666 WHERE owner_id = %d
667 AND parent_id = 0
668 AND trashed_at_ms IS NULL",
669 $user_id
670 ),
671 ARRAY_A
672 );
673 $occupied = array();
674 foreach ( (array) $existing as $row ) {
675 $col = max( 0, (int) round( ( (int) $row['x'] - 16 ) / 96 ) );
676 $row_idx = max( 0, (int) round( ( (int) $row['y'] - 16 ) / 110 ) );
677 $occupied[ "$col,$row_idx" ] = true;
678 }
679
680 $find_next = function () use ( &$occupied ) {
681 for ( $col = 0; $col < 999; $col++ ) {
682 for ( $row = 0; $row < 999; $row++ ) {
683 $key = "$col,$row";
684 if ( ! isset( $occupied[ $key ] ) ) {
685 $occupied[ $key ] = true;
686 return array( $col, $row );
687 }
688 }
689 }
690 return array( 0, 0 );
691 };
692
693 $placed = 0;
694 $emit_at = function ( $type, $ref, $col, $row ) use ( $user_id, &$occupied, &$placed ) {
695 $occupied[ "$col,$row" ] = true;
696 $result = desktop_mode_files_place(
697 $user_id,
698 0,
699 $type,
700 (string) $ref,
701 array(
702 'x' => 16 + $col * 96,
703 'y' => 16 + $row * 110,
704 )
705 );
706 if ( ! is_wp_error( $result ) ) {
707 $placed++;
708 }
709 };
710 $emit_next = function ( $type, $ref ) use ( $find_next, $emit_at ) {
711 list( $col, $row ) = $find_next();
712 $emit_at( $type, $ref, $col, $row );
713 };
714
715 // Pinned shortcuts get reserved top-left slots. Anchored to
716 // column 0 (x=16) so the JS layer's pinned-slot math
717 // (`GRID_PADDING + n*GRID_CELL_H`) lines up with the row the
718 // server picks. Mark the slot occupied BEFORE other orphans
719 // flow in so a draggable tile never lands on top of the
720 // anchored "My WordPress" icon.
721 $pinned_ids = array();
722 foreach ( $shortcut_ids as $id ) {
723 $entry = is_array( $registry ) && isset( $registry[ $id ] ) ? $registry[ $id ] : null;
724 if ( is_array( $entry ) && ! empty( $entry['pinned'] ) ) {
725 $pinned_ids[] = $id;
726 }
727 }
728 $pinned_set = array_flip( $pinned_ids );
729 $pinned_idx = 0;
730 foreach ( $pinned_ids as $id ) {
731 // Force the slot at (col=0, row=$pinned_idx). Any pre-
732 // existing occupant on that slot is left alone — the layer
733 // re-renders the pinned tile on top via the
734 // client-side override anyway, but a future cleanup pass
735 // can compact the column.
736 $occupied[ "0,$pinned_idx" ] = true;
737 $emit_at( 'shortcut', $id, 0, $pinned_idx );
738 $pinned_idx++;
739 }
740
741 foreach ( $folder_rows as $row ) {
742 $emit_next( 'folder', $row['id'] );
743 }
744 foreach ( $shortcut_ids as $id ) {
745 if ( isset( $pinned_set[ $id ] ) ) {
746 continue;
747 }
748 $emit_next( 'shortcut', $id );
749 }
750 return $placed;
751 }
752
753 /**
754 * Backwards-compat alias for the older folder-only name.
755 *
756 * @deprecated 0.9.0 Use {@see desktop_mode_files_auto_place_orphans}.
757 *
758 * @param int $user_id Viewer.
759 * @return int
760 */
761 function desktop_mode_files_auto_place_orphan_folders( $user_id ) {
762 return desktop_mode_files_auto_place_orphans( $user_id );
763 }
764
765 /**
766 * Coerce wpdb's stringly-typed row into typed values + decoded
767 * meta. Internal helper.
768 *
769 * @since 0.9.0
770 * @internal
771 *
772 * @param array $row Raw wpdb row.
773 * @return array
774 */
775 function desktop_mode_files_normalize_placement_row( $row ) {
776 $meta_raw = isset( $row['meta'] ) ? (string) $row['meta'] : '';
777 $meta = '' !== $meta_raw ? json_decode( $meta_raw, true ) : null;
778 return array(
779 'id' => (int) $row['id'],
780 'owner_id' => (int) $row['owner_id'],
781 // `updated_by` is v10. Null on legacy rows — callers that
782 // need the actor (e.g. `desktop_mode_files_check_if_match`)
783 // fall back to `owner_id` when this is null/missing.
784 'updated_by' => isset( $row['updated_by'] ) ? (int) $row['updated_by'] : null,
785 'parent_id' => (int) $row['parent_id'],
786 'file_type' => (string) $row['file_type'],
787 'file_ref' => (string) $row['file_ref'],
788 'x' => (int) $row['x'],
789 'y' => (int) $row['y'],
790 'sort_order' => (int) $row['sort_order'],
791 'updated_at_ms' => (int) $row['updated_at_ms'],
792 'meta' => is_array( $meta ) ? $meta : null,
793 );
794 }
795
796 /**
797 * Write a tombstone row.
798 *
799 * Invariant (enforced by callers): tombstones may exist only for
800 * ids of PERMANENTLY-DELETED rows. Never write one for a soft-
801 * trashed row — soft-trash is reversible and the heartbeat already
802 * surfaces it via the `trashed_at_ms IS NOT NULL` query in
803 * `desktop_mode_files_compute_heartbeat_delta`. A tombstone on a
804 * soft-trashed row lingers past restore and tells clients the row
805 * is gone while it is in fact alive — see the "shared folder
806 * disappears on refresh" bug fixed in 0.8.5.
807 *
808 * Pair every revival path (`desktop_mode_files_restore_placement`,
809 * `desktop_mode_files_restore_folder`, and the duplicate-key
810 * revival branch in `desktop_mode_files_place`) with
811 * {@see desktop_mode_files_clear_tombstones_for} so a row coming
812 * back to life never carries lingering tombstones from a previous
813 * removal that turned out to be reversible.
814 *
815 * @since 0.9.0
816 *
817 * @param string $kind 'placement' | 'folder'.
818 * @param int $ref Removed id.
819 */
820 function desktop_mode_files_write_tombstone( $kind, $ref ) {
821 global $wpdb;
822 $tables = desktop_mode_files_table_names();
823 $wpdb->insert(
824 $tables['tombstones'],
825 array(
826 'kind' => (string) $kind,
827 'ref_id' => (int) $ref,
828 'removed_at_ms' => desktop_mode_files_now_ms(),
829 ),
830 array( '%s', '%d', '%d' )
831 );
832 }
833
834 /**
835 * Drop every tombstone referring to `($kind, $ref_id)`. Called from
836 * the row-revival paths so a placement/folder coming back to life
837 * never carries lingering "this is gone" tombstones from a
838 * previous removal that turned out to be reversible.
839 *
840 * Idempotent — running it on a ref with no tombstones is a no-op.
841 *
842 * @since 0.8.5
843 *
844 * @param string $kind 'placement' | 'folder'.
845 * @param int $ref_id Row id whose tombstones should be dropped.
846 */
847 function desktop_mode_files_clear_tombstones_for( $kind, $ref_id ) {
848 global $wpdb;
849 $ref_id = (int) $ref_id;
850 if ( $ref_id <= 0 ) {
851 return;
852 }
853 $tables = desktop_mode_files_table_names();
854 $wpdb->delete(
855 $tables['tombstones'],
856 array(
857 'kind' => (string) $kind,
858 'ref_id' => $ref_id,
859 ),
860 array( '%s', '%d' )
861 );
862 }
863
864 /**
865 * Daily prune of tombstones older than 7 days. Phase 6 may tune
866 * the retention window when the Heartbeat sync lands; for now 7d
867 * is plenty since a client that's been offline that long will
868 * always need a full REST resync anyway.
869 *
870 * @since 0.9.0
871 */
872 function desktop_mode_files_prune_tombstones() {
873 global $wpdb;
874 $tables = desktop_mode_files_table_names();
875 $cutoff = desktop_mode_files_now_ms() - ( 7 * DAY_IN_SECONDS * 1000 );
876 $wpdb->query( $wpdb->prepare( "DELETE FROM {$tables['tombstones']} WHERE removed_at_ms < %d", $cutoff ) );
877 }
878 add_action( 'desktop_mode_files_daily_prune', 'desktop_mode_files_prune_tombstones' );
879
880 /**
881 * Schedule the daily prune. Hooked on `init` and idempotent via
882 * wp_next_scheduled(), so a manual file-copy install (no activation
883 * hook) still gets the cron event.
884 *
885 * @since 0.9.0
886 */
887 function desktop_mode_files_schedule_prune() {
888 if ( ! wp_next_scheduled( 'desktop_mode_files_daily_prune' ) ) {
889 wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'desktop_mode_files_daily_prune' );
890 }
891 }
892 add_action( 'init', 'desktop_mode_files_schedule_prune' );
893
894 /**
895 * Walk the folder-parentage chain upward from `$target_parent_id` and
896 * return `true` when `$moving_folder_id` appears anywhere in it —
897 * meaning a move that sets `moving_folder.parent_id = target_parent`
898 * would produce an unreachable cycle (folder placed inside itself or
899 * inside one of its own descendants).
900 *
901 * Folder-parentage is determined by the parent_id of the folder's
902 * placement row, not by anything on the `folders` table. We look up
903 * one live placement per cursor (`LIMIT 1`) — folders with multiple
904 * placements (rare; shared semantics) are still safely covered
905 * because any one upward chain hitting the moving folder is enough
906 * to flag the cycle.
907 *
908 * Defends against pre-existing cycles in the data: if we re-visit a
909 * cursor we've already seen, we treat it as a cycle and reject, so a
910 * corrupted history can't drive this function into an infinite loop.
911 *
912 * @since 0.8.6
913 *
914 * @param int $user_id Acting user.
915 * @param int $moving_folder_id Folder being moved (its `folders.id`).
916 * @param int $target_parent_id New container folder id (0 = desktop root).
917 * @return bool True when the move would create a cycle.
918 */
919 function desktop_mode_files_would_create_folder_cycle( $user_id, $moving_folder_id, $target_parent_id ) {
920 $moving_folder_id = (int) $moving_folder_id;
921 $target_parent_id = (int) $target_parent_id;
922 $user_id = (int) $user_id;
923 if ( $moving_folder_id <= 0 || $target_parent_id <= 0 || $user_id <= 0 ) {
924 return false;
925 }
926 if ( $moving_folder_id === $target_parent_id ) {
927 return true;
928 }
929 global $wpdb;
930 $tables = desktop_mode_files_table_names();
931 $visited = array();
932 $cursor = $target_parent_id;
933 // Hard cap to defend against catastrophically deep trees too —
934 // real installs won't approach 256.
935 $max_depth = 256;
936 while ( $cursor > 0 && $max_depth-- > 0 ) {
937 if ( $cursor === $moving_folder_id ) {
938 return true;
939 }
940 if ( isset( $visited[ $cursor ] ) ) {
941 // Pre-existing cycle in the data — bail safe by treating
942 // the move as cycle-creating too. Better to refuse a
943 // suspicious move than to deepen the damage.
944 return true;
945 }
946 $visited[ $cursor ] = true;
947 // `LIMIT 1` is enough — any upward chain that reaches the
948 // moving folder flags the cycle. Trashed rows excluded so a
949 // recycled-then-recovered ancestor doesn't poison the check.
950 $parent_of_cursor = $wpdb->get_var(
951 $wpdb->prepare(
952 "SELECT parent_id FROM {$tables['placements']}
953 WHERE owner_id = %d
954 AND file_type = 'folder'
955 AND file_ref = %s
956 AND trashed_at_ms IS NULL
957 LIMIT 1",
958 $user_id,
959 (string) $cursor
960 )
961 );
962 if ( null === $parent_of_cursor ) {
963 // Folder has no live placement under this user — chain
964 // ends here. No cycle.
965 return false;
966 }
967 $cursor = (int) $parent_of_cursor;
968 }
969 return false;
970 }
971