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

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