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

724 lines 23.9 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 // A previous attempt may have accepted the invite but failed to plant
212 // its tile (for example, a busy upload lock). Allow that step to retry.
213 $tables = openstation_files_table_names();
214 $placed = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$tables['placements']} WHERE owner_id = %d AND file_type = 'upload' AND file_ref = %s LIMIT 1", $user_id, (string) $row['folder_id'] ) );
215 if ( $placed ) {
216 return $row;
217 }
218 }
219 if ( 'denied' === $row['state'] ) {
220 return new WP_Error( 'openstation_files_share_already_denied', __( 'This invite was denied.', 'desktop-mode' ), array( 'status' => 410 ) );
221 }
222
223 $tables = openstation_files_table_names();
224 $wpdb->update(
225 $tables['shares'],
226 array(
227 'state' => 'accepted',
228 'decided_at_ms' => openstation_files_now_ms(),
229 ),
230 array( 'id' => $share_id ),
231 array( '%s', '%d' ),
232 array( '%d' )
233 );
234
235 // Plant the tile — AFTER the state flip so the placement's
236 // `can_read` gate sees the accepted share.
237 $file_id = (int) $row['folder_id'];
238 /** This filter is documented in includes/desktop-files/shares-store.php */
239 $parent_id = (int) apply_filters( 'openstation_folder_share_accept_default_parent', 0, $file_id, $user_id, $row );
240 $placed = openstation_files_place_at_next_free_slot( $user_id, $parent_id, 'upload', (string) $file_id );
241 if ( is_wp_error( $placed ) ) {
242 return $placed;
243 }
244
245 $next = openstation_files_get_share( $share_id );
246
247 /** This action is documented in includes/desktop-files/shares-store.php */
248 do_action( 'openstation_files_share_accepted', $share_id, $next, $user_id );
249
250 return $next;
251 }
252
253 /**
254 * Recipient denies a file share.
255 *
256 * @param int $share_id Share id.
257 * @param int $user_id Recipient.
258 * @return array|WP_Error Updated share row.
259 */
260 function openstation_stored_file_share_deny( $share_id, $user_id ) {
261 global $wpdb;
262 $share_id = (int) $share_id;
263 $user_id = (int) $user_id;
264 $row = openstation_files_get_share( $share_id );
265 if ( ! $row || 'file' !== $row['target_type'] ) {
266 return new WP_Error( 'openstation_files_share_not_found', __( 'Share not found.', 'desktop-mode' ), array( 'status' => 404 ) );
267 }
268 if ( 'user' !== $row['principal_type'] || (int) $row['principal_ref'] !== $user_id ) {
269 return new WP_Error( 'openstation_files_share_not_recipient', __( 'This invite is not for you.', 'desktop-mode' ), array( 'status' => 403 ) );
270 }
271 if ( 'denied' === $row['state'] ) {
272 return $row;
273 }
274 $was_accepted = 'accepted' === $row['state'];
275
276 $tables = openstation_files_table_names();
277 $wpdb->update(
278 $tables['shares'],
279 array(
280 'state' => 'denied',
281 'decided_at_ms' => openstation_files_now_ms(),
282 ),
283 array( 'id' => $share_id ),
284 array( '%s', '%d' ),
285 array( '%d' )
286 );
287 if ( $was_accepted ) {
288 openstation_files_trash_upload_for_user( (int) $row['folder_id'], $user_id );
289 }
290
291 $next = openstation_files_get_share( $share_id );
292
293 /** This action is documented in includes/desktop-files/shares-store.php */
294 do_action( 'openstation_files_share_denied', $share_id, $next, $user_id );
295
296 return $next;
297 }
298
299 /**
300 * Recipient leaves a previously accepted file share.
301 *
302 * @param int $file_id Stored-file id.
303 * @param int $user_id Recipient.
304 * @return true|WP_Error
305 */
306 function openstation_stored_file_share_leave( $file_id, $user_id ) {
307 global $wpdb;
308 $file_id = (int) $file_id;
309 $user_id = (int) $user_id;
310 $file = openstation_stored_files_get( $file_id );
311 if ( ! $file ) {
312 return new WP_Error( 'openstation_files_not_found', __( 'File not found.', 'desktop-mode' ), array( 'status' => 404 ) );
313 }
314 if ( (int) $file['owner_id'] === $user_id ) {
315 return new WP_Error( 'openstation_files_owner_cannot_leave', __( 'Owners cannot leave their own file.', 'desktop-mode' ), array( 'status' => 400 ) );
316 }
317
318 $tables = openstation_files_table_names();
319 $row = $wpdb->get_row(
320 $wpdb->prepare(
321 "SELECT * FROM {$tables['shares']}
322 WHERE target_type = 'file' AND folder_id = %d
323 AND principal_type = 'user' AND principal_ref = %s",
324 $file_id,
325 (string) $user_id
326 ),
327 ARRAY_A
328 );
329
330 // Scrub the recipient's tile regardless — lingering placements
331 // from a previously revoked share must go too.
332 openstation_files_trash_upload_for_user( $file_id, $user_id );
333
334 if ( ! $row ) {
335 return new WP_Error( 'openstation_files_not_member', __( 'You do not have access to this file.', 'desktop-mode' ), array( 'status' => 404 ) );
336 }
337 $normalized = openstation_files_normalize_share_row( $row );
338 $wpdb->update(
339 $tables['shares'],
340 array(
341 'state' => 'denied',
342 'decided_at_ms' => openstation_files_now_ms(),
343 ),
344 array( 'id' => (int) $row['id'] ),
345 array( '%s', '%d' ),
346 array( '%d' )
347 );
348
349 /** This action is documented in includes/desktop-files/shares-store.php */
350 do_action( 'openstation_files_share_left', (int) $row['id'], $normalized, $user_id );
351
352 return true;
353 }
354
355 /**
356 * Owner revokes a file share.
357 *
358 * @param int $share_id Share id.
359 * @param int $actor_id Actor.
360 * @return true|WP_Error
361 */
362 function openstation_stored_file_share_revoke( $share_id, $actor_id ) {
363 global $wpdb;
364 $share_id = (int) $share_id;
365 $actor_id = (int) $actor_id;
366 $row = openstation_files_get_share( $share_id );
367 if ( ! $row || 'file' !== $row['target_type'] ) {
368 return new WP_Error( 'openstation_files_share_not_found', __( 'Share not found.', 'desktop-mode' ), array( 'status' => 404 ) );
369 }
370 if ( ! openstation_stored_files_share_can_manage( (int) $row['folder_id'], $actor_id ) ) {
371 return new WP_Error( 'openstation_files_forbidden', __( 'You cannot manage shares for this file.', 'desktop-mode' ), array( 'status' => 403 ) );
372 }
373
374 $tables = openstation_files_table_names();
375 $wpdb->delete( $tables['shares'], array( 'id' => $share_id ), array( '%d' ) );
376 $wpdb->delete( $tables['decisions'], array( 'share_id' => $share_id ), array( '%d' ) );
377
378 if ( 'accepted' === $row['state'] ) {
379 openstation_files_trash_upload_for_user( (int) $row['folder_id'], (int) $row['principal_ref'] );
380 }
381
382 /** This action is documented in includes/desktop-files/shares-store.php */
383 do_action( 'openstation_files_share_revoked', $share_id, $row, $actor_id );
384
385 return true;
386 }
387
388 /**
389 * Soft-trash a recipient's placements of an uploaded file (their
390 * desktop tile). Direct DB update on purpose — the owner-lock trash
391 * gate would (correctly) refuse a recipient-initiated trash through
392 * the normal flow; this administrative scrub bypasses it. No
393 * tombstones: soft-trash rides the heartbeat's `trashed_at_ms`
394 * channel (same invariant as the folder scrub).
395 *
396 * @param int $file_id Stored-file id.
397 * @param int $user_id Recipient whose placements to scrub.
398 * @return int Rows scrubbed.
399 */
400 function openstation_files_trash_upload_for_user( $file_id, $user_id ) {
401 global $wpdb;
402 $file_id = (int) $file_id;
403 $user_id = (int) $user_id;
404 if ( $file_id <= 0 || $user_id <= 0 ) {
405 return 0;
406 }
407 $tables = openstation_files_table_names();
408 $now = openstation_files_now_ms();
409 $rows = $wpdb->get_results(
410 $wpdb->prepare(
411 "SELECT id FROM {$tables['placements']}
412 WHERE owner_id = %d
413 AND file_type = 'upload'
414 AND file_ref = %s
415 AND trashed_at_ms IS NULL",
416 $user_id,
417 (string) $file_id
418 ),
419 ARRAY_A
420 );
421 $count = 0;
422 foreach ( (array) $rows as $row ) {
423 $wpdb->update(
424 $tables['placements'],
425 array(
426 'trashed_at_ms' => $now,
427 'trashed_by' => $user_id,
428 ),
429 array( 'id' => (int) $row['id'] ),
430 array( '%d', '%d' ),
431 array( '%d' )
432 );
433 ++$count;
434 }
435 return $count;
436 }
437
438 /**
439 * Pending file-share invites for a user (heartbeat + shell-config
440 * delivery). User-principal only.
441 *
442 * @param int $user_id Viewer.
443 * @param int $since_ms Only rows with `invited_at_ms > since`.
444 * @return array[] Normalized share rows.
445 */
446 function openstation_files_get_pending_file_shares_for_user( $user_id, $since_ms = 0 ) {
447 global $wpdb;
448 $user_id = (int) $user_id;
449 if ( $user_id <= 0 ) {
450 return array();
451 }
452 $tables = openstation_files_table_names();
453 $rows = $wpdb->get_results(
454 $wpdb->prepare(
455 "SELECT s.* FROM {$tables['shares']} s
456 INNER JOIN {$tables['stored_files']} sf ON sf.id = s.folder_id
457 WHERE s.target_type = 'file'
458 AND s.state = 'pending'
459 AND s.invited_at_ms > %d
460 AND s.principal_type = 'user'
461 AND s.principal_ref = %s
462 ORDER BY s.invited_at_ms ASC, s.id ASC",
463 (int) $since_ms,
464 (string) $user_id
465 ),
466 ARRAY_A
467 );
468 $out = array();
469 foreach ( (array) $rows as $row ) {
470 $out[] = openstation_files_normalize_share_row( $row );
471 }
472 return $out;
473 }
474
475 /**
476 * Wire shape for a file share, enriched for the invite banner.
477 *
478 * @param array $row Normalized share row (`target_type='file'`).
479 * @return array
480 */
481 function openstation_files_shape_file_share( $row ) {
482 $file = openstation_stored_files_get( (int) $row['folder_id'] );
483 $shape = array(
484 'id' => (int) $row['id'],
485 'targetType' => 'file',
486 'fileId' => (int) $row['folder_id'],
487 'principalType' => (string) $row['principal_type'],
488 'principalRef' => (string) $row['principal_ref'],
489 'capability' => 'read',
490 'state' => (string) $row['state'],
491 'invitedBy' => (int) $row['invited_by'],
492 'invitedAtMs' => (int) $row['invited_at_ms'],
493 'decidedAtMs' => isset( $row['decided_at_ms'] ) ? $row['decided_at_ms'] : null,
494 );
495 if ( $file ) {
496 $shape['fileName'] = (string) $file['display_name'];
497 $shape['ownerId'] = (int) $file['owner_id'];
498 $owner = get_userdata( (int) $file['owner_id'] );
499 $shape['ownerName'] = $owner ? $owner->display_name : '';
500 $shape['ownerAvatar'] = $owner ? get_avatar_url( $owner->ID, array( 'size' => 48 ) ) : '';
501 }
502 // Principal enrichment for the owner-side share list.
503 $principal = get_userdata( (int) $row['principal_ref'] );
504 $shape['displayName'] = $principal ? $principal->display_name : '';
505 $shape['avatarUrl'] = $principal ? get_avatar_url( $principal->ID, array( 'size' => 48 ) ) : '';
506 return $shape;
507 }
508
509 // ---------------------------------------------------------------------------
510 // REST routes.
511 // ---------------------------------------------------------------------------
512
513 /**
514 * Register the file-share routes. Same 404-when-disabled gate as
515 * every other share route (`openstation_files_rest_share_permission`).
516 */
517 function openstation_files_register_file_share_rest_routes() {
518 $ns = 'desktop-mode/v1';
519
520 register_rest_route(
521 $ns,
522 '/files/uploads/(?P<id>\d+)/shares',
523 array(
524 array(
525 'methods' => WP_REST_Server::READABLE,
526 'permission_callback' => 'openstation_files_rest_share_permission',
527 'callback' => 'openstation_files_rest_list_file_shares',
528 ),
529 array(
530 'methods' => WP_REST_Server::CREATABLE,
531 'permission_callback' => 'openstation_files_rest_share_permission',
532 'callback' => 'openstation_files_rest_create_file_share',
533 'args' => array(
534 'userId' => array(
535 'type' => 'integer',
536 'required' => true,
537 ),
538 ),
539 ),
540 )
541 );
542 register_rest_route(
543 $ns,
544 '/files/uploads/(?P<id>\d+)/shares/(?P<shareId>\d+)',
545 array(
546 'methods' => WP_REST_Server::DELETABLE,
547 'permission_callback' => 'openstation_files_rest_share_permission',
548 'callback' => 'openstation_files_rest_delete_file_share',
549 )
550 );
551 register_rest_route(
552 $ns,
553 '/files/uploads/(?P<id>\d+)/shares/(?P<shareId>\d+)/accept',
554 array(
555 'methods' => WP_REST_Server::CREATABLE,
556 'permission_callback' => 'openstation_files_rest_share_permission',
557 'callback' => 'openstation_files_rest_accept_file_share',
558 )
559 );
560 register_rest_route(
561 $ns,
562 '/files/uploads/(?P<id>\d+)/shares/(?P<shareId>\d+)/deny',
563 array(
564 'methods' => WP_REST_Server::CREATABLE,
565 'permission_callback' => 'openstation_files_rest_share_permission',
566 'callback' => 'openstation_files_rest_deny_file_share',
567 )
568 );
569 register_rest_route(
570 $ns,
571 '/files/uploads/(?P<id>\d+)/leave',
572 array(
573 'methods' => WP_REST_Server::CREATABLE,
574 'permission_callback' => 'openstation_files_rest_share_permission',
575 'callback' => 'openstation_files_rest_leave_file_share',
576 )
577 );
578 }
579 add_action( 'rest_api_init', 'openstation_files_register_file_share_rest_routes' );
580
581 /**
582 * Resolve the `{shareId}` inside `{id}` or fail with a masked 404.
583 *
584 * @internal
585 *
586 * @param WP_REST_Request $req Request.
587 * @return array|WP_Error Normalized share row.
588 */
589 function openstation_files_rest_resolve_file_share( WP_REST_Request $req ) {
590 $row = openstation_files_get_share( (int) $req['shareId'] );
591 if ( ! $row || 'file' !== $row['target_type'] || (int) $row['folder_id'] !== (int) $req['id'] ) {
592 return new WP_Error( 'openstation_files_share_not_found', __( 'Share not found.', 'desktop-mode' ), array( 'status' => 404 ) );
593 }
594 return $row;
595 }
596
597 /**
598 * GET /files/uploads/<id>/shares (managers only).
599 */
600 function openstation_files_rest_list_file_shares( WP_REST_Request $req ) {
601 $file_id = (int) $req['id'];
602 $user_id = get_current_user_id();
603 if ( ! openstation_stored_files_share_can_manage( $file_id, $user_id ) ) {
604 return openstation_files_download_not_found();
605 }
606 $out = array();
607 foreach ( openstation_stored_files_get_file_shares( $file_id ) as $row ) {
608 $out[] = openstation_files_shape_file_share( $row );
609 }
610 return rest_ensure_response( array( 'shares' => $out ) );
611 }
612
613 /**
614 * POST /files/uploads/<id>/shares — invite (read tier, always).
615 * A `capability` param, if sent, must be `read` — `write` is 400.
616 */
617 function openstation_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 'openstation_files_invalid_capability',
622 __( 'Uploaded files can only be shared read-only.', 'desktop-mode' ),
623 array( 'status' => 400 )
624 );
625 }
626 $id = openstation_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( openstation_files_shape_file_share( openstation_files_get_share( $id ) ) );
635 }
636
637 /**
638 * DELETE /files/uploads/<id>/shares/<shareId> — revoke.
639 */
640 function openstation_files_rest_delete_file_share( WP_REST_Request $req ) {
641 $row = openstation_files_rest_resolve_file_share( $req );
642 if ( is_wp_error( $row ) ) {
643 return $row;
644 }
645 $ok = openstation_stored_file_share_revoke( (int) $row['id'], get_current_user_id() );
646 if ( is_wp_error( $ok ) ) {
647 return $ok;
648 }
649 return rest_ensure_response( array( 'deleted' => true ) );
650 }
651
652 /**
653 * POST .../accept
654 */
655 function openstation_files_rest_accept_file_share( WP_REST_Request $req ) {
656 $row = openstation_files_rest_resolve_file_share( $req );
657 if ( is_wp_error( $row ) ) {
658 return $row;
659 }
660 $next = openstation_stored_file_share_accept( (int) $row['id'], get_current_user_id() );
661 if ( is_wp_error( $next ) ) {
662 return $next;
663 }
664 return rest_ensure_response( openstation_files_shape_file_share( $next ) );
665 }
666
667 /**
668 * POST .../deny
669 */
670 function openstation_files_rest_deny_file_share( WP_REST_Request $req ) {
671 $row = openstation_files_rest_resolve_file_share( $req );
672 if ( is_wp_error( $row ) ) {
673 return $row;
674 }
675 $next = openstation_stored_file_share_deny( (int) $row['id'], get_current_user_id() );
676 if ( is_wp_error( $next ) ) {
677 return $next;
678 }
679 return rest_ensure_response( openstation_files_shape_file_share( $next ) );
680 }
681
682 /**
683 * POST /files/uploads/<id>/leave
684 */
685 function openstation_files_rest_leave_file_share( WP_REST_Request $req ) {
686 $ok = openstation_stored_file_share_leave( (int) $req['id'], get_current_user_id() );
687 if ( is_wp_error( $ok ) ) {
688 return $ok;
689 }
690 return rest_ensure_response( array( 'left' => true ) );
691 }
692
693 // ---------------------------------------------------------------------------
694 // Delivery: shell config + heartbeat.
695 // ---------------------------------------------------------------------------
696
697 /**
698 * Append pending file-share invites to the boot-time
699 * `serverPendingShares` array (after the folder injection at 20).
700 * File shapes carry `targetType: 'file'` + `fileId` / `fileName`
701 * so the invite banner can branch.
702 *
703 * @param array $config Shell config.
704 * @return array
705 */
706 function openstation_files_file_share_inject_shell_config( $config ) {
707 $user_id = get_current_user_id();
708 $sharing_enabled = function_exists( 'openstation_files_sharing_enabled_for' )
709 ? openstation_files_sharing_enabled_for( $user_id )
710 : true;
711 if ( $user_id <= 0 || ! $sharing_enabled ) {
712 return $config;
713 }
714 $pending = isset( $config['serverPendingShares'] ) && is_array( $config['serverPendingShares'] )
715 ? $config['serverPendingShares']
716 : array();
717 foreach ( openstation_files_get_pending_file_shares_for_user( $user_id, 0 ) as $row ) {
718 $pending[] = openstation_files_shape_file_share( $row );
719 }
720 $config['serverPendingShares'] = $pending;
721 return $config;
722 }
723 add_filter( 'openstation_shell_config', 'openstation_files_file_share_inject_shell_config', 21 );
724