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

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

1,211 lines 40.0 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 * GET /placements
414 */
415 function openstation_files_rest_list_placements( WP_REST_Request $req ) {
416 $user_id = get_current_user_id();
417 $parent_id = (int) $req->get_param( 'folder' );
418 // Self-healing backfill — see
419 // `openstation_files_auto_place_orphan_folders` for the why.
420 // Only runs at the root because that's the only context where
421 // auto-placing an orphan folder as a tile is unambiguous.
422 if ( 0 === $parent_id ) {
423 openstation_files_auto_place_orphans( $user_id );
424 }
425 $rows = openstation_files_get_for_user_folder( $user_id, $parent_id );
426 $out = array();
427 foreach ( $rows as $row ) {
428 $out[] = openstation_files_shape_placement( $row );
429 }
430 return rest_ensure_response(
431 array(
432 'placements' => $out,
433 'folderId' => $parent_id,
434 )
435 );
436 }
437
438 /**
439 * POST /placements
440 */
441 function openstation_files_rest_create_placement( WP_REST_Request $req ) {
442 $type = (string) $req->get_param( 'type' );
443 $ref = (string) $req->get_param( 'ref' );
444 $meta = $req->get_param( 'meta' );
445
446 // `link` placements get a server-resolved favicon stuffed onto
447 // `meta.iconUrl` so the tile renderer can paint it without the
448 // browser making a third-party request on every render. Other
449 // types skip the resolver entirely (no extra fetch latency).
450 if ( 'link' === $type && '' !== $ref ) {
451 $icon_data_uri = openstation_resolve_favicon( $ref );
452 if ( is_string( $icon_data_uri ) && '' !== $icon_data_uri ) {
453 $meta_arr = is_array( $meta ) ? $meta : array();
454 $meta_arr['iconUrl'] = $icon_data_uri;
455 $meta = $meta_arr;
456 }
457 }
458
459 $id = openstation_files_place(
460 get_current_user_id(),
461 (int) $req->get_param( 'parentId' ),
462 $type,
463 $ref,
464 array(
465 'x' => (int) $req->get_param( 'x' ),
466 'y' => (int) $req->get_param( 'y' ),
467 'sort_order' => (int) $req->get_param( 'sortOrder' ),
468 'meta' => $meta,
469 )
470 );
471 if ( is_wp_error( $id ) ) {
472 return $id;
473 }
474 $row = openstation_files_get_placement( $id );
475 return rest_ensure_response( openstation_files_shape_placement( $row ) );
476 }
477
478 /**
479 * PATCH /placements/<id>
480 */
481 function openstation_files_rest_update_placement( WP_REST_Request $req ) {
482 $id = (int) $req['id'];
483 $json = $req->get_json_params();
484 $body = $json ? $json : $req->get_params();
485 $current = openstation_files_get_placement( $id );
486 if ( ! $current ) {
487 return new WP_Error( 'openstation_files_not_found', __( 'Placement not found.', 'desktop-mode' ), array( 'status' => 404 ) );
488 }
489 $conflict = openstation_files_check_if_match( (int) $current['updated_at_ms'], $req, $current );
490 if ( is_wp_error( $conflict ) ) {
491 return $conflict;
492 }
493 $changes = array();
494 foreach ( array(
495 'parentId' => 'parent_id',
496 'x' => 'x',
497 'y' => 'y',
498 'sortOrder' => 'sort_order',
499 'meta' => 'meta',
500 ) as $in => $col ) {
501 if ( array_key_exists( $in, $body ) ) {
502 $changes[ $col ] = $body[ $in ];
503 }
504 }
505 $ok = openstation_files_move( $id, get_current_user_id(), $changes );
506 if ( is_wp_error( $ok ) ) {
507 return $ok;
508 }
509 return rest_ensure_response( openstation_files_shape_placement( openstation_files_get_placement( $id ) ) );
510 }
511
512 /**
513 * DELETE /placements/<id>
514 */
515 function openstation_files_rest_delete_placement( WP_REST_Request $req ) {
516 $id = (int) $req['id'];
517 $user_id = get_current_user_id();
518 // `force=1` query param permanently deletes (purges the row).
519 // Default DELETE soft-trashes — the row lands in the recycle
520 // bin and the user can restore. Same convention WP core REST
521 // uses on every other resource.
522 $force = '1' === (string) $req->get_param( 'force' )
523 || true === $req->get_param( 'force' );
524 $ok = $force
525 ? openstation_files_purge_placement( $user_id, $id )
526 : openstation_files_trash_placement( $user_id, $id );
527 if ( is_wp_error( $ok ) ) {
528 return $ok;
529 }
530 return rest_ensure_response(
531 array(
532 'deleted' => true,
533 'force' => $force,
534 )
535 );
536 }
537
538 /**
539 * GET /folders
540 */
541 function openstation_files_rest_list_folders() {
542 $rows = openstation_files_get_visible_folders( get_current_user_id() );
543 $out = array();
544 foreach ( $rows as $row ) {
545 $out[] = openstation_files_shape_folder( $row );
546 }
547 return rest_ensure_response( array( 'folders' => $out ) );
548 }
549
550 /**
551 * POST /folders
552 */
553 function openstation_files_rest_create_folder( WP_REST_Request $req ) {
554 $id = openstation_files_create_folder(
555 get_current_user_id(),
556 array(
557 'name' => (string) $req->get_param( 'name' ),
558 'share_mode' => (string) $req->get_param( 'shareMode' ),
559 'share_meta' => $req->get_param( 'shareMeta' ),
560 )
561 );
562 if ( is_wp_error( $id ) ) {
563 return $id;
564 }
565 return rest_ensure_response( openstation_files_shape_folder( openstation_files_get_folder( $id ) ) );
566 }
567
568 /**
569 * PATCH /folders/<id>
570 */
571 function openstation_files_rest_update_folder( WP_REST_Request $req ) {
572 $id = (int) $req['id'];
573 $json = $req->get_json_params();
574 $body = $json ? $json : $req->get_params();
575 $current = openstation_files_get_folder( $id );
576 if ( ! $current ) {
577 return new WP_Error( 'openstation_files_not_found', __( 'Folder not found.', 'desktop-mode' ), array( 'status' => 404 ) );
578 }
579 $conflict = openstation_files_check_if_match( (int) $current['updated_at_ms'], $req, $current );
580 if ( is_wp_error( $conflict ) ) {
581 return $conflict;
582 }
583 $changes = array();
584 foreach ( array(
585 'name' => 'name',
586 'shareMode' => 'share_mode',
587 'shareMeta' => 'share_meta',
588 ) as $in => $col ) {
589 if ( array_key_exists( $in, $body ) ) {
590 $changes[ $col ] = $body[ $in ];
591 }
592 }
593 $ok = openstation_files_update_folder( $id, get_current_user_id(), $changes );
594 if ( is_wp_error( $ok ) ) {
595 return $ok;
596 }
597 return rest_ensure_response( openstation_files_shape_folder( openstation_files_get_folder( $id ) ) );
598 }
599
600 /**
601 * DELETE /folders/<id>
602 */
603 function openstation_files_rest_delete_folder( WP_REST_Request $req ) {
604 $id = (int) $req['id'];
605 $user_id = get_current_user_id();
606 $force = '1' === (string) $req->get_param( 'force' )
607 || true === $req->get_param( 'force' );
608 // Default DELETE soft-trashes the folder + cascades to child
609 // placements (see `openstation_files_trash_folder`). `force=1`
610 // permanently deletes both the folder row AND every child
611 // placement that was trashed via the cascade.
612 $ok = $force
613 ? openstation_files_purge_folder( $user_id, $id )
614 : openstation_files_trash_folder( $user_id, $id );
615 if ( is_wp_error( $ok ) ) {
616 return $ok;
617 }
618 return rest_ensure_response(
619 array(
620 'deleted' => true,
621 'force' => $force,
622 )
623 );
624 }
625
626 /**
627 * PUT /associations — replaces the entire user-association map.
628 */
629 function openstation_files_rest_save_associations( WP_REST_Request $req ) {
630 $assoc = (array) $req->get_param( 'associations' );
631 $clean = array();
632 foreach ( $assoc as $type => $opener_id ) {
633 $type = sanitize_key( (string) $type );
634 $opener_id = sanitize_key( (string) $opener_id );
635 if ( '' === $type || '' === $opener_id ) {
636 continue;
637 }
638 $clean[ $type ] = $opener_id;
639 }
640 update_user_meta( get_current_user_id(), OPENSTATION_FILE_ASSOCIATIONS_META, $clean );
641 return rest_ensure_response(
642 array(
643 'associations' => openstation_get_user_file_associations( get_current_user_id() ),
644 )
645 );
646 }
647
648 /**
649 * Shape a placement row for the wire — converts snake_case to
650 * camelCase and merges in the resolved `OpenStation_File`
651 * shape so the JS side can render without a second fetch.
652 *
653 * @param array|null $row Normalized placement row.
654 * @return array
655 */
656 function openstation_files_shape_placement( $row ) {
657 if ( ! is_array( $row ) ) {
658 return array();
659 }
660 // Access-gated rows (shared-folder view, viewer lacks read on
661 // the underlying entity) get a redacted shape — the viewer may
662 // learn THAT the owner placed something here, not WHAT it is.
663 // Skipping the resolver keeps entity metadata (title, permalink,
664 // status, roles, …) from crossing the read-access boundary; the
665 // tile renderer paints the lock overlay off `accessGated`.
666 $access_gated = ! empty( $row['access_gated'] );
667 if ( $access_gated ) {
668 $shape = array(
669 'type' => $row['file_type'],
670 'ref' => $row['file_ref'],
671 'title' => __( 'Restricted item', 'desktop-mode' ),
672 'icon' => 'dashicons-lock',
673 'previewUrl' => '',
674 'exists' => true,
675 );
676 } else {
677 $file = openstation_resolve_file( $row['file_type'], $row['file_ref'] );
678 $shape = $file ? $file->serialize() : array(
679 'type' => $row['file_type'],
680 'ref' => $row['file_ref'],
681 'title' => '',
682 'icon' => 'dashicons-warning',
683 'previewUrl' => '',
684 'exists' => false,
685 );
686 }
687 // `canTrash` carries the server's answer to "can the viewer
688 // move this placement to the recycle bin?" so the client can
689 // proactively suppress the trash affordance — both the tile's
690 // right-click "Move to recycle bin" menu item and the trash
691 // drop target's accept-check. Without it, the only feedback for
692 // a forbidden drop was a 403 logged to the console, leaving the
693 // user staring at a tile that wouldn't move. Falls back to
694 // `false` when the helper isn't loaded (defensive — early-boot
695 // REST calls before trash.php is required can't grant permission
696 // they don't know about).
697 $viewer_id = (int) get_current_user_id();
698 $can_trash = false;
699 if ( $viewer_id > 0 && function_exists( 'openstation_files_user_can_trash_placement' ) ) {
700 $can_trash = openstation_files_user_can_trash_placement( $viewer_id, $row );
701 }
702
703 return array(
704 'id' => (int) $row['id'],
705 'parentId' => (int) $row['parent_id'],
706 'x' => (int) $row['x'],
707 'y' => (int) $row['y'],
708 'sortOrder' => (int) $row['sort_order'],
709 'updatedAtMs' => (int) $row['updated_at_ms'],
710 'meta' => isset( $row['meta'] ) ? $row['meta'] : null,
711 'file' => $shape,
712 // `accessGated` is true when the viewer can't read the
713 // underlying entity but the placement is shown anyway (the
714 // shared-folder-view UX). Tile renderer surfaces it as a
715 // lock overlay + tooltip; the `file` shape above is redacted.
716 'accessGated' => $access_gated,
717 'canTrash' => $can_trash,
718 );
719 }
720
721 /**
722 * @param array|null $row Folder row.
723 * @return array
724 */
725 function openstation_files_shape_folder( $row ) {
726 if ( ! is_array( $row ) ) {
727 return array();
728 }
729 $shape = array(
730 'id' => (int) $row['id'],
731 'ownerId' => (int) $row['owner_id'],
732 'name' => (string) $row['name'],
733 'shareMode' => (string) $row['share_mode'],
734 'shareMeta' => isset( $row['share_meta'] ) ? $row['share_meta'] : null,
735 'updatedAtMs' => (int) $row['updated_at_ms'],
736 );
737 if ( function_exists( 'openstation_files_get_folder_shares' ) ) {
738 $shares = openstation_files_get_folder_shares( (int) $row['id'] );
739 $accepted_count = 0;
740 $has_all = 'all' === (string) $row['share_mode'];
741 foreach ( $shares as $s ) {
742 if ( 'accepted' === $s['state'] ) {
743 ++$accepted_count;
744 }
745 }
746 // `shared` is viewer-agnostic — recipients need it for the
747 // shared-folder badge. The recipient COUNT is owner-internal
748 // (the dedicated shares endpoint gates the full roster on
749 // `share_can_manage`), so only managers get the real number;
750 // every other viewer sees `0`, keeping the wire shape stable.
751 $can_manage = function_exists( 'openstation_files_share_can_manage' )
752 && openstation_files_share_can_manage( (int) $row['id'], get_current_user_id() );
753 $shape['shareSummary'] = array(
754 'shared' => $has_all || $accepted_count > 0,
755 'recipientCount' => $can_manage ? $accepted_count + ( $has_all ? 1 : 0 ) : 0,
756 );
757 }
758 return $shape;
759 }
760
761 /**
762 * Conditional-write helper. Reads `If-Match` from the request and
763 * returns a 409 `WP_Error` when the stored row's `updated_at_ms`
764 * doesn't match the supplied value. Returns null in every other
765 * case (header absent → back-compat last-write-wins; header
766 * matches → caller proceeds).
767 *
768 * The 409 body carries a structured `data` payload the client
769 * surfaces as a toast: `{ reason, actor: { id,name,avatar },
770 * current: { parentId, parentName, updatedAtMs } }`.
771 *
772 * @param int $current_ms Current `updated_at_ms` on the row.
773 * @param WP_REST_Request $req Inbound request.
774 * @param array $row Normalized row (placement or folder).
775 * @return WP_Error|null
776 */
777 function openstation_files_check_if_match( $current_ms, WP_REST_Request $req, $row ) {
778 $header = $req->get_header( 'if_match' );
779 if ( null === $header || '' === $header ) {
780 return null;
781 }
782 $expected = (int) trim( str_replace( '"', '', (string) $header ) );
783 if ( $expected === (int) $current_ms ) {
784 return null;
785 }
786 // Prefer `updated_by` (v10+) so the conflict toast attributes
787 // the change to the SESSION that won the race. Falls back to
788 // `owner_id` (placement creator / folder owner) for legacy
789 // rows from before the v10 `updated_by` column was added — in
790 // a non-shared-write workflow that still happens to be the
791 // right person; in shared-write the toast may be slightly
792 // misleading for the lifetime of pre-v10 rows. New mutations
793 // stamp the column accurately. See
794 // `openstation_files_ensure_updated_by_column`.
795 $actor_id = 0;
796 if ( isset( $row['updated_by'] ) && (int) $row['updated_by'] > 0 ) {
797 $actor_id = (int) $row['updated_by'];
798 } elseif ( isset( $row['owner_id'] ) ) {
799 $actor_id = (int) $row['owner_id'];
800 }
801 $actor = $actor_id ? get_userdata( $actor_id ) : null;
802
803 $parent_id = isset( $row['parent_id'] ) ? (int) $row['parent_id'] : 0;
804 $parent_name = '';
805 if ( $parent_id > 0 ) {
806 $parent_folder = openstation_files_get_folder( $parent_id );
807 $parent_name = $parent_folder ? (string) $parent_folder['name'] : '';
808 }
809
810 $reason = 'parent_changed';
811 if ( ! empty( $row['trashed_at_ms'] ) ) {
812 $reason = 'trashed';
813 }
814
815 // PII gate. The conflict toast names the actor (display name +
816 // avatar) and the row's parent folder only when the requesting
817 // viewer is in the same collaboration scope as the actor — i.e.
818 // owns the row, owns the parent folder, or has at least read
819 // access to the parent folder via the shares table. For any
820 // other viewer the actor degrades to a generic "another
821 // session" — `id: 0`, empty name + avatar — and `current`
822 // drops the parent id/name, so a write attempt can't be used
823 // to enumerate other users' display names or folder names
824 // (this check runs BEFORE the store's ownership gate, so the
825 // 409 body must not leak what the later 403 would protect).
826 $viewer_id = (int) get_current_user_id();
827 $viewer_owns_row = isset( $row['owner_id'] ) && (int) $row['owner_id'] === $viewer_id;
828 $viewer_can_see = $viewer_owns_row;
829 if ( ! $viewer_can_see && $parent_id > 0 && isset( $parent_folder ) && $parent_folder ) {
830 if ( (int) $parent_folder['owner_id'] === $viewer_id ) {
831 $viewer_can_see = true;
832 } elseif ( function_exists( 'openstation_folder_share_user_capability' ) ) {
833 $viewer_can_see = 'none' !== openstation_folder_share_user_capability( $parent_id, $viewer_id );
834 }
835 }
836 $actor_payload = array(
837 'id' => $viewer_can_see ? $actor_id : 0,
838 'name' => $viewer_can_see && $actor ? $actor->display_name : '',
839 'avatar' => $viewer_can_see && $actor ? get_avatar_url( $actor->ID, array( 'size' => 32 ) ) : '',
840 );
841
842 return new WP_Error(
843 'openstation_files_conflict',
844 __( 'This row was changed by another session.', 'desktop-mode' ),
845 array(
846 'status' => 409,
847 'data' => array(
848 'reason' => $reason,
849 'actor' => $actor_payload,
850 'current' => array(
851 'parentId' => $viewer_can_see ? $parent_id : 0,
852 'parentName' => $viewer_can_see ? $parent_name : '',
853 'updatedAtMs' => (int) $current_ms,
854 ),
855 ),
856 )
857 );
858 }
859
860 /**
861 * Shape a share row for the wire.
862 *
863 * @param array|null $row Normalized share row.
864 * @return array
865 */
866 function openstation_files_shape_share( $row ) {
867 if ( ! is_array( $row ) ) {
868 return array();
869 }
870 $shape = array(
871 'id' => (int) $row['id'],
872 'folderId' => (int) $row['folder_id'],
873 'principalType' => (string) $row['principal_type'],
874 'principalRef' => (string) $row['principal_ref'],
875 'capability' => (string) $row['capability'],
876 'state' => (string) $row['state'],
877 'invitedBy' => (int) $row['invited_by'],
878 'invitedAtMs' => (int) $row['invited_at_ms'],
879 'decidedAtMs' => isset( $row['decided_at_ms'] ) ? $row['decided_at_ms'] : null,
880 );
881 if ( 'user' === $row['principal_type'] ) {
882 $uid = (int) $row['principal_ref'];
883 $user = $uid > 0 ? get_userdata( $uid ) : null;
884 $shape['displayName'] = $user ? $user->display_name : '';
885 $shape['avatarUrl'] = $user ? get_avatar_url( $uid, array( 'size' => 48 ) ) : '';
886 } else {
887 $roles = wp_roles();
888 $info = $roles && isset( $roles->roles[ $row['principal_ref'] ] ) ? $roles->roles[ $row['principal_ref'] ] : null;
889 $shape['displayName'] = $info ? translate_user_role( (string) $info['name'] ) : (string) $row['principal_ref'];
890 $shape['avatarUrl'] = '';
891 }
892 return $shape;
893 }
894
895 /**
896 * GET /folders/<id>/shares — owner only.
897 */
898 function openstation_files_rest_list_shares( WP_REST_Request $req ) {
899 $folder_id = (int) $req['id'];
900 $user_id = get_current_user_id();
901 if ( ! openstation_files_share_can_manage( $folder_id, $user_id ) ) {
902 return new WP_Error( 'openstation_files_forbidden', __( 'You cannot view shares for this folder.', 'desktop-mode' ), array( 'status' => 403 ) );
903 }
904 $folder = openstation_files_get_folder( $folder_id );
905 if ( ! $folder ) {
906 return new WP_Error( 'openstation_files_not_found', __( 'Folder not found.', 'desktop-mode' ), array( 'status' => 404 ) );
907 }
908 $rows = openstation_files_get_folder_shares( $folder_id );
909 $out = array();
910 foreach ( $rows as $row ) {
911 $out[] = openstation_files_shape_share( $row );
912 }
913 return rest_ensure_response(
914 array(
915 'shares' => $out,
916 'shareMode' => (string) $folder['share_mode'],
917 'all' => 'all' === (string) $folder['share_mode'],
918 )
919 );
920 }
921
922 /**
923 * POST /folders/<id>/shares — owner only.
924 */
925 function openstation_files_rest_create_share( WP_REST_Request $req ) {
926 $folder_id = (int) $req['id'];
927 $actor_id = get_current_user_id();
928 $id = openstation_folder_share_invite(
929 $folder_id,
930 $actor_id,
931 (string) $req->get_param( 'principalType' ),
932 (string) $req->get_param( 'principalRef' ),
933 (string) $req->get_param( 'capability' )
934 );
935 if ( is_wp_error( $id ) ) {
936 return $id;
937 }
938 return rest_ensure_response( openstation_files_shape_share( openstation_files_get_share( $id ) ) );
939 }
940
941 /**
942 * Verify that the share id in the URL actually belongs to the
943 * folder id in the URL. Returns the loaded share row or a
944 * `WP_Error` (404 unknown share / 404 mismatch). The underlying
945 * mutation functions still gate on the share's true folder, so a
946 * mismatched URL never escalates permission — but the routes are
947 * hierarchical (`/folders/{id}/shares/{shareId}/…`), so honoring
948 * both path segments is the contract callers expect.
949 *
950 * @param WP_REST_Request $req Request.
951 * @return array|WP_Error
952 */
953 function openstation_files_rest_resolve_share_in_folder( WP_REST_Request $req ) {
954 $folder_id = (int) $req['id'];
955 $share_id = (int) $req['shareId'];
956 $share = openstation_files_get_share( $share_id );
957 if ( ! $share ) {
958 return new WP_Error(
959 'openstation_files_not_found',
960 __( 'Share not found.', 'desktop-mode' ),
961 array( 'status' => 404 )
962 );
963 }
964 if ( (int) $share['folder_id'] !== $folder_id ) {
965 return new WP_Error(
966 'openstation_files_not_found',
967 __( 'Share not found in this folder.', 'desktop-mode' ),
968 array( 'status' => 404 )
969 );
970 }
971 return $share;
972 }
973
974 /**
975 * PATCH /folders/<id>/shares/<shareId> — owner only.
976 */
977 function openstation_files_rest_update_share( WP_REST_Request $req ) {
978 $share = openstation_files_rest_resolve_share_in_folder( $req );
979 if ( is_wp_error( $share ) ) {
980 return $share;
981 }
982 $share_id = (int) $share['id'];
983 $ok = openstation_folder_share_update_capability( $share_id, get_current_user_id(), (string) $req->get_param( 'capability' ) );
984 if ( is_wp_error( $ok ) ) {
985 return $ok;
986 }
987 return rest_ensure_response( openstation_files_shape_share( openstation_files_get_share( $share_id ) ) );
988 }
989
990 /**
991 * DELETE /folders/<id>/shares/<shareId> — owner only.
992 */
993 function openstation_files_rest_delete_share( WP_REST_Request $req ) {
994 $share = openstation_files_rest_resolve_share_in_folder( $req );
995 if ( is_wp_error( $share ) ) {
996 return $share;
997 }
998 $ok = openstation_folder_share_revoke( (int) $share['id'], get_current_user_id() );
999 if ( is_wp_error( $ok ) ) {
1000 return $ok;
1001 }
1002 return rest_ensure_response( array( 'deleted' => true ) );
1003 }
1004
1005 /**
1006 * POST /folders/<id>/shares/<shareId>/accept — recipient only.
1007 */
1008 function openstation_files_rest_accept_share( WP_REST_Request $req ) {
1009 $share = openstation_files_rest_resolve_share_in_folder( $req );
1010 if ( is_wp_error( $share ) ) {
1011 return $share;
1012 }
1013 $row = openstation_folder_share_accept( (int) $share['id'], get_current_user_id() );
1014 if ( is_wp_error( $row ) ) {
1015 return $row;
1016 }
1017 return rest_ensure_response( openstation_files_shape_share( $row ) );
1018 }
1019
1020 /**
1021 * POST /folders/<id>/shares/<shareId>/deny — recipient only.
1022 */
1023 function openstation_files_rest_deny_share( WP_REST_Request $req ) {
1024 $share = openstation_files_rest_resolve_share_in_folder( $req );
1025 if ( is_wp_error( $share ) ) {
1026 return $share;
1027 }
1028 $row = openstation_folder_share_deny( (int) $share['id'], get_current_user_id() );
1029 if ( is_wp_error( $row ) ) {
1030 return $row;
1031 }
1032 return rest_ensure_response( openstation_files_shape_share( $row ) );
1033 }
1034
1035 /**
1036 * POST /folders/<id>/leave — recipient-initiated leave.
1037 *
1038 * Unlike `/shares/{id}/deny` which targets a specific share row,
1039 * this endpoint finds whichever grant currently lets the user
1040 * see the folder (user-principal or role-principal) and removes
1041 * their access — for role shares without affecting other role
1042 * members, via the per-user decisions table.
1043 */
1044 function openstation_files_rest_leave_folder( WP_REST_Request $req ) {
1045 $folder_id = (int) $req['id'];
1046 $ok = openstation_folder_share_leave( $folder_id, get_current_user_id() );
1047 if ( is_wp_error( $ok ) ) {
1048 return $ok;
1049 }
1050 return rest_ensure_response( array( 'left' => true ) );
1051 }
1052
1053 /**
1054 * POST /files/folder-sharing-tables/purge — destructive cleanup
1055 * that drops every table the folder-sharing feature ever created
1056 * (current `folder_shares` + `share_user_decisions`, plus any
1057 * future variants enumerated via the
1058 * `openstation_files_sharing_tables_for_purge` filter).
1059 *
1060 * Restricted to `manage_options` by the permission callback. The
1061 * schema-version option is cleared so the next admin-init runs
1062 * `install_schema` and recreates the empty tables — keeps the
1063 * code path that ASSUMES the tables exist (e.g. heartbeat
1064 * delivery queries) working even after a purge.
1065 */
1066 function openstation_files_rest_purge_sharing_tables() {
1067 global $wpdb;
1068 $tables = openstation_files_table_names();
1069
1070 $to_drop = array( $tables['shares'], $tables['decisions'] );
1071 /**
1072 * Filter the list of table names dropped by the
1073 * "Delete folder sharing data" admin action.
1074 *
1075 * @param string[] $tables Default = shares + decisions.
1076 */
1077 $to_drop = (array) apply_filters( 'openstation_files_sharing_tables_for_purge', $to_drop );
1078
1079 $dropped = array();
1080 $skipped = array();
1081 $prefix = (string) $wpdb->prefix;
1082 foreach ( $to_drop as $tbl ) {
1083 $tbl = (string) $tbl;
1084 if ( '' === $tbl ) {
1085 continue;
1086 }
1087 // Defense-in-depth: a misbehaving filter could push any
1088 // string into `$to_drop` and we're about to interpolate
1089 // the value directly into a `DROP TABLE` statement (wpdb
1090 // has no placeholder for identifiers). Two gates:
1091 // 1. Must match the `[A-Za-z0-9_]+` identifier pattern —
1092 // keeps quotes/backticks/spaces out of the SQL even
1093 // if a filter author smuggled them in.
1094 // 2. Must start with the wpdb prefix — keeps a malicious
1095 // filter from dropping system tables (`wp_users`,
1096 // `wp_options`, …) on a multi-prefix install.
1097 if (
1098 ! preg_match( '/^[A-Za-z0-9_]+$/', $tbl ) ||
1099 0 !== strpos( $tbl, $prefix )
1100 ) {
1101 $skipped[] = $tbl;
1102 continue;
1103 }
1104 $prev_suppress = $wpdb->suppress_errors( true );
1105 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1106 $wpdb->query( "DROP TABLE IF EXISTS `{$tbl}`" );
1107 $wpdb->suppress_errors( $prev_suppress );
1108 $dropped[] = $tbl;
1109 }
1110
1111 // Force the next admin-init / rest-init to re-run
1112 // `install_schema` so the tables are recreated empty. Code
1113 // paths that JOIN against them (heartbeat, sharing.php
1114 // visibility) keep working without a per-request existence
1115 // check.
1116 delete_option( OPENSTATION_FILES_SCHEMA_OPTION );
1117
1118 /**
1119 * Fires after the folder-sharing tables are purged. Plugins
1120 * that mirror share state into their own storage can react
1121 * here.
1122 *
1123 * @param string[] $dropped Table names that were dropped.
1124 */
1125 do_action( 'openstation_files_sharing_tables_purged', $dropped );
1126
1127 return rest_ensure_response(
1128 array(
1129 'dropped' => $dropped,
1130 'skipped' => $skipped,
1131 )
1132 );
1133 }
1134
1135 /**
1136 * Permission gate for /users/search. Requires `edit_posts` —
1137 * `openstation_files_rest_permission` would let any logged-in
1138 * openstation user pull the directory, which is too broad for an
1139 * autocomplete that exposes display names + emails.
1140 */
1141 function openstation_files_rest_search_users_permission() {
1142 $base = openstation_files_rest_permission();
1143 if ( is_wp_error( $base ) ) {
1144 return $base;
1145 }
1146 if ( ! current_user_can( 'edit_posts' ) ) {
1147 return new WP_Error( 'openstation_files_forbidden', __( 'You cannot search users.', 'desktop-mode' ), array( 'status' => 403 ) );
1148 }
1149 return true;
1150 }
1151
1152 /**
1153 * GET /files/users/search?q=<>&exclude=<csv> — autocomplete for the
1154 * folder share picker.
1155 */
1156 function openstation_files_rest_search_users( WP_REST_Request $req ) {
1157 $q = trim( (string) $req->get_param( 'q' ) );
1158 $exclude = array_filter( array_map( 'intval', explode( ',', (string) $req->get_param( 'exclude' ) ) ) );
1159
1160 // Always exclude the current viewer — sharing with yourself is
1161 // a no-op the modal already rejects, no point spending a slot
1162 // in the dropdown on it.
1163 $exclude[] = (int) get_current_user_id();
1164 $exclude = array_values( array_unique( array_filter( $exclude ) ) );
1165
1166 $args = array(
1167 'number' => 20,
1168 'orderby' => 'display_name',
1169 'order' => 'ASC',
1170 'exclude' => $exclude,
1171 // `fields => 'all'` returns full WP_User objects so the
1172 // capability check below resolves role caps correctly. A
1173 // stdClass with stripped fields breaks `user_can()` on
1174 // some WordPress versions and silently drops every row.
1175 'fields' => 'all',
1176 );
1177 if ( '' !== $q ) {
1178 $args['search'] = '*' . $q . '*';
1179 $args['search_columns'] = array( 'user_login', 'user_email', 'display_name', 'user_nicename' );
1180 }
1181
1182 /**
1183 * Filter the WP_User_Query args used by the share picker.
1184 *
1185 * @param array $args Default args.
1186 * @param array $req Request params (`q`, `exclude`).
1187 */
1188 $args = (array) apply_filters( 'openstation_files_share_user_query_args', $args, $req->get_params() );
1189
1190 $query = new WP_User_Query( $args );
1191 $users = $query->get_results();
1192 $out = array();
1193 foreach ( (array) $users as $user ) {
1194 if ( ! user_can( $user, 'edit_posts' ) ) {
1195 continue;
1196 }
1197 // Disambiguation handle uses `user_nicename` (the public
1198 // URL slug) instead of `user_login` — the login is the auth
1199 // credential and exposing it to every `edit_posts` user is
1200 // broader than needed for a share picker. Matches the
1201 // `slug` field WP's own `/wp/v2/users` endpoint surfaces.
1202 $out[] = array(
1203 'id' => (int) $user->ID,
1204 'name' => (string) $user->display_name,
1205 'slug' => (string) $user->user_nicename,
1206 'avatarUrl' => get_avatar_url( $user->ID, array( 'size' => 48 ) ),
1207 );
1208 }
1209 return rest_ensure_response( array( 'users' => $out ) );
1210 }
1211