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

1,569 lines 51.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — 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 WPDesktopMode
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 * `desktop_mode_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 desktop_mode_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( 'desktop_mode_get_os_settings' ) ) {
53 $settings = desktop_mode_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( 'desktop_mode_files_sharing_enabled_for', $enabled, $user_id );
63 }
64
65 /** Allowed principal-type values. */
66 function desktop_mode_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 `desktop_mode_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( 'desktop_mode_files_shareable_types', function ( $types ) {
82 * $types[] = 'post';
83 * return $types;
84 * } );
85 * add_filter( 'desktop_mode_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 desktop_mode_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( 'desktop_mode_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 desktop_mode_files_share_target_owner( $target_type, $target_id ) {
123 $owner = 0;
124 if ( 'folder' === $target_type ) {
125 $folder = desktop_mode_files_get_folder( (int) $target_id );
126 if ( $folder ) {
127 $owner = (int) $folder['owner_id'];
128 }
129 } elseif ( 'file' === $target_type && function_exists( 'desktop_mode_stored_files_get' ) ) {
130 $file = desktop_mode_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( 'desktop_mode_files_share_target_owner', $owner, (string) $target_type, (string) $target_id );
143 }
144
145 /** Allowed capability values. */
146 function desktop_mode_files_share_capabilities() {
147 return array( 'read', 'write' );
148 }
149
150 /** Allowed state values. */
151 function desktop_mode_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 `desktop_mode_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 desktop_mode_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( 'desktop_mode_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 desktop_mode_files_share_can_manage( $folder_id, $user_id ) {
196 $folder = desktop_mode_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( 'desktop_mode_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 desktop_mode_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 desktop_mode_files_get_share( $share_id ) {
247 global $wpdb;
248 $tables = desktop_mode_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 desktop_mode_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 desktop_mode_files_get_folder_shares( $folder_id ) {
266 global $wpdb;
267 $tables = desktop_mode_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[] = desktop_mode_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 desktop_mode_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, desktop_mode_files_share_principal_types(), true ) ) {
301 return new WP_Error( 'desktop_mode_files_invalid_principal_type', __( 'Invalid principal type.', 'desktop-mode' ), array( 'status' => 400 ) );
302 }
303 if ( ! in_array( $capability, desktop_mode_files_share_capabilities(), true ) ) {
304 return new WP_Error( 'desktop_mode_files_invalid_capability', __( 'Invalid capability.', 'desktop-mode' ), array( 'status' => 400 ) );
305 }
306 if ( ! desktop_mode_files_share_can_manage( $folder_id, $actor_id ) ) {
307 return new WP_Error( 'desktop_mode_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( 'desktop_mode_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( 'desktop_mode_files_unknown_user', __( 'Unknown user.', 'desktop-mode' ), array( 'status' => 404 ) );
322 }
323 $folder = desktop_mode_files_get_folder( $folder_id );
324 $owner_id = $folder ? (int) $folder['owner_id'] : 0;
325 if ( $uid === $owner_id ) {
326 return new WP_Error( 'desktop_mode_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( 'desktop_mode_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( desktop_mode_files_share_eligible_roles(), 'slug' );
334 if ( ! in_array( $principal_ref, $eligible, true ) ) {
335 return new WP_Error( 'desktop_mode_files_ineligible_role', __( 'This role is not eligible.', 'desktop-mode' ), array( 'status' => 400 ) );
336 }
337 }
338
339 $tables = desktop_mode_files_table_names();
340 $now = desktop_mode_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 = desktop_mode_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( 'desktop_mode_files_share_insert_failed', __( 'Failed to record share.', 'desktop-mode' ), array( 'status' => 500 ) );
390 }
391 $id = (int) $wpdb->insert_id;
392 $row = desktop_mode_files_get_share( $id );
393 }
394
395 desktop_mode_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( 'desktop_mode_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 desktop_mode_folder_share_revoke( $share_id, $actor_id ) {
417 global $wpdb;
418 $share_id = (int) $share_id;
419 $actor_id = (int) $actor_id;
420 $row = desktop_mode_files_get_share( $share_id );
421 if ( ! $row ) {
422 return new WP_Error( 'desktop_mode_files_share_not_found', __( 'Share not found.', 'desktop-mode' ), array( 'status' => 404 ) );
423 }
424 if ( ! desktop_mode_files_share_can_manage( $row['folder_id'], $actor_id ) ) {
425 return new WP_Error( 'desktop_mode_files_forbidden', __( 'You cannot manage shares for this folder.', 'desktop-mode' ), array( 'status' => 403 ) );
426 }
427
428 $tables = desktop_mode_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 desktop_mode_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 desktop_mode_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 desktop_mode_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( 'desktop_mode_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 desktop_mode_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, desktop_mode_files_share_capabilities(), true ) ) {
485 return new WP_Error( 'desktop_mode_files_invalid_capability', __( 'Invalid capability.', 'desktop-mode' ), array( 'status' => 400 ) );
486 }
487 $row = desktop_mode_files_get_share( $share_id );
488 if ( ! $row ) {
489 return new WP_Error( 'desktop_mode_files_share_not_found', __( 'Share not found.', 'desktop-mode' ), array( 'status' => 404 ) );
490 }
491 if ( ! desktop_mode_files_share_can_manage( $row['folder_id'], $actor_id ) ) {
492 return new WP_Error( 'desktop_mode_files_forbidden', __( 'You cannot manage shares for this folder.', 'desktop-mode' ), array( 'status' => 403 ) );
493 }
494
495 $tables = desktop_mode_files_table_names();
496 $wpdb->update( $tables['shares'], array( 'capability' => $capability ), array( 'id' => $share_id ), array( '%s' ), array( '%d' ) );
497
498 desktop_mode_files_bump_folder_updated_at( $row['folder_id'] );
499
500 $next = desktop_mode_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( 'desktop_mode_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 desktop_mode_files_get_user_decision( $share_id, $user_id ) {
525 global $wpdb;
526 $tables = desktop_mode_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 desktop_mode_files_upsert_user_decision( $share_id, $user_id, $state ) {
557 global $wpdb;
558 $tables = desktop_mode_files_table_names();
559 $now = desktop_mode_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 desktop_mode_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 = desktop_mode_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 desktop_mode_folder_share_accept( $share_id, $user_id ) {
608 global $wpdb;
609 $share_id = (int) $share_id;
610 $user_id = (int) $user_id;
611 $row = desktop_mode_files_get_share( $share_id );
612 if ( ! $row || 'folder' !== $row['target_type'] ) {
613 return new WP_Error( 'desktop_mode_files_share_not_found', __( 'Share not found.', 'desktop-mode' ), array( 'status' => 404 ) );
614 }
615 if ( ! desktop_mode_files_share_principal_matches_user( $row, $user_id ) ) {
616 return new WP_Error( 'desktop_mode_files_share_not_recipient', __( 'This invite is not for you.', 'desktop-mode' ), array( 'status' => 403 ) );
617 }
618 $state = desktop_mode_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( 'desktop_mode_files_share_already_denied', __( 'This invite was denied.', 'desktop-mode' ), array( 'status' => 410 ) );
624 }
625
626 $tables = desktop_mode_files_table_names();
627 $now = desktop_mode_files_now_ms();
628
629 if ( 'user' === $row['principal_type'] ) {
630 $wpdb->update(
631 $tables['shares'],
632 array( 'state' => 'accepted', 'decided_at_ms' => $now ),
633 array( 'id' => $share_id ),
634 array( '%s', '%d' ),
635 array( '%d' )
636 );
637 } else {
638 desktop_mode_files_upsert_user_decision( $share_id, $user_id, 'accepted' );
639 }
640
641 desktop_mode_files_bump_folder_updated_at( $row['folder_id'] );
642
643 // Place the folder on the recipient's desktop root.
644 $parent_id = (int) apply_filters( 'desktop_mode_folder_share_accept_default_parent', 0, $row['folder_id'], $user_id, $row );
645 desktop_mode_files_place_at_next_free_slot( $user_id, $parent_id, 'folder', (string) $row['folder_id'] );
646
647 $next = desktop_mode_files_get_share( $share_id );
648
649 /**
650 * Fires after a share is accepted by its recipient.
651 *
652 * @param int $share_id Share id.
653 * @param array $row Updated share row.
654 * @param int $user_id Acting user (recipient).
655 */
656 do_action( 'desktop_mode_files_share_accepted', $share_id, $next, $user_id );
657
658 return $next;
659 }
660
661 /**
662 * Recipient denies a share.
663 *
664 * @param int $share_id Share id.
665 * @param int $user_id Recipient.
666 * @return array|WP_Error Share row on success.
667 */
668 function desktop_mode_folder_share_deny( $share_id, $user_id ) {
669 global $wpdb;
670 $share_id = (int) $share_id;
671 $user_id = (int) $user_id;
672 $row = desktop_mode_files_get_share( $share_id );
673 if ( ! $row || 'folder' !== $row['target_type'] ) {
674 return new WP_Error( 'desktop_mode_files_share_not_found', __( 'Share not found.', 'desktop-mode' ), array( 'status' => 404 ) );
675 }
676 if ( ! desktop_mode_files_share_principal_matches_user( $row, $user_id ) ) {
677 return new WP_Error( 'desktop_mode_files_share_not_recipient', __( 'This invite is not for you.', 'desktop-mode' ), array( 'status' => 403 ) );
678 }
679 $state = desktop_mode_files_share_user_state( $row, $user_id );
680 if ( 'denied' === $state ) {
681 return $row;
682 }
683
684 $tables = desktop_mode_files_table_names();
685 $now = desktop_mode_files_now_ms();
686
687 if ( 'user' === $row['principal_type'] ) {
688 $wpdb->update(
689 $tables['shares'],
690 array( 'state' => 'denied', 'decided_at_ms' => $now ),
691 array( 'id' => $share_id ),
692 array( '%s', '%d' ),
693 array( '%d' )
694 );
695 } else {
696 // Role-principal: per-user decision keeps other role members untouched.
697 desktop_mode_files_upsert_user_decision( $share_id, $user_id, 'denied' );
698 }
699
700 desktop_mode_files_bump_folder_updated_at( $row['folder_id'] );
701
702 // If the recipient had previously accepted and is now denying
703 // (e.g. they hit deny on a placeholder they already opened),
704 // scrub their local placement too. Works for BOTH user- and
705 // role-principals since the trash helper is user-scoped.
706 if ( 'accepted' === $state ) {
707 desktop_mode_files_trash_folder_for_user( $row['folder_id'], $user_id );
708 }
709
710 $next = desktop_mode_files_get_share( $share_id );
711
712 /**
713 * Fires after a share is denied.
714 *
715 * @param int $share_id Share id.
716 * @param array $row Updated share row.
717 * @param int $user_id Acting user (recipient).
718 */
719 do_action( 'desktop_mode_files_share_denied', $share_id, $next, $user_id );
720
721 return $next;
722 }
723
724 /**
725 * Recipient-initiated leave. Finds whichever share row grants
726 * the user access to `$folder_id` (user-principal or matching
727 * role-principal) and marks them as denied, then scrubs their
728 * local placements. Idempotent — no-op if the user has no share.
729 *
730 * @param int $folder_id Folder id.
731 * @param int $user_id Recipient leaving.
732 * @return true|WP_Error
733 */
734 function desktop_mode_folder_share_leave( $folder_id, $user_id ) {
735 global $wpdb;
736 $folder_id = (int) $folder_id;
737 $user_id = (int) $user_id;
738 if ( $folder_id <= 0 || $user_id <= 0 ) {
739 return new WP_Error( 'desktop_mode_files_bad_request', __( 'Invalid arguments.', 'desktop-mode' ), array( 'status' => 400 ) );
740 }
741 $folder = desktop_mode_files_get_folder( $folder_id );
742 if ( ! $folder ) {
743 return new WP_Error( 'desktop_mode_files_not_found', __( 'Folder not found.', 'desktop-mode' ), array( 'status' => 404 ) );
744 }
745 if ( (int) $folder['owner_id'] === $user_id ) {
746 return new WP_Error( 'desktop_mode_files_owner_cannot_leave', __( 'Owners cannot leave their own folder.', 'desktop-mode' ), array( 'status' => 400 ) );
747 }
748
749 $user = get_userdata( $user_id );
750 $roles = $user ? (array) $user->roles : array();
751
752 $tables = desktop_mode_files_table_names();
753 $rows = $wpdb->get_results(
754 $wpdb->prepare(
755 "SELECT * FROM {$tables['shares']} WHERE target_type = 'folder' AND folder_id = %d",
756 $folder_id
757 ),
758 ARRAY_A
759 );
760 $touched = 0;
761 foreach ( (array) $rows as $raw ) {
762 $row = desktop_mode_files_normalize_share_row( $raw );
763 if ( ! desktop_mode_files_share_principal_matches_user( $row, $user_id ) ) {
764 continue;
765 }
766 if ( 'user' === $row['principal_type'] ) {
767 $wpdb->update(
768 $tables['shares'],
769 array( 'state' => 'denied', 'decided_at_ms' => desktop_mode_files_now_ms() ),
770 array( 'id' => $row['id'] ),
771 array( '%s', '%d' ),
772 array( '%d' )
773 );
774 } else {
775 desktop_mode_files_upsert_user_decision( $row['id'], $user_id, 'denied' );
776 }
777 $touched++;
778 /**
779 * Fires after a recipient leaves a shared folder. Distinct
780 * from `_denied` (owner-side audit) because this is always
781 * recipient-initiated, after acceptance.
782 *
783 * @param int $share_id Share id.
784 * @param array $row Share row (last-known).
785 * @param int $user_id Recipient leaving.
786 */
787 do_action( 'desktop_mode_files_share_left', $row['id'], $row, $user_id );
788 }
789
790 // Scrub the recipient's view regardless of whether a share row
791 // matched (the user might have a placement from a previously
792 // revoked share that lingered).
793 desktop_mode_files_trash_folder_for_user( $folder_id, $user_id );
794 desktop_mode_files_bump_folder_updated_at( $folder_id );
795
796 if ( 0 === $touched ) {
797 return new WP_Error( 'desktop_mode_files_not_member', __( 'You do not have access to this folder.', 'desktop-mode' ), array( 'status' => 404 ) );
798 }
799 return true;
800 }
801
802 /**
803 * Does `$share_row` target `$user_id` (directly or by role)?
804 *
805 * @internal
806 *
807 * @param array $share_row Normalized share row.
808 * @param int $user_id User to test.
809 * @return bool
810 */
811 function desktop_mode_files_share_principal_matches_user( $share_row, $user_id ) {
812 $user_id = (int) $user_id;
813 if ( $user_id <= 0 ) {
814 return false;
815 }
816 if ( 'user' === $share_row['principal_type'] ) {
817 return (int) $share_row['principal_ref'] === $user_id;
818 }
819 if ( 'role' === $share_row['principal_type'] ) {
820 $user = get_userdata( $user_id );
821 if ( ! $user ) {
822 return false;
823 }
824 return in_array( (string) $share_row['principal_ref'], (array) $user->roles, true );
825 }
826 return false;
827 }
828
829 /**
830 * Capability `$user_id` holds on `$folder_id`. `write` beats `read`
831 * beats `none`. Owner always returns `'write'`. `share_mode='all'`
832 * yields a default of `'read'` (filterable).
833 *
834 * @param int $folder_id Folder id.
835 * @param int $user_id Viewer.
836 * @return string 'none' | 'read' | 'write'
837 */
838 function desktop_mode_folder_share_user_capability( $folder_id, $user_id ) {
839 $folder_id = (int) $folder_id;
840 $user_id = (int) $user_id;
841 if ( $folder_id <= 0 || $user_id <= 0 ) {
842 return 'none';
843 }
844
845 $folder = desktop_mode_files_get_folder( $folder_id );
846 if ( ! $folder ) {
847 return 'none';
848 }
849 if ( (int) $folder['owner_id'] === $user_id ) {
850 return 'write';
851 }
852
853 // Cascade — walk the folder's ancestor chain. A folder nested
854 // inside a shared folder inherits the share. The most permissive
855 // ancestor cap wins. Bail out as soon as we hit 'write'.
856 $cascade_cap = desktop_mode_folder_share_user_capability_cascade( $folder_id, $user_id );
857 if ( 'write' === $cascade_cap ) {
858 return 'write';
859 }
860
861 $cap = 'none';
862 if ( 'all' === $folder['share_mode'] ) {
863 /**
864 * Filter the default capability for `share_mode='all'`.
865 *
866 * @param string $cap Default 'read'.
867 * @param int $folder_id Folder id.
868 * @param int $user_id Viewer.
869 */
870 $cap = (string) apply_filters( 'desktop_mode_files_share_all_default_capability', 'read', $folder_id, $user_id );
871 }
872
873 $user_roles = array();
874 $user = get_userdata( $user_id );
875 if ( $user ) {
876 $user_roles = (array) $user->roles;
877 }
878
879 global $wpdb;
880 $tables = desktop_mode_files_table_names();
881 // User-principal grants — state lives on the shares row.
882 $rows = $wpdb->get_results(
883 $wpdb->prepare(
884 "SELECT id, principal_type, principal_ref, capability FROM {$tables['shares']}
885 WHERE target_type = 'folder' AND folder_id = %d AND principal_type = 'user' AND state = 'accepted'",
886 $folder_id
887 ),
888 ARRAY_A
889 );
890 // Role-principal grants — opt-in is per-user via the decisions
891 // table. We join so a role member only gets a hit if they've
892 // individually accepted (no "first to click decides for all").
893 $role_rows = $wpdb->get_results(
894 $wpdb->prepare(
895 "SELECT s.id, s.principal_type, s.principal_ref, s.capability
896 FROM {$tables['shares']} s
897 INNER JOIN {$tables['decisions']} d ON d.share_id = s.id AND d.user_id = %d AND d.state = 'accepted'
898 WHERE s.target_type = 'folder' AND s.folder_id = %d AND s.principal_type = 'role'",
899 $user_id,
900 $folder_id
901 ),
902 ARRAY_A
903 );
904 $rows = array_merge( (array) $rows, (array) $role_rows );
905 foreach ( $rows as $row ) {
906 $matches = false;
907 if ( 'user' === $row['principal_type'] && (int) $row['principal_ref'] === $user_id ) {
908 $matches = true;
909 } elseif ( 'role' === $row['principal_type'] && in_array( (string) $row['principal_ref'], $user_roles, true ) ) {
910 $matches = true;
911 }
912 if ( $matches ) {
913 $row_cap = (string) $row['capability'];
914 if ( 'write' === $row_cap ) {
915 $cap = 'write';
916 break; // Most permissive wins; can't beat 'write'.
917 }
918 if ( 'read' === $row_cap && 'none' === $cap ) {
919 $cap = 'read';
920 }
921 }
922 }
923
924 // Fold the cascaded ancestor cap into the result if it beats
925 // what direct shares granted. (`cascade_cap` was computed above
926 // before the early-write-bail — we already know it's not 'write'
927 // at this point, otherwise we returned earlier.)
928 if ( 'read' === $cascade_cap && 'none' === $cap ) {
929 $cap = 'read';
930 }
931
932 /**
933 * Filter the resolved capability.
934 *
935 * @param string $cap 'none' | 'read' | 'write'.
936 * @param int $folder_id Folder id.
937 * @param int $user_id Viewer.
938 * @param array $folder Normalized folder row.
939 */
940 return (string) apply_filters( 'desktop_mode_folder_share_user_capability', $cap, $folder_id, $user_id, $folder );
941 }
942
943 /**
944 * Walk the ancestor chain of `$folder_id` and return the most
945 * permissive DIRECT share cap any ancestor has for `$user_id`.
946 * Used by `desktop_mode_folder_share_user_capability` to cascade
947 * a share grant from a parent folder into every folder nested
948 * inside it.
949 *
950 * "Direct" means the share row exists for that ancestor — we
951 * don't recurse the cascade resolver to avoid infinite loops
952 * and quadratic complexity.
953 *
954 * Performance: collapses the per-ancestor capability check into
955 * three batched queries regardless of chain depth — one
956 * `folders IN (…)` for ownership + `'all'` share-mode, one
957 * `shares IN (…)` for user-principal accepted rows, one
958 * `shares IN (…) JOIN decisions` for role-principal accepted
959 * rows. Replaces the previous loop that fired up to two queries
960 * per ancestor (32 on cold caches at the 16-level cap).
961 *
962 * @param int $folder_id Folder whose ancestors to walk.
963 * @param int $user_id Viewer.
964 * @return string 'none' | 'read' | 'write'
965 */
966 function desktop_mode_folder_share_user_capability_cascade( $folder_id, $user_id ) {
967 $user_id = (int) $user_id;
968 $ancestors = desktop_mode_folder_ancestors( (int) $folder_id );
969 if ( empty( $ancestors ) || $user_id <= 0 ) {
970 return 'none';
971 }
972
973 global $wpdb;
974 $tables = desktop_mode_files_table_names();
975
976 // Coerce + dedupe to keep the IN clause small and safe to
977 // interpolate. Every value is an int by the time it lands
978 // in the SQL.
979 $ancestor_ids = array_values( array_unique( array_map( 'intval', $ancestors ) ) );
980 $ids_csv = implode( ',', $ancestor_ids );
981
982 // One query for ancestor folder rows — covers ownership
983 // short-circuit AND `share_mode='all'` ancestors.
984 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- ids are intval'd above.
985 $folder_rows = $wpdb->get_results(
986 "SELECT id, owner_id, share_mode FROM {$tables['folders']} WHERE id IN ($ids_csv)",
987 ARRAY_A
988 );
989
990 $cap = 'none';
991 foreach ( (array) $folder_rows as $f ) {
992 if ( (int) $f['owner_id'] === $user_id ) {
993 return 'write';
994 }
995 if ( 'all' === $f['share_mode'] ) {
996 $all_cap = (string) apply_filters(
997 'desktop_mode_files_share_all_default_capability',
998 'read',
999 (int) $f['id'],
1000 $user_id
1001 );
1002 if ( 'write' === $all_cap ) {
1003 return 'write';
1004 }
1005 if ( 'read' === $all_cap && 'none' === $cap ) {
1006 $cap = 'read';
1007 }
1008 }
1009 }
1010
1011 // User-principal accepted shares across every ancestor.
1012 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- ids cast above.
1013 $user_rows = $wpdb->get_results(
1014 $wpdb->prepare(
1015 "SELECT folder_id, capability FROM {$tables['shares']}
1016 WHERE target_type = 'folder'
1017 AND folder_id IN ($ids_csv)
1018 AND principal_type = 'user'
1019 AND principal_ref = %s
1020 AND state = 'accepted'",
1021 (string) $user_id
1022 ),
1023 ARRAY_A
1024 );
1025 foreach ( (array) $user_rows as $row ) {
1026 $row_cap = (string) $row['capability'];
1027 if ( 'write' === $row_cap ) {
1028 return 'write';
1029 }
1030 if ( 'read' === $row_cap && 'none' === $cap ) {
1031 $cap = 'read';
1032 }
1033 }
1034
1035 // Role-principal accepted shares — one query only when the
1036 // user actually has roles to match.
1037 $user = get_userdata( $user_id );
1038 $user_roles = $user ? (array) $user->roles : array();
1039 if ( ! empty( $user_roles ) ) {
1040 $role_placeholders = implode( ',', array_fill( 0, count( $user_roles ), '%s' ) );
1041 $args = array_merge( array( $user_id ), $user_roles );
1042 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- placeholders generated above; ids intval'd.
1043 $role_rows = $wpdb->get_results(
1044 $wpdb->prepare(
1045 "SELECT s.folder_id, s.capability
1046 FROM {$tables['shares']} s
1047 INNER JOIN {$tables['decisions']} d ON d.share_id = s.id AND d.user_id = %d AND d.state = 'accepted'
1048 WHERE s.target_type = 'folder'
1049 AND s.folder_id IN ($ids_csv)
1050 AND s.principal_type = 'role'
1051 AND s.principal_ref IN ($role_placeholders)",
1052 $args
1053 ),
1054 ARRAY_A
1055 );
1056 foreach ( (array) $role_rows as $row ) {
1057 $row_cap = (string) $row['capability'];
1058 if ( 'write' === $row_cap ) {
1059 return 'write';
1060 }
1061 if ( 'read' === $row_cap && 'none' === $cap ) {
1062 $cap = 'read';
1063 }
1064 }
1065 }
1066
1067 return $cap;
1068 }
1069
1070 /**
1071 * Direct (non-cascading) capability resolver. Same logic as
1072 * `desktop_mode_folder_share_user_capability` minus the cascade
1073 * walk — owner / share rows / role decisions only.
1074 *
1075 * Currently uncalled: the cascade resolver
1076 * (`desktop_mode_folder_share_user_capability_cascade`) resolves
1077 * every ancestor via three batched `IN (…)` queries instead of
1078 * querying ancestors one at a time. Kept as a single-folder,
1079 * non-cascading resolver.
1080 *
1081 * @internal
1082 */
1083 function desktop_mode_folder_share_user_capability_direct( $folder_id, $user_id ) {
1084 $folder_id = (int) $folder_id;
1085 $user_id = (int) $user_id;
1086 if ( $folder_id <= 0 || $user_id <= 0 ) {
1087 return 'none';
1088 }
1089 $folder = desktop_mode_files_get_folder( $folder_id );
1090 if ( ! $folder ) {
1091 return 'none';
1092 }
1093 if ( (int) $folder['owner_id'] === $user_id ) {
1094 return 'write';
1095 }
1096 $cap = 'none';
1097 if ( 'all' === $folder['share_mode'] ) {
1098 $cap = (string) apply_filters( 'desktop_mode_files_share_all_default_capability', 'read', $folder_id, $user_id );
1099 }
1100
1101 $user_roles = array();
1102 $user = get_userdata( $user_id );
1103 if ( $user ) {
1104 $user_roles = (array) $user->roles;
1105 }
1106
1107 global $wpdb;
1108 $tables = desktop_mode_files_table_names();
1109 $rows = $wpdb->get_results(
1110 $wpdb->prepare(
1111 "SELECT id, principal_type, principal_ref, capability FROM {$tables['shares']}
1112 WHERE target_type = 'folder' AND folder_id = %d AND principal_type = 'user' AND state = 'accepted'",
1113 $folder_id
1114 ),
1115 ARRAY_A
1116 );
1117 $role_rows = $wpdb->get_results(
1118 $wpdb->prepare(
1119 "SELECT s.id, s.principal_type, s.principal_ref, s.capability
1120 FROM {$tables['shares']} s
1121 INNER JOIN {$tables['decisions']} d ON d.share_id = s.id AND d.user_id = %d AND d.state = 'accepted'
1122 WHERE s.target_type = 'folder' AND s.folder_id = %d AND s.principal_type = 'role'",
1123 $user_id,
1124 $folder_id
1125 ),
1126 ARRAY_A
1127 );
1128 foreach ( array_merge( (array) $rows, (array) $role_rows ) as $row ) {
1129 $matches = false;
1130 if ( 'user' === $row['principal_type'] && (int) $row['principal_ref'] === $user_id ) {
1131 $matches = true;
1132 } elseif ( 'role' === $row['principal_type'] && in_array( (string) $row['principal_ref'], $user_roles, true ) ) {
1133 $matches = true;
1134 }
1135 if ( $matches ) {
1136 $row_cap = (string) $row['capability'];
1137 if ( 'write' === $row_cap ) {
1138 return 'write';
1139 }
1140 if ( 'read' === $row_cap && 'none' === $cap ) {
1141 $cap = 'read';
1142 }
1143 }
1144 }
1145 return $cap;
1146 }
1147
1148 /**
1149 * Return the chain of ancestor folder ids above `$folder_id`,
1150 * walking the owner's canonical placement. The first element is
1151 * the immediate parent; the last is the root-most ancestor.
1152 *
1153 * Why owner's placement: a folder can be placed in multiple
1154 * locations (one per user), so "parent" is ambiguous. The owner's
1155 * placement is the canonical one (the owner decides the tree).
1156 *
1157 * Hard-capped at 16 levels deep + a visited set to make pathological
1158 * inputs (cycles, deep nests) bounded.
1159 *
1160 * @param int $folder_id Folder whose ancestors to walk.
1161 * @param int $limit Max ancestor count (default 16).
1162 * @return int[]
1163 */
1164 function desktop_mode_folder_ancestors( $folder_id, $limit = 16 ) {
1165 global $wpdb;
1166 $folder_id = (int) $folder_id;
1167 if ( $folder_id <= 0 ) {
1168 return array();
1169 }
1170 $tables = desktop_mode_files_table_names();
1171 $ancestors = array();
1172 $current = $folder_id;
1173 $visited = array();
1174 $depth = 0;
1175 while ( $depth < $limit ) {
1176 if ( isset( $visited[ $current ] ) ) {
1177 break;
1178 }
1179 $visited[ $current ] = true;
1180 $folder = desktop_mode_files_get_folder( $current );
1181 if ( ! $folder ) {
1182 break;
1183 }
1184 $owner = (int) $folder['owner_id'];
1185 $row = $wpdb->get_row(
1186 $wpdb->prepare(
1187 "SELECT parent_id FROM {$tables['placements']}
1188 WHERE owner_id = %d
1189 AND file_type = 'folder'
1190 AND file_ref = %s
1191 AND trashed_at_ms IS NULL
1192 ORDER BY id ASC
1193 LIMIT 1",
1194 $owner,
1195 (string) $current
1196 ),
1197 ARRAY_A
1198 );
1199 if ( ! $row ) {
1200 break;
1201 }
1202 $parent_id = (int) $row['parent_id'];
1203 if ( $parent_id <= 0 ) {
1204 break;
1205 }
1206 $ancestors[] = $parent_id;
1207 $current = $parent_id;
1208 $depth++;
1209 }
1210 return $ancestors;
1211 }
1212
1213 /**
1214 * Pending invites for `$user_id` across every folder. Used by the
1215 * heartbeat `shares.pending` payload.
1216 *
1217 * @param int $user_id Viewer.
1218 * @param int $since_ms Optional. Only include rows with `invited_at_ms > since`.
1219 * @return array[]
1220 */
1221 function desktop_mode_files_get_pending_shares_for_user( $user_id, $since_ms = 0 ) {
1222 global $wpdb;
1223 $user_id = (int) $user_id;
1224 $since_ms = (int) $since_ms;
1225 if ( $user_id <= 0 ) {
1226 return array();
1227 }
1228 $user = get_userdata( $user_id );
1229 if ( ! $user ) {
1230 return array();
1231 }
1232 $roles = (array) $user->roles;
1233
1234 $tables = desktop_mode_files_table_names();
1235
1236 // User-principal: state lives on the share row. Surface where
1237 // state='pending' AND principal_ref matches the user.
1238 $user_pending = $wpdb->get_results(
1239 $wpdb->prepare(
1240 "SELECT s.* FROM {$tables['shares']} s
1241 INNER JOIN {$tables['folders']} f ON f.id = s.folder_id AND f.trashed_at_ms IS NULL
1242 WHERE s.target_type = 'folder'
1243 AND s.state = 'pending'
1244 AND s.invited_at_ms > %d
1245 AND s.principal_type = 'user'
1246 AND s.principal_ref = %s
1247 ORDER BY s.invited_at_ms ASC, s.id ASC",
1248 $since_ms,
1249 (string) $user_id
1250 ),
1251 ARRAY_A
1252 );
1253
1254 // Role-principal: surface every role-share the user matches
1255 // where they have NO decision row yet OR their decision is
1256 // 'pending'. Denied/accepted decisions suppress the prompt.
1257 $role_pending = array();
1258 if ( ! empty( $roles ) ) {
1259 $placeholders = implode( ',', array_fill( 0, count( $roles ), '%s' ) );
1260 $prepare = array_merge( array( $user_id, $since_ms ), $roles );
1261 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1262 $role_pending = $wpdb->get_results(
1263 $wpdb->prepare(
1264 "SELECT s.* FROM {$tables['shares']} s
1265 INNER JOIN {$tables['folders']} f ON f.id = s.folder_id AND f.trashed_at_ms IS NULL
1266 LEFT JOIN {$tables['decisions']} d ON d.share_id = s.id AND d.user_id = %d
1267 WHERE s.target_type = 'folder'
1268 AND s.invited_at_ms > %d
1269 AND s.principal_type = 'role'
1270 AND s.principal_ref IN ($placeholders)
1271 AND ( d.state IS NULL OR d.state = 'pending' )
1272 ORDER BY s.invited_at_ms ASC, s.id ASC",
1273 $prepare
1274 ),
1275 ARRAY_A
1276 );
1277 }
1278
1279 $out = array();
1280 foreach ( array_merge( (array) $user_pending, (array) $role_pending ) as $row ) {
1281 $out[] = desktop_mode_files_normalize_share_row( $row );
1282 }
1283 return $out;
1284 }
1285
1286 /**
1287 * Bump a folder's `updated_at_ms`. Internal helper — every share
1288 * mutation should bump the parent folder so heartbeat clients pick
1289 * up the change in the same delta window.
1290 *
1291 * @internal
1292 *
1293 * @param int $folder_id Folder id.
1294 */
1295 function desktop_mode_files_bump_folder_updated_at( $folder_id ) {
1296 global $wpdb;
1297 $tables = desktop_mode_files_table_names();
1298 $wpdb->update(
1299 $tables['folders'],
1300 array( 'updated_at_ms' => desktop_mode_files_now_ms() ),
1301 array( 'id' => (int) $folder_id ),
1302 array( '%d' ),
1303 array( '%d' )
1304 );
1305 }
1306
1307 /**
1308 * Recipient-scoped trash. Removes the recipient's placement of the
1309 * folder + their placements INSIDE the folder. Does NOT cascade
1310 * into the shared icon namespace (other users' placements survive).
1311 *
1312 * @param int $folder_id Folder id.
1313 * @param int $user_id Recipient.
1314 * @return int Number of placement rows trashed.
1315 */
1316 function desktop_mode_files_trash_folder_for_user( $folder_id, $user_id ) {
1317 global $wpdb;
1318 $folder_id = (int) $folder_id;
1319 $user_id = (int) $user_id;
1320 if ( $folder_id <= 0 || $user_id <= 0 ) {
1321 return 0;
1322 }
1323 $tables = desktop_mode_files_table_names();
1324 $now = desktop_mode_files_now_ms();
1325
1326 // The recipient's folder-shortcut placement (parent_id=0,
1327 // file_type='folder', file_ref=$folder_id) + every placement
1328 // they own INSIDE this folder (parent_id=$folder_id).
1329 $rows = $wpdb->get_results(
1330 $wpdb->prepare(
1331 "SELECT id FROM {$tables['placements']}
1332 WHERE owner_id = %d
1333 AND trashed_at_ms IS NULL
1334 AND (
1335 ( file_type = 'folder' AND file_ref = %s AND parent_id = 0 )
1336 OR parent_id = %d
1337 )",
1338 $user_id,
1339 (string) $folder_id,
1340 $folder_id
1341 ),
1342 ARRAY_A
1343 );
1344 $count = 0;
1345 foreach ( (array) $rows as $row ) {
1346 $pid = (int) $row['id'];
1347 $wpdb->update(
1348 $tables['placements'],
1349 array(
1350 'trashed_at_ms' => $now,
1351 'trashed_by' => $user_id,
1352 ),
1353 array( 'id' => $pid ),
1354 array( '%d', '%d' ),
1355 array( '%d' )
1356 );
1357 // Soft-trash only — DO NOT write a tombstone here.
1358 // Tombstones represent permanent removal (hard delete); the
1359 // heartbeat already surfaces soft-trashed rows via the
1360 // `trashed_at_ms IS NOT NULL` query in
1361 // `desktop_mode_files_compute_heartbeat_delta`. Writing a
1362 // tombstone on every soft-trash conflates the two states and,
1363 // when the row is later restored (e.g. the recipient re-
1364 // accepts the same share), the lingering tombstone keeps
1365 // telling clients "this is gone" while the same placement
1366 // row is also being upserted as alive — causing the row to
1367 // disappear from the desktop on every heartbeat tick. See
1368 // the user-reported "shared folder vanishes after refresh"
1369 // bug.
1370 $count++;
1371 }
1372 return $count;
1373 }
1374
1375 /**
1376 * Hook into the trash gate so a read-only recipient cannot trash
1377 * placements they "own" inside a shared folder. The ownership
1378 * check at the placement level passes (each user has their own
1379 * placement row), so the gate needs an extra read-only veto.
1380 *
1381 * @param bool $can Default decision (ownership match).
1382 * @param int $user_id Acting user.
1383 * @param array $row Placement row.
1384 * @return bool
1385 */
1386 function desktop_mode_files_share_gate_trash( $can, $user_id, $row ) {
1387 $parent_id = isset( $row['parent_id'] ) ? (int) $row['parent_id'] : 0;
1388 $user_id = (int) $user_id;
1389
1390 // Root-level placement of a SHARED FOLDER (the recipient's
1391 // desktop copy of a folder owned by someone else). The
1392 // recipient technically "owns" their placement row, so the
1393 // default ownership rule grants trash — but the destructive
1394 // "Move to Trash" affordance is misleading here. The correct
1395 // action is "Leave shared folder", which fires the share-leave
1396 // flow (revokes their decision, scrubs the placement, leaves
1397 // the original intact). Veto the trash gate when the viewer
1398 // has no WRITE cap on the folder so the client suppresses
1399 // "Move to Trash" + rejects the trash drop, leaving "Leave
1400 // shared folder" as the only way out.
1401 if (
1402 $parent_id <= 0 &&
1403 isset( $row['file_type'] ) &&
1404 'folder' === (string) $row['file_type'] &&
1405 isset( $row['file_ref'] )
1406 ) {
1407 $folder_ref = (int) $row['file_ref'];
1408 if ( $folder_ref > 0 ) {
1409 $folder_row = desktop_mode_files_get_folder( $folder_ref );
1410 if (
1411 $folder_row &&
1412 (int) $folder_row['owner_id'] !== $user_id
1413 ) {
1414 // ANY non-owner recipient of a shared folder is
1415 // blocked from trashing their root placement — the
1416 // correct action is "Leave shared folder". This
1417 // applies equally to read-only AND write recipients:
1418 // a writer's destructive intent should be expressed
1419 // via the leave flow (which scrubs their own
1420 // placement) instead of via Move to Trash (which is
1421 // reserved for the owner's destructive cascade).
1422 $root_cap = desktop_mode_folder_share_user_capability( $folder_ref, $user_id );
1423 if ( 'none' !== $root_cap ) {
1424 return false;
1425 }
1426 }
1427 }
1428 return $can;
1429 }
1430
1431 if ( $parent_id <= 0 ) {
1432 // Any other root placement (not a shared-folder tile) —
1433 // default ownership rule stands.
1434 return $can;
1435 }
1436 $folder = desktop_mode_files_get_folder( $parent_id );
1437 if ( ! $folder ) {
1438 return $can;
1439 }
1440 $is_owner = (int) $folder['owner_id'] === $user_id;
1441 $cap = desktop_mode_folder_share_user_capability( $parent_id, $user_id );
1442
1443 // Folder owner can always trash anything inside their folder.
1444 if ( $is_owner ) {
1445 return true;
1446 }
1447 // Non-owner: require write cap on the folder, regardless of
1448 // who originally placed the row. This is the upgrade path —
1449 // writers can trash any icon in the shared folder. Readers
1450 // can't trash anything (even their own placement in this folder).
1451 return 'write' === $cap;
1452 }
1453 add_filter( 'desktop_mode_files_user_can_trash_placement', 'desktop_mode_files_share_gate_trash', 10, 3 );
1454
1455 /**
1456 * Inject share-related state into the shell config so the share
1457 * settings modal + role picker have what they need without
1458 * round-tripping.
1459 *
1460 * @param array $config Shell config.
1461 * @return array
1462 */
1463 function desktop_mode_files_share_inject_shell_config( $config ) {
1464 if ( ! is_array( $config ) ) {
1465 $config = array();
1466 }
1467 $config['shareEligibleRoles'] = desktop_mode_files_share_eligible_roles();
1468 $config['filesUsersSearchUrl'] = esc_url_raw( rest_url( 'desktop-mode/v1/files/users/search' ) );
1469 $config['folderSharesUrl'] = esc_url_raw( rest_url( 'desktop-mode/v1/files/folders' ) );
1470 $user_id = (int) get_current_user_id();
1471 if ( ! isset( $config['currentUserId'] ) ) {
1472 $config['currentUserId'] = $user_id;
1473 }
1474
1475 // Seed the shares store with the viewer's current pending invites
1476 // on the first paint, so the accept/deny modal opens immediately
1477 // on refresh instead of waiting for the first heartbeat tick to
1478 // deliver them. Same kill-switch + shape as the heartbeat path —
1479 // see `desktop_mode_files_collect_heartbeat_delta()` for the
1480 // canonical builder.
1481 $pending = array();
1482 $sharing_enabled = function_exists( 'desktop_mode_files_sharing_enabled_for' )
1483 ? desktop_mode_files_sharing_enabled_for( $user_id )
1484 : true;
1485 if (
1486 $user_id > 0 &&
1487 $sharing_enabled &&
1488 function_exists( 'desktop_mode_files_get_pending_shares_for_user' )
1489 ) {
1490 $rows = desktop_mode_files_get_pending_shares_for_user( $user_id, 0 );
1491 foreach ( $rows as $row ) {
1492 $shape = desktop_mode_files_shape_share( $row );
1493 $folder = desktop_mode_files_get_folder( $row['folder_id'] );
1494 if ( $folder ) {
1495 $shape['folderName'] = (string) $folder['name'];
1496 $shape['ownerId'] = (int) $folder['owner_id'];
1497 $owner_user = get_userdata( (int) $folder['owner_id'] );
1498 $shape['ownerName'] = $owner_user ? $owner_user->display_name : '';
1499 $shape['ownerAvatar'] = $owner_user ? get_avatar_url( $owner_user->ID, array( 'size' => 48 ) ) : '';
1500 }
1501 $pending[] = $shape;
1502 }
1503 }
1504 $config['serverPendingShares'] = $pending;
1505
1506 return $config;
1507 }
1508 add_filter( 'desktop_mode_shell_config', 'desktop_mode_files_share_inject_shell_config', 20 );
1509
1510 /**
1511 * Place an icon at the next free row-major slot in a user's view
1512 * of `$parent_id`. Internal helper used by share-accept and
1513 * fan-out. Mirrors the grid math in `src/desktop-files/grid.ts`
1514 * (padding 16 + col 96 + row 110).
1515 *
1516 * @param int $user_id Viewer.
1517 * @param int $parent_id Folder id (0 = desktop root).
1518 * @param string $type File-type slug.
1519 * @param string $ref Entity reference.
1520 * @return int|WP_Error Placement id or error.
1521 */
1522 function desktop_mode_files_place_at_next_free_slot( $user_id, $parent_id, $type, $ref ) {
1523 global $wpdb;
1524 $user_id = (int) $user_id;
1525 $parent_id = max( 0, (int) $parent_id );
1526
1527 $tables = desktop_mode_files_table_names();
1528 $existing = $wpdb->get_results(
1529 $wpdb->prepare(
1530 "SELECT x, y FROM {$tables['placements']}
1531 WHERE owner_id = %d
1532 AND parent_id = %d
1533 AND trashed_at_ms IS NULL",
1534 $user_id,
1535 $parent_id
1536 ),
1537 ARRAY_A
1538 );
1539 $occupied = array();
1540 foreach ( (array) $existing as $row ) {
1541 $col = max( 0, (int) round( ( (int) $row['x'] - 16 ) / 96 ) );
1542 $r = max( 0, (int) round( ( (int) $row['y'] - 16 ) / 110 ) );
1543 $occupied[ "$col,$r" ] = true;
1544 }
1545
1546 $pick_col = 0;
1547 $pick_row = 0;
1548 for ( $r = 0; $r < 999; $r++ ) {
1549 for ( $col = 0; $col < 999; $col++ ) {
1550 if ( ! isset( $occupied[ "$col,$r" ] ) ) {
1551 $pick_col = $col;
1552 $pick_row = $r;
1553 break 2;
1554 }
1555 }
1556 }
1557
1558 return desktop_mode_files_place(
1559 $user_id,
1560 $parent_id,
1561 $type,
1562 $ref,
1563 array(
1564 'x' => 16 + $pick_col * 96,
1565 'y' => 16 + $pick_row * 110,
1566 )
1567 );
1568 }
1569