| 1 |
<?php |
| 2 |
/** |
| 3 |
* OpenStation — Files REST routes. |
| 4 |
* |
| 5 |
* Routes under `/desktop-mode/v1/files`: |
| 6 |
* |
| 7 |
* GET /placements?folder=<id> List the viewer's placements |
| 8 |
* under `<id>` (0 for desktop root). |
| 9 |
* POST /placements Create a placement. |
| 10 |
* PATCH /placements/(?P<id>\d+) Move / update a placement. |
| 11 |
* DELETE /placements/(?P<id>\d+) Remove a placement. |
| 12 |
* |
| 13 |
* GET /folders List folders visible to the viewer. |
| 14 |
* POST /folders Create a folder. |
| 15 |
* PATCH /folders/(?P<id>\d+) Update a folder. |
| 16 |
* DELETE /folders/(?P<id>\d+) Delete a folder. |
| 17 |
* |
| 18 |
* GET /folders/(?P<id>\d+)/shares List a folder's shares |
| 19 |
* (owner only). |
| 20 |
* POST /folders/(?P<id>\d+)/shares Invite a user/role (owner only). |
| 21 |
* PATCH /folders/(?P<id>\d+)/shares/(?P<shareId>\d+) |
| 22 |
* Change a share's capability. |
| 23 |
* DELETE /folders/(?P<id>\d+)/shares/(?P<shareId>\d+) |
| 24 |
* Revoke a share. |
| 25 |
* POST /folders/(?P<id>\d+)/shares/(?P<shareId>\d+)/accept |
| 26 |
* Accept an invite (recipient). |
| 27 |
* POST /folders/(?P<id>\d+)/shares/(?P<shareId>\d+)/deny |
| 28 |
* Deny an invite (recipient). |
| 29 |
* POST /folders/(?P<id>\d+)/leave Leave a shared folder |
| 30 |
* (recipient). |
| 31 |
* |
| 32 |
* GET /users/search Share-picker autocomplete. |
| 33 |
* POST /folder-sharing-tables/purge |
| 34 |
* Drop the folder-sharing tables |
| 35 |
* (site admin only). |
| 36 |
* |
| 37 |
* PUT /associations Replace the viewer's full |
| 38 |
* `{ type => opener_id }` map. |
| 39 |
* |
| 40 |
* Permission: every route requires a logged-in user with desktop |
| 41 |
* mode enabled. Per-row gating happens inside the store. On top of |
| 42 |
* that base, the share/accept/deny/leave routes also gate on the |
| 43 |
* viewer's folder-sharing OS Setting via |
| 44 |
* `openstation_files_rest_share_permission`, `/users/search` |
| 45 |
* additionally requires `edit_posts`, and the sharing-tables purge |
| 46 |
* requires `manage_options`. |
| 47 |
* |
| 48 |
* @package OpenStation |
| 49 |
*/ |
| 50 |
|
| 51 |
defined( 'ABSPATH' ) || exit; |
| 52 |
|
| 53 |
/** |
| 54 |
* Base permission check shared by the desktop-files REST routes: |
| 55 |
* requires a logged-in user with OpenStation enabled. |
| 56 |
*/ |
| 57 |
function openstation_files_rest_permission() { |
| 58 |
if ( ! is_user_logged_in() ) { |
| 59 |
return new WP_Error( 'openstation_files_unauthenticated', __( 'You must be logged in.', 'desktop-mode' ), array( 'status' => 401 ) ); |
| 60 |
} |
| 61 |
if ( function_exists( 'openstation_is_enabled' ) && ! openstation_is_enabled( get_current_user_id() ) ) { |
| 62 |
return new WP_Error( 'openstation_files_disabled', __( 'OpenStation is not enabled for this user.', 'desktop-mode' ), array( 'status' => 403 ) ); |
| 63 |
} |
| 64 |
return true; |
| 65 |
} |
| 66 |
|
| 67 |
/** |
| 68 |
* Permission callback layered ON TOP of |
| 69 |
* `openstation_files_rest_permission` for every share-related |
| 70 |
* route. Returns a 404 (looks the same as a route that doesn't |
| 71 |
* exist) when the viewer has the folder-sharing feature toggled |
| 72 |
* off in OS Settings — no information leak about whether the |
| 73 |
* feature is even installed. |
| 74 |
*/ |
| 75 |
function openstation_files_rest_share_permission() { |
| 76 |
$base = openstation_files_rest_permission(); |
| 77 |
if ( is_wp_error( $base ) ) { |
| 78 |
return $base; |
| 79 |
} |
| 80 |
if ( |
| 81 |
function_exists( 'openstation_files_sharing_enabled_for' ) |
| 82 |
&& ! openstation_files_sharing_enabled_for( get_current_user_id() ) |
| 83 |
) { |
| 84 |
return new WP_Error( |
| 85 |
'rest_no_route', |
| 86 |
__( 'No route was found matching the URL and request method.', 'desktop-mode' ), |
| 87 |
array( 'status' => 404 ) |
| 88 |
); |
| 89 |
} |
| 90 |
return true; |
| 91 |
} |
| 92 |
|
| 93 |
/** |
| 94 |
* Permission callback for the destructive site-admin actions |
| 95 |
* (currently: drop the folder-sharing tables). Requires |
| 96 |
* `manage_options` — site-wide schema mutation should never be |
| 97 |
* exposed below that capability. |
| 98 |
*/ |
| 99 |
function openstation_files_rest_admin_permission() { |
| 100 |
if ( ! current_user_can( 'manage_options' ) ) { |
| 101 |
return new WP_Error( |
| 102 |
'openstation_files_forbidden', |
| 103 |
__( 'You do not have permission to perform this action.', 'desktop-mode' ), |
| 104 |
array( 'status' => 403 ) |
| 105 |
); |
| 106 |
} |
| 107 |
return true; |
| 108 |
} |
| 109 |
|
| 110 |
/** |
| 111 |
* Register the routes. |
| 112 |
*/ |
| 113 |
function openstation_files_register_rest_routes() { |
| 114 |
$ns = 'desktop-mode/v1'; |
| 115 |
|
| 116 |
register_rest_route( |
| 117 |
$ns, |
| 118 |
'/files/placements', |
| 119 |
array( |
| 120 |
array( |
| 121 |
'methods' => WP_REST_Server::READABLE, |
| 122 |
'permission_callback' => 'openstation_files_rest_permission', |
| 123 |
'callback' => 'openstation_files_rest_list_placements', |
| 124 |
'args' => array( |
| 125 |
'folder' => array( |
| 126 |
'type' => 'integer', |
| 127 |
'default' => 0, |
| 128 |
'sanitize_callback' => 'absint', |
| 129 |
), |
| 130 |
), |
| 131 |
), |
| 132 |
array( |
| 133 |
'methods' => WP_REST_Server::CREATABLE, |
| 134 |
'permission_callback' => 'openstation_files_rest_permission', |
| 135 |
'callback' => 'openstation_files_rest_create_placement', |
| 136 |
'args' => array( |
| 137 |
'parentId' => array( |
| 138 |
'type' => 'integer', |
| 139 |
'default' => 0, |
| 140 |
), |
| 141 |
'type' => array( |
| 142 |
'type' => 'string', |
| 143 |
'required' => true, |
| 144 |
), |
| 145 |
'ref' => array( |
| 146 |
'type' => 'string', |
| 147 |
'required' => true, |
| 148 |
), |
| 149 |
'x' => array( |
| 150 |
'type' => 'integer', |
| 151 |
'default' => 0, |
| 152 |
), |
| 153 |
'y' => array( |
| 154 |
'type' => 'integer', |
| 155 |
'default' => 0, |
| 156 |
), |
| 157 |
'sortOrder' => array( |
| 158 |
'type' => 'integer', |
| 159 |
'default' => 0, |
| 160 |
), |
| 161 |
'meta' => array( |
| 162 |
'type' => 'object', |
| 163 |
'required' => false, |
| 164 |
), |
| 165 |
), |
| 166 |
), |
| 167 |
) |
| 168 |
); |
| 169 |
|
| 170 |
register_rest_route( |
| 171 |
$ns, |
| 172 |
'/files/placements/(?P<id>\d+)', |
| 173 |
array( |
| 174 |
array( |
| 175 |
'methods' => WP_REST_Server::EDITABLE, |
| 176 |
'permission_callback' => 'openstation_files_rest_permission', |
| 177 |
'callback' => 'openstation_files_rest_update_placement', |
| 178 |
), |
| 179 |
array( |
| 180 |
'methods' => WP_REST_Server::DELETABLE, |
| 181 |
'permission_callback' => 'openstation_files_rest_permission', |
| 182 |
'callback' => 'openstation_files_rest_delete_placement', |
| 183 |
), |
| 184 |
) |
| 185 |
); |
| 186 |
|
| 187 |
register_rest_route( |
| 188 |
$ns, |
| 189 |
'/files/folders', |
| 190 |
array( |
| 191 |
array( |
| 192 |
'methods' => WP_REST_Server::READABLE, |
| 193 |
'permission_callback' => 'openstation_files_rest_permission', |
| 194 |
'callback' => 'openstation_files_rest_list_folders', |
| 195 |
), |
| 196 |
array( |
| 197 |
'methods' => WP_REST_Server::CREATABLE, |
| 198 |
'permission_callback' => 'openstation_files_rest_permission', |
| 199 |
'callback' => 'openstation_files_rest_create_folder', |
| 200 |
'args' => array( |
| 201 |
'name' => array( |
| 202 |
'type' => 'string', |
| 203 |
'required' => true, |
| 204 |
), |
| 205 |
'shareMode' => array( |
| 206 |
'type' => 'string', |
| 207 |
'default' => 'private', |
| 208 |
), |
| 209 |
'shareMeta' => array( |
| 210 |
'type' => 'object', |
| 211 |
'required' => false, |
| 212 |
), |
| 213 |
), |
| 214 |
), |
| 215 |
) |
| 216 |
); |
| 217 |
|
| 218 |
register_rest_route( |
| 219 |
$ns, |
| 220 |
'/files/folders/(?P<id>\d+)', |
| 221 |
array( |
| 222 |
array( |
| 223 |
'methods' => WP_REST_Server::EDITABLE, |
| 224 |
'permission_callback' => 'openstation_files_rest_permission', |
| 225 |
'callback' => 'openstation_files_rest_update_folder', |
| 226 |
), |
| 227 |
array( |
| 228 |
'methods' => WP_REST_Server::DELETABLE, |
| 229 |
'permission_callback' => 'openstation_files_rest_permission', |
| 230 |
'callback' => 'openstation_files_rest_delete_folder', |
| 231 |
), |
| 232 |
) |
| 233 |
); |
| 234 |
|
| 235 |
register_rest_route( |
| 236 |
$ns, |
| 237 |
'/files/associations', |
| 238 |
array( |
| 239 |
'methods' => 'PUT', |
| 240 |
'permission_callback' => 'openstation_files_rest_permission', |
| 241 |
'callback' => 'openstation_files_rest_save_associations', |
| 242 |
'args' => array( |
| 243 |
'associations' => array( |
| 244 |
'type' => 'object', |
| 245 |
'required' => true, |
| 246 |
), |
| 247 |
), |
| 248 |
) |
| 249 |
); |
| 250 |
|
| 251 |
// Every share-related route gates on the user's |
| 252 |
// `foldersSharingEnabled` OS Setting via |
| 253 |
// `openstation_files_rest_share_permission` — when a user has |
| 254 |
// flipped sharing off, these routes return 404 (looks the same |
| 255 |
// as a feature that isn't installed; no info leak about the |
| 256 |
// kill switch's existence). |
| 257 |
register_rest_route( |
| 258 |
$ns, |
| 259 |
'/files/folders/(?P<id>\d+)/shares', |
| 260 |
array( |
| 261 |
array( |
| 262 |
'methods' => WP_REST_Server::READABLE, |
| 263 |
'permission_callback' => 'openstation_files_rest_share_permission', |
| 264 |
'callback' => 'openstation_files_rest_list_shares', |
| 265 |
), |
| 266 |
array( |
| 267 |
'methods' => WP_REST_Server::CREATABLE, |
| 268 |
'permission_callback' => 'openstation_files_rest_share_permission', |
| 269 |
'callback' => 'openstation_files_rest_create_share', |
| 270 |
'args' => array( |
| 271 |
'principalType' => array( |
| 272 |
'type' => 'string', |
| 273 |
'enum' => array( 'user', 'role' ), |
| 274 |
'required' => true, |
| 275 |
), |
| 276 |
'principalRef' => array( |
| 277 |
'type' => 'string', |
| 278 |
'required' => true, |
| 279 |
), |
| 280 |
'capability' => array( |
| 281 |
'type' => 'string', |
| 282 |
'enum' => array( 'read', 'write' ), |
| 283 |
'default' => 'read', |
| 284 |
), |
| 285 |
), |
| 286 |
), |
| 287 |
) |
| 288 |
); |
| 289 |
|
| 290 |
register_rest_route( |
| 291 |
$ns, |
| 292 |
'/files/folders/(?P<id>\d+)/shares/(?P<shareId>\d+)', |
| 293 |
array( |
| 294 |
array( |
| 295 |
'methods' => WP_REST_Server::EDITABLE, |
| 296 |
'permission_callback' => 'openstation_files_rest_share_permission', |
| 297 |
'callback' => 'openstation_files_rest_update_share', |
| 298 |
'args' => array( |
| 299 |
'capability' => array( |
| 300 |
'type' => 'string', |
| 301 |
'enum' => array( 'read', 'write' ), |
| 302 |
'required' => true, |
| 303 |
), |
| 304 |
), |
| 305 |
), |
| 306 |
array( |
| 307 |
'methods' => WP_REST_Server::DELETABLE, |
| 308 |
'permission_callback' => 'openstation_files_rest_share_permission', |
| 309 |
'callback' => 'openstation_files_rest_delete_share', |
| 310 |
), |
| 311 |
) |
| 312 |
); |
| 313 |
|
| 314 |
register_rest_route( |
| 315 |
$ns, |
| 316 |
'/files/folders/(?P<id>\d+)/shares/(?P<shareId>\d+)/accept', |
| 317 |
array( |
| 318 |
'methods' => WP_REST_Server::CREATABLE, |
| 319 |
'permission_callback' => 'openstation_files_rest_share_permission', |
| 320 |
'callback' => 'openstation_files_rest_accept_share', |
| 321 |
) |
| 322 |
); |
| 323 |
|
| 324 |
register_rest_route( |
| 325 |
$ns, |
| 326 |
'/files/folders/(?P<id>\d+)/shares/(?P<shareId>\d+)/deny', |
| 327 |
array( |
| 328 |
'methods' => WP_REST_Server::CREATABLE, |
| 329 |
'permission_callback' => 'openstation_files_rest_share_permission', |
| 330 |
'callback' => 'openstation_files_rest_deny_share', |
| 331 |
) |
| 332 |
); |
| 333 |
|
| 334 |
register_rest_route( |
| 335 |
$ns, |
| 336 |
'/files/folders/(?P<id>\d+)/leave', |
| 337 |
array( |
| 338 |
'methods' => WP_REST_Server::CREATABLE, |
| 339 |
'permission_callback' => 'openstation_files_rest_share_permission', |
| 340 |
'callback' => 'openstation_files_rest_leave_folder', |
| 341 |
) |
| 342 |
); |
| 343 |
|
| 344 |
register_rest_route( |
| 345 |
$ns, |
| 346 |
'/files/users/search', |
| 347 |
array( |
| 348 |
'methods' => WP_REST_Server::READABLE, |
| 349 |
'permission_callback' => 'openstation_files_rest_search_users_permission', |
| 350 |
'callback' => 'openstation_files_rest_search_users', |
| 351 |
'args' => array( |
| 352 |
'q' => array( |
| 353 |
'type' => 'string', |
| 354 |
'default' => '', |
| 355 |
), |
| 356 |
'exclude' => array( |
| 357 |
'type' => 'string', |
| 358 |
'default' => '', |
| 359 |
), |
| 360 |
), |
| 361 |
) |
| 362 |
); |
| 363 |
|
| 364 |
// Site-admin only: destructive cleanup that drops the folder- |
| 365 |
// sharing tables outright (legacy + current). Surfaced from |
| 366 |
// the OS Settings → Features → Advanced panel. |
| 367 |
register_rest_route( |
| 368 |
$ns, |
| 369 |
'/files/folder-sharing-tables/purge', |
| 370 |
array( |
| 371 |
'methods' => WP_REST_Server::CREATABLE, |
| 372 |
'permission_callback' => 'openstation_files_rest_admin_permission', |
| 373 |
'callback' => 'openstation_files_rest_purge_sharing_tables', |
| 374 |
) |
| 375 |
); |
| 376 |
} |
| 377 |
add_action( 'rest_api_init', 'openstation_files_register_rest_routes' ); |
| 378 |
|
| 379 |
/** |
| 380 |
* Inline the root folder's placements into the boot-time shell |
| 381 |
* config so the desktop file grid hydrates without a REST |
| 382 |
* round-trip — this was the only REST call the shell had to await |
| 383 |
* before revealing the desktop. Mirrors the GET /placements handler |
| 384 |
* for `folder=0` exactly (same orphan backfill, same shape) so the |
| 385 |
* client store can't tell the difference; the JS consumer |
| 386 |
* (`src/desktop-files/layer.ts`) consumes the key one-shot, so any |
| 387 |
* later re-hydration still goes through REST for fresh state. |
| 388 |
* |
| 389 |
* The `openstation_shell_config` filter only runs while rendering |
| 390 |
* the shell for an enabled, logged-in user — the same gate the REST |
| 391 |
* permission callback enforces. |
| 392 |
* |
| 393 |
* @param array $config Shell config. |
| 394 |
* @return array |
| 395 |
*/ |
| 396 |
function openstation_files_inject_boot_placements( $config ) { |
| 397 |
$user_id = get_current_user_id(); |
| 398 |
if ( $user_id <= 0 ) { |
| 399 |
return $config; |
| 400 |
} |
| 401 |
openstation_files_auto_place_orphans( $user_id ); |
| 402 |
$rows = openstation_files_get_for_user_folder( $user_id, 0 ); |
| 403 |
$out = array(); |
| 404 |
foreach ( $rows as $row ) { |
| 405 |
$out[] = openstation_files_shape_placement( $row ); |
| 406 |
} |
| 407 |
$config['filesBootPlacements'] = $out; |
| 408 |
return $config; |
| 409 |
} |
| 410 |
add_filter( 'openstation_shell_config', 'openstation_files_inject_boot_placements', 20 ); |
| 411 |
|
| 412 |
/** |
| 413 |
* Inline the viewer's visible folder rows into the boot-time shell |
| 414 |
* config, the same way the root placements are. |
| 415 |
* |
| 416 |
* The client keeps a folders map alongside its placements, and |
| 417 |
* anything that needs to know a folder's OWNER reads it there — |
| 418 |
* notably the "Share folder" title-bar button, which is owner-only |
| 419 |
* and has nothing but a window id to work from. Nothing on the |
| 420 |
* normal boot path filled that map: placement hydration populates |
| 421 |
* placements, and `listFolders()` only ran after a create, a rename |
| 422 |
* or an untrash. So a plain reload left every folder ownerless, and |
| 423 |
* the owner of a folder lost the one control that manages its |
| 424 |
* sharing until something happened to repopulate the map. |
| 425 |
* |
| 426 |
* Mirrors GET /folders exactly (same visibility resolution — owned |
| 427 |
* folders plus accepted shares plus `share_mode='all'` — same |
| 428 |
* shape), and the client consumes it one-shot, so any later |
| 429 |
* re-hydration still goes through REST for fresh state. |
| 430 |
* |
| 431 |
* @param array $config Shell config. |
| 432 |
* @return array |
| 433 |
*/ |
| 434 |
function openstation_files_inject_boot_folders( $config ) { |
| 435 |
$user_id = get_current_user_id(); |
| 436 |
if ( $user_id <= 0 ) { |
| 437 |
return $config; |
| 438 |
} |
| 439 |
$out = array(); |
| 440 |
foreach ( openstation_files_get_visible_folders( $user_id ) as $row ) { |
| 441 |
$out[] = openstation_files_shape_folder( $row ); |
| 442 |
} |
| 443 |
$config['filesBootFolders'] = $out; |
| 444 |
return $config; |
| 445 |
} |
| 446 |
add_filter( 'openstation_shell_config', 'openstation_files_inject_boot_folders', 20 ); |
| 447 |
|
| 448 |
/** |
| 449 |
* GET /placements |
| 450 |
*/ |
| 451 |
function openstation_files_rest_list_placements( WP_REST_Request $req ) { |
| 452 |
$user_id = get_current_user_id(); |
| 453 |
$parent_id = (int) $req->get_param( 'folder' ); |
| 454 |
// Self-healing backfill — see |
| 455 |
// `openstation_files_auto_place_orphan_folders` for the why. |
| 456 |
// Only runs at the root because that's the only context where |
| 457 |
// auto-placing an orphan folder as a tile is unambiguous. |
| 458 |
if ( 0 === $parent_id ) { |
| 459 |
openstation_files_auto_place_orphans( $user_id ); |
| 460 |
} |
| 461 |
$rows = openstation_files_get_for_user_folder( $user_id, $parent_id ); |
| 462 |
$out = array(); |
| 463 |
foreach ( $rows as $row ) { |
| 464 |
$out[] = openstation_files_shape_placement( $row ); |
| 465 |
} |
| 466 |
return rest_ensure_response( |
| 467 |
array( |
| 468 |
'placements' => $out, |
| 469 |
'folderId' => $parent_id, |
| 470 |
) |
| 471 |
); |
| 472 |
} |
| 473 |
|
| 474 |
/** |
| 475 |
* POST /placements |
| 476 |
*/ |
| 477 |
function openstation_files_rest_create_placement( WP_REST_Request $req ) { |
| 478 |
$type = (string) $req->get_param( 'type' ); |
| 479 |
$ref = (string) $req->get_param( 'ref' ); |
| 480 |
$meta = $req->get_param( 'meta' ); |
| 481 |
|
| 482 |
// `link` placements get a server-resolved favicon stuffed onto |
| 483 |
// `meta.iconUrl` so the tile renderer can paint it without the |
| 484 |
// browser making a third-party request on every render. Other |
| 485 |
// types skip the resolver entirely (no extra fetch latency). |
| 486 |
if ( 'link' === $type && '' !== $ref ) { |
| 487 |
$icon_data_uri = openstation_resolve_favicon( $ref ); |
| 488 |
if ( is_string( $icon_data_uri ) && '' !== $icon_data_uri ) { |
| 489 |
$meta_arr = is_array( $meta ) ? $meta : array(); |
| 490 |
$meta_arr['iconUrl'] = $icon_data_uri; |
| 491 |
$meta = $meta_arr; |
| 492 |
} |
| 493 |
} |
| 494 |
|
| 495 |
$id = openstation_files_place( |
| 496 |
get_current_user_id(), |
| 497 |
(int) $req->get_param( 'parentId' ), |
| 498 |
$type, |
| 499 |
$ref, |
| 500 |
array( |
| 501 |
'x' => (int) $req->get_param( 'x' ), |
| 502 |
'y' => (int) $req->get_param( 'y' ), |
| 503 |
'sort_order' => (int) $req->get_param( 'sortOrder' ), |
| 504 |
'meta' => $meta, |
| 505 |
) |
| 506 |
); |
| 507 |
if ( is_wp_error( $id ) ) { |
| 508 |
return $id; |
| 509 |
} |
| 510 |
$row = openstation_files_get_placement( $id ); |
| 511 |
return rest_ensure_response( openstation_files_shape_placement( $row ) ); |
| 512 |
} |
| 513 |
|
| 514 |
/** |
| 515 |
* PATCH /placements/<id> |
| 516 |
*/ |
| 517 |
function openstation_files_rest_update_placement( WP_REST_Request $req ) { |
| 518 |
$id = (int) $req['id']; |
| 519 |
$json = $req->get_json_params(); |
| 520 |
$body = $json ? $json : $req->get_params(); |
| 521 |
$current = openstation_files_get_placement( $id ); |
| 522 |
if ( ! $current ) { |
| 523 |
return new WP_Error( 'openstation_files_not_found', __( 'Placement not found.', 'desktop-mode' ), array( 'status' => 404 ) ); |
| 524 |
} |
| 525 |
$conflict = openstation_files_check_if_match( (int) $current['updated_at_ms'], $req, $current ); |
| 526 |
if ( is_wp_error( $conflict ) ) { |
| 527 |
return $conflict; |
| 528 |
} |
| 529 |
$changes = array(); |
| 530 |
foreach ( array( |
| 531 |
'parentId' => 'parent_id', |
| 532 |
'x' => 'x', |
| 533 |
'y' => 'y', |
| 534 |
'sortOrder' => 'sort_order', |
| 535 |
'meta' => 'meta', |
| 536 |
) as $in => $col ) { |
| 537 |
if ( array_key_exists( $in, $body ) ) { |
| 538 |
$changes[ $col ] = $body[ $in ]; |
| 539 |
} |
| 540 |
} |
| 541 |
$ok = openstation_files_move( $id, get_current_user_id(), $changes ); |
| 542 |
if ( is_wp_error( $ok ) ) { |
| 543 |
return $ok; |
| 544 |
} |
| 545 |
return rest_ensure_response( openstation_files_shape_placement( openstation_files_get_placement( $id ) ) ); |
| 546 |
} |
| 547 |
|
| 548 |
/** |
| 549 |
* DELETE /placements/<id> |
| 550 |
*/ |
| 551 |
function openstation_files_rest_delete_placement( WP_REST_Request $req ) { |
| 552 |
$id = (int) $req['id']; |
| 553 |
$user_id = get_current_user_id(); |
| 554 |
// `force=1` query param permanently deletes (purges the row). |
| 555 |
// Default DELETE soft-trashes — the row lands in the recycle |
| 556 |
// bin and the user can restore. Same convention WP core REST |
| 557 |
// uses on every other resource. |
| 558 |
$force = '1' === (string) $req->get_param( 'force' ) |
| 559 |
|| true === $req->get_param( 'force' ); |
| 560 |
$ok = $force |
| 561 |
? openstation_files_purge_placement( $user_id, $id ) |
| 562 |
: openstation_files_trash_placement( $user_id, $id ); |
| 563 |
if ( is_wp_error( $ok ) ) { |
| 564 |
return $ok; |
| 565 |
} |
| 566 |
return rest_ensure_response( |
| 567 |
array( |
| 568 |
'deleted' => true, |
| 569 |
'force' => $force, |
| 570 |
) |
| 571 |
); |
| 572 |
} |
| 573 |
|
| 574 |
/** |
| 575 |
* GET /folders |
| 576 |
*/ |
| 577 |
function openstation_files_rest_list_folders() { |
| 578 |
$rows = openstation_files_get_visible_folders( get_current_user_id() ); |
| 579 |
$out = array(); |
| 580 |
foreach ( $rows as $row ) { |
| 581 |
$out[] = openstation_files_shape_folder( $row ); |
| 582 |
} |
| 583 |
return rest_ensure_response( array( 'folders' => $out ) ); |
| 584 |
} |
| 585 |
|
| 586 |
/** |
| 587 |
* POST /folders |
| 588 |
*/ |
| 589 |
function openstation_files_rest_create_folder( WP_REST_Request $req ) { |
| 590 |
$id = openstation_files_create_folder( |
| 591 |
get_current_user_id(), |
| 592 |
array( |
| 593 |
'name' => (string) $req->get_param( 'name' ), |
| 594 |
'share_mode' => (string) $req->get_param( 'shareMode' ), |
| 595 |
'share_meta' => $req->get_param( 'shareMeta' ), |
| 596 |
) |
| 597 |
); |
| 598 |
if ( is_wp_error( $id ) ) { |
| 599 |
return $id; |
| 600 |
} |
| 601 |
return rest_ensure_response( openstation_files_shape_folder( openstation_files_get_folder( $id ) ) ); |
| 602 |
} |
| 603 |
|
| 604 |
/** |
| 605 |
* PATCH /folders/<id> |
| 606 |
*/ |
| 607 |
function openstation_files_rest_update_folder( WP_REST_Request $req ) { |
| 608 |
$id = (int) $req['id']; |
| 609 |
$json = $req->get_json_params(); |
| 610 |
$body = $json ? $json : $req->get_params(); |
| 611 |
$current = openstation_files_get_folder( $id ); |
| 612 |
if ( ! $current ) { |
| 613 |
return new WP_Error( 'openstation_files_not_found', __( 'Folder not found.', 'desktop-mode' ), array( 'status' => 404 ) ); |
| 614 |
} |
| 615 |
$conflict = openstation_files_check_if_match( (int) $current['updated_at_ms'], $req, $current ); |
| 616 |
if ( is_wp_error( $conflict ) ) { |
| 617 |
return $conflict; |
| 618 |
} |
| 619 |
$changes = array(); |
| 620 |
foreach ( array( |
| 621 |
'name' => 'name', |
| 622 |
'shareMode' => 'share_mode', |
| 623 |
'shareMeta' => 'share_meta', |
| 624 |
) as $in => $col ) { |
| 625 |
if ( array_key_exists( $in, $body ) ) { |
| 626 |
$changes[ $col ] = $body[ $in ]; |
| 627 |
} |
| 628 |
} |
| 629 |
$ok = openstation_files_update_folder( $id, get_current_user_id(), $changes ); |
| 630 |
if ( is_wp_error( $ok ) ) { |
| 631 |
return $ok; |
| 632 |
} |
| 633 |
return rest_ensure_response( openstation_files_shape_folder( openstation_files_get_folder( $id ) ) ); |
| 634 |
} |
| 635 |
|
| 636 |
/** |
| 637 |
* DELETE /folders/<id> |
| 638 |
*/ |
| 639 |
function openstation_files_rest_delete_folder( WP_REST_Request $req ) { |
| 640 |
$id = (int) $req['id']; |
| 641 |
$user_id = get_current_user_id(); |
| 642 |
$force = '1' === (string) $req->get_param( 'force' ) |
| 643 |
|| true === $req->get_param( 'force' ); |
| 644 |
// Default DELETE soft-trashes the folder + cascades to child |
| 645 |
// placements (see `openstation_files_trash_folder`). `force=1` |
| 646 |
// permanently deletes both the folder row AND every child |
| 647 |
// placement that was trashed via the cascade. |
| 648 |
$ok = $force |
| 649 |
? openstation_files_purge_folder( $user_id, $id ) |
| 650 |
: openstation_files_trash_folder( $user_id, $id ); |
| 651 |
if ( is_wp_error( $ok ) ) { |
| 652 |
return $ok; |
| 653 |
} |
| 654 |
return rest_ensure_response( |
| 655 |
array( |
| 656 |
'deleted' => true, |
| 657 |
'force' => $force, |
| 658 |
) |
| 659 |
); |
| 660 |
} |
| 661 |
|
| 662 |
/** |
| 663 |
* PUT /associations — replaces the entire user-association map. |
| 664 |
*/ |
| 665 |
function openstation_files_rest_save_associations( WP_REST_Request $req ) { |
| 666 |
$assoc = (array) $req->get_param( 'associations' ); |
| 667 |
$clean = array(); |
| 668 |
foreach ( $assoc as $type => $opener_id ) { |
| 669 |
$type = sanitize_key( (string) $type ); |
| 670 |
$opener_id = sanitize_key( (string) $opener_id ); |
| 671 |
if ( '' === $type || '' === $opener_id ) { |
| 672 |
continue; |
| 673 |
} |
| 674 |
$clean[ $type ] = $opener_id; |
| 675 |
} |
| 676 |
update_user_meta( get_current_user_id(), OPENSTATION_FILE_ASSOCIATIONS_META, $clean ); |
| 677 |
return rest_ensure_response( |
| 678 |
array( |
| 679 |
'associations' => openstation_get_user_file_associations( get_current_user_id() ), |
| 680 |
) |
| 681 |
); |
| 682 |
} |
| 683 |
|
| 684 |
/** |
| 685 |
* Shape a placement row for the wire — converts snake_case to |
| 686 |
* camelCase and merges in the resolved `OpenStation_File` |
| 687 |
* shape so the JS side can render without a second fetch. |
| 688 |
* |
| 689 |
* @param array|null $row Normalized placement row. |
| 690 |
* @return array |
| 691 |
*/ |
| 692 |
function openstation_files_shape_placement( $row ) { |
| 693 |
if ( ! is_array( $row ) ) { |
| 694 |
return array(); |
| 695 |
} |
| 696 |
// Access-gated rows (shared-folder view, viewer lacks read on |
| 697 |
// the underlying entity) get a redacted shape — the viewer may |
| 698 |
// learn THAT the owner placed something here, not WHAT it is. |
| 699 |
// Skipping the resolver keeps entity metadata (title, permalink, |
| 700 |
// status, roles, …) from crossing the read-access boundary; the |
| 701 |
// tile renderer paints the lock overlay off `accessGated`. |
| 702 |
$access_gated = ! empty( $row['access_gated'] ); |
| 703 |
if ( $access_gated ) { |
| 704 |
$shape = array( |
| 705 |
'type' => $row['file_type'], |
| 706 |
'ref' => $row['file_ref'], |
| 707 |
'title' => __( 'Restricted item', 'desktop-mode' ), |
| 708 |
'icon' => 'dashicons-lock', |
| 709 |
'previewUrl' => '', |
| 710 |
'exists' => true, |
| 711 |
); |
| 712 |
} else { |
| 713 |
$file = openstation_resolve_file( $row['file_type'], $row['file_ref'] ); |
| 714 |
$shape = $file ? $file->serialize() : array( |
| 715 |
'type' => $row['file_type'], |
| 716 |
'ref' => $row['file_ref'], |
| 717 |
'title' => '', |
| 718 |
'icon' => 'dashicons-warning', |
| 719 |
'previewUrl' => '', |
| 720 |
'exists' => false, |
| 721 |
); |
| 722 |
} |
| 723 |
// `canTrash` carries the server's answer to "can the viewer |
| 724 |
// move this placement to the recycle bin?" so the client can |
| 725 |
// proactively suppress the trash affordance — both the tile's |
| 726 |
// right-click "Move to recycle bin" menu item and the trash |
| 727 |
// drop target's accept-check. Without it, the only feedback for |
| 728 |
// a forbidden drop was a 403 logged to the console, leaving the |
| 729 |
// user staring at a tile that wouldn't move. Falls back to |
| 730 |
// `false` when the helper isn't loaded (defensive — early-boot |
| 731 |
// REST calls before trash.php is required can't grant permission |
| 732 |
// they don't know about). |
| 733 |
$viewer_id = (int) get_current_user_id(); |
| 734 |
$can_trash = false; |
| 735 |
if ( $viewer_id > 0 && function_exists( 'openstation_files_user_can_trash_placement' ) ) { |
| 736 |
$can_trash = openstation_files_user_can_trash_placement( $viewer_id, $row ); |
| 737 |
} |
| 738 |
|
| 739 |
return array( |
| 740 |
'id' => (int) $row['id'], |
| 741 |
'parentId' => (int) $row['parent_id'], |
| 742 |
'x' => (int) $row['x'], |
| 743 |
'y' => (int) $row['y'], |
| 744 |
'sortOrder' => (int) $row['sort_order'], |
| 745 |
'updatedAtMs' => (int) $row['updated_at_ms'], |
| 746 |
'meta' => isset( $row['meta'] ) ? $row['meta'] : null, |
| 747 |
'file' => $shape, |
| 748 |
// `accessGated` is true when the viewer can't read the |
| 749 |
// underlying entity but the placement is shown anyway (the |
| 750 |
// shared-folder-view UX). Tile renderer surfaces it as a |
| 751 |
// lock overlay + tooltip; the `file` shape above is redacted. |
| 752 |
'accessGated' => $access_gated, |
| 753 |
'canTrash' => $can_trash, |
| 754 |
); |
| 755 |
} |
| 756 |
|
| 757 |
/** |
| 758 |
* @param array|null $row Folder row. |
| 759 |
* @return array |
| 760 |
*/ |
| 761 |
function openstation_files_shape_folder( $row ) { |
| 762 |
if ( ! is_array( $row ) ) { |
| 763 |
return array(); |
| 764 |
} |
| 765 |
$shape = array( |
| 766 |
'id' => (int) $row['id'], |
| 767 |
'ownerId' => (int) $row['owner_id'], |
| 768 |
'name' => (string) $row['name'], |
| 769 |
'shareMode' => (string) $row['share_mode'], |
| 770 |
'shareMeta' => isset( $row['share_meta'] ) ? $row['share_meta'] : null, |
| 771 |
'updatedAtMs' => (int) $row['updated_at_ms'], |
| 772 |
); |
| 773 |
// Same summary `OpenStation_Folder_File::serialize()` puts on a |
| 774 |
// folder PLACEMENT, so a tile paints identically whichever |
| 775 |
// response it came from. |
| 776 |
if ( function_exists( 'openstation_files_folder_share_summary' ) ) { |
| 777 |
$shape['shareSummary'] = openstation_files_folder_share_summary( $row ); |
| 778 |
} |
| 779 |
return $shape; |
| 780 |
} |
| 781 |
|
| 782 |
/** |
| 783 |
* Conditional-write helper. Reads `If-Match` from the request and |
| 784 |
* returns a 409 `WP_Error` when the stored row's `updated_at_ms` |
| 785 |
* doesn't match the supplied value. Returns null in every other |
| 786 |
* case (header absent → back-compat last-write-wins; header |
| 787 |
* matches → caller proceeds). |
| 788 |
* |
| 789 |
* The 409 body carries a structured `data` payload the client |
| 790 |
* surfaces as a toast: `{ reason, actor: { id,name,avatar }, |
| 791 |
* current: { parentId, parentName, updatedAtMs } }`. |
| 792 |
* |
| 793 |
* @param int $current_ms Current `updated_at_ms` on the row. |
| 794 |
* @param WP_REST_Request $req Inbound request. |
| 795 |
* @param array $row Normalized row (placement or folder). |
| 796 |
* @return WP_Error|null |
| 797 |
*/ |
| 798 |
function openstation_files_check_if_match( $current_ms, WP_REST_Request $req, $row ) { |
| 799 |
$header = $req->get_header( 'if_match' ); |
| 800 |
if ( null === $header || '' === $header ) { |
| 801 |
return null; |
| 802 |
} |
| 803 |
$expected = (int) trim( str_replace( '"', '', (string) $header ) ); |
| 804 |
if ( $expected === (int) $current_ms ) { |
| 805 |
return null; |
| 806 |
} |
| 807 |
// Prefer `updated_by` (v10+) so the conflict toast attributes |
| 808 |
// the change to the SESSION that won the race. Falls back to |
| 809 |
// `owner_id` (placement creator / folder owner) for legacy |
| 810 |
// rows from before the v10 `updated_by` column was added — in |
| 811 |
// a non-shared-write workflow that still happens to be the |
| 812 |
// right person; in shared-write the toast may be slightly |
| 813 |
// misleading for the lifetime of pre-v10 rows. New mutations |
| 814 |
// stamp the column accurately. See |
| 815 |
// `openstation_files_ensure_updated_by_column`. |
| 816 |
$actor_id = 0; |
| 817 |
if ( isset( $row['updated_by'] ) && (int) $row['updated_by'] > 0 ) { |
| 818 |
$actor_id = (int) $row['updated_by']; |
| 819 |
} elseif ( isset( $row['owner_id'] ) ) { |
| 820 |
$actor_id = (int) $row['owner_id']; |
| 821 |
} |
| 822 |
$actor = $actor_id ? get_userdata( $actor_id ) : null; |
| 823 |
|
| 824 |
$parent_id = isset( $row['parent_id'] ) ? (int) $row['parent_id'] : 0; |
| 825 |
$parent_name = ''; |
| 826 |
if ( $parent_id > 0 ) { |
| 827 |
$parent_folder = openstation_files_get_folder( $parent_id ); |
| 828 |
$parent_name = $parent_folder ? (string) $parent_folder['name'] : ''; |
| 829 |
} |
| 830 |
|
| 831 |
$reason = 'parent_changed'; |
| 832 |
if ( ! empty( $row['trashed_at_ms'] ) ) { |
| 833 |
$reason = 'trashed'; |
| 834 |
} |
| 835 |
|
| 836 |
// PII gate. The conflict toast names the actor (display name + |
| 837 |
// avatar) and the row's parent folder only when the requesting |
| 838 |
// viewer is in the same collaboration scope as the actor — i.e. |
| 839 |
// owns the row, owns the parent folder, or has at least read |
| 840 |
// access to the parent folder via the shares table. For any |
| 841 |
// other viewer the actor degrades to a generic "another |
| 842 |
// session" — `id: 0`, empty name + avatar — and `current` |
| 843 |
// drops the parent id/name, so a write attempt can't be used |
| 844 |
// to enumerate other users' display names or folder names |
| 845 |
// (this check runs BEFORE the store's ownership gate, so the |
| 846 |
// 409 body must not leak what the later 403 would protect). |
| 847 |
$viewer_id = (int) get_current_user_id(); |
| 848 |
$viewer_owns_row = isset( $row['owner_id'] ) && (int) $row['owner_id'] === $viewer_id; |
| 849 |
$viewer_can_see = $viewer_owns_row; |
| 850 |
if ( ! $viewer_can_see && $parent_id > 0 && isset( $parent_folder ) && $parent_folder ) { |
| 851 |
if ( (int) $parent_folder['owner_id'] === $viewer_id ) { |
| 852 |
$viewer_can_see = true; |
| 853 |
} elseif ( function_exists( 'openstation_folder_share_user_capability' ) ) { |
| 854 |
$viewer_can_see = 'none' !== openstation_folder_share_user_capability( $parent_id, $viewer_id ); |
| 855 |
} |
| 856 |
} |
| 857 |
$actor_payload = array( |
| 858 |
'id' => $viewer_can_see ? $actor_id : 0, |
| 859 |
'name' => $viewer_can_see && $actor ? $actor->display_name : '', |
| 860 |
'avatar' => $viewer_can_see && $actor ? get_avatar_url( $actor->ID, array( 'size' => 32 ) ) : '', |
| 861 |
); |
| 862 |
|
| 863 |
return new WP_Error( |
| 864 |
'openstation_files_conflict', |
| 865 |
__( 'This row was changed by another session.', 'desktop-mode' ), |
| 866 |
array( |
| 867 |
'status' => 409, |
| 868 |
'data' => array( |
| 869 |
'reason' => $reason, |
| 870 |
'actor' => $actor_payload, |
| 871 |
'current' => array( |
| 872 |
'parentId' => $viewer_can_see ? $parent_id : 0, |
| 873 |
'parentName' => $viewer_can_see ? $parent_name : '', |
| 874 |
'updatedAtMs' => (int) $current_ms, |
| 875 |
), |
| 876 |
), |
| 877 |
) |
| 878 |
); |
| 879 |
} |
| 880 |
|
| 881 |
/** |
| 882 |
* Shape a share row for the wire. |
| 883 |
* |
| 884 |
* @param array|null $row Normalized share row. |
| 885 |
* @return array |
| 886 |
*/ |
| 887 |
function openstation_files_shape_share( $row ) { |
| 888 |
if ( ! is_array( $row ) ) { |
| 889 |
return array(); |
| 890 |
} |
| 891 |
$shape = array( |
| 892 |
'id' => (int) $row['id'], |
| 893 |
'folderId' => (int) $row['folder_id'], |
| 894 |
'principalType' => (string) $row['principal_type'], |
| 895 |
'principalRef' => (string) $row['principal_ref'], |
| 896 |
'capability' => (string) $row['capability'], |
| 897 |
'state' => (string) $row['state'], |
| 898 |
'invitedBy' => (int) $row['invited_by'], |
| 899 |
'invitedAtMs' => (int) $row['invited_at_ms'], |
| 900 |
'decidedAtMs' => isset( $row['decided_at_ms'] ) ? $row['decided_at_ms'] : null, |
| 901 |
); |
| 902 |
if ( 'user' === $row['principal_type'] ) { |
| 903 |
$uid = (int) $row['principal_ref']; |
| 904 |
$user = $uid > 0 ? get_userdata( $uid ) : null; |
| 905 |
$shape['displayName'] = $user ? $user->display_name : ''; |
| 906 |
$shape['avatarUrl'] = $user ? get_avatar_url( $uid, array( 'size' => 48 ) ) : ''; |
| 907 |
} else { |
| 908 |
$roles = wp_roles(); |
| 909 |
$info = $roles && isset( $roles->roles[ $row['principal_ref'] ] ) ? $roles->roles[ $row['principal_ref'] ] : null; |
| 910 |
$shape['displayName'] = $info ? translate_user_role( (string) $info['name'] ) : (string) $row['principal_ref']; |
| 911 |
$shape['avatarUrl'] = ''; |
| 912 |
} |
| 913 |
return $shape; |
| 914 |
} |
| 915 |
|
| 916 |
/** |
| 917 |
* GET /folders/<id>/shares — owner only. |
| 918 |
*/ |
| 919 |
function openstation_files_rest_list_shares( WP_REST_Request $req ) { |
| 920 |
$folder_id = (int) $req['id']; |
| 921 |
$user_id = get_current_user_id(); |
| 922 |
if ( ! openstation_files_share_can_manage( $folder_id, $user_id ) ) { |
| 923 |
return new WP_Error( 'openstation_files_forbidden', __( 'You cannot view shares for this folder.', 'desktop-mode' ), array( 'status' => 403 ) ); |
| 924 |
} |
| 925 |
$folder = openstation_files_get_folder( $folder_id ); |
| 926 |
if ( ! $folder ) { |
| 927 |
return new WP_Error( 'openstation_files_not_found', __( 'Folder not found.', 'desktop-mode' ), array( 'status' => 404 ) ); |
| 928 |
} |
| 929 |
$rows = openstation_files_get_folder_shares( $folder_id ); |
| 930 |
$out = array(); |
| 931 |
foreach ( $rows as $row ) { |
| 932 |
$out[] = openstation_files_shape_share( $row ); |
| 933 |
} |
| 934 |
return rest_ensure_response( |
| 935 |
array( |
| 936 |
'shares' => $out, |
| 937 |
'shareMode' => (string) $folder['share_mode'], |
| 938 |
'all' => 'all' === (string) $folder['share_mode'], |
| 939 |
) |
| 940 |
); |
| 941 |
} |
| 942 |
|
| 943 |
/** |
| 944 |
* POST /folders/<id>/shares — owner only. |
| 945 |
*/ |
| 946 |
function openstation_files_rest_create_share( WP_REST_Request $req ) { |
| 947 |
$folder_id = (int) $req['id']; |
| 948 |
$actor_id = get_current_user_id(); |
| 949 |
$id = openstation_folder_share_invite( |
| 950 |
$folder_id, |
| 951 |
$actor_id, |
| 952 |
(string) $req->get_param( 'principalType' ), |
| 953 |
(string) $req->get_param( 'principalRef' ), |
| 954 |
(string) $req->get_param( 'capability' ) |
| 955 |
); |
| 956 |
if ( is_wp_error( $id ) ) { |
| 957 |
return $id; |
| 958 |
} |
| 959 |
return rest_ensure_response( openstation_files_shape_share( openstation_files_get_share( $id ) ) ); |
| 960 |
} |
| 961 |
|
| 962 |
/** |
| 963 |
* Verify that the share id in the URL actually belongs to the |
| 964 |
* folder id in the URL. Returns the loaded share row or a |
| 965 |
* `WP_Error` (404 unknown share / 404 mismatch). The underlying |
| 966 |
* mutation functions still gate on the share's true folder, so a |
| 967 |
* mismatched URL never escalates permission — but the routes are |
| 968 |
* hierarchical (`/folders/{id}/shares/{shareId}/…`), so honoring |
| 969 |
* both path segments is the contract callers expect. |
| 970 |
* |
| 971 |
* @param WP_REST_Request $req Request. |
| 972 |
* @return array|WP_Error |
| 973 |
*/ |
| 974 |
function openstation_files_rest_resolve_share_in_folder( WP_REST_Request $req ) { |
| 975 |
$folder_id = (int) $req['id']; |
| 976 |
$share_id = (int) $req['shareId']; |
| 977 |
$share = openstation_files_get_share( $share_id ); |
| 978 |
if ( ! $share ) { |
| 979 |
return new WP_Error( |
| 980 |
'openstation_files_not_found', |
| 981 |
__( 'Share not found.', 'desktop-mode' ), |
| 982 |
array( 'status' => 404 ) |
| 983 |
); |
| 984 |
} |
| 985 |
if ( (int) $share['folder_id'] !== $folder_id ) { |
| 986 |
return new WP_Error( |
| 987 |
'openstation_files_not_found', |
| 988 |
__( 'Share not found in this folder.', 'desktop-mode' ), |
| 989 |
array( 'status' => 404 ) |
| 990 |
); |
| 991 |
} |
| 992 |
return $share; |
| 993 |
} |
| 994 |
|
| 995 |
/** |
| 996 |
* PATCH /folders/<id>/shares/<shareId> — owner only. |
| 997 |
*/ |
| 998 |
function openstation_files_rest_update_share( WP_REST_Request $req ) { |
| 999 |
$share = openstation_files_rest_resolve_share_in_folder( $req ); |
| 1000 |
if ( is_wp_error( $share ) ) { |
| 1001 |
return $share; |
| 1002 |
} |
| 1003 |
$share_id = (int) $share['id']; |
| 1004 |
$ok = openstation_folder_share_update_capability( $share_id, get_current_user_id(), (string) $req->get_param( 'capability' ) ); |
| 1005 |
if ( is_wp_error( $ok ) ) { |
| 1006 |
return $ok; |
| 1007 |
} |
| 1008 |
return rest_ensure_response( openstation_files_shape_share( openstation_files_get_share( $share_id ) ) ); |
| 1009 |
} |
| 1010 |
|
| 1011 |
/** |
| 1012 |
* DELETE /folders/<id>/shares/<shareId> — owner only. |
| 1013 |
*/ |
| 1014 |
function openstation_files_rest_delete_share( WP_REST_Request $req ) { |
| 1015 |
$share = openstation_files_rest_resolve_share_in_folder( $req ); |
| 1016 |
if ( is_wp_error( $share ) ) { |
| 1017 |
return $share; |
| 1018 |
} |
| 1019 |
$ok = openstation_folder_share_revoke( (int) $share['id'], get_current_user_id() ); |
| 1020 |
if ( is_wp_error( $ok ) ) { |
| 1021 |
return $ok; |
| 1022 |
} |
| 1023 |
return rest_ensure_response( array( 'deleted' => true ) ); |
| 1024 |
} |
| 1025 |
|
| 1026 |
/** |
| 1027 |
* POST /folders/<id>/shares/<shareId>/accept — recipient only. |
| 1028 |
*/ |
| 1029 |
function openstation_files_rest_accept_share( WP_REST_Request $req ) { |
| 1030 |
$share = openstation_files_rest_resolve_share_in_folder( $req ); |
| 1031 |
if ( is_wp_error( $share ) ) { |
| 1032 |
return $share; |
| 1033 |
} |
| 1034 |
$row = openstation_folder_share_accept( (int) $share['id'], get_current_user_id() ); |
| 1035 |
if ( is_wp_error( $row ) ) { |
| 1036 |
return $row; |
| 1037 |
} |
| 1038 |
return rest_ensure_response( openstation_files_shape_share( $row ) ); |
| 1039 |
} |
| 1040 |
|
| 1041 |
/** |
| 1042 |
* POST /folders/<id>/shares/<shareId>/deny — recipient only. |
| 1043 |
*/ |
| 1044 |
function openstation_files_rest_deny_share( WP_REST_Request $req ) { |
| 1045 |
$share = openstation_files_rest_resolve_share_in_folder( $req ); |
| 1046 |
if ( is_wp_error( $share ) ) { |
| 1047 |
return $share; |
| 1048 |
} |
| 1049 |
$row = openstation_folder_share_deny( (int) $share['id'], get_current_user_id() ); |
| 1050 |
if ( is_wp_error( $row ) ) { |
| 1051 |
return $row; |
| 1052 |
} |
| 1053 |
return rest_ensure_response( openstation_files_shape_share( $row ) ); |
| 1054 |
} |
| 1055 |
|
| 1056 |
/** |
| 1057 |
* POST /folders/<id>/leave — recipient-initiated leave. |
| 1058 |
* |
| 1059 |
* Unlike `/shares/{id}/deny` which targets a specific share row, |
| 1060 |
* this endpoint finds whichever grant currently lets the user |
| 1061 |
* see the folder (user-principal or role-principal) and removes |
| 1062 |
* their access — for role shares without affecting other role |
| 1063 |
* members, via the per-user decisions table. |
| 1064 |
*/ |
| 1065 |
function openstation_files_rest_leave_folder( WP_REST_Request $req ) { |
| 1066 |
$folder_id = (int) $req['id']; |
| 1067 |
$ok = openstation_folder_share_leave( $folder_id, get_current_user_id() ); |
| 1068 |
if ( is_wp_error( $ok ) ) { |
| 1069 |
return $ok; |
| 1070 |
} |
| 1071 |
return rest_ensure_response( array( 'left' => true ) ); |
| 1072 |
} |
| 1073 |
|
| 1074 |
/** |
| 1075 |
* POST /files/folder-sharing-tables/purge — destructive cleanup |
| 1076 |
* that drops every table the folder-sharing feature ever created |
| 1077 |
* (current `folder_shares` + `share_user_decisions`, plus any |
| 1078 |
* future variants enumerated via the |
| 1079 |
* `openstation_files_sharing_tables_for_purge` filter). |
| 1080 |
* |
| 1081 |
* Restricted to `manage_options` by the permission callback. The |
| 1082 |
* schema-version option is cleared so the next admin-init runs |
| 1083 |
* `install_schema` and recreates the empty tables — keeps the |
| 1084 |
* code path that ASSUMES the tables exist (e.g. heartbeat |
| 1085 |
* delivery queries) working even after a purge. |
| 1086 |
*/ |
| 1087 |
function openstation_files_rest_purge_sharing_tables() { |
| 1088 |
global $wpdb; |
| 1089 |
$tables = openstation_files_table_names(); |
| 1090 |
|
| 1091 |
$to_drop = array( $tables['shares'], $tables['decisions'] ); |
| 1092 |
/** |
| 1093 |
* Filter the list of table names dropped by the |
| 1094 |
* "Delete folder sharing data" admin action. |
| 1095 |
* |
| 1096 |
* @param string[] $tables Default = shares + decisions. |
| 1097 |
*/ |
| 1098 |
$to_drop = (array) apply_filters( 'openstation_files_sharing_tables_for_purge', $to_drop ); |
| 1099 |
|
| 1100 |
$dropped = array(); |
| 1101 |
$skipped = array(); |
| 1102 |
$prefix = (string) $wpdb->prefix; |
| 1103 |
foreach ( $to_drop as $tbl ) { |
| 1104 |
$tbl = (string) $tbl; |
| 1105 |
if ( '' === $tbl ) { |
| 1106 |
continue; |
| 1107 |
} |
| 1108 |
// Defense-in-depth: a misbehaving filter could push any |
| 1109 |
// string into `$to_drop` and we're about to interpolate |
| 1110 |
// the value directly into a `DROP TABLE` statement (wpdb |
| 1111 |
// has no placeholder for identifiers). Two gates: |
| 1112 |
// 1. Must match the `[A-Za-z0-9_]+` identifier pattern — |
| 1113 |
// keeps quotes/backticks/spaces out of the SQL even |
| 1114 |
// if a filter author smuggled them in. |
| 1115 |
// 2. Must start with the wpdb prefix — keeps a malicious |
| 1116 |
// filter from dropping system tables (`wp_users`, |
| 1117 |
// `wp_options`, …) on a multi-prefix install. |
| 1118 |
if ( |
| 1119 |
! preg_match( '/^[A-Za-z0-9_]+$/', $tbl ) || |
| 1120 |
0 !== strpos( $tbl, $prefix ) |
| 1121 |
) { |
| 1122 |
$skipped[] = $tbl; |
| 1123 |
continue; |
| 1124 |
} |
| 1125 |
$prev_suppress = $wpdb->suppress_errors( true ); |
| 1126 |
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| 1127 |
$wpdb->query( "DROP TABLE IF EXISTS `{$tbl}`" ); |
| 1128 |
$wpdb->suppress_errors( $prev_suppress ); |
| 1129 |
$dropped[] = $tbl; |
| 1130 |
} |
| 1131 |
|
| 1132 |
// Force the next admin-init / rest-init to re-run |
| 1133 |
// `install_schema` so the tables are recreated empty. Code |
| 1134 |
// paths that JOIN against them (heartbeat, sharing.php |
| 1135 |
// visibility) keep working without a per-request existence |
| 1136 |
// check. |
| 1137 |
delete_option( OPENSTATION_FILES_SCHEMA_OPTION ); |
| 1138 |
|
| 1139 |
/** |
| 1140 |
* Fires after the folder-sharing tables are purged. Plugins |
| 1141 |
* that mirror share state into their own storage can react |
| 1142 |
* here. |
| 1143 |
* |
| 1144 |
* @param string[] $dropped Table names that were dropped. |
| 1145 |
*/ |
| 1146 |
do_action( 'openstation_files_sharing_tables_purged', $dropped ); |
| 1147 |
|
| 1148 |
return rest_ensure_response( |
| 1149 |
array( |
| 1150 |
'dropped' => $dropped, |
| 1151 |
'skipped' => $skipped, |
| 1152 |
) |
| 1153 |
); |
| 1154 |
} |
| 1155 |
|
| 1156 |
/** |
| 1157 |
* Permission gate for /users/search. Requires `edit_posts` — |
| 1158 |
* `openstation_files_rest_permission` would let any logged-in |
| 1159 |
* openstation user pull the directory, which is too broad for an |
| 1160 |
* autocomplete that exposes display names + emails. |
| 1161 |
*/ |
| 1162 |
function openstation_files_rest_search_users_permission() { |
| 1163 |
$base = openstation_files_rest_permission(); |
| 1164 |
if ( is_wp_error( $base ) ) { |
| 1165 |
return $base; |
| 1166 |
} |
| 1167 |
if ( ! current_user_can( 'edit_posts' ) ) { |
| 1168 |
return new WP_Error( 'openstation_files_forbidden', __( 'You cannot search users.', 'desktop-mode' ), array( 'status' => 403 ) ); |
| 1169 |
} |
| 1170 |
return true; |
| 1171 |
} |
| 1172 |
|
| 1173 |
/** |
| 1174 |
* GET /files/users/search?q=<>&exclude=<csv> — autocomplete for the |
| 1175 |
* folder share picker. |
| 1176 |
*/ |
| 1177 |
function openstation_files_rest_search_users( WP_REST_Request $req ) { |
| 1178 |
$q = trim( (string) $req->get_param( 'q' ) ); |
| 1179 |
$exclude = array_filter( array_map( 'intval', explode( ',', (string) $req->get_param( 'exclude' ) ) ) ); |
| 1180 |
|
| 1181 |
// Always exclude the current viewer — sharing with yourself is |
| 1182 |
// a no-op the modal already rejects, no point spending a slot |
| 1183 |
// in the dropdown on it. |
| 1184 |
$exclude[] = (int) get_current_user_id(); |
| 1185 |
$exclude = array_values( array_unique( array_filter( $exclude ) ) ); |
| 1186 |
|
| 1187 |
$args = array( |
| 1188 |
'number' => 20, |
| 1189 |
'orderby' => 'display_name', |
| 1190 |
'order' => 'ASC', |
| 1191 |
'exclude' => $exclude, |
| 1192 |
// `fields => 'all'` returns full WP_User objects so the |
| 1193 |
// capability check below resolves role caps correctly. A |
| 1194 |
// stdClass with stripped fields breaks `user_can()` on |
| 1195 |
// some WordPress versions and silently drops every row. |
| 1196 |
'fields' => 'all', |
| 1197 |
); |
| 1198 |
if ( '' !== $q ) { |
| 1199 |
$args['search'] = '*' . $q . '*'; |
| 1200 |
$args['search_columns'] = array( 'user_login', 'user_email', 'display_name', 'user_nicename' ); |
| 1201 |
} |
| 1202 |
|
| 1203 |
/** |
| 1204 |
* Filter the WP_User_Query args used by the share picker. |
| 1205 |
* |
| 1206 |
* @param array $args Default args. |
| 1207 |
* @param array $req Request params (`q`, `exclude`). |
| 1208 |
*/ |
| 1209 |
$args = (array) apply_filters( 'openstation_files_share_user_query_args', $args, $req->get_params() ); |
| 1210 |
|
| 1211 |
$query = new WP_User_Query( $args ); |
| 1212 |
$users = $query->get_results(); |
| 1213 |
$out = array(); |
| 1214 |
foreach ( (array) $users as $user ) { |
| 1215 |
if ( ! user_can( $user, 'edit_posts' ) ) { |
| 1216 |
continue; |
| 1217 |
} |
| 1218 |
// Disambiguation handle uses `user_nicename` (the public |
| 1219 |
// URL slug) instead of `user_login` — the login is the auth |
| 1220 |
// credential and exposing it to every `edit_posts` user is |
| 1221 |
// broader than needed for a share picker. Matches the |
| 1222 |
// `slug` field WP's own `/wp/v2/users` endpoint surfaces. |
| 1223 |
$out[] = array( |
| 1224 |
'id' => (int) $user->ID, |
| 1225 |
'name' => (string) $user->display_name, |
| 1226 |
'slug' => (string) $user->user_nicename, |
| 1227 |
'avatarUrl' => get_avatar_url( $user->ID, array( 'size' => 48 ) ), |
| 1228 |
); |
| 1229 |
} |
| 1230 |
return rest_ensure_response( array( 'users' => $out ) ); |
| 1231 |
} |
| 1232 |
|