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 / shares-store.php

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

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