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

715 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 * OpenStation — 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 OpenStation
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 openstation_stored_files_share_can_manage( $file_id, $user_id ) {
37 $file = openstation_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( 'openstation_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 openstation_stored_files_get_file_shares( $file_id ) {
57 global $wpdb;
58 $tables = openstation_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[] = openstation_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 openstation_stored_file_share_state( $file_id, $user_id ) {
82 global $wpdb;
83 $tables = openstation_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 openstation_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 = openstation_stored_files_get( $file_id );
111 if ( ! $file ) {
112 return new WP_Error( 'openstation_stored_files_not_found', __( 'Stored file not found.', 'desktop-mode' ), array( 'status' => 404 ) );
113 }
114 if ( ! openstation_stored_files_share_can_manage( $file_id, $actor_id ) ) {
115 return new WP_Error( 'openstation_files_forbidden', __( 'You cannot manage shares for this file.', 'desktop-mode' ), array( 'status' => 403 ) );
116 }
117 if ( $uid <= 0 ) {
118 return new WP_Error( 'openstation_files_invalid_user', __( 'Invalid user id.', 'desktop-mode' ), array( 'status' => 400 ) );
119 }
120 if ( $uid === (int) $file['owner_id'] ) {
121 return new WP_Error( 'openstation_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( 'openstation_files_unknown_user', __( 'Unknown user.', 'desktop-mode' ), array( 'status' => 404 ) );
126 }
127 if ( ! user_can( $user, 'edit_posts' ) ) {
128 return new WP_Error( 'openstation_files_ineligible_principal', __( 'This user is not eligible.', 'desktop-mode' ), array( 'status' => 400 ) );
129 }
130
131 $tables = openstation_files_table_names();
132 $now = openstation_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( 'openstation_files_share_insert_failed', __( 'Failed to record share.', 'desktop-mode' ), array( 'status' => 500 ) );
179 }
180 $id = (int) $wpdb->insert_id;
181 }
182
183 $row = openstation_files_get_share( $id );
184
185 /** This action is documented in includes/desktop-files/shares-store.php */
186 do_action( 'openstation_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 openstation_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 = openstation_files_get_share( $share_id );
204 if ( ! $row || 'file' !== $row['target_type'] ) {
205 return new WP_Error( 'openstation_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( 'openstation_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( 'openstation_files_share_already_denied', __( 'This invite was denied.', 'desktop-mode' ), array( 'status' => 410 ) );
215 }
216
217 $tables = openstation_files_table_names();
218 $wpdb->update(
219 $tables['shares'],
220 array(
221 'state' => 'accepted',
222 'decided_at_ms' => openstation_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( 'openstation_folder_share_accept_default_parent', 0, $file_id, $user_id, $row );
234 openstation_files_place_at_next_free_slot( $user_id, $parent_id, 'upload', (string) $file_id );
235
236 $next = openstation_files_get_share( $share_id );
237
238 /** This action is documented in includes/desktop-files/shares-store.php */
239 do_action( 'openstation_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 openstation_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 = openstation_files_get_share( $share_id );
256 if ( ! $row || 'file' !== $row['target_type'] ) {
257 return new WP_Error( 'openstation_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( 'openstation_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 = openstation_files_table_names();
268 $wpdb->update(
269 $tables['shares'],
270 array(
271 'state' => 'denied',
272 'decided_at_ms' => openstation_files_now_ms(),
273 ),
274 array( 'id' => $share_id ),
275 array( '%s', '%d' ),
276 array( '%d' )
277 );
278 if ( $was_accepted ) {
279 openstation_files_trash_upload_for_user( (int) $row['folder_id'], $user_id );
280 }
281
282 $next = openstation_files_get_share( $share_id );
283
284 /** This action is documented in includes/desktop-files/shares-store.php */
285 do_action( 'openstation_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 openstation_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 = openstation_stored_files_get( $file_id );
302 if ( ! $file ) {
303 return new WP_Error( 'openstation_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( 'openstation_files_owner_cannot_leave', __( 'Owners cannot leave their own file.', 'desktop-mode' ), array( 'status' => 400 ) );
307 }
308
309 $tables = openstation_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 openstation_files_trash_upload_for_user( $file_id, $user_id );
324
325 if ( ! $row ) {
326 return new WP_Error( 'openstation_files_not_member', __( 'You do not have access to this file.', 'desktop-mode' ), array( 'status' => 404 ) );
327 }
328 $normalized = openstation_files_normalize_share_row( $row );
329 $wpdb->update(
330 $tables['shares'],
331 array(
332 'state' => 'denied',
333 'decided_at_ms' => openstation_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( 'openstation_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 openstation_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 = openstation_files_get_share( $share_id );
358 if ( ! $row || 'file' !== $row['target_type'] ) {
359 return new WP_Error( 'openstation_files_share_not_found', __( 'Share not found.', 'desktop-mode' ), array( 'status' => 404 ) );
360 }
361 if ( ! openstation_stored_files_share_can_manage( (int) $row['folder_id'], $actor_id ) ) {
362 return new WP_Error( 'openstation_files_forbidden', __( 'You cannot manage shares for this file.', 'desktop-mode' ), array( 'status' => 403 ) );
363 }
364
365 $tables = openstation_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 openstation_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( 'openstation_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 openstation_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 = openstation_files_table_names();
399 $now = openstation_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 openstation_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 = openstation_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[] = openstation_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 openstation_files_shape_file_share( $row ) {
473 $file = openstation_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 (`openstation_files_rest_share_permission`).
507 */
508 function openstation_files_register_file_share_rest_routes() {
509 $ns = 'desktop-mode/v1';
510
511 register_rest_route(
512 $ns,
513 '/files/uploads/(?P<id>\d+)/shares',
514 array(
515 array(
516 'methods' => WP_REST_Server::READABLE,
517 'permission_callback' => 'openstation_files_rest_share_permission',
518 'callback' => 'openstation_files_rest_list_file_shares',
519 ),
520 array(
521 'methods' => WP_REST_Server::CREATABLE,
522 'permission_callback' => 'openstation_files_rest_share_permission',
523 'callback' => 'openstation_files_rest_create_file_share',
524 'args' => array(
525 'userId' => array(
526 'type' => 'integer',
527 'required' => true,
528 ),
529 ),
530 ),
531 )
532 );
533 register_rest_route(
534 $ns,
535 '/files/uploads/(?P<id>\d+)/shares/(?P<shareId>\d+)',
536 array(
537 'methods' => WP_REST_Server::DELETABLE,
538 'permission_callback' => 'openstation_files_rest_share_permission',
539 'callback' => 'openstation_files_rest_delete_file_share',
540 )
541 );
542 register_rest_route(
543 $ns,
544 '/files/uploads/(?P<id>\d+)/shares/(?P<shareId>\d+)/accept',
545 array(
546 'methods' => WP_REST_Server::CREATABLE,
547 'permission_callback' => 'openstation_files_rest_share_permission',
548 'callback' => 'openstation_files_rest_accept_file_share',
549 )
550 );
551 register_rest_route(
552 $ns,
553 '/files/uploads/(?P<id>\d+)/shares/(?P<shareId>\d+)/deny',
554 array(
555 'methods' => WP_REST_Server::CREATABLE,
556 'permission_callback' => 'openstation_files_rest_share_permission',
557 'callback' => 'openstation_files_rest_deny_file_share',
558 )
559 );
560 register_rest_route(
561 $ns,
562 '/files/uploads/(?P<id>\d+)/leave',
563 array(
564 'methods' => WP_REST_Server::CREATABLE,
565 'permission_callback' => 'openstation_files_rest_share_permission',
566 'callback' => 'openstation_files_rest_leave_file_share',
567 )
568 );
569 }
570 add_action( 'rest_api_init', 'openstation_files_register_file_share_rest_routes' );
571
572 /**
573 * Resolve the `{shareId}` inside `{id}` or fail with a masked 404.
574 *
575 * @internal
576 *
577 * @param WP_REST_Request $req Request.
578 * @return array|WP_Error Normalized share row.
579 */
580 function openstation_files_rest_resolve_file_share( WP_REST_Request $req ) {
581 $row = openstation_files_get_share( (int) $req['shareId'] );
582 if ( ! $row || 'file' !== $row['target_type'] || (int) $row['folder_id'] !== (int) $req['id'] ) {
583 return new WP_Error( 'openstation_files_share_not_found', __( 'Share not found.', 'desktop-mode' ), array( 'status' => 404 ) );
584 }
585 return $row;
586 }
587
588 /**
589 * GET /files/uploads/<id>/shares (managers only).
590 */
591 function openstation_files_rest_list_file_shares( WP_REST_Request $req ) {
592 $file_id = (int) $req['id'];
593 $user_id = get_current_user_id();
594 if ( ! openstation_stored_files_share_can_manage( $file_id, $user_id ) ) {
595 return openstation_files_download_not_found();
596 }
597 $out = array();
598 foreach ( openstation_stored_files_get_file_shares( $file_id ) as $row ) {
599 $out[] = openstation_files_shape_file_share( $row );
600 }
601 return rest_ensure_response( array( 'shares' => $out ) );
602 }
603
604 /**
605 * POST /files/uploads/<id>/shares — invite (read tier, always).
606 * A `capability` param, if sent, must be `read` — `write` is 400.
607 */
608 function openstation_files_rest_create_file_share( WP_REST_Request $req ) {
609 $capability = $req->get_param( 'capability' );
610 if ( null !== $capability && 'read' !== (string) $capability ) {
611 return new WP_Error(
612 'openstation_files_invalid_capability',
613 __( 'Uploaded files can only be shared read-only.', 'desktop-mode' ),
614 array( 'status' => 400 )
615 );
616 }
617 $id = openstation_stored_file_share_invite(
618 (int) $req['id'],
619 get_current_user_id(),
620 (int) $req->get_param( 'userId' )
621 );
622 if ( is_wp_error( $id ) ) {
623 return $id;
624 }
625 return rest_ensure_response( openstation_files_shape_file_share( openstation_files_get_share( $id ) ) );
626 }
627
628 /**
629 * DELETE /files/uploads/<id>/shares/<shareId> — revoke.
630 */
631 function openstation_files_rest_delete_file_share( WP_REST_Request $req ) {
632 $row = openstation_files_rest_resolve_file_share( $req );
633 if ( is_wp_error( $row ) ) {
634 return $row;
635 }
636 $ok = openstation_stored_file_share_revoke( (int) $row['id'], get_current_user_id() );
637 if ( is_wp_error( $ok ) ) {
638 return $ok;
639 }
640 return rest_ensure_response( array( 'deleted' => true ) );
641 }
642
643 /**
644 * POST .../accept
645 */
646 function openstation_files_rest_accept_file_share( WP_REST_Request $req ) {
647 $row = openstation_files_rest_resolve_file_share( $req );
648 if ( is_wp_error( $row ) ) {
649 return $row;
650 }
651 $next = openstation_stored_file_share_accept( (int) $row['id'], get_current_user_id() );
652 if ( is_wp_error( $next ) ) {
653 return $next;
654 }
655 return rest_ensure_response( openstation_files_shape_file_share( $next ) );
656 }
657
658 /**
659 * POST .../deny
660 */
661 function openstation_files_rest_deny_file_share( WP_REST_Request $req ) {
662 $row = openstation_files_rest_resolve_file_share( $req );
663 if ( is_wp_error( $row ) ) {
664 return $row;
665 }
666 $next = openstation_stored_file_share_deny( (int) $row['id'], get_current_user_id() );
667 if ( is_wp_error( $next ) ) {
668 return $next;
669 }
670 return rest_ensure_response( openstation_files_shape_file_share( $next ) );
671 }
672
673 /**
674 * POST /files/uploads/<id>/leave
675 */
676 function openstation_files_rest_leave_file_share( WP_REST_Request $req ) {
677 $ok = openstation_stored_file_share_leave( (int) $req['id'], get_current_user_id() );
678 if ( is_wp_error( $ok ) ) {
679 return $ok;
680 }
681 return rest_ensure_response( array( 'left' => true ) );
682 }
683
684 // ---------------------------------------------------------------------------
685 // Delivery: shell config + heartbeat.
686 // ---------------------------------------------------------------------------
687
688 /**
689 * Append pending file-share invites to the boot-time
690 * `serverPendingShares` array (after the folder injection at 20).
691 * File shapes carry `targetType: 'file'` + `fileId` / `fileName`
692 * so the invite banner can branch.
693 *
694 * @param array $config Shell config.
695 * @return array
696 */
697 function openstation_files_file_share_inject_shell_config( $config ) {
698 $user_id = get_current_user_id();
699 $sharing_enabled = function_exists( 'openstation_files_sharing_enabled_for' )
700 ? openstation_files_sharing_enabled_for( $user_id )
701 : true;
702 if ( $user_id <= 0 || ! $sharing_enabled ) {
703 return $config;
704 }
705 $pending = isset( $config['serverPendingShares'] ) && is_array( $config['serverPendingShares'] )
706 ? $config['serverPendingShares']
707 : array();
708 foreach ( openstation_files_get_pending_file_shares_for_user( $user_id, 0 ) as $row ) {
709 $pending[] = openstation_files_shape_file_share( $row );
710 }
711 $config['serverPendingShares'] = $pending;
712 return $config;
713 }
714 add_filter( 'openstation_shell_config', 'openstation_files_file_share_inject_shell_config', 21 );
715