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