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

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