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

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

692 lines 23.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 — single-file sharing (`target_type='file'`).
4 *
5 * Shares one stored upload with specific users. Reuses the folder-
6 * sharing tables via the `target_type` column the schema shipped
7 * for exactly this (the `folder_id` column carries the STORED-FILE
8 * id on these rows — historical column name).
9 *
10 * Deliberate divergences from folder sharing:
11 *
12 * - **Read tier only.** The capability is hard-forced to `read`
13 * — recipients get view + download, never move/rename/delete
14 * (DESKMOD-45's owner-locked model; the write tier does not
15 * exist for files).
16 * - **User principals only (v1).** No role invites.
17 *
18 * Lifecycle mirrors folders: invite (pending) → heartbeat delivers
19 * → accept (placement planted at the recipient's desktop root) /
20 * deny / leave / revoke, every removal scrubbing the recipient's
21 * placement.
22 *
23 * @package WPDesktopMode
24 */
25
26 defined( 'ABSPATH' ) || exit;
27
28 /**
29 * Whether `$user_id` may manage a stored file's shares. Owner-only
30 * by default, filterable like the folder equivalent.
31 *
32 * @param int $file_id Stored-file id.
33 * @param int $user_id Viewer.
34 * @return bool
35 */
36 function desktop_mode_stored_files_share_can_manage( $file_id, $user_id ) {
37 $file = desktop_mode_stored_files_get( (int) $file_id );
38 $can = $file && (int) $file['owner_id'] === (int) $user_id;
39 /**
40 * Filter who can manage a stored file's shares.
41 *
42 * @param bool $can Default: owner-only.
43 * @param int $file_id Stored-file id.
44 * @param int $user_id Viewer.
45 * @param array|null $file Stored-file row (null when missing).
46 */
47 return (bool) apply_filters( 'desktop_mode_stored_files_share_can_manage', $can, (int) $file_id, (int) $user_id, $file );
48 }
49
50 /**
51 * All share rows for one stored file (owner-internal view).
52 *
53 * @param int $file_id Stored-file id.
54 * @return array[]
55 */
56 function desktop_mode_stored_files_get_file_shares( $file_id ) {
57 global $wpdb;
58 $tables = desktop_mode_files_table_names();
59 $rows = $wpdb->get_results(
60 $wpdb->prepare(
61 "SELECT * FROM {$tables['shares']} WHERE target_type = 'file' AND folder_id = %d ORDER BY invited_at_ms ASC, id ASC",
62 (int) $file_id
63 ),
64 ARRAY_A
65 );
66 $out = array();
67 foreach ( (array) $rows as $row ) {
68 $out[] = desktop_mode_files_normalize_share_row( $row );
69 }
70 return $out;
71 }
72
73 /**
74 * The viewer's state on a stored file: 'none' when no share row
75 * targets them, else the row's state.
76 *
77 * @param int $file_id Stored-file id.
78 * @param int $user_id Viewer.
79 * @return string 'none' | 'pending' | 'accepted' | 'denied'
80 */
81 function desktop_mode_stored_file_share_state( $file_id, $user_id ) {
82 global $wpdb;
83 $tables = desktop_mode_files_table_names();
84 $state = $wpdb->get_var(
85 $wpdb->prepare(
86 "SELECT state FROM {$tables['shares']}
87 WHERE target_type = 'file' AND folder_id = %d
88 AND principal_type = 'user' AND principal_ref = %s",
89 (int) $file_id,
90 (string) (int) $user_id
91 )
92 );
93 return null === $state ? 'none' : (string) $state;
94 }
95
96 /**
97 * Invite a user to a stored file. Capability is always `read`.
98 *
99 * @param int $file_id Stored-file id.
100 * @param int $actor_id Actor (must manage the file's shares).
101 * @param int $recipient_user_id Recipient.
102 * @return int|WP_Error Share id.
103 */
104 function desktop_mode_stored_file_share_invite( $file_id, $actor_id, $recipient_user_id ) {
105 global $wpdb;
106 $file_id = (int) $file_id;
107 $actor_id = (int) $actor_id;
108 $uid = (int) $recipient_user_id;
109
110 $file = desktop_mode_stored_files_get( $file_id );
111 if ( ! $file ) {
112 return new WP_Error( 'desktop_mode_stored_files_not_found', __( 'Stored file not found.', 'desktop-mode' ), array( 'status' => 404 ) );
113 }
114 if ( ! desktop_mode_stored_files_share_can_manage( $file_id, $actor_id ) ) {
115 return new WP_Error( 'desktop_mode_files_forbidden', __( 'You cannot manage shares for this file.', 'desktop-mode' ), array( 'status' => 403 ) );
116 }
117 if ( $uid <= 0 ) {
118 return new WP_Error( 'desktop_mode_files_invalid_user', __( 'Invalid user id.', 'desktop-mode' ), array( 'status' => 400 ) );
119 }
120 if ( $uid === (int) $file['owner_id'] ) {
121 return new WP_Error( 'desktop_mode_files_share_owner', __( 'You cannot share with the file owner.', 'desktop-mode' ), array( 'status' => 400 ) );
122 }
123 $user = get_userdata( $uid );
124 if ( ! $user ) {
125 return new WP_Error( 'desktop_mode_files_unknown_user', __( 'Unknown user.', 'desktop-mode' ), array( 'status' => 404 ) );
126 }
127 if ( ! user_can( $user, 'edit_posts' ) ) {
128 return new WP_Error( 'desktop_mode_files_ineligible_principal', __( 'This user is not eligible.', 'desktop-mode' ), array( 'status' => 400 ) );
129 }
130
131 $tables = desktop_mode_files_table_names();
132 $now = desktop_mode_files_now_ms();
133
134 // Idempotent invite, mirroring the folder rules: denied →
135 // pending again; pending/accepted keep their state. Capability
136 // stays 'read' unconditionally.
137 $existing = $wpdb->get_row(
138 $wpdb->prepare(
139 "SELECT * FROM {$tables['shares']}
140 WHERE target_type = 'file' AND folder_id = %d
141 AND principal_type = 'user' AND principal_ref = %s",
142 $file_id,
143 (string) $uid
144 ),
145 ARRAY_A
146 );
147 if ( $existing ) {
148 $id = (int) $existing['id'];
149 $next_state = 'denied' === $existing['state'] ? 'pending' : $existing['state'];
150 $set = array(
151 'capability' => 'read',
152 'state' => $next_state,
153 'invited_by' => $actor_id,
154 'invited_at_ms' => $now,
155 );
156 $fmt = array( '%s', '%s', '%d', '%d' );
157 if ( 'denied' === $existing['state'] ) {
158 $set['decided_at_ms'] = null;
159 $fmt[] = '%s';
160 }
161 $wpdb->update( $tables['shares'], $set, array( 'id' => $id ), $fmt, array( '%d' ) );
162 } else {
163 $ok = $wpdb->insert(
164 $tables['shares'],
165 array(
166 'target_type' => 'file',
167 'folder_id' => $file_id,
168 'principal_type' => 'user',
169 'principal_ref' => (string) $uid,
170 'capability' => 'read',
171 'state' => 'pending',
172 'invited_by' => $actor_id,
173 'invited_at_ms' => $now,
174 ),
175 array( '%s', '%d', '%s', '%s', '%s', '%s', '%d', '%d' )
176 );
177 if ( false === $ok ) {
178 return new WP_Error( 'desktop_mode_files_share_insert_failed', __( 'Failed to record share.', 'desktop-mode' ), array( 'status' => 500 ) );
179 }
180 $id = (int) $wpdb->insert_id;
181 }
182
183 $row = desktop_mode_files_get_share( $id );
184
185 /** This action is documented in includes/desktop-files/shares-store.php */
186 do_action( 'desktop_mode_files_share_invited', $id, $row, $actor_id );
187
188 return $id;
189 }
190
191 /**
192 * Recipient accepts a file share. Plants an `upload` placement at
193 * their desktop root.
194 *
195 * @param int $share_id Share id.
196 * @param int $user_id Recipient.
197 * @return array|WP_Error Updated share row.
198 */
199 function desktop_mode_stored_file_share_accept( $share_id, $user_id ) {
200 global $wpdb;
201 $share_id = (int) $share_id;
202 $user_id = (int) $user_id;
203 $row = desktop_mode_files_get_share( $share_id );
204 if ( ! $row || 'file' !== $row['target_type'] ) {
205 return new WP_Error( 'desktop_mode_files_share_not_found', __( 'Share not found.', 'desktop-mode' ), array( 'status' => 404 ) );
206 }
207 if ( 'user' !== $row['principal_type'] || (int) $row['principal_ref'] !== $user_id ) {
208 return new WP_Error( 'desktop_mode_files_share_not_recipient', __( 'This invite is not for you.', 'desktop-mode' ), array( 'status' => 403 ) );
209 }
210 if ( 'accepted' === $row['state'] ) {
211 return $row;
212 }
213 if ( 'denied' === $row['state'] ) {
214 return new WP_Error( 'desktop_mode_files_share_already_denied', __( 'This invite was denied.', 'desktop-mode' ), array( 'status' => 410 ) );
215 }
216
217 $tables = desktop_mode_files_table_names();
218 $wpdb->update(
219 $tables['shares'],
220 array(
221 'state' => 'accepted',
222 'decided_at_ms' => desktop_mode_files_now_ms(),
223 ),
224 array( 'id' => $share_id ),
225 array( '%s', '%d' ),
226 array( '%d' )
227 );
228
229 // Plant the tile — AFTER the state flip so the placement's
230 // `can_read` gate sees the accepted share.
231 $file_id = (int) $row['folder_id'];
232 /** This filter is documented in includes/desktop-files/shares-store.php */
233 $parent_id = (int) apply_filters( 'desktop_mode_folder_share_accept_default_parent', 0, $file_id, $user_id, $row );
234 desktop_mode_files_place_at_next_free_slot( $user_id, $parent_id, 'upload', (string) $file_id );
235
236 $next = desktop_mode_files_get_share( $share_id );
237
238 /** This action is documented in includes/desktop-files/shares-store.php */
239 do_action( 'desktop_mode_files_share_accepted', $share_id, $next, $user_id );
240
241 return $next;
242 }
243
244 /**
245 * Recipient denies a file share.
246 *
247 * @param int $share_id Share id.
248 * @param int $user_id Recipient.
249 * @return array|WP_Error Updated share row.
250 */
251 function desktop_mode_stored_file_share_deny( $share_id, $user_id ) {
252 global $wpdb;
253 $share_id = (int) $share_id;
254 $user_id = (int) $user_id;
255 $row = desktop_mode_files_get_share( $share_id );
256 if ( ! $row || 'file' !== $row['target_type'] ) {
257 return new WP_Error( 'desktop_mode_files_share_not_found', __( 'Share not found.', 'desktop-mode' ), array( 'status' => 404 ) );
258 }
259 if ( 'user' !== $row['principal_type'] || (int) $row['principal_ref'] !== $user_id ) {
260 return new WP_Error( 'desktop_mode_files_share_not_recipient', __( 'This invite is not for you.', 'desktop-mode' ), array( 'status' => 403 ) );
261 }
262 if ( 'denied' === $row['state'] ) {
263 return $row;
264 }
265 $was_accepted = 'accepted' === $row['state'];
266
267 $tables = desktop_mode_files_table_names();
268 $wpdb->update(
269 $tables['shares'],
270 array(
271 'state' => 'denied',
272 'decided_at_ms' => desktop_mode_files_now_ms(),
273 ),
274 array( 'id' => $share_id ),
275 array( '%s', '%d' ),
276 array( '%d' )
277 );
278 if ( $was_accepted ) {
279 desktop_mode_files_trash_upload_for_user( (int) $row['folder_id'], $user_id );
280 }
281
282 $next = desktop_mode_files_get_share( $share_id );
283
284 /** This action is documented in includes/desktop-files/shares-store.php */
285 do_action( 'desktop_mode_files_share_denied', $share_id, $next, $user_id );
286
287 return $next;
288 }
289
290 /**
291 * Recipient leaves a previously accepted file share.
292 *
293 * @param int $file_id Stored-file id.
294 * @param int $user_id Recipient.
295 * @return true|WP_Error
296 */
297 function desktop_mode_stored_file_share_leave( $file_id, $user_id ) {
298 global $wpdb;
299 $file_id = (int) $file_id;
300 $user_id = (int) $user_id;
301 $file = desktop_mode_stored_files_get( $file_id );
302 if ( ! $file ) {
303 return new WP_Error( 'desktop_mode_files_not_found', __( 'File not found.', 'desktop-mode' ), array( 'status' => 404 ) );
304 }
305 if ( (int) $file['owner_id'] === $user_id ) {
306 return new WP_Error( 'desktop_mode_files_owner_cannot_leave', __( 'Owners cannot leave their own file.', 'desktop-mode' ), array( 'status' => 400 ) );
307 }
308
309 $tables = desktop_mode_files_table_names();
310 $row = $wpdb->get_row(
311 $wpdb->prepare(
312 "SELECT * FROM {$tables['shares']}
313 WHERE target_type = 'file' AND folder_id = %d
314 AND principal_type = 'user' AND principal_ref = %s",
315 $file_id,
316 (string) $user_id
317 ),
318 ARRAY_A
319 );
320
321 // Scrub the recipient's tile regardless — lingering placements
322 // from a previously revoked share must go too.
323 desktop_mode_files_trash_upload_for_user( $file_id, $user_id );
324
325 if ( ! $row ) {
326 return new WP_Error( 'desktop_mode_files_not_member', __( 'You do not have access to this file.', 'desktop-mode' ), array( 'status' => 404 ) );
327 }
328 $normalized = desktop_mode_files_normalize_share_row( $row );
329 $wpdb->update(
330 $tables['shares'],
331 array(
332 'state' => 'denied',
333 'decided_at_ms' => desktop_mode_files_now_ms(),
334 ),
335 array( 'id' => (int) $row['id'] ),
336 array( '%s', '%d' ),
337 array( '%d' )
338 );
339
340 /** This action is documented in includes/desktop-files/shares-store.php */
341 do_action( 'desktop_mode_files_share_left', (int) $row['id'], $normalized, $user_id );
342
343 return true;
344 }
345
346 /**
347 * Owner revokes a file share.
348 *
349 * @param int $share_id Share id.
350 * @param int $actor_id Actor.
351 * @return true|WP_Error
352 */
353 function desktop_mode_stored_file_share_revoke( $share_id, $actor_id ) {
354 global $wpdb;
355 $share_id = (int) $share_id;
356 $actor_id = (int) $actor_id;
357 $row = desktop_mode_files_get_share( $share_id );
358 if ( ! $row || 'file' !== $row['target_type'] ) {
359 return new WP_Error( 'desktop_mode_files_share_not_found', __( 'Share not found.', 'desktop-mode' ), array( 'status' => 404 ) );
360 }
361 if ( ! desktop_mode_stored_files_share_can_manage( (int) $row['folder_id'], $actor_id ) ) {
362 return new WP_Error( 'desktop_mode_files_forbidden', __( 'You cannot manage shares for this file.', 'desktop-mode' ), array( 'status' => 403 ) );
363 }
364
365 $tables = desktop_mode_files_table_names();
366 $wpdb->delete( $tables['shares'], array( 'id' => $share_id ), array( '%d' ) );
367 $wpdb->delete( $tables['decisions'], array( 'share_id' => $share_id ), array( '%d' ) );
368
369 if ( 'accepted' === $row['state'] ) {
370 desktop_mode_files_trash_upload_for_user( (int) $row['folder_id'], (int) $row['principal_ref'] );
371 }
372
373 /** This action is documented in includes/desktop-files/shares-store.php */
374 do_action( 'desktop_mode_files_share_revoked', $share_id, $row, $actor_id );
375
376 return true;
377 }
378
379 /**
380 * Soft-trash a recipient's placements of an uploaded file (their
381 * desktop tile). Direct DB update on purpose — the owner-lock trash
382 * gate would (correctly) refuse a recipient-initiated trash through
383 * the normal flow; this administrative scrub bypasses it. No
384 * tombstones: soft-trash rides the heartbeat's `trashed_at_ms`
385 * channel (same invariant as the folder scrub).
386 *
387 * @param int $file_id Stored-file id.
388 * @param int $user_id Recipient whose placements to scrub.
389 * @return int Rows scrubbed.
390 */
391 function desktop_mode_files_trash_upload_for_user( $file_id, $user_id ) {
392 global $wpdb;
393 $file_id = (int) $file_id;
394 $user_id = (int) $user_id;
395 if ( $file_id <= 0 || $user_id <= 0 ) {
396 return 0;
397 }
398 $tables = desktop_mode_files_table_names();
399 $now = desktop_mode_files_now_ms();
400 $rows = $wpdb->get_results(
401 $wpdb->prepare(
402 "SELECT id FROM {$tables['placements']}
403 WHERE owner_id = %d
404 AND file_type = 'upload'
405 AND file_ref = %s
406 AND trashed_at_ms IS NULL",
407 $user_id,
408 (string) $file_id
409 ),
410 ARRAY_A
411 );
412 $count = 0;
413 foreach ( (array) $rows as $row ) {
414 $wpdb->update(
415 $tables['placements'],
416 array(
417 'trashed_at_ms' => $now,
418 'trashed_by' => $user_id,
419 ),
420 array( 'id' => (int) $row['id'] ),
421 array( '%d', '%d' ),
422 array( '%d' )
423 );
424 $count++;
425 }
426 return $count;
427 }
428
429 /**
430 * Pending file-share invites for a user (heartbeat + shell-config
431 * delivery). User-principal only.
432 *
433 * @param int $user_id Viewer.
434 * @param int $since_ms Only rows with `invited_at_ms > since`.
435 * @return array[] Normalized share rows.
436 */
437 function desktop_mode_files_get_pending_file_shares_for_user( $user_id, $since_ms = 0 ) {
438 global $wpdb;
439 $user_id = (int) $user_id;
440 if ( $user_id <= 0 ) {
441 return array();
442 }
443 $tables = desktop_mode_files_table_names();
444 $rows = $wpdb->get_results(
445 $wpdb->prepare(
446 "SELECT s.* FROM {$tables['shares']} s
447 INNER JOIN {$tables['stored_files']} sf ON sf.id = s.folder_id
448 WHERE s.target_type = 'file'
449 AND s.state = 'pending'
450 AND s.invited_at_ms > %d
451 AND s.principal_type = 'user'
452 AND s.principal_ref = %s
453 ORDER BY s.invited_at_ms ASC, s.id ASC",
454 (int) $since_ms,
455 (string) $user_id
456 ),
457 ARRAY_A
458 );
459 $out = array();
460 foreach ( (array) $rows as $row ) {
461 $out[] = desktop_mode_files_normalize_share_row( $row );
462 }
463 return $out;
464 }
465
466 /**
467 * Wire shape for a file share, enriched for the invite banner.
468 *
469 * @param array $row Normalized share row (`target_type='file'`).
470 * @return array
471 */
472 function desktop_mode_files_shape_file_share( $row ) {
473 $file = desktop_mode_stored_files_get( (int) $row['folder_id'] );
474 $shape = array(
475 'id' => (int) $row['id'],
476 'targetType' => 'file',
477 'fileId' => (int) $row['folder_id'],
478 'principalType' => (string) $row['principal_type'],
479 'principalRef' => (string) $row['principal_ref'],
480 'capability' => 'read',
481 'state' => (string) $row['state'],
482 'invitedBy' => (int) $row['invited_by'],
483 'invitedAtMs' => (int) $row['invited_at_ms'],
484 'decidedAtMs' => isset( $row['decided_at_ms'] ) ? $row['decided_at_ms'] : null,
485 );
486 if ( $file ) {
487 $shape['fileName'] = (string) $file['display_name'];
488 $shape['ownerId'] = (int) $file['owner_id'];
489 $owner = get_userdata( (int) $file['owner_id'] );
490 $shape['ownerName'] = $owner ? $owner->display_name : '';
491 $shape['ownerAvatar'] = $owner ? get_avatar_url( $owner->ID, array( 'size' => 48 ) ) : '';
492 }
493 // Principal enrichment for the owner-side share list.
494 $principal = get_userdata( (int) $row['principal_ref'] );
495 $shape['displayName'] = $principal ? $principal->display_name : '';
496 $shape['avatarUrl'] = $principal ? get_avatar_url( $principal->ID, array( 'size' => 48 ) ) : '';
497 return $shape;
498 }
499
500 // ---------------------------------------------------------------------------
501 // REST routes.
502 // ---------------------------------------------------------------------------
503
504 /**
505 * Register the file-share routes. Same 404-when-disabled gate as
506 * every other share route (`desktop_mode_files_rest_share_permission`).
507 */
508 function desktop_mode_files_register_file_share_rest_routes() {
509 $ns = 'desktop-mode/v1';
510
511 register_rest_route( $ns, '/files/uploads/(?P<id>\d+)/shares', array(
512 array(
513 'methods' => WP_REST_Server::READABLE,
514 'permission_callback' => 'desktop_mode_files_rest_share_permission',
515 'callback' => 'desktop_mode_files_rest_list_file_shares',
516 ),
517 array(
518 'methods' => WP_REST_Server::CREATABLE,
519 'permission_callback' => 'desktop_mode_files_rest_share_permission',
520 'callback' => 'desktop_mode_files_rest_create_file_share',
521 'args' => array(
522 'userId' => array( 'type' => 'integer', 'required' => true ),
523 ),
524 ),
525 ) );
526 register_rest_route( $ns, '/files/uploads/(?P<id>\d+)/shares/(?P<shareId>\d+)', array(
527 'methods' => WP_REST_Server::DELETABLE,
528 'permission_callback' => 'desktop_mode_files_rest_share_permission',
529 'callback' => 'desktop_mode_files_rest_delete_file_share',
530 ) );
531 register_rest_route( $ns, '/files/uploads/(?P<id>\d+)/shares/(?P<shareId>\d+)/accept', array(
532 'methods' => WP_REST_Server::CREATABLE,
533 'permission_callback' => 'desktop_mode_files_rest_share_permission',
534 'callback' => 'desktop_mode_files_rest_accept_file_share',
535 ) );
536 register_rest_route( $ns, '/files/uploads/(?P<id>\d+)/shares/(?P<shareId>\d+)/deny', array(
537 'methods' => WP_REST_Server::CREATABLE,
538 'permission_callback' => 'desktop_mode_files_rest_share_permission',
539 'callback' => 'desktop_mode_files_rest_deny_file_share',
540 ) );
541 register_rest_route( $ns, '/files/uploads/(?P<id>\d+)/leave', array(
542 'methods' => WP_REST_Server::CREATABLE,
543 'permission_callback' => 'desktop_mode_files_rest_share_permission',
544 'callback' => 'desktop_mode_files_rest_leave_file_share',
545 ) );
546 }
547 add_action( 'rest_api_init', 'desktop_mode_files_register_file_share_rest_routes' );
548
549 /**
550 * Resolve the `{shareId}` inside `{id}` or fail with a masked 404.
551 *
552 * @internal
553 *
554 * @param WP_REST_Request $req Request.
555 * @return array|WP_Error Normalized share row.
556 */
557 function desktop_mode_files_rest_resolve_file_share( WP_REST_Request $req ) {
558 $row = desktop_mode_files_get_share( (int) $req['shareId'] );
559 if ( ! $row || 'file' !== $row['target_type'] || (int) $row['folder_id'] !== (int) $req['id'] ) {
560 return new WP_Error( 'desktop_mode_files_share_not_found', __( 'Share not found.', 'desktop-mode' ), array( 'status' => 404 ) );
561 }
562 return $row;
563 }
564
565 /**
566 * GET /files/uploads/<id>/shares (managers only).
567 */
568 function desktop_mode_files_rest_list_file_shares( WP_REST_Request $req ) {
569 $file_id = (int) $req['id'];
570 $user_id = get_current_user_id();
571 if ( ! desktop_mode_stored_files_share_can_manage( $file_id, $user_id ) ) {
572 return desktop_mode_files_download_not_found();
573 }
574 $out = array();
575 foreach ( desktop_mode_stored_files_get_file_shares( $file_id ) as $row ) {
576 $out[] = desktop_mode_files_shape_file_share( $row );
577 }
578 return rest_ensure_response( array( 'shares' => $out ) );
579 }
580
581 /**
582 * POST /files/uploads/<id>/shares — invite (read tier, always).
583 * A `capability` param, if sent, must be `read` — `write` is 400.
584 */
585 function desktop_mode_files_rest_create_file_share( WP_REST_Request $req ) {
586 $capability = $req->get_param( 'capability' );
587 if ( null !== $capability && 'read' !== (string) $capability ) {
588 return new WP_Error(
589 'desktop_mode_files_invalid_capability',
590 __( 'Uploaded files can only be shared read-only.', 'desktop-mode' ),
591 array( 'status' => 400 )
592 );
593 }
594 $id = desktop_mode_stored_file_share_invite(
595 (int) $req['id'],
596 get_current_user_id(),
597 (int) $req->get_param( 'userId' )
598 );
599 if ( is_wp_error( $id ) ) {
600 return $id;
601 }
602 return rest_ensure_response( desktop_mode_files_shape_file_share( desktop_mode_files_get_share( $id ) ) );
603 }
604
605 /**
606 * DELETE /files/uploads/<id>/shares/<shareId> — revoke.
607 */
608 function desktop_mode_files_rest_delete_file_share( WP_REST_Request $req ) {
609 $row = desktop_mode_files_rest_resolve_file_share( $req );
610 if ( is_wp_error( $row ) ) {
611 return $row;
612 }
613 $ok = desktop_mode_stored_file_share_revoke( (int) $row['id'], get_current_user_id() );
614 if ( is_wp_error( $ok ) ) {
615 return $ok;
616 }
617 return rest_ensure_response( array( 'deleted' => true ) );
618 }
619
620 /**
621 * POST .../accept
622 */
623 function desktop_mode_files_rest_accept_file_share( WP_REST_Request $req ) {
624 $row = desktop_mode_files_rest_resolve_file_share( $req );
625 if ( is_wp_error( $row ) ) {
626 return $row;
627 }
628 $next = desktop_mode_stored_file_share_accept( (int) $row['id'], get_current_user_id() );
629 if ( is_wp_error( $next ) ) {
630 return $next;
631 }
632 return rest_ensure_response( desktop_mode_files_shape_file_share( $next ) );
633 }
634
635 /**
636 * POST .../deny
637 */
638 function desktop_mode_files_rest_deny_file_share( WP_REST_Request $req ) {
639 $row = desktop_mode_files_rest_resolve_file_share( $req );
640 if ( is_wp_error( $row ) ) {
641 return $row;
642 }
643 $next = desktop_mode_stored_file_share_deny( (int) $row['id'], get_current_user_id() );
644 if ( is_wp_error( $next ) ) {
645 return $next;
646 }
647 return rest_ensure_response( desktop_mode_files_shape_file_share( $next ) );
648 }
649
650 /**
651 * POST /files/uploads/<id>/leave
652 */
653 function desktop_mode_files_rest_leave_file_share( WP_REST_Request $req ) {
654 $ok = desktop_mode_stored_file_share_leave( (int) $req['id'], get_current_user_id() );
655 if ( is_wp_error( $ok ) ) {
656 return $ok;
657 }
658 return rest_ensure_response( array( 'left' => true ) );
659 }
660
661 // ---------------------------------------------------------------------------
662 // Delivery: shell config + heartbeat.
663 // ---------------------------------------------------------------------------
664
665 /**
666 * Append pending file-share invites to the boot-time
667 * `serverPendingShares` array (after the folder injection at 20).
668 * File shapes carry `targetType: 'file'` + `fileId` / `fileName`
669 * so the invite banner can branch.
670 *
671 * @param array $config Shell config.
672 * @return array
673 */
674 function desktop_mode_files_file_share_inject_shell_config( $config ) {
675 $user_id = get_current_user_id();
676 $sharing_enabled = function_exists( 'desktop_mode_files_sharing_enabled_for' )
677 ? desktop_mode_files_sharing_enabled_for( $user_id )
678 : true;
679 if ( $user_id <= 0 || ! $sharing_enabled ) {
680 return $config;
681 }
682 $pending = isset( $config['serverPendingShares'] ) && is_array( $config['serverPendingShares'] )
683 ? $config['serverPendingShares']
684 : array();
685 foreach ( desktop_mode_files_get_pending_file_shares_for_user( $user_id, 0 ) as $row ) {
686 $pending[] = desktop_mode_files_shape_file_share( $row );
687 }
688 $config['serverPendingShares'] = $pending;
689 return $config;
690 }
691 add_filter( 'desktop_mode_shell_config', 'desktop_mode_files_file_share_inject_shell_config', 21 );
692