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

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

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