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

1,043 lines 35.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — Files REST routes.
4 *
5 * Routes under `/desktop-mode/v1/files`:
6 *
7 * GET /placements?folder=<id> List the viewer's placements
8 * under `<id>` (0 for desktop root).
9 * POST /placements Create a placement.
10 * PATCH /placements/(?P<id>\d+) Move / update a placement.
11 * DELETE /placements/(?P<id>\d+) Remove a placement.
12 *
13 * GET /folders List folders visible to the viewer.
14 * POST /folders Create a folder.
15 * PATCH /folders/(?P<id>\d+) Update a folder.
16 * DELETE /folders/(?P<id>\d+) Delete a folder.
17 *
18 * 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['user_id'] ) ) {
618 $actor_id = (int) $row['user_id'];
619 } elseif ( isset( $row['owner_id'] ) ) {
620 $actor_id = (int) $row['owner_id'];
621 }
622 $actor = $actor_id ? get_userdata( $actor_id ) : null;
623
624 $parent_id = isset( $row['parent_id'] ) ? (int) $row['parent_id'] : 0;
625 $parent_name = '';
626 if ( $parent_id > 0 ) {
627 $parent_folder = desktop_mode_files_get_folder( $parent_id );
628 $parent_name = $parent_folder ? (string) $parent_folder['name'] : '';
629 }
630
631 $reason = 'parent_changed';
632 if ( ! empty( $row['trashed_at_ms'] ) ) {
633 $reason = 'trashed';
634 }
635
636 // PII gate. The conflict toast names the actor (display name +
637 // avatar) only when the requesting viewer is in the same
638 // collaboration scope as the actor — i.e. owns the row, owns
639 // the parent folder, or has at least read access to the parent
640 // folder via the shares table. For any other viewer the actor
641 // degrades to a generic "another session" — `id: 0`, empty
642 // name + avatar — so a write attempt can't be used to enumerate
643 // other users' display names.
644 $viewer_id = (int) get_current_user_id();
645 $viewer_owns_row = isset( $row['user_id'] ) && (int) $row['user_id'] === $viewer_id;
646 $viewer_can_see = $viewer_owns_row;
647 if ( ! $viewer_can_see && $parent_id > 0 && isset( $parent_folder ) && $parent_folder ) {
648 if ( (int) $parent_folder['owner_id'] === $viewer_id ) {
649 $viewer_can_see = true;
650 } elseif ( function_exists( 'desktop_mode_folder_share_user_capability' ) ) {
651 $viewer_can_see = 'none' !== desktop_mode_folder_share_user_capability( $parent_id, $viewer_id );
652 }
653 }
654 $actor_payload = array(
655 'id' => $viewer_can_see ? $actor_id : 0,
656 'name' => $viewer_can_see && $actor ? $actor->display_name : '',
657 'avatar' => $viewer_can_see && $actor ? get_avatar_url( $actor->ID, array( 'size' => 32 ) ) : '',
658 );
659
660 return new WP_Error(
661 'desktop_mode_files_conflict',
662 __( 'This row was changed by another session.', 'desktop-mode' ),
663 array(
664 'status' => 409,
665 'data' => array(
666 'reason' => $reason,
667 'actor' => $actor_payload,
668 'current' => array(
669 'parentId' => $parent_id,
670 'parentName' => $parent_name,
671 'updatedAtMs' => (int) $current_ms,
672 ),
673 ),
674 )
675 );
676 }
677
678 /**
679 * Shape a share row for the wire.
680 *
681 * @since 0.18.0
682 *
683 * @param array|null $row Normalized share row.
684 * @return array
685 */
686 function desktop_mode_files_shape_share( $row ) {
687 if ( ! is_array( $row ) ) {
688 return array();
689 }
690 $shape = array(
691 'id' => (int) $row['id'],
692 'folderId' => (int) $row['folder_id'],
693 'principalType' => (string) $row['principal_type'],
694 'principalRef' => (string) $row['principal_ref'],
695 'capability' => (string) $row['capability'],
696 'state' => (string) $row['state'],
697 'invitedBy' => (int) $row['invited_by'],
698 'invitedAtMs' => (int) $row['invited_at_ms'],
699 'decidedAtMs' => isset( $row['decided_at_ms'] ) ? $row['decided_at_ms'] : null,
700 );
701 if ( 'user' === $row['principal_type'] ) {
702 $uid = (int) $row['principal_ref'];
703 $user = $uid > 0 ? get_userdata( $uid ) : null;
704 $shape['displayName'] = $user ? $user->display_name : '';
705 $shape['avatarUrl'] = $user ? get_avatar_url( $uid, array( 'size' => 48 ) ) : '';
706 } else {
707 $roles = wp_roles();
708 $info = $roles && isset( $roles->roles[ $row['principal_ref'] ] ) ? $roles->roles[ $row['principal_ref'] ] : null;
709 $shape['displayName'] = $info ? translate_user_role( (string) $info['name'] ) : (string) $row['principal_ref'];
710 $shape['avatarUrl'] = '';
711 }
712 return $shape;
713 }
714
715 /**
716 * GET /folders/<id>/shares — owner only.
717 */
718 function desktop_mode_files_rest_list_shares( WP_REST_Request $req ) {
719 $folder_id = (int) $req['id'];
720 $user_id = get_current_user_id();
721 if ( ! desktop_mode_files_share_can_manage( $folder_id, $user_id ) ) {
722 return new WP_Error( 'desktop_mode_files_forbidden', __( 'You cannot view shares for this folder.', 'desktop-mode' ), array( 'status' => 403 ) );
723 }
724 $folder = desktop_mode_files_get_folder( $folder_id );
725 if ( ! $folder ) {
726 return new WP_Error( 'desktop_mode_files_not_found', __( 'Folder not found.', 'desktop-mode' ), array( 'status' => 404 ) );
727 }
728 $rows = desktop_mode_files_get_folder_shares( $folder_id );
729 $out = array();
730 foreach ( $rows as $row ) {
731 $out[] = desktop_mode_files_shape_share( $row );
732 }
733 return rest_ensure_response(
734 array(
735 'shares' => $out,
736 'shareMode' => (string) $folder['share_mode'],
737 'all' => 'all' === (string) $folder['share_mode'],
738 )
739 );
740 }
741
742 /**
743 * POST /folders/<id>/shares — owner only.
744 */
745 function desktop_mode_files_rest_create_share( WP_REST_Request $req ) {
746 $folder_id = (int) $req['id'];
747 $actor_id = get_current_user_id();
748 $id = desktop_mode_folder_share_invite(
749 $folder_id,
750 $actor_id,
751 (string) $req->get_param( 'principalType' ),
752 (string) $req->get_param( 'principalRef' ),
753 (string) $req->get_param( 'capability' )
754 );
755 if ( is_wp_error( $id ) ) {
756 return $id;
757 }
758 return rest_ensure_response( desktop_mode_files_shape_share( desktop_mode_files_get_share( $id ) ) );
759 }
760
761 /**
762 * Verify that the share id in the URL actually belongs to the
763 * folder id in the URL. Returns the loaded share row or a
764 * `WP_Error` (404 unknown share / 404 mismatch). The underlying
765 * mutation functions still gate on the share's true folder, so a
766 * mismatched URL never escalates permission — but the routes are
767 * hierarchical (`/folders/{id}/shares/{shareId}/…`), so honoring
768 * both path segments is the contract callers expect.
769 *
770 * @since 0.18.x
771 *
772 * @param WP_REST_Request $req Request.
773 * @return array|WP_Error
774 */
775 function desktop_mode_files_rest_resolve_share_in_folder( WP_REST_Request $req ) {
776 $folder_id = (int) $req['id'];
777 $share_id = (int) $req['shareId'];
778 $share = desktop_mode_files_get_share( $share_id );
779 if ( ! $share ) {
780 return new WP_Error(
781 'desktop_mode_files_not_found',
782 __( 'Share not found.', 'desktop-mode' ),
783 array( 'status' => 404 )
784 );
785 }
786 if ( (int) $share['folder_id'] !== $folder_id ) {
787 return new WP_Error(
788 'desktop_mode_files_not_found',
789 __( 'Share not found in this folder.', 'desktop-mode' ),
790 array( 'status' => 404 )
791 );
792 }
793 return $share;
794 }
795
796 /**
797 * PATCH /folders/<id>/shares/<shareId> — owner only.
798 */
799 function desktop_mode_files_rest_update_share( WP_REST_Request $req ) {
800 $share = desktop_mode_files_rest_resolve_share_in_folder( $req );
801 if ( is_wp_error( $share ) ) {
802 return $share;
803 }
804 $share_id = (int) $share['id'];
805 $ok = desktop_mode_folder_share_update_capability( $share_id, get_current_user_id(), (string) $req->get_param( 'capability' ) );
806 if ( is_wp_error( $ok ) ) {
807 return $ok;
808 }
809 return rest_ensure_response( desktop_mode_files_shape_share( desktop_mode_files_get_share( $share_id ) ) );
810 }
811
812 /**
813 * DELETE /folders/<id>/shares/<shareId> — owner only.
814 */
815 function desktop_mode_files_rest_delete_share( WP_REST_Request $req ) {
816 $share = desktop_mode_files_rest_resolve_share_in_folder( $req );
817 if ( is_wp_error( $share ) ) {
818 return $share;
819 }
820 $ok = desktop_mode_folder_share_revoke( (int) $share['id'], get_current_user_id() );
821 if ( is_wp_error( $ok ) ) {
822 return $ok;
823 }
824 return rest_ensure_response( array( 'deleted' => true ) );
825 }
826
827 /**
828 * POST /folders/<id>/shares/<shareId>/accept — recipient only.
829 */
830 function desktop_mode_files_rest_accept_share( WP_REST_Request $req ) {
831 $share = desktop_mode_files_rest_resolve_share_in_folder( $req );
832 if ( is_wp_error( $share ) ) {
833 return $share;
834 }
835 $row = desktop_mode_folder_share_accept( (int) $share['id'], get_current_user_id() );
836 if ( is_wp_error( $row ) ) {
837 return $row;
838 }
839 return rest_ensure_response( desktop_mode_files_shape_share( $row ) );
840 }
841
842 /**
843 * POST /folders/<id>/shares/<shareId>/deny — recipient only.
844 */
845 function desktop_mode_files_rest_deny_share( WP_REST_Request $req ) {
846 $share = desktop_mode_files_rest_resolve_share_in_folder( $req );
847 if ( is_wp_error( $share ) ) {
848 return $share;
849 }
850 $row = desktop_mode_folder_share_deny( (int) $share['id'], get_current_user_id() );
851 if ( is_wp_error( $row ) ) {
852 return $row;
853 }
854 return rest_ensure_response( desktop_mode_files_shape_share( $row ) );
855 }
856
857 /**
858 * POST /folders/<id>/leave — recipient-initiated leave.
859 *
860 * Unlike `/shares/{id}/deny` which targets a specific share row,
861 * this endpoint finds whichever grant currently lets the user
862 * see the folder (user-principal or role-principal) and removes
863 * their access — for role shares without affecting other role
864 * members, via the per-user decisions table.
865 */
866 function desktop_mode_files_rest_leave_folder( WP_REST_Request $req ) {
867 $folder_id = (int) $req['id'];
868 $ok = desktop_mode_folder_share_leave( $folder_id, get_current_user_id() );
869 if ( is_wp_error( $ok ) ) {
870 return $ok;
871 }
872 return rest_ensure_response( array( 'left' => true ) );
873 }
874
875 /**
876 * POST /files/folder-sharing-tables/purge — destructive cleanup
877 * that drops every table the folder-sharing feature ever created
878 * (current `folder_shares` + `share_user_decisions`, plus any
879 * future variants enumerated via the
880 * `desktop_mode_files_sharing_tables_for_purge` filter).
881 *
882 * Restricted to `manage_options` by the permission callback. The
883 * schema-version option is cleared so the next admin-init runs
884 * `install_schema` and recreates the empty tables — keeps the
885 * code path that ASSUMES the tables exist (e.g. heartbeat
886 * delivery queries) working even after a purge.
887 *
888 * @since 0.18.x
889 */
890 function desktop_mode_files_rest_purge_sharing_tables() {
891 global $wpdb;
892 $tables = desktop_mode_files_table_names();
893
894 $to_drop = array( $tables['shares'], $tables['decisions'] );
895 /**
896 * Filter the list of table names dropped by the
897 * "Delete folder sharing data" admin action.
898 *
899 * @since 0.18.x
900 *
901 * @param string[] $tables Default = shares + decisions.
902 */
903 $to_drop = (array) apply_filters( 'desktop_mode_files_sharing_tables_for_purge', $to_drop );
904
905 $dropped = array();
906 $skipped = array();
907 $prefix = (string) $wpdb->prefix;
908 foreach ( $to_drop as $tbl ) {
909 $tbl = (string) $tbl;
910 if ( '' === $tbl ) {
911 continue;
912 }
913 // Defense-in-depth: a misbehaving filter could push any
914 // string into `$to_drop` and we're about to interpolate
915 // the value directly into a `DROP TABLE` statement (wpdb
916 // has no placeholder for identifiers). Two gates:
917 // 1. Must match the `[A-Za-z0-9_]+` identifier pattern —
918 // keeps quotes/backticks/spaces out of the SQL even
919 // if a filter author smuggled them in.
920 // 2. Must start with the wpdb prefix — keeps a malicious
921 // filter from dropping system tables (`wp_users`,
922 // `wp_options`, …) on a multi-prefix install.
923 if (
924 ! preg_match( '/^[A-Za-z0-9_]+$/', $tbl ) ||
925 0 !== strpos( $tbl, $prefix )
926 ) {
927 $skipped[] = $tbl;
928 continue;
929 }
930 $prev_suppress = $wpdb->suppress_errors( true );
931 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
932 $wpdb->query( "DROP TABLE IF EXISTS `{$tbl}`" );
933 $wpdb->suppress_errors( $prev_suppress );
934 $dropped[] = $tbl;
935 }
936
937 // Force the next admin-init / rest-init to re-run
938 // `install_schema` so the tables are recreated empty. Code
939 // paths that JOIN against them (heartbeat, sharing.php
940 // visibility) keep working without a per-request existence
941 // check.
942 delete_option( DESKTOP_MODE_FILES_SCHEMA_OPTION );
943
944 /**
945 * Fires after the folder-sharing tables are purged. Plugins
946 * that mirror share state into their own storage can react
947 * here.
948 *
949 * @since 0.18.x
950 *
951 * @param string[] $dropped Table names that were dropped.
952 */
953 do_action( 'desktop_mode_files_sharing_tables_purged', $dropped );
954
955 return rest_ensure_response( array(
956 'dropped' => $dropped,
957 'skipped' => $skipped,
958 ) );
959 }
960
961 /**
962 * Permission gate for /users/search. Requires `edit_posts` —
963 * `desktop_mode_files_rest_permission` would let any logged-in
964 * desktop-mode user pull the directory, which is too broad for an
965 * autocomplete that exposes display names + emails.
966 *
967 * @since 0.18.0
968 */
969 function desktop_mode_files_rest_search_users_permission() {
970 $base = desktop_mode_files_rest_permission();
971 if ( is_wp_error( $base ) ) {
972 return $base;
973 }
974 if ( ! current_user_can( 'edit_posts' ) ) {
975 return new WP_Error( 'desktop_mode_files_forbidden', __( 'You cannot search users.', 'desktop-mode' ), array( 'status' => 403 ) );
976 }
977 return true;
978 }
979
980 /**
981 * GET /files/users/search?q=<>&exclude=<csv> — autocomplete for the
982 * folder share picker.
983 *
984 * @since 0.18.0
985 */
986 function desktop_mode_files_rest_search_users( WP_REST_Request $req ) {
987 $q = trim( (string) $req->get_param( 'q' ) );
988 $exclude = array_filter( array_map( 'intval', explode( ',', (string) $req->get_param( 'exclude' ) ) ) );
989
990 // Always exclude the current viewer — sharing with yourself is
991 // a no-op the modal already rejects, no point spending a slot
992 // in the dropdown on it.
993 $exclude[] = (int) get_current_user_id();
994 $exclude = array_values( array_unique( array_filter( $exclude ) ) );
995
996 $args = array(
997 'number' => 20,
998 'orderby' => 'display_name',
999 'order' => 'ASC',
1000 'exclude' => $exclude,
1001 // `fields => 'all'` returns full WP_User objects so the
1002 // capability check below resolves role caps correctly. A
1003 // stdClass with stripped fields breaks `user_can()` on
1004 // some WordPress versions and silently drops every row.
1005 'fields' => 'all',
1006 );
1007 if ( '' !== $q ) {
1008 $args['search'] = '*' . $q . '*';
1009 $args['search_columns'] = array( 'user_login', 'user_email', 'display_name', 'user_nicename' );
1010 }
1011
1012 /**
1013 * Filter the WP_User_Query args used by the share picker.
1014 *
1015 * @since 0.18.0
1016 *
1017 * @param array $args Default args.
1018 * @param array $req Request params (`q`, `exclude`).
1019 */
1020 $args = (array) apply_filters( 'desktop_mode_files_share_user_query_args', $args, $req->get_params() );
1021
1022 $query = new WP_User_Query( $args );
1023 $users = $query->get_results();
1024 $out = array();
1025 foreach ( (array) $users as $user ) {
1026 if ( ! user_can( $user, 'edit_posts' ) ) {
1027 continue;
1028 }
1029 // Disambiguation handle uses `user_nicename` (the public
1030 // URL slug) instead of `user_login` — the login is the auth
1031 // credential and exposing it to every `edit_posts` user is
1032 // broader than needed for a share picker. Matches the
1033 // `slug` field WP's own `/wp/v2/users` endpoint surfaces.
1034 $out[] = array(
1035 'id' => (int) $user->ID,
1036 'name' => (string) $user->display_name,
1037 'slug' => (string) $user->user_nicename,
1038 'avatarUrl' => get_avatar_url( $user->ID, array( 'size' => 48 ) ),
1039 );
1040 }
1041 return rest_ensure_response( array( 'users' => $out ) );
1042 }
1043