PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.8
1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 0.8.6 All 33 releases
desktop-mode / includes / desktop-files / rest.php

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

1,232 lines 40.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Files REST routes.
4 *
5 * Routes under `/desktop-mode/v1/files`:
6 *
7 * GET /placements?folder=<id> List the viewer's placements
8 * under `<id>` (0 for desktop root).
9 * POST /placements Create a placement.
10 * PATCH /placements/(?P<id>\d+) Move / update a placement.
11 * DELETE /placements/(?P<id>\d+) Remove a placement.
12 *
13 * GET /folders List folders visible to the viewer.
14 * POST /folders Create a folder.
15 * PATCH /folders/(?P<id>\d+) Update a folder.
16 * DELETE /folders/(?P<id>\d+) Delete a folder.
17 *
18 * GET /folders/(?P<id>\d+)/shares List a folder's shares
19 * (owner only).
20 * POST /folders/(?P<id>\d+)/shares Invite a user/role (owner only).
21 * PATCH /folders/(?P<id>\d+)/shares/(?P<shareId>\d+)
22 * Change a share's capability.
23 * DELETE /folders/(?P<id>\d+)/shares/(?P<shareId>\d+)
24 * Revoke a share.
25 * POST /folders/(?P<id>\d+)/shares/(?P<shareId>\d+)/accept
26 * Accept an invite (recipient).
27 * POST /folders/(?P<id>\d+)/shares/(?P<shareId>\d+)/deny
28 * Deny an invite (recipient).
29 * POST /folders/(?P<id>\d+)/leave Leave a shared folder
30 * (recipient).
31 *
32 * GET /users/search Share-picker autocomplete.
33 * POST /folder-sharing-tables/purge
34 * Drop the folder-sharing tables
35 * (site admin only).
36 *
37 * PUT /associations Replace the viewer's full
38 * `{ type => opener_id }` map.
39 *
40 * Permission: every route requires a logged-in user with desktop
41 * mode enabled. Per-row gating happens inside the store. On top of
42 * that base, the share/accept/deny/leave routes also gate on the
43 * viewer's folder-sharing OS Setting via
44 * `openstation_files_rest_share_permission`, `/users/search`
45 * additionally requires `edit_posts`, and the sharing-tables purge
46 * requires `manage_options`.
47 *
48 * @package OpenStation
49 */
50
51 defined( 'ABSPATH' ) || exit;
52
53 /**
54 * Base permission check shared by the desktop-files REST routes:
55 * requires a logged-in user with OpenStation enabled.
56 */
57 function openstation_files_rest_permission() {
58 if ( ! is_user_logged_in() ) {
59 return new WP_Error( 'openstation_files_unauthenticated', __( 'You must be logged in.', 'desktop-mode' ), array( 'status' => 401 ) );
60 }
61 if ( function_exists( 'openstation_is_enabled' ) && ! openstation_is_enabled( get_current_user_id() ) ) {
62 return new WP_Error( 'openstation_files_disabled', __( 'OpenStation is not enabled for this user.', 'desktop-mode' ), array( 'status' => 403 ) );
63 }
64 return true;
65 }
66
67 /**
68 * Permission callback layered ON TOP of
69 * `openstation_files_rest_permission` for every share-related
70 * route. Returns a 404 (looks the same as a route that doesn't
71 * exist) when the viewer has the folder-sharing feature toggled
72 * off in OS Settings — no information leak about whether the
73 * feature is even installed.
74 */
75 function openstation_files_rest_share_permission() {
76 $base = openstation_files_rest_permission();
77 if ( is_wp_error( $base ) ) {
78 return $base;
79 }
80 if (
81 function_exists( 'openstation_files_sharing_enabled_for' )
82 && ! openstation_files_sharing_enabled_for( get_current_user_id() )
83 ) {
84 return new WP_Error(
85 'rest_no_route',
86 __( 'No route was found matching the URL and request method.', 'desktop-mode' ),
87 array( 'status' => 404 )
88 );
89 }
90 return true;
91 }
92
93 /**
94 * Permission callback for the destructive site-admin actions
95 * (currently: drop the folder-sharing tables). Requires
96 * `manage_options` — site-wide schema mutation should never be
97 * exposed below that capability.
98 */
99 function openstation_files_rest_admin_permission() {
100 if ( ! current_user_can( 'manage_options' ) ) {
101 return new WP_Error(
102 'openstation_files_forbidden',
103 __( 'You do not have permission to perform this action.', 'desktop-mode' ),
104 array( 'status' => 403 )
105 );
106 }
107 return true;
108 }
109
110 /**
111 * Register the routes.
112 */
113 function openstation_files_register_rest_routes() {
114 $ns = 'desktop-mode/v1';
115
116 register_rest_route(
117 $ns,
118 '/files/placements',
119 array(
120 array(
121 'methods' => WP_REST_Server::READABLE,
122 'permission_callback' => 'openstation_files_rest_permission',
123 'callback' => 'openstation_files_rest_list_placements',
124 'args' => array(
125 'folder' => array(
126 'type' => 'integer',
127 'default' => 0,
128 'sanitize_callback' => 'absint',
129 ),
130 ),
131 ),
132 array(
133 'methods' => WP_REST_Server::CREATABLE,
134 'permission_callback' => 'openstation_files_rest_permission',
135 'callback' => 'openstation_files_rest_create_placement',
136 'args' => array(
137 'parentId' => array(
138 'type' => 'integer',
139 'default' => 0,
140 ),
141 'type' => array(
142 'type' => 'string',
143 'required' => true,
144 ),
145 'ref' => array(
146 'type' => 'string',
147 'required' => true,
148 ),
149 'x' => array(
150 'type' => 'integer',
151 'default' => 0,
152 ),
153 'y' => array(
154 'type' => 'integer',
155 'default' => 0,
156 ),
157 'sortOrder' => array(
158 'type' => 'integer',
159 'default' => 0,
160 ),
161 'meta' => array(
162 'type' => 'object',
163 'required' => false,
164 ),
165 ),
166 ),
167 )
168 );
169
170 register_rest_route(
171 $ns,
172 '/files/placements/(?P<id>\d+)',
173 array(
174 array(
175 'methods' => WP_REST_Server::EDITABLE,
176 'permission_callback' => 'openstation_files_rest_permission',
177 'callback' => 'openstation_files_rest_update_placement',
178 ),
179 array(
180 'methods' => WP_REST_Server::DELETABLE,
181 'permission_callback' => 'openstation_files_rest_permission',
182 'callback' => 'openstation_files_rest_delete_placement',
183 ),
184 )
185 );
186
187 register_rest_route(
188 $ns,
189 '/files/folders',
190 array(
191 array(
192 'methods' => WP_REST_Server::READABLE,
193 'permission_callback' => 'openstation_files_rest_permission',
194 'callback' => 'openstation_files_rest_list_folders',
195 ),
196 array(
197 'methods' => WP_REST_Server::CREATABLE,
198 'permission_callback' => 'openstation_files_rest_permission',
199 'callback' => 'openstation_files_rest_create_folder',
200 'args' => array(
201 'name' => array(
202 'type' => 'string',
203 'required' => true,
204 ),
205 'shareMode' => array(
206 'type' => 'string',
207 'default' => 'private',
208 ),
209 'shareMeta' => array(
210 'type' => 'object',
211 'required' => false,
212 ),
213 ),
214 ),
215 )
216 );
217
218 register_rest_route(
219 $ns,
220 '/files/folders/(?P<id>\d+)',
221 array(
222 array(
223 'methods' => WP_REST_Server::EDITABLE,
224 'permission_callback' => 'openstation_files_rest_permission',
225 'callback' => 'openstation_files_rest_update_folder',
226 ),
227 array(
228 'methods' => WP_REST_Server::DELETABLE,
229 'permission_callback' => 'openstation_files_rest_permission',
230 'callback' => 'openstation_files_rest_delete_folder',
231 ),
232 )
233 );
234
235 register_rest_route(
236 $ns,
237 '/files/associations',
238 array(
239 'methods' => 'PUT',
240 'permission_callback' => 'openstation_files_rest_permission',
241 'callback' => 'openstation_files_rest_save_associations',
242 'args' => array(
243 'associations' => array(
244 'type' => 'object',
245 'required' => true,
246 ),
247 ),
248 )
249 );
250
251 // Every share-related route gates on the user's
252 // `foldersSharingEnabled` OS Setting via
253 // `openstation_files_rest_share_permission` — when a user has
254 // flipped sharing off, these routes return 404 (looks the same
255 // as a feature that isn't installed; no info leak about the
256 // kill switch's existence).
257 register_rest_route(
258 $ns,
259 '/files/folders/(?P<id>\d+)/shares',
260 array(
261 array(
262 'methods' => WP_REST_Server::READABLE,
263 'permission_callback' => 'openstation_files_rest_share_permission',
264 'callback' => 'openstation_files_rest_list_shares',
265 ),
266 array(
267 'methods' => WP_REST_Server::CREATABLE,
268 'permission_callback' => 'openstation_files_rest_share_permission',
269 'callback' => 'openstation_files_rest_create_share',
270 'args' => array(
271 'principalType' => array(
272 'type' => 'string',
273 'enum' => array( 'user', 'role' ),
274 'required' => true,
275 ),
276 'principalRef' => array(
277 'type' => 'string',
278 'required' => true,
279 ),
280 'capability' => array(
281 'type' => 'string',
282 'enum' => array( 'read', 'write' ),
283 'default' => 'read',
284 ),
285 ),
286 ),
287 )
288 );
289
290 register_rest_route(
291 $ns,
292 '/files/folders/(?P<id>\d+)/shares/(?P<shareId>\d+)',
293 array(
294 array(
295 'methods' => WP_REST_Server::EDITABLE,
296 'permission_callback' => 'openstation_files_rest_share_permission',
297 'callback' => 'openstation_files_rest_update_share',
298 'args' => array(
299 'capability' => array(
300 'type' => 'string',
301 'enum' => array( 'read', 'write' ),
302 'required' => true,
303 ),
304 ),
305 ),
306 array(
307 'methods' => WP_REST_Server::DELETABLE,
308 'permission_callback' => 'openstation_files_rest_share_permission',
309 'callback' => 'openstation_files_rest_delete_share',
310 ),
311 )
312 );
313
314 register_rest_route(
315 $ns,
316 '/files/folders/(?P<id>\d+)/shares/(?P<shareId>\d+)/accept',
317 array(
318 'methods' => WP_REST_Server::CREATABLE,
319 'permission_callback' => 'openstation_files_rest_share_permission',
320 'callback' => 'openstation_files_rest_accept_share',
321 )
322 );
323
324 register_rest_route(
325 $ns,
326 '/files/folders/(?P<id>\d+)/shares/(?P<shareId>\d+)/deny',
327 array(
328 'methods' => WP_REST_Server::CREATABLE,
329 'permission_callback' => 'openstation_files_rest_share_permission',
330 'callback' => 'openstation_files_rest_deny_share',
331 )
332 );
333
334 register_rest_route(
335 $ns,
336 '/files/folders/(?P<id>\d+)/leave',
337 array(
338 'methods' => WP_REST_Server::CREATABLE,
339 'permission_callback' => 'openstation_files_rest_share_permission',
340 'callback' => 'openstation_files_rest_leave_folder',
341 )
342 );
343
344 register_rest_route(
345 $ns,
346 '/files/users/search',
347 array(
348 'methods' => WP_REST_Server::READABLE,
349 'permission_callback' => 'openstation_files_rest_search_users_permission',
350 'callback' => 'openstation_files_rest_search_users',
351 'args' => array(
352 'q' => array(
353 'type' => 'string',
354 'default' => '',
355 ),
356 'exclude' => array(
357 'type' => 'string',
358 'default' => '',
359 ),
360 ),
361 )
362 );
363
364 // Site-admin only: destructive cleanup that drops the folder-
365 // sharing tables outright (legacy + current). Surfaced from
366 // the OS Settings → Features → Advanced panel.
367 register_rest_route(
368 $ns,
369 '/files/folder-sharing-tables/purge',
370 array(
371 'methods' => WP_REST_Server::CREATABLE,
372 'permission_callback' => 'openstation_files_rest_admin_permission',
373 'callback' => 'openstation_files_rest_purge_sharing_tables',
374 )
375 );
376 }
377 add_action( 'rest_api_init', 'openstation_files_register_rest_routes' );
378
379 /**
380 * Inline the root folder's placements into the boot-time shell
381 * config so the desktop file grid hydrates without a REST
382 * round-trip — this was the only REST call the shell had to await
383 * before revealing the desktop. Mirrors the GET /placements handler
384 * for `folder=0` exactly (same orphan backfill, same shape) so the
385 * client store can't tell the difference; the JS consumer
386 * (`src/desktop-files/layer.ts`) consumes the key one-shot, so any
387 * later re-hydration still goes through REST for fresh state.
388 *
389 * The `openstation_shell_config` filter only runs while rendering
390 * the shell for an enabled, logged-in user — the same gate the REST
391 * permission callback enforces.
392 *
393 * @param array $config Shell config.
394 * @return array
395 */
396 function openstation_files_inject_boot_placements( $config ) {
397 $user_id = get_current_user_id();
398 if ( $user_id <= 0 ) {
399 return $config;
400 }
401 openstation_files_auto_place_orphans( $user_id );
402 $rows = openstation_files_get_for_user_folder( $user_id, 0 );
403 $out = array();
404 foreach ( $rows as $row ) {
405 $out[] = openstation_files_shape_placement( $row );
406 }
407 $config['filesBootPlacements'] = $out;
408 return $config;
409 }
410 add_filter( 'openstation_shell_config', 'openstation_files_inject_boot_placements', 20 );
411
412 /**
413 * Inline the viewer's visible folder rows into the boot-time shell
414 * config, the same way the root placements are.
415 *
416 * The client keeps a folders map alongside its placements, and
417 * anything that needs to know a folder's OWNER reads it there —
418 * notably the "Share folder" title-bar button, which is owner-only
419 * and has nothing but a window id to work from. Nothing on the
420 * normal boot path filled that map: placement hydration populates
421 * placements, and `listFolders()` only ran after a create, a rename
422 * or an untrash. So a plain reload left every folder ownerless, and
423 * the owner of a folder lost the one control that manages its
424 * sharing until something happened to repopulate the map.
425 *
426 * Mirrors GET /folders exactly (same visibility resolution — owned
427 * folders plus accepted shares plus `share_mode='all'` — same
428 * shape), and the client consumes it one-shot, so any later
429 * re-hydration still goes through REST for fresh state.
430 *
431 * @param array $config Shell config.
432 * @return array
433 */
434 function openstation_files_inject_boot_folders( $config ) {
435 $user_id = get_current_user_id();
436 if ( $user_id <= 0 ) {
437 return $config;
438 }
439 $out = array();
440 foreach ( openstation_files_get_visible_folders( $user_id ) as $row ) {
441 $out[] = openstation_files_shape_folder( $row );
442 }
443 $config['filesBootFolders'] = $out;
444 return $config;
445 }
446 add_filter( 'openstation_shell_config', 'openstation_files_inject_boot_folders', 20 );
447
448 /**
449 * GET /placements
450 */
451 function openstation_files_rest_list_placements( WP_REST_Request $req ) {
452 $user_id = get_current_user_id();
453 $parent_id = (int) $req->get_param( 'folder' );
454 // Self-healing backfill — see
455 // `openstation_files_auto_place_orphan_folders` for the why.
456 // Only runs at the root because that's the only context where
457 // auto-placing an orphan folder as a tile is unambiguous.
458 if ( 0 === $parent_id ) {
459 openstation_files_auto_place_orphans( $user_id );
460 }
461 $rows = openstation_files_get_for_user_folder( $user_id, $parent_id );
462 $out = array();
463 foreach ( $rows as $row ) {
464 $out[] = openstation_files_shape_placement( $row );
465 }
466 return rest_ensure_response(
467 array(
468 'placements' => $out,
469 'folderId' => $parent_id,
470 )
471 );
472 }
473
474 /**
475 * POST /placements
476 */
477 function openstation_files_rest_create_placement( WP_REST_Request $req ) {
478 $type = (string) $req->get_param( 'type' );
479 $ref = (string) $req->get_param( 'ref' );
480 $meta = $req->get_param( 'meta' );
481
482 // `link` placements get a server-resolved favicon stuffed onto
483 // `meta.iconUrl` so the tile renderer can paint it without the
484 // browser making a third-party request on every render. Other
485 // types skip the resolver entirely (no extra fetch latency).
486 if ( 'link' === $type && '' !== $ref ) {
487 $icon_data_uri = openstation_resolve_favicon( $ref );
488 if ( is_string( $icon_data_uri ) && '' !== $icon_data_uri ) {
489 $meta_arr = is_array( $meta ) ? $meta : array();
490 $meta_arr['iconUrl'] = $icon_data_uri;
491 $meta = $meta_arr;
492 }
493 }
494
495 $id = openstation_files_place(
496 get_current_user_id(),
497 (int) $req->get_param( 'parentId' ),
498 $type,
499 $ref,
500 array(
501 'x' => (int) $req->get_param( 'x' ),
502 'y' => (int) $req->get_param( 'y' ),
503 'sort_order' => (int) $req->get_param( 'sortOrder' ),
504 'meta' => $meta,
505 )
506 );
507 if ( is_wp_error( $id ) ) {
508 return $id;
509 }
510 $row = openstation_files_get_placement( $id );
511 return rest_ensure_response( openstation_files_shape_placement( $row ) );
512 }
513
514 /**
515 * PATCH /placements/<id>
516 */
517 function openstation_files_rest_update_placement( WP_REST_Request $req ) {
518 $id = (int) $req['id'];
519 $json = $req->get_json_params();
520 $body = $json ? $json : $req->get_params();
521 $current = openstation_files_get_placement( $id );
522 if ( ! $current ) {
523 return new WP_Error( 'openstation_files_not_found', __( 'Placement not found.', 'desktop-mode' ), array( 'status' => 404 ) );
524 }
525 $conflict = openstation_files_check_if_match( (int) $current['updated_at_ms'], $req, $current );
526 if ( is_wp_error( $conflict ) ) {
527 return $conflict;
528 }
529 $changes = array();
530 foreach ( array(
531 'parentId' => 'parent_id',
532 'x' => 'x',
533 'y' => 'y',
534 'sortOrder' => 'sort_order',
535 'meta' => 'meta',
536 ) as $in => $col ) {
537 if ( array_key_exists( $in, $body ) ) {
538 $changes[ $col ] = $body[ $in ];
539 }
540 }
541 $ok = openstation_files_move( $id, get_current_user_id(), $changes );
542 if ( is_wp_error( $ok ) ) {
543 return $ok;
544 }
545 return rest_ensure_response( openstation_files_shape_placement( openstation_files_get_placement( $id ) ) );
546 }
547
548 /**
549 * DELETE /placements/<id>
550 */
551 function openstation_files_rest_delete_placement( WP_REST_Request $req ) {
552 $id = (int) $req['id'];
553 $user_id = get_current_user_id();
554 // `force=1` query param permanently deletes (purges the row).
555 // Default DELETE soft-trashes — the row lands in the recycle
556 // bin and the user can restore. Same convention WP core REST
557 // uses on every other resource.
558 $force = '1' === (string) $req->get_param( 'force' )
559 || true === $req->get_param( 'force' );
560 $ok = $force
561 ? openstation_files_purge_placement( $user_id, $id )
562 : openstation_files_trash_placement( $user_id, $id );
563 if ( is_wp_error( $ok ) ) {
564 return $ok;
565 }
566 return rest_ensure_response(
567 array(
568 'deleted' => true,
569 'force' => $force,
570 )
571 );
572 }
573
574 /**
575 * GET /folders
576 */
577 function openstation_files_rest_list_folders() {
578 $rows = openstation_files_get_visible_folders( get_current_user_id() );
579 $out = array();
580 foreach ( $rows as $row ) {
581 $out[] = openstation_files_shape_folder( $row );
582 }
583 return rest_ensure_response( array( 'folders' => $out ) );
584 }
585
586 /**
587 * POST /folders
588 */
589 function openstation_files_rest_create_folder( WP_REST_Request $req ) {
590 $id = openstation_files_create_folder(
591 get_current_user_id(),
592 array(
593 'name' => (string) $req->get_param( 'name' ),
594 'share_mode' => (string) $req->get_param( 'shareMode' ),
595 'share_meta' => $req->get_param( 'shareMeta' ),
596 )
597 );
598 if ( is_wp_error( $id ) ) {
599 return $id;
600 }
601 return rest_ensure_response( openstation_files_shape_folder( openstation_files_get_folder( $id ) ) );
602 }
603
604 /**
605 * PATCH /folders/<id>
606 */
607 function openstation_files_rest_update_folder( WP_REST_Request $req ) {
608 $id = (int) $req['id'];
609 $json = $req->get_json_params();
610 $body = $json ? $json : $req->get_params();
611 $current = openstation_files_get_folder( $id );
612 if ( ! $current ) {
613 return new WP_Error( 'openstation_files_not_found', __( 'Folder not found.', 'desktop-mode' ), array( 'status' => 404 ) );
614 }
615 $conflict = openstation_files_check_if_match( (int) $current['updated_at_ms'], $req, $current );
616 if ( is_wp_error( $conflict ) ) {
617 return $conflict;
618 }
619 $changes = array();
620 foreach ( array(
621 'name' => 'name',
622 'shareMode' => 'share_mode',
623 'shareMeta' => 'share_meta',
624 ) as $in => $col ) {
625 if ( array_key_exists( $in, $body ) ) {
626 $changes[ $col ] = $body[ $in ];
627 }
628 }
629 $ok = openstation_files_update_folder( $id, get_current_user_id(), $changes );
630 if ( is_wp_error( $ok ) ) {
631 return $ok;
632 }
633 return rest_ensure_response( openstation_files_shape_folder( openstation_files_get_folder( $id ) ) );
634 }
635
636 /**
637 * DELETE /folders/<id>
638 */
639 function openstation_files_rest_delete_folder( WP_REST_Request $req ) {
640 $id = (int) $req['id'];
641 $user_id = get_current_user_id();
642 $force = '1' === (string) $req->get_param( 'force' )
643 || true === $req->get_param( 'force' );
644 // Default DELETE soft-trashes the folder + cascades to child
645 // placements (see `openstation_files_trash_folder`). `force=1`
646 // permanently deletes both the folder row AND every child
647 // placement that was trashed via the cascade.
648 $ok = $force
649 ? openstation_files_purge_folder( $user_id, $id )
650 : openstation_files_trash_folder( $user_id, $id );
651 if ( is_wp_error( $ok ) ) {
652 return $ok;
653 }
654 return rest_ensure_response(
655 array(
656 'deleted' => true,
657 'force' => $force,
658 )
659 );
660 }
661
662 /**
663 * PUT /associations — replaces the entire user-association map.
664 */
665 function openstation_files_rest_save_associations( WP_REST_Request $req ) {
666 $assoc = (array) $req->get_param( 'associations' );
667 $clean = array();
668 foreach ( $assoc as $type => $opener_id ) {
669 $type = sanitize_key( (string) $type );
670 $opener_id = sanitize_key( (string) $opener_id );
671 if ( '' === $type || '' === $opener_id ) {
672 continue;
673 }
674 $clean[ $type ] = $opener_id;
675 }
676 update_user_meta( get_current_user_id(), OPENSTATION_FILE_ASSOCIATIONS_META, $clean );
677 return rest_ensure_response(
678 array(
679 'associations' => openstation_get_user_file_associations( get_current_user_id() ),
680 )
681 );
682 }
683
684 /**
685 * Shape a placement row for the wire — converts snake_case to
686 * camelCase and merges in the resolved `OpenStation_File`
687 * shape so the JS side can render without a second fetch.
688 *
689 * @param array|null $row Normalized placement row.
690 * @return array
691 */
692 function openstation_files_shape_placement( $row ) {
693 if ( ! is_array( $row ) ) {
694 return array();
695 }
696 // Access-gated rows (shared-folder view, viewer lacks read on
697 // the underlying entity) get a redacted shape — the viewer may
698 // learn THAT the owner placed something here, not WHAT it is.
699 // Skipping the resolver keeps entity metadata (title, permalink,
700 // status, roles, …) from crossing the read-access boundary; the
701 // tile renderer paints the lock overlay off `accessGated`.
702 $access_gated = ! empty( $row['access_gated'] );
703 if ( $access_gated ) {
704 $shape = array(
705 'type' => $row['file_type'],
706 'ref' => $row['file_ref'],
707 'title' => __( 'Restricted item', 'desktop-mode' ),
708 'icon' => 'dashicons-lock',
709 'previewUrl' => '',
710 'exists' => true,
711 );
712 } else {
713 $file = openstation_resolve_file( $row['file_type'], $row['file_ref'] );
714 $shape = $file ? $file->serialize() : array(
715 'type' => $row['file_type'],
716 'ref' => $row['file_ref'],
717 'title' => '',
718 'icon' => 'dashicons-warning',
719 'previewUrl' => '',
720 'exists' => false,
721 );
722 }
723 // `canTrash` carries the server's answer to "can the viewer
724 // move this placement to the recycle bin?" so the client can
725 // proactively suppress the trash affordance — both the tile's
726 // right-click "Move to recycle bin" menu item and the trash
727 // drop target's accept-check. Without it, the only feedback for
728 // a forbidden drop was a 403 logged to the console, leaving the
729 // user staring at a tile that wouldn't move. Falls back to
730 // `false` when the helper isn't loaded (defensive — early-boot
731 // REST calls before trash.php is required can't grant permission
732 // they don't know about).
733 $viewer_id = (int) get_current_user_id();
734 $can_trash = false;
735 if ( $viewer_id > 0 && function_exists( 'openstation_files_user_can_trash_placement' ) ) {
736 $can_trash = openstation_files_user_can_trash_placement( $viewer_id, $row );
737 }
738
739 return array(
740 'id' => (int) $row['id'],
741 'parentId' => (int) $row['parent_id'],
742 'x' => (int) $row['x'],
743 'y' => (int) $row['y'],
744 'sortOrder' => (int) $row['sort_order'],
745 'updatedAtMs' => (int) $row['updated_at_ms'],
746 'meta' => isset( $row['meta'] ) ? $row['meta'] : null,
747 'file' => $shape,
748 // `accessGated` is true when the viewer can't read the
749 // underlying entity but the placement is shown anyway (the
750 // shared-folder-view UX). Tile renderer surfaces it as a
751 // lock overlay + tooltip; the `file` shape above is redacted.
752 'accessGated' => $access_gated,
753 'canTrash' => $can_trash,
754 );
755 }
756
757 /**
758 * @param array|null $row Folder row.
759 * @return array
760 */
761 function openstation_files_shape_folder( $row ) {
762 if ( ! is_array( $row ) ) {
763 return array();
764 }
765 $shape = array(
766 'id' => (int) $row['id'],
767 'ownerId' => (int) $row['owner_id'],
768 'name' => (string) $row['name'],
769 'shareMode' => (string) $row['share_mode'],
770 'shareMeta' => isset( $row['share_meta'] ) ? $row['share_meta'] : null,
771 'updatedAtMs' => (int) $row['updated_at_ms'],
772 );
773 // Same summary `OpenStation_Folder_File::serialize()` puts on a
774 // folder PLACEMENT, so a tile paints identically whichever
775 // response it came from.
776 if ( function_exists( 'openstation_files_folder_share_summary' ) ) {
777 $shape['shareSummary'] = openstation_files_folder_share_summary( $row );
778 }
779 return $shape;
780 }
781
782 /**
783 * Conditional-write helper. Reads `If-Match` from the request and
784 * returns a 409 `WP_Error` when the stored row's `updated_at_ms`
785 * doesn't match the supplied value. Returns null in every other
786 * case (header absent → back-compat last-write-wins; header
787 * matches → caller proceeds).
788 *
789 * The 409 body carries a structured `data` payload the client
790 * surfaces as a toast: `{ reason, actor: { id,name,avatar },
791 * current: { parentId, parentName, updatedAtMs } }`.
792 *
793 * @param int $current_ms Current `updated_at_ms` on the row.
794 * @param WP_REST_Request $req Inbound request.
795 * @param array $row Normalized row (placement or folder).
796 * @return WP_Error|null
797 */
798 function openstation_files_check_if_match( $current_ms, WP_REST_Request $req, $row ) {
799 $header = $req->get_header( 'if_match' );
800 if ( null === $header || '' === $header ) {
801 return null;
802 }
803 $expected = (int) trim( str_replace( '"', '', (string) $header ) );
804 if ( $expected === (int) $current_ms ) {
805 return null;
806 }
807 // Prefer `updated_by` (v10+) so the conflict toast attributes
808 // the change to the SESSION that won the race. Falls back to
809 // `owner_id` (placement creator / folder owner) for legacy
810 // rows from before the v10 `updated_by` column was added — in
811 // a non-shared-write workflow that still happens to be the
812 // right person; in shared-write the toast may be slightly
813 // misleading for the lifetime of pre-v10 rows. New mutations
814 // stamp the column accurately. See
815 // `openstation_files_ensure_updated_by_column`.
816 $actor_id = 0;
817 if ( isset( $row['updated_by'] ) && (int) $row['updated_by'] > 0 ) {
818 $actor_id = (int) $row['updated_by'];
819 } elseif ( isset( $row['owner_id'] ) ) {
820 $actor_id = (int) $row['owner_id'];
821 }
822 $actor = $actor_id ? get_userdata( $actor_id ) : null;
823
824 $parent_id = isset( $row['parent_id'] ) ? (int) $row['parent_id'] : 0;
825 $parent_name = '';
826 if ( $parent_id > 0 ) {
827 $parent_folder = openstation_files_get_folder( $parent_id );
828 $parent_name = $parent_folder ? (string) $parent_folder['name'] : '';
829 }
830
831 $reason = 'parent_changed';
832 if ( ! empty( $row['trashed_at_ms'] ) ) {
833 $reason = 'trashed';
834 }
835
836 // PII gate. The conflict toast names the actor (display name +
837 // avatar) and the row's parent folder only when the requesting
838 // viewer is in the same collaboration scope as the actor — i.e.
839 // owns the row, owns the parent folder, or has at least read
840 // access to the parent folder via the shares table. For any
841 // other viewer the actor degrades to a generic "another
842 // session" — `id: 0`, empty name + avatar — and `current`
843 // drops the parent id/name, so a write attempt can't be used
844 // to enumerate other users' display names or folder names
845 // (this check runs BEFORE the store's ownership gate, so the
846 // 409 body must not leak what the later 403 would protect).
847 $viewer_id = (int) get_current_user_id();
848 $viewer_owns_row = isset( $row['owner_id'] ) && (int) $row['owner_id'] === $viewer_id;
849 $viewer_can_see = $viewer_owns_row;
850 if ( ! $viewer_can_see && $parent_id > 0 && isset( $parent_folder ) && $parent_folder ) {
851 if ( (int) $parent_folder['owner_id'] === $viewer_id ) {
852 $viewer_can_see = true;
853 } elseif ( function_exists( 'openstation_folder_share_user_capability' ) ) {
854 $viewer_can_see = 'none' !== openstation_folder_share_user_capability( $parent_id, $viewer_id );
855 }
856 }
857 $actor_payload = array(
858 'id' => $viewer_can_see ? $actor_id : 0,
859 'name' => $viewer_can_see && $actor ? $actor->display_name : '',
860 'avatar' => $viewer_can_see && $actor ? get_avatar_url( $actor->ID, array( 'size' => 32 ) ) : '',
861 );
862
863 return new WP_Error(
864 'openstation_files_conflict',
865 __( 'This row was changed by another session.', 'desktop-mode' ),
866 array(
867 'status' => 409,
868 'data' => array(
869 'reason' => $reason,
870 'actor' => $actor_payload,
871 'current' => array(
872 'parentId' => $viewer_can_see ? $parent_id : 0,
873 'parentName' => $viewer_can_see ? $parent_name : '',
874 'updatedAtMs' => (int) $current_ms,
875 ),
876 ),
877 )
878 );
879 }
880
881 /**
882 * Shape a share row for the wire.
883 *
884 * @param array|null $row Normalized share row.
885 * @return array
886 */
887 function openstation_files_shape_share( $row ) {
888 if ( ! is_array( $row ) ) {
889 return array();
890 }
891 $shape = array(
892 'id' => (int) $row['id'],
893 'folderId' => (int) $row['folder_id'],
894 'principalType' => (string) $row['principal_type'],
895 'principalRef' => (string) $row['principal_ref'],
896 'capability' => (string) $row['capability'],
897 'state' => (string) $row['state'],
898 'invitedBy' => (int) $row['invited_by'],
899 'invitedAtMs' => (int) $row['invited_at_ms'],
900 'decidedAtMs' => isset( $row['decided_at_ms'] ) ? $row['decided_at_ms'] : null,
901 );
902 if ( 'user' === $row['principal_type'] ) {
903 $uid = (int) $row['principal_ref'];
904 $user = $uid > 0 ? get_userdata( $uid ) : null;
905 $shape['displayName'] = $user ? $user->display_name : '';
906 $shape['avatarUrl'] = $user ? get_avatar_url( $uid, array( 'size' => 48 ) ) : '';
907 } else {
908 $roles = wp_roles();
909 $info = $roles && isset( $roles->roles[ $row['principal_ref'] ] ) ? $roles->roles[ $row['principal_ref'] ] : null;
910 $shape['displayName'] = $info ? translate_user_role( (string) $info['name'] ) : (string) $row['principal_ref'];
911 $shape['avatarUrl'] = '';
912 }
913 return $shape;
914 }
915
916 /**
917 * GET /folders/<id>/shares — owner only.
918 */
919 function openstation_files_rest_list_shares( WP_REST_Request $req ) {
920 $folder_id = (int) $req['id'];
921 $user_id = get_current_user_id();
922 if ( ! openstation_files_share_can_manage( $folder_id, $user_id ) ) {
923 return new WP_Error( 'openstation_files_forbidden', __( 'You cannot view shares for this folder.', 'desktop-mode' ), array( 'status' => 403 ) );
924 }
925 $folder = openstation_files_get_folder( $folder_id );
926 if ( ! $folder ) {
927 return new WP_Error( 'openstation_files_not_found', __( 'Folder not found.', 'desktop-mode' ), array( 'status' => 404 ) );
928 }
929 $rows = openstation_files_get_folder_shares( $folder_id );
930 $out = array();
931 foreach ( $rows as $row ) {
932 $out[] = openstation_files_shape_share( $row );
933 }
934 return rest_ensure_response(
935 array(
936 'shares' => $out,
937 'shareMode' => (string) $folder['share_mode'],
938 'all' => 'all' === (string) $folder['share_mode'],
939 )
940 );
941 }
942
943 /**
944 * POST /folders/<id>/shares — owner only.
945 */
946 function openstation_files_rest_create_share( WP_REST_Request $req ) {
947 $folder_id = (int) $req['id'];
948 $actor_id = get_current_user_id();
949 $id = openstation_folder_share_invite(
950 $folder_id,
951 $actor_id,
952 (string) $req->get_param( 'principalType' ),
953 (string) $req->get_param( 'principalRef' ),
954 (string) $req->get_param( 'capability' )
955 );
956 if ( is_wp_error( $id ) ) {
957 return $id;
958 }
959 return rest_ensure_response( openstation_files_shape_share( openstation_files_get_share( $id ) ) );
960 }
961
962 /**
963 * Verify that the share id in the URL actually belongs to the
964 * folder id in the URL. Returns the loaded share row or a
965 * `WP_Error` (404 unknown share / 404 mismatch). The underlying
966 * mutation functions still gate on the share's true folder, so a
967 * mismatched URL never escalates permission — but the routes are
968 * hierarchical (`/folders/{id}/shares/{shareId}/…`), so honoring
969 * both path segments is the contract callers expect.
970 *
971 * @param WP_REST_Request $req Request.
972 * @return array|WP_Error
973 */
974 function openstation_files_rest_resolve_share_in_folder( WP_REST_Request $req ) {
975 $folder_id = (int) $req['id'];
976 $share_id = (int) $req['shareId'];
977 $share = openstation_files_get_share( $share_id );
978 if ( ! $share ) {
979 return new WP_Error(
980 'openstation_files_not_found',
981 __( 'Share not found.', 'desktop-mode' ),
982 array( 'status' => 404 )
983 );
984 }
985 if ( (int) $share['folder_id'] !== $folder_id ) {
986 return new WP_Error(
987 'openstation_files_not_found',
988 __( 'Share not found in this folder.', 'desktop-mode' ),
989 array( 'status' => 404 )
990 );
991 }
992 return $share;
993 }
994
995 /**
996 * PATCH /folders/<id>/shares/<shareId> — owner only.
997 */
998 function openstation_files_rest_update_share( WP_REST_Request $req ) {
999 $share = openstation_files_rest_resolve_share_in_folder( $req );
1000 if ( is_wp_error( $share ) ) {
1001 return $share;
1002 }
1003 $share_id = (int) $share['id'];
1004 $ok = openstation_folder_share_update_capability( $share_id, get_current_user_id(), (string) $req->get_param( 'capability' ) );
1005 if ( is_wp_error( $ok ) ) {
1006 return $ok;
1007 }
1008 return rest_ensure_response( openstation_files_shape_share( openstation_files_get_share( $share_id ) ) );
1009 }
1010
1011 /**
1012 * DELETE /folders/<id>/shares/<shareId> — owner only.
1013 */
1014 function openstation_files_rest_delete_share( WP_REST_Request $req ) {
1015 $share = openstation_files_rest_resolve_share_in_folder( $req );
1016 if ( is_wp_error( $share ) ) {
1017 return $share;
1018 }
1019 $ok = openstation_folder_share_revoke( (int) $share['id'], get_current_user_id() );
1020 if ( is_wp_error( $ok ) ) {
1021 return $ok;
1022 }
1023 return rest_ensure_response( array( 'deleted' => true ) );
1024 }
1025
1026 /**
1027 * POST /folders/<id>/shares/<shareId>/accept — recipient only.
1028 */
1029 function openstation_files_rest_accept_share( WP_REST_Request $req ) {
1030 $share = openstation_files_rest_resolve_share_in_folder( $req );
1031 if ( is_wp_error( $share ) ) {
1032 return $share;
1033 }
1034 $row = openstation_folder_share_accept( (int) $share['id'], get_current_user_id() );
1035 if ( is_wp_error( $row ) ) {
1036 return $row;
1037 }
1038 return rest_ensure_response( openstation_files_shape_share( $row ) );
1039 }
1040
1041 /**
1042 * POST /folders/<id>/shares/<shareId>/deny — recipient only.
1043 */
1044 function openstation_files_rest_deny_share( WP_REST_Request $req ) {
1045 $share = openstation_files_rest_resolve_share_in_folder( $req );
1046 if ( is_wp_error( $share ) ) {
1047 return $share;
1048 }
1049 $row = openstation_folder_share_deny( (int) $share['id'], get_current_user_id() );
1050 if ( is_wp_error( $row ) ) {
1051 return $row;
1052 }
1053 return rest_ensure_response( openstation_files_shape_share( $row ) );
1054 }
1055
1056 /**
1057 * POST /folders/<id>/leave — recipient-initiated leave.
1058 *
1059 * Unlike `/shares/{id}/deny` which targets a specific share row,
1060 * this endpoint finds whichever grant currently lets the user
1061 * see the folder (user-principal or role-principal) and removes
1062 * their access — for role shares without affecting other role
1063 * members, via the per-user decisions table.
1064 */
1065 function openstation_files_rest_leave_folder( WP_REST_Request $req ) {
1066 $folder_id = (int) $req['id'];
1067 $ok = openstation_folder_share_leave( $folder_id, get_current_user_id() );
1068 if ( is_wp_error( $ok ) ) {
1069 return $ok;
1070 }
1071 return rest_ensure_response( array( 'left' => true ) );
1072 }
1073
1074 /**
1075 * POST /files/folder-sharing-tables/purge — destructive cleanup
1076 * that drops every table the folder-sharing feature ever created
1077 * (current `folder_shares` + `share_user_decisions`, plus any
1078 * future variants enumerated via the
1079 * `openstation_files_sharing_tables_for_purge` filter).
1080 *
1081 * Restricted to `manage_options` by the permission callback. The
1082 * schema-version option is cleared so the next admin-init runs
1083 * `install_schema` and recreates the empty tables — keeps the
1084 * code path that ASSUMES the tables exist (e.g. heartbeat
1085 * delivery queries) working even after a purge.
1086 */
1087 function openstation_files_rest_purge_sharing_tables() {
1088 global $wpdb;
1089 $tables = openstation_files_table_names();
1090
1091 $to_drop = array( $tables['shares'], $tables['decisions'] );
1092 /**
1093 * Filter the list of table names dropped by the
1094 * "Delete folder sharing data" admin action.
1095 *
1096 * @param string[] $tables Default = shares + decisions.
1097 */
1098 $to_drop = (array) apply_filters( 'openstation_files_sharing_tables_for_purge', $to_drop );
1099
1100 $dropped = array();
1101 $skipped = array();
1102 $prefix = (string) $wpdb->prefix;
1103 foreach ( $to_drop as $tbl ) {
1104 $tbl = (string) $tbl;
1105 if ( '' === $tbl ) {
1106 continue;
1107 }
1108 // Defense-in-depth: a misbehaving filter could push any
1109 // string into `$to_drop` and we're about to interpolate
1110 // the value directly into a `DROP TABLE` statement (wpdb
1111 // has no placeholder for identifiers). Two gates:
1112 // 1. Must match the `[A-Za-z0-9_]+` identifier pattern —
1113 // keeps quotes/backticks/spaces out of the SQL even
1114 // if a filter author smuggled them in.
1115 // 2. Must start with the wpdb prefix — keeps a malicious
1116 // filter from dropping system tables (`wp_users`,
1117 // `wp_options`, …) on a multi-prefix install.
1118 if (
1119 ! preg_match( '/^[A-Za-z0-9_]+$/', $tbl ) ||
1120 0 !== strpos( $tbl, $prefix )
1121 ) {
1122 $skipped[] = $tbl;
1123 continue;
1124 }
1125 $prev_suppress = $wpdb->suppress_errors( true );
1126 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1127 $wpdb->query( "DROP TABLE IF EXISTS `{$tbl}`" );
1128 $wpdb->suppress_errors( $prev_suppress );
1129 $dropped[] = $tbl;
1130 }
1131
1132 // Force the next admin-init / rest-init to re-run
1133 // `install_schema` so the tables are recreated empty. Code
1134 // paths that JOIN against them (heartbeat, sharing.php
1135 // visibility) keep working without a per-request existence
1136 // check.
1137 delete_option( OPENSTATION_FILES_SCHEMA_OPTION );
1138
1139 /**
1140 * Fires after the folder-sharing tables are purged. Plugins
1141 * that mirror share state into their own storage can react
1142 * here.
1143 *
1144 * @param string[] $dropped Table names that were dropped.
1145 */
1146 do_action( 'openstation_files_sharing_tables_purged', $dropped );
1147
1148 return rest_ensure_response(
1149 array(
1150 'dropped' => $dropped,
1151 'skipped' => $skipped,
1152 )
1153 );
1154 }
1155
1156 /**
1157 * Permission gate for /users/search. Requires `edit_posts` —
1158 * `openstation_files_rest_permission` would let any logged-in
1159 * openstation user pull the directory, which is too broad for an
1160 * autocomplete that exposes display names + emails.
1161 */
1162 function openstation_files_rest_search_users_permission() {
1163 $base = openstation_files_rest_permission();
1164 if ( is_wp_error( $base ) ) {
1165 return $base;
1166 }
1167 if ( ! current_user_can( 'edit_posts' ) ) {
1168 return new WP_Error( 'openstation_files_forbidden', __( 'You cannot search users.', 'desktop-mode' ), array( 'status' => 403 ) );
1169 }
1170 return true;
1171 }
1172
1173 /**
1174 * GET /files/users/search?q=<>&exclude=<csv> — autocomplete for the
1175 * folder share picker.
1176 */
1177 function openstation_files_rest_search_users( WP_REST_Request $req ) {
1178 $q = trim( (string) $req->get_param( 'q' ) );
1179 $exclude = array_filter( array_map( 'intval', explode( ',', (string) $req->get_param( 'exclude' ) ) ) );
1180
1181 // Always exclude the current viewer — sharing with yourself is
1182 // a no-op the modal already rejects, no point spending a slot
1183 // in the dropdown on it.
1184 $exclude[] = (int) get_current_user_id();
1185 $exclude = array_values( array_unique( array_filter( $exclude ) ) );
1186
1187 $args = array(
1188 'number' => 20,
1189 'orderby' => 'display_name',
1190 'order' => 'ASC',
1191 'exclude' => $exclude,
1192 // `fields => 'all'` returns full WP_User objects so the
1193 // capability check below resolves role caps correctly. A
1194 // stdClass with stripped fields breaks `user_can()` on
1195 // some WordPress versions and silently drops every row.
1196 'fields' => 'all',
1197 );
1198 if ( '' !== $q ) {
1199 $args['search'] = '*' . $q . '*';
1200 $args['search_columns'] = array( 'user_login', 'user_email', 'display_name', 'user_nicename' );
1201 }
1202
1203 /**
1204 * Filter the WP_User_Query args used by the share picker.
1205 *
1206 * @param array $args Default args.
1207 * @param array $req Request params (`q`, `exclude`).
1208 */
1209 $args = (array) apply_filters( 'openstation_files_share_user_query_args', $args, $req->get_params() );
1210
1211 $query = new WP_User_Query( $args );
1212 $users = $query->get_results();
1213 $out = array();
1214 foreach ( (array) $users as $user ) {
1215 if ( ! user_can( $user, 'edit_posts' ) ) {
1216 continue;
1217 }
1218 // Disambiguation handle uses `user_nicename` (the public
1219 // URL slug) instead of `user_login` — the login is the auth
1220 // credential and exposing it to every `edit_posts` user is
1221 // broader than needed for a share picker. Matches the
1222 // `slug` field WP's own `/wp/v2/users` endpoint surfaces.
1223 $out[] = array(
1224 'id' => (int) $user->ID,
1225 'name' => (string) $user->display_name,
1226 'slug' => (string) $user->user_nicename,
1227 'avatarUrl' => get_avatar_url( $user->ID, array( 'size' => 48 ) ),
1228 );
1229 }
1230 return rest_ensure_response( array( 'users' => $out ) );
1231 }
1232