PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.0.0
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.0.0
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 / shares-store.php

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

1,578 lines 51.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Folder shares store.
4 *
5 * CRUD + ACL resolver for the v8 `_desktop_mode_folder_shares`
6 * table. Each row is a single (folder, principal) grant carrying:
7 *
8 * - `capability` (`read` | `write`) — what the recipient may do
9 * to the FOLDER ICON (move it, trash it, place icons inside).
10 * This is intentionally orthogonal to capabilities on the
11 * underlying item (sharing a folder that contains a post does
12 * NOT grant edit_posts on that post).
13 *
14 * - `state` (`pending` | `accepted` | `denied`) — opt-in marker.
15 * A pending grant is visible to the recipient via the
16 * heartbeat `shares.pending` channel only; the folder itself
17 * does not appear in `compute_visible_folders` until accept.
18 *
19 * The owner of a folder (folders.owner_id) is the implicit "admin"
20 * of its shares: always writes, never visible in the shares table.
21 *
22 * @package OpenStation
23 */
24
25 defined( 'ABSPATH' ) || exit;
26
27 /**
28 * Whether the folder-sharing feature is enabled for a viewer.
29 *
30 * Reads `foldersSharingEnabled` from the user's OS Settings
31 * (defaults to true). Gates every share-related delivery and REST
32 * route — when a user has it off:
33 *
34 * - The heartbeat skips the `shares.pending` payload for them.
35 * - The REST share routes return 404 (look the same as a
36 * plugin that doesn't ship the feature, no information leak).
37 * - The client suppresses every share UI surface.
38 *
39 * Plugins can short-circuit via the
40 * `openstation_files_sharing_enabled_for` filter (e.g. force-off
41 * on a multisite subsite, gate by capability, etc.).
42 *
43 * @param int $user_id Viewer id. `0` is treated as disabled.
44 * @return bool
45 */
46 function openstation_files_sharing_enabled_for( $user_id ) {
47 $user_id = (int) $user_id;
48 if ( $user_id <= 0 ) {
49 return false;
50 }
51 $enabled = true;
52 if ( function_exists( 'openstation_get_os_settings' ) ) {
53 $settings = openstation_get_os_settings( $user_id );
54 $enabled = ! empty( $settings['foldersSharingEnabled'] );
55 }
56 /**
57 * Filter the per-user folder-sharing kill switch.
58 *
59 * @param bool $enabled Default reads from OS Settings.
60 * @param int $user_id Viewer.
61 */
62 return (bool) apply_filters( 'openstation_files_sharing_enabled_for', $enabled, $user_id );
63 }
64
65 /** Allowed principal-type values. */
66 function openstation_files_share_principal_types() {
67 return array( 'user', 'role' );
68 }
69
70 /**
71 * Target types that support sharing. The shares table carries a
72 * `target_type` column on every row; this filter declares which
73 * values the framework will accept on `openstation_share_invite`
74 * + friends.
75 *
76 * Default ships with `'folder'`. A plugin that wants to add
77 * shareable posts (or any other entity) registers their type plus
78 * an owner resolver:
79 *
80 * ```php
81 * add_filter( 'openstation_files_shareable_types', function ( $types ) {
82 * $types[] = 'post';
83 * return $types;
84 * } );
85 * add_filter( 'openstation_files_share_target_owner', function ( $owner_id, $type, $ref ) {
86 * if ( 'post' === $type ) {
87 * return (int) get_post_field( 'post_author', (int) $ref );
88 * }
89 * return $owner_id;
90 * }, 10, 3 );
91 * ```
92 *
93 * v1 of the share-settings modal only knows about folders; the
94 * REST routes live at `/folders/{id}/shares`. A future modal
95 * generalisation (or a per-type opener) can hit the same store
96 * functions with a different `$target_type`.
97 *
98 * @return string[]
99 */
100 function openstation_files_shareable_types() {
101 /**
102 * Filter the list of target types that support sharing.
103 *
104 * @param string[] $types Default `[ 'folder', 'file' ]` ('file'
105 * = stored uploads).
106 */
107 $types = (array) apply_filters( 'openstation_files_shareable_types', array( 'folder', 'file' ) );
108 return array_values( array_unique( array_filter( array_map( 'strval', $types ) ) ) );
109 }
110
111 /**
112 * Owner of a shareable target. Defaults to the folder owner when
113 * `$target_type === 'folder'`. Plugins extending the system to a
114 * new type register a filter that returns the correct owner id.
115 *
116 * @param string $target_type Target type slug.
117 * @param string $target_id Target id (stringified — folder ids
118 * are integers, but custom types may
119 * use slugs).
120 * @return int Owner user id, or 0 if unknown.
121 */
122 function openstation_files_share_target_owner( $target_type, $target_id ) {
123 $owner = 0;
124 if ( 'folder' === $target_type ) {
125 $folder = openstation_files_get_folder( (int) $target_id );
126 if ( $folder ) {
127 $owner = (int) $folder['owner_id'];
128 }
129 } elseif ( 'file' === $target_type && function_exists( 'openstation_stored_files_get' ) ) {
130 $file = openstation_stored_files_get( (int) $target_id );
131 if ( $file ) {
132 $owner = (int) $file['owner_id'];
133 }
134 }
135 /**
136 * Filter the owner of a shareable target.
137 *
138 * @param int $owner Default owner. 0 = unknown.
139 * @param string $target_type Target type slug.
140 * @param string $target_id Target id.
141 */
142 return (int) apply_filters( 'openstation_files_share_target_owner', $owner, (string) $target_type, (string) $target_id );
143 }
144
145 /** Allowed capability values. */
146 function openstation_files_share_capabilities() {
147 return array( 'read', 'write' );
148 }
149
150 /** Allowed state values. */
151 function openstation_files_share_states() {
152 return array( 'pending', 'accepted', 'denied' );
153 }
154
155 /**
156 * Roles eligible to appear in the share picker. Defaults to every
157 * role on the site that carries `edit_posts`. Plugins can override
158 * via the `openstation_files_share_eligible_roles` filter — site
159 * owners typically use this to whitelist a custom team role.
160 *
161 * @return array<int, array{ slug:string, name:string }>
162 */
163 function openstation_files_share_eligible_roles() {
164 $out = array();
165 $roles = wp_roles();
166 if ( $roles && is_array( $roles->roles ) ) {
167 foreach ( $roles->roles as $slug => $info ) {
168 $caps = isset( $info['capabilities'] ) ? (array) $info['capabilities'] : array();
169 if ( ! empty( $caps['edit_posts'] ) ) {
170 $out[] = array(
171 'slug' => (string) $slug,
172 'name' => isset( $info['name'] ) ? translate_user_role( (string) $info['name'] ) : (string) $slug,
173 );
174 }
175 }
176 }
177 /**
178 * Filter the roles eligible to appear in the folder share picker.
179 *
180 * @param array<int, array{ slug:string, name:string }> $roles Default = roles with `edit_posts`.
181 */
182 $out = (array) apply_filters( 'openstation_files_share_eligible_roles', $out );
183 return $out;
184 }
185
186 /**
187 * Whether `$user_id` may manage the share rules of `$folder_id`.
188 * Default: only the folder's owner. Plugins (e.g. a team-admin
189 * extension) can broaden this via the filter.
190 *
191 * @param int $folder_id Folder id.
192 * @param int $user_id Viewer.
193 * @return bool
194 */
195 function openstation_files_share_can_manage( $folder_id, $user_id ) {
196 $folder = openstation_files_get_folder( (int) $folder_id );
197 $can = $folder && (int) $folder['owner_id'] === (int) $user_id;
198 /**
199 * Filter who can manage a folder's share rules.
200 *
201 * @param bool $can Default: owner-only.
202 * @param int $folder_id Folder id.
203 * @param int $user_id Viewer.
204 * @param array|null $folder Normalized folder row (null when missing).
205 */
206 return (bool) apply_filters( 'openstation_files_share_can_manage', $can, (int) $folder_id, (int) $user_id, $folder );
207 }
208
209 /**
210 * Coerce a raw wpdb shares row to typed values.
211 *
212 * @internal
213 *
214 * @param array $row Raw wpdb row.
215 * @return array
216 */
217 function openstation_files_normalize_share_row( $row ) {
218 return array(
219 'id' => (int) $row['id'],
220 // `target_type` defaults to 'folder' for rows that predate
221 // the column. The `folder_id` column carries the TARGET id —
222 // a folder id for folder shares, a stored-file id for
223 // `target_type='file'` rows (historical column name).
224 'target_type' => isset( $row['target_type'] ) && '' !== (string) $row['target_type']
225 ? (string) $row['target_type']
226 : 'folder',
227 'folder_id' => (int) $row['folder_id'],
228 'principal_type' => (string) $row['principal_type'],
229 'principal_ref' => (string) $row['principal_ref'],
230 'capability' => (string) $row['capability'],
231 'state' => (string) $row['state'],
232 'invited_by' => (int) $row['invited_by'],
233 'invited_at_ms' => (int) $row['invited_at_ms'],
234 'decided_at_ms' => isset( $row['decided_at_ms'] ) && null !== $row['decided_at_ms']
235 ? (int) $row['decided_at_ms']
236 : null,
237 );
238 }
239
240 /**
241 * Read a single share row by id.
242 *
243 * @param int $share_id Share id.
244 * @return array|null
245 */
246 function openstation_files_get_share( $share_id ) {
247 global $wpdb;
248 $tables = openstation_files_table_names();
249 $row = $wpdb->get_row(
250 $wpdb->prepare( "SELECT * FROM {$tables['shares']} WHERE id = %d", (int) $share_id ),
251 ARRAY_A
252 );
253 if ( ! $row ) {
254 return null;
255 }
256 return openstation_files_normalize_share_row( $row );
257 }
258
259 /**
260 * Every share row for a folder. Owner-internal view.
261 *
262 * @param int $folder_id Folder id.
263 * @return array[]
264 */
265 function openstation_files_get_folder_shares( $folder_id ) {
266 global $wpdb;
267 $tables = openstation_files_table_names();
268 $rows = $wpdb->get_results(
269 $wpdb->prepare(
270 "SELECT * FROM {$tables['shares']} WHERE target_type = 'folder' AND folder_id = %d ORDER BY invited_at_ms ASC, id ASC",
271 (int) $folder_id
272 ),
273 ARRAY_A
274 );
275 $out = array();
276 foreach ( (array) $rows as $row ) {
277 $out[] = openstation_files_normalize_share_row( $row );
278 }
279 return $out;
280 }
281
282 /**
283 * Invite a principal to a folder.
284 *
285 * @param int $folder_id Folder id.
286 * @param int $actor_id Actor (must be able to manage the folder).
287 * @param string $principal_type 'user' | 'role'.
288 * @param string $principal_ref User id (stringified) or role slug.
289 * @param string $capability 'read' | 'write'.
290 * @return int|WP_Error Share id on success.
291 */
292 function openstation_folder_share_invite( $folder_id, $actor_id, $principal_type, $principal_ref, $capability = 'read' ) {
293 global $wpdb;
294 $folder_id = (int) $folder_id;
295 $actor_id = (int) $actor_id;
296 $principal_type = (string) $principal_type;
297 $principal_ref = (string) $principal_ref;
298 $capability = (string) $capability;
299
300 if ( ! in_array( $principal_type, openstation_files_share_principal_types(), true ) ) {
301 return new WP_Error( 'openstation_files_invalid_principal_type', __( 'Invalid principal type.', 'desktop-mode' ), array( 'status' => 400 ) );
302 }
303 if ( ! in_array( $capability, openstation_files_share_capabilities(), true ) ) {
304 return new WP_Error( 'openstation_files_invalid_capability', __( 'Invalid capability.', 'desktop-mode' ), array( 'status' => 400 ) );
305 }
306 if ( ! openstation_files_share_can_manage( $folder_id, $actor_id ) ) {
307 return new WP_Error( 'openstation_files_forbidden', __( 'You cannot manage shares for this folder.', 'desktop-mode' ), array( 'status' => 403 ) );
308 }
309
310 // Eligibility gate. Users must have `edit_posts`; roles must
311 // appear in the eligible-roles list. This is the only place
312 // the "exclude low-tier roles" rule is enforced — visibility
313 // is computed downstream from accepted rows on this table.
314 if ( 'user' === $principal_type ) {
315 $uid = (int) $principal_ref;
316 if ( $uid <= 0 ) {
317 return new WP_Error( 'openstation_files_invalid_user', __( 'Invalid user id.', 'desktop-mode' ), array( 'status' => 400 ) );
318 }
319 $user = get_userdata( $uid );
320 if ( ! $user ) {
321 return new WP_Error( 'openstation_files_unknown_user', __( 'Unknown user.', 'desktop-mode' ), array( 'status' => 404 ) );
322 }
323 $folder = openstation_files_get_folder( $folder_id );
324 $owner_id = $folder ? (int) $folder['owner_id'] : 0;
325 if ( $uid === $owner_id ) {
326 return new WP_Error( 'openstation_files_share_owner', __( 'You cannot share with the folder owner.', 'desktop-mode' ), array( 'status' => 400 ) );
327 }
328 if ( ! user_can( $user, 'edit_posts' ) ) {
329 return new WP_Error( 'openstation_files_ineligible_principal', __( 'This user is not eligible.', 'desktop-mode' ), array( 'status' => 400 ) );
330 }
331 $principal_ref = (string) $uid;
332 } else {
333 $eligible = wp_list_pluck( openstation_files_share_eligible_roles(), 'slug' );
334 if ( ! in_array( $principal_ref, $eligible, true ) ) {
335 return new WP_Error( 'openstation_files_ineligible_role', __( 'This role is not eligible.', 'desktop-mode' ), array( 'status' => 400 ) );
336 }
337 }
338
339 $tables = openstation_files_table_names();
340 $now = openstation_files_now_ms();
341
342 // Idempotent invite: a pre-existing row with state='denied'
343 // becomes 'pending' again (owner re-inviting after a no);
344 // a pre-existing 'pending' or 'accepted' row keeps its state
345 // but may have its capability bumped to the new value.
346 $existing = $wpdb->get_row(
347 $wpdb->prepare(
348 "SELECT * FROM {$tables['shares']}
349 WHERE target_type = 'folder' AND folder_id = %d AND principal_type = %s AND principal_ref = %s",
350 $folder_id,
351 $principal_type,
352 $principal_ref
353 ),
354 ARRAY_A
355 );
356 if ( $existing ) {
357 $id = (int) $existing['id'];
358 $next_state = 'denied' === $existing['state'] ? 'pending' : $existing['state'];
359 $next_cap = $capability;
360 $set = array(
361 'capability' => $next_cap,
362 'state' => $next_state,
363 'invited_by' => $actor_id,
364 'invited_at_ms' => $now,
365 );
366 $fmt = array( '%s', '%s', '%d', '%d' );
367 if ( 'denied' === $existing['state'] ) {
368 $set['decided_at_ms'] = null;
369 $fmt[] = '%s';
370 }
371 $wpdb->update( $tables['shares'], $set, array( 'id' => $id ), $fmt, array( '%d' ) );
372 $row = openstation_files_get_share( $id );
373 } else {
374 $ok = $wpdb->insert(
375 $tables['shares'],
376 array(
377 'target_type' => 'folder',
378 'folder_id' => $folder_id,
379 'principal_type' => $principal_type,
380 'principal_ref' => $principal_ref,
381 'capability' => $capability,
382 'state' => 'pending',
383 'invited_by' => $actor_id,
384 'invited_at_ms' => $now,
385 ),
386 array( '%s', '%d', '%s', '%s', '%s', '%s', '%d', '%d' )
387 );
388 if ( false === $ok ) {
389 return new WP_Error( 'openstation_files_share_insert_failed', __( 'Failed to record share.', 'desktop-mode' ), array( 'status' => 500 ) );
390 }
391 $id = (int) $wpdb->insert_id;
392 $row = openstation_files_get_share( $id );
393 }
394
395 openstation_files_bump_folder_updated_at( $folder_id );
396
397 /**
398 * Fires after a share is invited (or re-invited).
399 *
400 * @param int $share_id Share id.
401 * @param array $row Share row.
402 * @param int $actor_id Acting user.
403 */
404 do_action( 'openstation_files_share_invited', $id, $row, $actor_id );
405
406 return $id;
407 }
408
409 /**
410 * Revoke a share. Owner-side action.
411 *
412 * @param int $share_id Share id.
413 * @param int $actor_id Actor.
414 * @return true|WP_Error
415 */
416 function openstation_folder_share_revoke( $share_id, $actor_id ) {
417 global $wpdb;
418 $share_id = (int) $share_id;
419 $actor_id = (int) $actor_id;
420 $row = openstation_files_get_share( $share_id );
421 if ( ! $row ) {
422 return new WP_Error( 'openstation_files_share_not_found', __( 'Share not found.', 'desktop-mode' ), array( 'status' => 404 ) );
423 }
424 if ( ! openstation_files_share_can_manage( $row['folder_id'], $actor_id ) ) {
425 return new WP_Error( 'openstation_files_forbidden', __( 'You cannot manage shares for this folder.', 'desktop-mode' ), array( 'status' => 403 ) );
426 }
427
428 $tables = openstation_files_table_names();
429 $wpdb->delete( $tables['shares'], array( 'id' => $share_id ), array( '%d' ) );
430 // Drop any per-user decision rows attached to this share so
431 // they don't leak past the row deletion.
432 $wpdb->delete( $tables['decisions'], array( 'share_id' => $share_id ), array( '%d' ) );
433
434 openstation_files_bump_folder_updated_at( $row['folder_id'] );
435
436 // Scrub recipient's local view. For user-principal grants
437 // that's the single recipient; for role-principal grants we
438 // scrub every user who had a decision row (i.e. interacted
439 // with the share). Users in the role who never interacted
440 // also lose visibility but have no local placement to trash.
441 if ( 'user' === $row['principal_type'] && 'accepted' === $row['state'] ) {
442 $uid = (int) $row['principal_ref'];
443 if ( $uid > 0 ) {
444 openstation_files_trash_folder_for_user( $row['folder_id'], $uid );
445 }
446 } elseif ( 'role' === $row['principal_type'] ) {
447 $decided_users = $wpdb->get_col(
448 $wpdb->prepare(
449 "SELECT DISTINCT user_id FROM {$tables['decisions']} WHERE share_id = %d",
450 $share_id
451 )
452 );
453 foreach ( (array) $decided_users as $uid ) {
454 openstation_files_trash_folder_for_user( $row['folder_id'], (int) $uid );
455 }
456 }
457
458 /**
459 * Fires after a share is revoked.
460 *
461 * @param int $share_id Share id.
462 * @param array $row Share row (last-known state).
463 * @param int $actor_id Acting user.
464 */
465 do_action( 'openstation_files_share_revoked', $share_id, $row, $actor_id );
466
467 return true;
468 }
469
470 /**
471 * Update the capability on a share. Owner-side action.
472 *
473 * @param int $share_id Share id.
474 * @param int $actor_id Actor.
475 * @param string $capability New capability.
476 * @return true|WP_Error
477 */
478 function openstation_folder_share_update_capability( $share_id, $actor_id, $capability ) {
479 global $wpdb;
480 $share_id = (int) $share_id;
481 $actor_id = (int) $actor_id;
482 $capability = (string) $capability;
483
484 if ( ! in_array( $capability, openstation_files_share_capabilities(), true ) ) {
485 return new WP_Error( 'openstation_files_invalid_capability', __( 'Invalid capability.', 'desktop-mode' ), array( 'status' => 400 ) );
486 }
487 $row = openstation_files_get_share( $share_id );
488 if ( ! $row ) {
489 return new WP_Error( 'openstation_files_share_not_found', __( 'Share not found.', 'desktop-mode' ), array( 'status' => 404 ) );
490 }
491 if ( ! openstation_files_share_can_manage( $row['folder_id'], $actor_id ) ) {
492 return new WP_Error( 'openstation_files_forbidden', __( 'You cannot manage shares for this folder.', 'desktop-mode' ), array( 'status' => 403 ) );
493 }
494
495 $tables = openstation_files_table_names();
496 $wpdb->update( $tables['shares'], array( 'capability' => $capability ), array( 'id' => $share_id ), array( '%s' ), array( '%d' ) );
497
498 openstation_files_bump_folder_updated_at( $row['folder_id'] );
499
500 $next = openstation_files_get_share( $share_id );
501
502 /**
503 * Fires after a share's capability is changed.
504 *
505 * @param int $share_id Share id.
506 * @param array $next Row after.
507 * @param array $prev Row before.
508 * @param int $actor_id Acting user.
509 */
510 do_action( 'openstation_files_share_capability_changed', $share_id, $next, $row, $actor_id );
511
512 return true;
513 }
514
515 /**
516 * Read this user's decision row for a share (role-principal only).
517 *
518 * @internal
519 *
520 * @param int $share_id Share id.
521 * @param int $user_id User.
522 * @return array|null Normalized decision row or null.
523 */
524 function openstation_files_get_user_decision( $share_id, $user_id ) {
525 global $wpdb;
526 $tables = openstation_files_table_names();
527 $row = $wpdb->get_row(
528 $wpdb->prepare(
529 "SELECT * FROM {$tables['decisions']} WHERE share_id = %d AND user_id = %d",
530 (int) $share_id,
531 (int) $user_id
532 ),
533 ARRAY_A
534 );
535 if ( ! $row ) {
536 return null;
537 }
538 return array(
539 'id' => (int) $row['id'],
540 'share_id' => (int) $row['share_id'],
541 'user_id' => (int) $row['user_id'],
542 'state' => (string) $row['state'],
543 'decided_at_ms' => (int) $row['decided_at_ms'],
544 );
545 }
546
547 /**
548 * Upsert a per-user decision (role-principal opt-in state).
549 *
550 * @internal
551 *
552 * @param int $share_id Share id.
553 * @param int $user_id User.
554 * @param string $state 'pending' | 'accepted' | 'denied'.
555 */
556 function openstation_files_upsert_user_decision( $share_id, $user_id, $state ) {
557 global $wpdb;
558 $tables = openstation_files_table_names();
559 $now = openstation_files_now_ms();
560 $wpdb->query(
561 $wpdb->prepare(
562 "INSERT INTO {$tables['decisions']}
563 (share_id, user_id, state, decided_at_ms)
564 VALUES (%d, %d, %s, %d)
565 ON DUPLICATE KEY UPDATE state = VALUES(state), decided_at_ms = VALUES(decided_at_ms)",
566 (int) $share_id,
567 (int) $user_id,
568 (string) $state,
569 $now
570 )
571 );
572 }
573
574 /**
575 * Resolve this user's effective state on a share:
576 *
577 * - user-principal: state lives on the share row itself.
578 * - role-principal: state lives on the per-user decisions
579 * table. Absence = 'pending' (user hasn't decided yet).
580 *
581 * @param array $share_row Normalized share row.
582 * @param int $user_id Viewer.
583 * @return string 'pending' | 'accepted' | 'denied'
584 */
585 function openstation_files_share_user_state( $share_row, $user_id ) {
586 if ( 'user' === $share_row['principal_type'] ) {
587 return (string) $share_row['state'];
588 }
589 if ( 'role' === $share_row['principal_type'] ) {
590 $dec = openstation_files_get_user_decision( (int) $share_row['id'], (int) $user_id );
591 if ( $dec ) {
592 return (string) $dec['state'];
593 }
594 return 'pending';
595 }
596 return 'pending';
597 }
598
599 /**
600 * Recipient accepts a share. Creates the recipient's placement of
601 * the folder at their desktop root.
602 *
603 * @param int $share_id Share id.
604 * @param int $user_id Acting user (must be the share's principal).
605 * @return array|WP_Error Share row on success.
606 */
607 function openstation_folder_share_accept( $share_id, $user_id ) {
608 global $wpdb;
609 $share_id = (int) $share_id;
610 $user_id = (int) $user_id;
611 $row = openstation_files_get_share( $share_id );
612 if ( ! $row || 'folder' !== $row['target_type'] ) {
613 return new WP_Error( 'openstation_files_share_not_found', __( 'Share not found.', 'desktop-mode' ), array( 'status' => 404 ) );
614 }
615 if ( ! openstation_files_share_principal_matches_user( $row, $user_id ) ) {
616 return new WP_Error( 'openstation_files_share_not_recipient', __( 'This invite is not for you.', 'desktop-mode' ), array( 'status' => 403 ) );
617 }
618 $state = openstation_files_share_user_state( $row, $user_id );
619 if ( 'accepted' === $state ) {
620 return $row;
621 }
622 if ( 'denied' === $state && 'user' === $row['principal_type'] ) {
623 return new WP_Error( 'openstation_files_share_already_denied', __( 'This invite was denied.', 'desktop-mode' ), array( 'status' => 410 ) );
624 }
625
626 $tables = openstation_files_table_names();
627 $now = openstation_files_now_ms();
628
629 if ( 'user' === $row['principal_type'] ) {
630 $wpdb->update(
631 $tables['shares'],
632 array(
633 'state' => 'accepted',
634 'decided_at_ms' => $now,
635 ),
636 array( 'id' => $share_id ),
637 array( '%s', '%d' ),
638 array( '%d' )
639 );
640 } else {
641 openstation_files_upsert_user_decision( $share_id, $user_id, 'accepted' );
642 }
643
644 openstation_files_bump_folder_updated_at( $row['folder_id'] );
645
646 // Place the folder on the recipient's desktop root.
647 $parent_id = (int) apply_filters( 'openstation_folder_share_accept_default_parent', 0, $row['folder_id'], $user_id, $row );
648 openstation_files_place_at_next_free_slot( $user_id, $parent_id, 'folder', (string) $row['folder_id'] );
649
650 $next = openstation_files_get_share( $share_id );
651
652 /**
653 * Fires after a share is accepted by its recipient.
654 *
655 * @param int $share_id Share id.
656 * @param array $row Updated share row.
657 * @param int $user_id Acting user (recipient).
658 */
659 do_action( 'openstation_files_share_accepted', $share_id, $next, $user_id );
660
661 return $next;
662 }
663
664 /**
665 * Recipient denies a share.
666 *
667 * @param int $share_id Share id.
668 * @param int $user_id Recipient.
669 * @return array|WP_Error Share row on success.
670 */
671 function openstation_folder_share_deny( $share_id, $user_id ) {
672 global $wpdb;
673 $share_id = (int) $share_id;
674 $user_id = (int) $user_id;
675 $row = openstation_files_get_share( $share_id );
676 if ( ! $row || 'folder' !== $row['target_type'] ) {
677 return new WP_Error( 'openstation_files_share_not_found', __( 'Share not found.', 'desktop-mode' ), array( 'status' => 404 ) );
678 }
679 if ( ! openstation_files_share_principal_matches_user( $row, $user_id ) ) {
680 return new WP_Error( 'openstation_files_share_not_recipient', __( 'This invite is not for you.', 'desktop-mode' ), array( 'status' => 403 ) );
681 }
682 $state = openstation_files_share_user_state( $row, $user_id );
683 if ( 'denied' === $state ) {
684 return $row;
685 }
686
687 $tables = openstation_files_table_names();
688 $now = openstation_files_now_ms();
689
690 if ( 'user' === $row['principal_type'] ) {
691 $wpdb->update(
692 $tables['shares'],
693 array(
694 'state' => 'denied',
695 'decided_at_ms' => $now,
696 ),
697 array( 'id' => $share_id ),
698 array( '%s', '%d' ),
699 array( '%d' )
700 );
701 } else {
702 // Role-principal: per-user decision keeps other role members untouched.
703 openstation_files_upsert_user_decision( $share_id, $user_id, 'denied' );
704 }
705
706 openstation_files_bump_folder_updated_at( $row['folder_id'] );
707
708 // If the recipient had previously accepted and is now denying
709 // (e.g. they hit deny on a placeholder they already opened),
710 // scrub their local placement too. Works for BOTH user- and
711 // role-principals since the trash helper is user-scoped.
712 if ( 'accepted' === $state ) {
713 openstation_files_trash_folder_for_user( $row['folder_id'], $user_id );
714 }
715
716 $next = openstation_files_get_share( $share_id );
717
718 /**
719 * Fires after a share is denied.
720 *
721 * @param int $share_id Share id.
722 * @param array $row Updated share row.
723 * @param int $user_id Acting user (recipient).
724 */
725 do_action( 'openstation_files_share_denied', $share_id, $next, $user_id );
726
727 return $next;
728 }
729
730 /**
731 * Recipient-initiated leave. Finds whichever share row grants
732 * the user access to `$folder_id` (user-principal or matching
733 * role-principal) and marks them as denied, then scrubs their
734 * local placements. Idempotent — no-op if the user has no share.
735 *
736 * @param int $folder_id Folder id.
737 * @param int $user_id Recipient leaving.
738 * @return true|WP_Error
739 */
740 function openstation_folder_share_leave( $folder_id, $user_id ) {
741 global $wpdb;
742 $folder_id = (int) $folder_id;
743 $user_id = (int) $user_id;
744 if ( $folder_id <= 0 || $user_id <= 0 ) {
745 return new WP_Error( 'openstation_files_bad_request', __( 'Invalid arguments.', 'desktop-mode' ), array( 'status' => 400 ) );
746 }
747 $folder = openstation_files_get_folder( $folder_id );
748 if ( ! $folder ) {
749 return new WP_Error( 'openstation_files_not_found', __( 'Folder not found.', 'desktop-mode' ), array( 'status' => 404 ) );
750 }
751 if ( (int) $folder['owner_id'] === $user_id ) {
752 return new WP_Error( 'openstation_files_owner_cannot_leave', __( 'Owners cannot leave their own folder.', 'desktop-mode' ), array( 'status' => 400 ) );
753 }
754
755 $user = get_userdata( $user_id );
756 $roles = $user ? (array) $user->roles : array();
757
758 $tables = openstation_files_table_names();
759 $rows = $wpdb->get_results(
760 $wpdb->prepare(
761 "SELECT * FROM {$tables['shares']} WHERE target_type = 'folder' AND folder_id = %d",
762 $folder_id
763 ),
764 ARRAY_A
765 );
766 $touched = 0;
767 foreach ( (array) $rows as $raw ) {
768 $row = openstation_files_normalize_share_row( $raw );
769 if ( ! openstation_files_share_principal_matches_user( $row, $user_id ) ) {
770 continue;
771 }
772 if ( 'user' === $row['principal_type'] ) {
773 $wpdb->update(
774 $tables['shares'],
775 array(
776 'state' => 'denied',
777 'decided_at_ms' => openstation_files_now_ms(),
778 ),
779 array( 'id' => $row['id'] ),
780 array( '%s', '%d' ),
781 array( '%d' )
782 );
783 } else {
784 openstation_files_upsert_user_decision( $row['id'], $user_id, 'denied' );
785 }
786 ++$touched;
787 /**
788 * Fires after a recipient leaves a shared folder. Distinct
789 * from `_denied` (owner-side audit) because this is always
790 * recipient-initiated, after acceptance.
791 *
792 * @param int $share_id Share id.
793 * @param array $row Share row (last-known).
794 * @param int $user_id Recipient leaving.
795 */
796 do_action( 'openstation_files_share_left', $row['id'], $row, $user_id );
797 }
798
799 // Scrub the recipient's view regardless of whether a share row
800 // matched (the user might have a placement from a previously
801 // revoked share that lingered).
802 openstation_files_trash_folder_for_user( $folder_id, $user_id );
803 openstation_files_bump_folder_updated_at( $folder_id );
804
805 if ( 0 === $touched ) {
806 return new WP_Error( 'openstation_files_not_member', __( 'You do not have access to this folder.', 'desktop-mode' ), array( 'status' => 404 ) );
807 }
808 return true;
809 }
810
811 /**
812 * Does `$share_row` target `$user_id` (directly or by role)?
813 *
814 * @internal
815 *
816 * @param array $share_row Normalized share row.
817 * @param int $user_id User to test.
818 * @return bool
819 */
820 function openstation_files_share_principal_matches_user( $share_row, $user_id ) {
821 $user_id = (int) $user_id;
822 if ( $user_id <= 0 ) {
823 return false;
824 }
825 if ( 'user' === $share_row['principal_type'] ) {
826 return (int) $share_row['principal_ref'] === $user_id;
827 }
828 if ( 'role' === $share_row['principal_type'] ) {
829 $user = get_userdata( $user_id );
830 if ( ! $user ) {
831 return false;
832 }
833 return in_array( (string) $share_row['principal_ref'], (array) $user->roles, true );
834 }
835 return false;
836 }
837
838 /**
839 * Capability `$user_id` holds on `$folder_id`. `write` beats `read`
840 * beats `none`. Owner always returns `'write'`. `share_mode='all'`
841 * yields a default of `'read'` (filterable).
842 *
843 * @param int $folder_id Folder id.
844 * @param int $user_id Viewer.
845 * @return string 'none' | 'read' | 'write'
846 */
847 function openstation_folder_share_user_capability( $folder_id, $user_id ) {
848 $folder_id = (int) $folder_id;
849 $user_id = (int) $user_id;
850 if ( $folder_id <= 0 || $user_id <= 0 ) {
851 return 'none';
852 }
853
854 $folder = openstation_files_get_folder( $folder_id );
855 if ( ! $folder ) {
856 return 'none';
857 }
858 if ( (int) $folder['owner_id'] === $user_id ) {
859 return 'write';
860 }
861
862 // Cascade — walk the folder's ancestor chain. A folder nested
863 // inside a shared folder inherits the share. The most permissive
864 // ancestor cap wins. Bail out as soon as we hit 'write'.
865 $cascade_cap = openstation_folder_share_user_capability_cascade( $folder_id, $user_id );
866 if ( 'write' === $cascade_cap ) {
867 return 'write';
868 }
869
870 $cap = 'none';
871 if ( 'all' === $folder['share_mode'] ) {
872 /**
873 * Filter the default capability for `share_mode='all'`.
874 *
875 * @param string $cap Default 'read'.
876 * @param int $folder_id Folder id.
877 * @param int $user_id Viewer.
878 */
879 $cap = (string) apply_filters( 'openstation_files_share_all_default_capability', 'read', $folder_id, $user_id );
880 }
881
882 $user_roles = array();
883 $user = get_userdata( $user_id );
884 if ( $user ) {
885 $user_roles = (array) $user->roles;
886 }
887
888 global $wpdb;
889 $tables = openstation_files_table_names();
890 // User-principal grants — state lives on the shares row.
891 $rows = $wpdb->get_results(
892 $wpdb->prepare(
893 "SELECT id, principal_type, principal_ref, capability FROM {$tables['shares']}
894 WHERE target_type = 'folder' AND folder_id = %d AND principal_type = 'user' AND state = 'accepted'",
895 $folder_id
896 ),
897 ARRAY_A
898 );
899 // Role-principal grants — opt-in is per-user via the decisions
900 // table. We join so a role member only gets a hit if they've
901 // individually accepted (no "first to click decides for all").
902 $role_rows = $wpdb->get_results(
903 $wpdb->prepare(
904 "SELECT s.id, s.principal_type, s.principal_ref, s.capability
905 FROM {$tables['shares']} s
906 INNER JOIN {$tables['decisions']} d ON d.share_id = s.id AND d.user_id = %d AND d.state = 'accepted'
907 WHERE s.target_type = 'folder' AND s.folder_id = %d AND s.principal_type = 'role'",
908 $user_id,
909 $folder_id
910 ),
911 ARRAY_A
912 );
913 $rows = array_merge( (array) $rows, (array) $role_rows );
914 foreach ( $rows as $row ) {
915 $matches = false;
916 if ( 'user' === $row['principal_type'] && (int) $row['principal_ref'] === $user_id ) {
917 $matches = true;
918 } elseif ( 'role' === $row['principal_type'] && in_array( (string) $row['principal_ref'], $user_roles, true ) ) {
919 $matches = true;
920 }
921 if ( $matches ) {
922 $row_cap = (string) $row['capability'];
923 if ( 'write' === $row_cap ) {
924 $cap = 'write';
925 break; // Most permissive wins; can't beat 'write'.
926 }
927 if ( 'read' === $row_cap && 'none' === $cap ) {
928 $cap = 'read';
929 }
930 }
931 }
932
933 // Fold the cascaded ancestor cap into the result if it beats
934 // what direct shares granted. (`cascade_cap` was computed above
935 // before the early-write-bail — we already know it's not 'write'
936 // at this point, otherwise we returned earlier.)
937 if ( 'read' === $cascade_cap && 'none' === $cap ) {
938 $cap = 'read';
939 }
940
941 /**
942 * Filter the resolved capability.
943 *
944 * @param string $cap 'none' | 'read' | 'write'.
945 * @param int $folder_id Folder id.
946 * @param int $user_id Viewer.
947 * @param array $folder Normalized folder row.
948 */
949 return (string) apply_filters( 'openstation_folder_share_user_capability', $cap, $folder_id, $user_id, $folder );
950 }
951
952 /**
953 * Walk the ancestor chain of `$folder_id` and return the most
954 * permissive DIRECT share cap any ancestor has for `$user_id`.
955 * Used by `openstation_folder_share_user_capability` to cascade
956 * a share grant from a parent folder into every folder nested
957 * inside it.
958 *
959 * "Direct" means the share row exists for that ancestor — we
960 * don't recurse the cascade resolver to avoid infinite loops
961 * and quadratic complexity.
962 *
963 * Performance: collapses the per-ancestor capability check into
964 * three batched queries regardless of chain depth — one
965 * `folders IN (…)` for ownership + `'all'` share-mode, one
966 * `shares IN (…)` for user-principal accepted rows, one
967 * `shares IN (…) JOIN decisions` for role-principal accepted
968 * rows. Replaces the previous loop that fired up to two queries
969 * per ancestor (32 on cold caches at the 16-level cap).
970 *
971 * @param int $folder_id Folder whose ancestors to walk.
972 * @param int $user_id Viewer.
973 * @return string 'none' | 'read' | 'write'
974 */
975 function openstation_folder_share_user_capability_cascade( $folder_id, $user_id ) {
976 $user_id = (int) $user_id;
977 $ancestors = openstation_folder_ancestors( (int) $folder_id );
978 if ( empty( $ancestors ) || $user_id <= 0 ) {
979 return 'none';
980 }
981
982 global $wpdb;
983 $tables = openstation_files_table_names();
984
985 // Coerce + dedupe to keep the IN clause small and safe to
986 // interpolate. Every value is an int by the time it lands
987 // in the SQL.
988 $ancestor_ids = array_values( array_unique( array_map( 'intval', $ancestors ) ) );
989 $ids_csv = implode( ',', $ancestor_ids );
990
991 // One query for ancestor folder rows — covers ownership
992 // short-circuit AND `share_mode='all'` ancestors.
993 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- ids are intval'd above.
994 $folder_rows = $wpdb->get_results(
995 "SELECT id, owner_id, share_mode FROM {$tables['folders']} WHERE id IN ($ids_csv)",
996 ARRAY_A
997 );
998
999 $cap = 'none';
1000 foreach ( (array) $folder_rows as $f ) {
1001 if ( (int) $f['owner_id'] === $user_id ) {
1002 return 'write';
1003 }
1004 if ( 'all' === $f['share_mode'] ) {
1005 $all_cap = (string) apply_filters(
1006 'openstation_files_share_all_default_capability',
1007 'read',
1008 (int) $f['id'],
1009 $user_id
1010 );
1011 if ( 'write' === $all_cap ) {
1012 return 'write';
1013 }
1014 if ( 'read' === $all_cap && 'none' === $cap ) {
1015 $cap = 'read';
1016 }
1017 }
1018 }
1019
1020 // User-principal accepted shares across every ancestor.
1021 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- ids cast above.
1022 $user_rows = $wpdb->get_results(
1023 $wpdb->prepare(
1024 "SELECT folder_id, capability FROM {$tables['shares']}
1025 WHERE target_type = 'folder'
1026 AND folder_id IN ($ids_csv)
1027 AND principal_type = 'user'
1028 AND principal_ref = %s
1029 AND state = 'accepted'",
1030 (string) $user_id
1031 ),
1032 ARRAY_A
1033 );
1034 foreach ( (array) $user_rows as $row ) {
1035 $row_cap = (string) $row['capability'];
1036 if ( 'write' === $row_cap ) {
1037 return 'write';
1038 }
1039 if ( 'read' === $row_cap && 'none' === $cap ) {
1040 $cap = 'read';
1041 }
1042 }
1043
1044 // Role-principal accepted shares — one query only when the
1045 // user actually has roles to match.
1046 $user = get_userdata( $user_id );
1047 $user_roles = $user ? (array) $user->roles : array();
1048 if ( ! empty( $user_roles ) ) {
1049 $role_placeholders = implode( ',', array_fill( 0, count( $user_roles ), '%s' ) );
1050 $args = array_merge( array( $user_id ), $user_roles );
1051 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- placeholders generated above; ids intval'd.
1052 $role_rows = $wpdb->get_results(
1053 $wpdb->prepare(
1054 "SELECT s.folder_id, s.capability
1055 FROM {$tables['shares']} s
1056 INNER JOIN {$tables['decisions']} d ON d.share_id = s.id AND d.user_id = %d AND d.state = 'accepted'
1057 WHERE s.target_type = 'folder'
1058 AND s.folder_id IN ($ids_csv)
1059 AND s.principal_type = 'role'
1060 AND s.principal_ref IN ($role_placeholders)",
1061 $args
1062 ),
1063 ARRAY_A
1064 );
1065 foreach ( (array) $role_rows as $row ) {
1066 $row_cap = (string) $row['capability'];
1067 if ( 'write' === $row_cap ) {
1068 return 'write';
1069 }
1070 if ( 'read' === $row_cap && 'none' === $cap ) {
1071 $cap = 'read';
1072 }
1073 }
1074 }
1075
1076 return $cap;
1077 }
1078
1079 /**
1080 * Direct (non-cascading) capability resolver. Same logic as
1081 * `openstation_folder_share_user_capability` minus the cascade
1082 * walk — owner / share rows / role decisions only.
1083 *
1084 * Currently uncalled: the cascade resolver
1085 * (`openstation_folder_share_user_capability_cascade`) resolves
1086 * every ancestor via three batched `IN (…)` queries instead of
1087 * querying ancestors one at a time. Kept as a single-folder,
1088 * non-cascading resolver.
1089 *
1090 * @internal
1091 */
1092 function openstation_folder_share_user_capability_direct( $folder_id, $user_id ) {
1093 $folder_id = (int) $folder_id;
1094 $user_id = (int) $user_id;
1095 if ( $folder_id <= 0 || $user_id <= 0 ) {
1096 return 'none';
1097 }
1098 $folder = openstation_files_get_folder( $folder_id );
1099 if ( ! $folder ) {
1100 return 'none';
1101 }
1102 if ( (int) $folder['owner_id'] === $user_id ) {
1103 return 'write';
1104 }
1105 $cap = 'none';
1106 if ( 'all' === $folder['share_mode'] ) {
1107 $cap = (string) apply_filters( 'openstation_files_share_all_default_capability', 'read', $folder_id, $user_id );
1108 }
1109
1110 $user_roles = array();
1111 $user = get_userdata( $user_id );
1112 if ( $user ) {
1113 $user_roles = (array) $user->roles;
1114 }
1115
1116 global $wpdb;
1117 $tables = openstation_files_table_names();
1118 $rows = $wpdb->get_results(
1119 $wpdb->prepare(
1120 "SELECT id, principal_type, principal_ref, capability FROM {$tables['shares']}
1121 WHERE target_type = 'folder' AND folder_id = %d AND principal_type = 'user' AND state = 'accepted'",
1122 $folder_id
1123 ),
1124 ARRAY_A
1125 );
1126 $role_rows = $wpdb->get_results(
1127 $wpdb->prepare(
1128 "SELECT s.id, s.principal_type, s.principal_ref, s.capability
1129 FROM {$tables['shares']} s
1130 INNER JOIN {$tables['decisions']} d ON d.share_id = s.id AND d.user_id = %d AND d.state = 'accepted'
1131 WHERE s.target_type = 'folder' AND s.folder_id = %d AND s.principal_type = 'role'",
1132 $user_id,
1133 $folder_id
1134 ),
1135 ARRAY_A
1136 );
1137 foreach ( array_merge( (array) $rows, (array) $role_rows ) as $row ) {
1138 $matches = false;
1139 if ( 'user' === $row['principal_type'] && (int) $row['principal_ref'] === $user_id ) {
1140 $matches = true;
1141 } elseif ( 'role' === $row['principal_type'] && in_array( (string) $row['principal_ref'], $user_roles, true ) ) {
1142 $matches = true;
1143 }
1144 if ( $matches ) {
1145 $row_cap = (string) $row['capability'];
1146 if ( 'write' === $row_cap ) {
1147 return 'write';
1148 }
1149 if ( 'read' === $row_cap && 'none' === $cap ) {
1150 $cap = 'read';
1151 }
1152 }
1153 }
1154 return $cap;
1155 }
1156
1157 /**
1158 * Return the chain of ancestor folder ids above `$folder_id`,
1159 * walking the owner's canonical placement. The first element is
1160 * the immediate parent; the last is the root-most ancestor.
1161 *
1162 * Why owner's placement: a folder can be placed in multiple
1163 * locations (one per user), so "parent" is ambiguous. The owner's
1164 * placement is the canonical one (the owner decides the tree).
1165 *
1166 * Hard-capped at 16 levels deep + a visited set to make pathological
1167 * inputs (cycles, deep nests) bounded.
1168 *
1169 * @param int $folder_id Folder whose ancestors to walk.
1170 * @param int $limit Max ancestor count (default 16).
1171 * @return int[]
1172 */
1173 function openstation_folder_ancestors( $folder_id, $limit = 16 ) {
1174 global $wpdb;
1175 $folder_id = (int) $folder_id;
1176 if ( $folder_id <= 0 ) {
1177 return array();
1178 }
1179 $tables = openstation_files_table_names();
1180 $ancestors = array();
1181 $current = $folder_id;
1182 $visited = array();
1183 $depth = 0;
1184 while ( $depth < $limit ) {
1185 if ( isset( $visited[ $current ] ) ) {
1186 break;
1187 }
1188 $visited[ $current ] = true;
1189 $folder = openstation_files_get_folder( $current );
1190 if ( ! $folder ) {
1191 break;
1192 }
1193 $owner = (int) $folder['owner_id'];
1194 $row = $wpdb->get_row(
1195 $wpdb->prepare(
1196 "SELECT parent_id FROM {$tables['placements']}
1197 WHERE owner_id = %d
1198 AND file_type = 'folder'
1199 AND file_ref = %s
1200 AND trashed_at_ms IS NULL
1201 ORDER BY id ASC
1202 LIMIT 1",
1203 $owner,
1204 (string) $current
1205 ),
1206 ARRAY_A
1207 );
1208 if ( ! $row ) {
1209 break;
1210 }
1211 $parent_id = (int) $row['parent_id'];
1212 if ( $parent_id <= 0 ) {
1213 break;
1214 }
1215 $ancestors[] = $parent_id;
1216 $current = $parent_id;
1217 ++$depth;
1218 }
1219 return $ancestors;
1220 }
1221
1222 /**
1223 * Pending invites for `$user_id` across every folder. Used by the
1224 * heartbeat `shares.pending` payload.
1225 *
1226 * @param int $user_id Viewer.
1227 * @param int $since_ms Optional. Only include rows with `invited_at_ms > since`.
1228 * @return array[]
1229 */
1230 function openstation_files_get_pending_shares_for_user( $user_id, $since_ms = 0 ) {
1231 global $wpdb;
1232 $user_id = (int) $user_id;
1233 $since_ms = (int) $since_ms;
1234 if ( $user_id <= 0 ) {
1235 return array();
1236 }
1237 $user = get_userdata( $user_id );
1238 if ( ! $user ) {
1239 return array();
1240 }
1241 $roles = (array) $user->roles;
1242
1243 $tables = openstation_files_table_names();
1244
1245 // User-principal: state lives on the share row. Surface where
1246 // state='pending' AND principal_ref matches the user.
1247 $user_pending = $wpdb->get_results(
1248 $wpdb->prepare(
1249 "SELECT s.* FROM {$tables['shares']} s
1250 INNER JOIN {$tables['folders']} f ON f.id = s.folder_id AND f.trashed_at_ms IS NULL
1251 WHERE s.target_type = 'folder'
1252 AND s.state = 'pending'
1253 AND s.invited_at_ms > %d
1254 AND s.principal_type = 'user'
1255 AND s.principal_ref = %s
1256 ORDER BY s.invited_at_ms ASC, s.id ASC",
1257 $since_ms,
1258 (string) $user_id
1259 ),
1260 ARRAY_A
1261 );
1262
1263 // Role-principal: surface every role-share the user matches
1264 // where they have NO decision row yet OR their decision is
1265 // 'pending'. Denied/accepted decisions suppress the prompt.
1266 $role_pending = array();
1267 if ( ! empty( $roles ) ) {
1268 $placeholders = implode( ',', array_fill( 0, count( $roles ), '%s' ) );
1269 $prepare = array_merge( array( $user_id, $since_ms ), $roles );
1270 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1271 $role_pending = $wpdb->get_results(
1272 $wpdb->prepare(
1273 "SELECT s.* FROM {$tables['shares']} s
1274 INNER JOIN {$tables['folders']} f ON f.id = s.folder_id AND f.trashed_at_ms IS NULL
1275 LEFT JOIN {$tables['decisions']} d ON d.share_id = s.id AND d.user_id = %d
1276 WHERE s.target_type = 'folder'
1277 AND s.invited_at_ms > %d
1278 AND s.principal_type = 'role'
1279 AND s.principal_ref IN ($placeholders)
1280 AND ( d.state IS NULL OR d.state = 'pending' )
1281 ORDER BY s.invited_at_ms ASC, s.id ASC",
1282 $prepare
1283 ),
1284 ARRAY_A
1285 );
1286 }
1287
1288 $out = array();
1289 foreach ( array_merge( (array) $user_pending, (array) $role_pending ) as $row ) {
1290 $out[] = openstation_files_normalize_share_row( $row );
1291 }
1292 return $out;
1293 }
1294
1295 /**
1296 * Bump a folder's `updated_at_ms`. Internal helper — every share
1297 * mutation should bump the parent folder so heartbeat clients pick
1298 * up the change in the same delta window.
1299 *
1300 * @internal
1301 *
1302 * @param int $folder_id Folder id.
1303 */
1304 function openstation_files_bump_folder_updated_at( $folder_id ) {
1305 global $wpdb;
1306 $tables = openstation_files_table_names();
1307 $wpdb->update(
1308 $tables['folders'],
1309 array( 'updated_at_ms' => openstation_files_now_ms() ),
1310 array( 'id' => (int) $folder_id ),
1311 array( '%d' ),
1312 array( '%d' )
1313 );
1314 }
1315
1316 /**
1317 * Recipient-scoped trash. Removes the recipient's placement of the
1318 * folder + their placements INSIDE the folder. Does NOT cascade
1319 * into the shared icon namespace (other users' placements survive).
1320 *
1321 * @param int $folder_id Folder id.
1322 * @param int $user_id Recipient.
1323 * @return int Number of placement rows trashed.
1324 */
1325 function openstation_files_trash_folder_for_user( $folder_id, $user_id ) {
1326 global $wpdb;
1327 $folder_id = (int) $folder_id;
1328 $user_id = (int) $user_id;
1329 if ( $folder_id <= 0 || $user_id <= 0 ) {
1330 return 0;
1331 }
1332 $tables = openstation_files_table_names();
1333 $now = openstation_files_now_ms();
1334
1335 // The recipient's folder-shortcut placement (parent_id=0,
1336 // file_type='folder', file_ref=$folder_id) + every placement
1337 // they own INSIDE this folder (parent_id=$folder_id).
1338 $rows = $wpdb->get_results(
1339 $wpdb->prepare(
1340 "SELECT id FROM {$tables['placements']}
1341 WHERE owner_id = %d
1342 AND trashed_at_ms IS NULL
1343 AND (
1344 ( file_type = 'folder' AND file_ref = %s AND parent_id = 0 )
1345 OR parent_id = %d
1346 )",
1347 $user_id,
1348 (string) $folder_id,
1349 $folder_id
1350 ),
1351 ARRAY_A
1352 );
1353 $count = 0;
1354 foreach ( (array) $rows as $row ) {
1355 $pid = (int) $row['id'];
1356 $wpdb->update(
1357 $tables['placements'],
1358 array(
1359 'trashed_at_ms' => $now,
1360 'trashed_by' => $user_id,
1361 ),
1362 array( 'id' => $pid ),
1363 array( '%d', '%d' ),
1364 array( '%d' )
1365 );
1366 // Soft-trash only — DO NOT write a tombstone here.
1367 // Tombstones represent permanent removal (hard delete); the
1368 // heartbeat already surfaces soft-trashed rows via the
1369 // `trashed_at_ms IS NOT NULL` query in
1370 // `openstation_files_compute_heartbeat_delta`. Writing a
1371 // tombstone on every soft-trash conflates the two states and,
1372 // when the row is later restored (e.g. the recipient re-
1373 // accepts the same share), the lingering tombstone keeps
1374 // telling clients "this is gone" while the same placement
1375 // row is also being upserted as alive — causing the row to
1376 // disappear from the desktop on every heartbeat tick. See
1377 // the user-reported "shared folder vanishes after refresh"
1378 // bug.
1379 ++$count;
1380 }
1381 return $count;
1382 }
1383
1384 /**
1385 * Hook into the trash gate so a read-only recipient cannot trash
1386 * placements they "own" inside a shared folder. The ownership
1387 * check at the placement level passes (each user has their own
1388 * placement row), so the gate needs an extra read-only veto.
1389 *
1390 * @param bool $can Default decision (ownership match).
1391 * @param int $user_id Acting user.
1392 * @param array $row Placement row.
1393 * @return bool
1394 */
1395 function openstation_files_share_gate_trash( $can, $user_id, $row ) {
1396 $parent_id = isset( $row['parent_id'] ) ? (int) $row['parent_id'] : 0;
1397 $user_id = (int) $user_id;
1398
1399 // Root-level placement of a SHARED FOLDER (the recipient's
1400 // desktop copy of a folder owned by someone else). The
1401 // recipient technically "owns" their placement row, so the
1402 // default ownership rule grants trash — but the destructive
1403 // "Move to Trash" affordance is misleading here. The correct
1404 // action is "Leave shared folder", which fires the share-leave
1405 // flow (revokes their decision, scrubs the placement, leaves
1406 // the original intact). Veto the trash gate when the viewer
1407 // has no WRITE cap on the folder so the client suppresses
1408 // "Move to Trash" + rejects the trash drop, leaving "Leave
1409 // shared folder" as the only way out.
1410 if (
1411 $parent_id <= 0 &&
1412 isset( $row['file_type'] ) &&
1413 'folder' === (string) $row['file_type'] &&
1414 isset( $row['file_ref'] )
1415 ) {
1416 $folder_ref = (int) $row['file_ref'];
1417 if ( $folder_ref > 0 ) {
1418 $folder_row = openstation_files_get_folder( $folder_ref );
1419 if (
1420 $folder_row &&
1421 (int) $folder_row['owner_id'] !== $user_id
1422 ) {
1423 // ANY non-owner recipient of a shared folder is
1424 // blocked from trashing their root placement — the
1425 // correct action is "Leave shared folder". This
1426 // applies equally to read-only AND write recipients:
1427 // a writer's destructive intent should be expressed
1428 // via the leave flow (which scrubs their own
1429 // placement) instead of via Move to Trash (which is
1430 // reserved for the owner's destructive cascade).
1431 $root_cap = openstation_folder_share_user_capability( $folder_ref, $user_id );
1432 if ( 'none' !== $root_cap ) {
1433 return false;
1434 }
1435 }
1436 }
1437 return $can;
1438 }
1439
1440 if ( $parent_id <= 0 ) {
1441 // Any other root placement (not a shared-folder tile) —
1442 // default ownership rule stands.
1443 return $can;
1444 }
1445 $folder = openstation_files_get_folder( $parent_id );
1446 if ( ! $folder ) {
1447 return $can;
1448 }
1449 $is_owner = (int) $folder['owner_id'] === $user_id;
1450 $cap = openstation_folder_share_user_capability( $parent_id, $user_id );
1451
1452 // Folder owner can always trash anything inside their folder.
1453 if ( $is_owner ) {
1454 return true;
1455 }
1456 // Non-owner: require write cap on the folder, regardless of
1457 // who originally placed the row. This is the upgrade path —
1458 // writers can trash any icon in the shared folder. Readers
1459 // can't trash anything (even their own placement in this folder).
1460 return 'write' === $cap;
1461 }
1462 add_filter( 'openstation_files_user_can_trash_placement', 'openstation_files_share_gate_trash', 10, 3 );
1463
1464 /**
1465 * Inject share-related state into the shell config so the share
1466 * settings modal + role picker have what they need without
1467 * round-tripping.
1468 *
1469 * @param array $config Shell config.
1470 * @return array
1471 */
1472 function openstation_files_share_inject_shell_config( $config ) {
1473 if ( ! is_array( $config ) ) {
1474 $config = array();
1475 }
1476 $config['shareEligibleRoles'] = openstation_files_share_eligible_roles();
1477 $config['filesUsersSearchUrl'] = esc_url_raw( rest_url( 'desktop-mode/v1/files/users/search' ) );
1478 $config['folderSharesUrl'] = esc_url_raw( rest_url( 'desktop-mode/v1/files/folders' ) );
1479 $user_id = (int) get_current_user_id();
1480 if ( ! isset( $config['currentUserId'] ) ) {
1481 $config['currentUserId'] = $user_id;
1482 }
1483
1484 // Seed the shares store with the viewer's current pending invites
1485 // on the first paint, so the accept/deny modal opens immediately
1486 // on refresh instead of waiting for the first heartbeat tick to
1487 // deliver them. Same kill-switch + shape as the heartbeat path —
1488 // see `openstation_files_collect_heartbeat_delta()` for the
1489 // canonical builder.
1490 $pending = array();
1491 $sharing_enabled = function_exists( 'openstation_files_sharing_enabled_for' )
1492 ? openstation_files_sharing_enabled_for( $user_id )
1493 : true;
1494 if (
1495 $user_id > 0 &&
1496 $sharing_enabled &&
1497 function_exists( 'openstation_files_get_pending_shares_for_user' )
1498 ) {
1499 $rows = openstation_files_get_pending_shares_for_user( $user_id, 0 );
1500 foreach ( $rows as $row ) {
1501 $shape = openstation_files_shape_share( $row );
1502 $folder = openstation_files_get_folder( $row['folder_id'] );
1503 if ( $folder ) {
1504 $shape['folderName'] = (string) $folder['name'];
1505 $shape['ownerId'] = (int) $folder['owner_id'];
1506 $owner_user = get_userdata( (int) $folder['owner_id'] );
1507 $shape['ownerName'] = $owner_user ? $owner_user->display_name : '';
1508 $shape['ownerAvatar'] = $owner_user ? get_avatar_url( $owner_user->ID, array( 'size' => 48 ) ) : '';
1509 }
1510 $pending[] = $shape;
1511 }
1512 }
1513 $config['serverPendingShares'] = $pending;
1514
1515 return $config;
1516 }
1517 add_filter( 'openstation_shell_config', 'openstation_files_share_inject_shell_config', 20 );
1518
1519 /**
1520 * Place an icon at the next free row-major slot in a user's view
1521 * of `$parent_id`. Internal helper used by share-accept and
1522 * fan-out. Mirrors the grid math in `src/desktop-files/grid.ts`
1523 * (padding 16 + col 96 + row 110).
1524 *
1525 * @param int $user_id Viewer.
1526 * @param int $parent_id Folder id (0 = desktop root).
1527 * @param string $type File-type slug.
1528 * @param string $ref Entity reference.
1529 * @return int|WP_Error Placement id or error.
1530 */
1531 function openstation_files_place_at_next_free_slot( $user_id, $parent_id, $type, $ref ) {
1532 global $wpdb;
1533 $user_id = (int) $user_id;
1534 $parent_id = max( 0, (int) $parent_id );
1535
1536 $tables = openstation_files_table_names();
1537 $existing = $wpdb->get_results(
1538 $wpdb->prepare(
1539 "SELECT x, y FROM {$tables['placements']}
1540 WHERE owner_id = %d
1541 AND parent_id = %d
1542 AND trashed_at_ms IS NULL",
1543 $user_id,
1544 $parent_id
1545 ),
1546 ARRAY_A
1547 );
1548 $occupied = array();
1549 foreach ( (array) $existing as $row ) {
1550 $col = max( 0, (int) round( ( (int) $row['x'] - 16 ) / 96 ) );
1551 $r = max( 0, (int) round( ( (int) $row['y'] - 16 ) / 110 ) );
1552 $occupied[ "$col,$r" ] = true;
1553 }
1554
1555 $pick_col = 0;
1556 $pick_row = 0;
1557 for ( $r = 0; $r < 999; $r++ ) {
1558 for ( $col = 0; $col < 999; $col++ ) {
1559 if ( ! isset( $occupied[ "$col,$r" ] ) ) {
1560 $pick_col = $col;
1561 $pick_row = $r;
1562 break 2;
1563 }
1564 }
1565 }
1566
1567 return openstation_files_place(
1568 $user_id,
1569 $parent_id,
1570 $type,
1571 $ref,
1572 array(
1573 'x' => 16 + $pick_col * 96,
1574 'y' => 16 + $pick_row * 110,
1575 )
1576 );
1577 }
1578