PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.6
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.6
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.6, at includes/desktop-files/file-shares.php

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