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

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