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

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

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