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

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