| @@ -290,17 +290,35 @@ | ||
| 290 | 290 | |
| 291 | 291 | /** |
| 292 | 292 | * `desktop-mode/get-post` permission callback. |
| 293 | 293 | * |
| 294 | - * `read_post` decides visibility (published / private / draft) and | |
| 295 | - * never the post password — WordPress splits the two deliberately, so | |
| 296 | - * a plain `read_post` check would hand a Subscriber the raw body of a | |
| 297 | - * password-protected post. Mirror Core: a sealed post stays sealed | |
| 298 | - * unless the caller can edit it (the same escape hatch | |
| 299 | - * `WP_REST_Posts_Controller::check_password_required()` grants), and | |
| 300 | - * because this ability returns RAW `post_content` there is no empty | |
| 301 | - * rendered field to fall back to — the only safe answer is to refuse. | |
| 294 | + * Asks every gate a single read has, in the order Core's REST | |
| 295 | + * controllers ask them: | |
| 302 | 296 | * |
| 297 | + * - A zero id is refused before any fetch: `get_post( 0 )` returns | |
| 298 | + * the global post, which would judge the request against whatever | |
| 299 | + * another plugin left there. | |
| 300 | + * - `read_post` decides visibility (published / private / draft) and | |
| 301 | + * stays the floor for every row. | |
| 302 | + * - The post password is a separate question — WordPress splits the | |
| 303 | + * two deliberately. A sealed post stays sealed unless the caller can | |
| 304 | + * edit it (the same escape hatch | |
| 305 | + * `WP_REST_Posts_Controller::check_password_required()` grants), and | |
| 306 | + * because this ability returns RAW `post_content` there is no empty | |
| 307 | + * rendered field to fall back to, so the answer is to refuse. | |
| 308 | + * - A post type with no readable front end (`is_post_type_viewable()` | |
| 309 | + * false: an order, a submission log, a queue entry) is read only by | |
| 310 | + * a caller who can edit the row. `map_meta_cap()` resolves | |
| 311 | + * `read_post` on a published row of such a type to plain `read`, | |
| 312 | + * which every logged-in user holds, so `read_post` alone does not | |
| 313 | + * answer the question for it. | |
| 314 | + * | |
| 315 | + * `openstation_ai_can_read_post()` (loaded unconditionally from the AI | |
| 316 | + * Copilot bootstrap, ahead of this module) implements the password and | |
| 317 | + * post-type gates; this callback keeps `read_post` in front of it so a | |
| 318 | + * plugin that narrows `read_post` on a public post still narrows this | |
| 319 | + * ability. | |
| 320 | + * | |
| 303 | 321 | * @param array $args Input args. |
| 304 | 322 | * @return bool |
| 305 | 323 | */ |
| 306 | 324 | function openstation_agents_ability_get_post_can( $args ) { |
| @@ -311,13 +329,9 @@ | ||
| 311 | 329 | } |
| 312 | 330 | if ( ! current_user_can( 'read_post', $post_id ) ) { |
| 313 | 331 | return false; |
| 314 | 332 | } |
| 315 | - $post = get_post( $post_id ); | |
| 316 | - if ( $post instanceof WP_Post && post_password_required( $post ) && ! current_user_can( 'edit_post', $post_id ) ) { | |
| 317 | - return false; | |
| 318 | - } | |
| 319 | - return true; | |
| 333 | + return openstation_ai_can_read_post( $post_id ); | |
| 320 | 334 | } |
| 321 | 335 | |
| 322 | 336 | /** |
| 323 | 337 | * `desktop-mode/get-media` execute callback. |
| @@ -355,14 +369,33 @@ | ||
| 355 | 369 | |
| 356 | 370 | /** |
| 357 | 371 | * `desktop-mode/get-media` permission callback. |
| 358 | 372 | * |
| 359 | - * Gates on `upload_files` (author+), deliberately NOT on `read_post`: | |
| 360 | - * for `inherit`-status attachments that check defers to the parent | |
| 361 | - * post (and effectively requires edit rights when unattached), which | |
| 362 | - * wrongly blocks read-only access to media whose file URL is public | |
| 363 | - * on a standard site anyway. | |
| 373 | + * Gates on `upload_files` (author+) — the capability the Media | |
| 374 | + * Library itself requires — rather than on `read_post` of the | |
| 375 | + * attachment. | |
| 364 | 376 | * |
| 377 | + * An attached file is a child of its parent post, and the result | |
| 378 | + * carries the attachment's title, caption and `attachedTo` (the parent | |
| 379 | + * id), so an attached file also requires that the caller can read the | |
| 380 | + * parent. That follows the shape of Core's rule for `inherit`-status | |
| 381 | + * attachments, `WP_REST_Posts_Controller::check_read_permission()` | |
| 382 | + * (the attachments controller inherits it): an attachment defers to | |
| 383 | + * its parent whenever one exists. The parent is judged by `read_post`, | |
| 384 | + * plus the post-type rule `desktop-mode/get-post` applies (a type with | |
| 385 | + * no readable front end needs `edit_post`), which is stricter than Core | |
| 386 | + * on a non-viewable parent: Core admits any `publish` parent of a | |
| 387 | + * REST-enabled type. Core's other requirement, that the parent's type | |
| 388 | + * be `show_in_rest`, is not copied: it would refuse media attached to a | |
| 389 | + * non-REST type for every caller, administrators included. The | |
| 390 | + * parent's password is not asked: the attachment's own fields are not | |
| 391 | + * the parent's body, and Core's attachment read does not ask it either. | |
| 392 | + * | |
| 393 | + * An unattached file, or one whose parent row no longer exists, is | |
| 394 | + * judged on `upload_files` alone, as Core treats a parentless | |
| 395 | + * `inherit` attachment as published. A zero id is refused before any | |
| 396 | + * fetch, because `get_post( 0 )` returns the global post. | |
| 397 | + * | |
| 365 | 398 | * @param array $args Input args. |
| 366 | 399 | * @return bool |
| 367 | 400 | */ |
| 368 | 401 | function openstation_agents_ability_get_media_can( $args ) { |
| @@ -370,9 +403,35 @@ | ||
| 370 | 403 | $attachment_id = isset( $args['attachment_id'] ) ? (int) $args['attachment_id'] : 0; |
| 371 | 404 | if ( $attachment_id <= 0 ) { |
| 372 | 405 | return false; |
| 373 | 406 | } |
| 374 | - return current_user_can( 'upload_files' ); | |
| 407 | + if ( ! current_user_can( 'upload_files' ) ) { | |
| 408 | + return false; | |
| 409 | + } | |
| 410 | + | |
| 411 | + $attachment = get_post( $attachment_id ); | |
| 412 | + if ( ! ( $attachment instanceof WP_Post ) || 'attachment' !== $attachment->post_type ) { | |
| 413 | + // The execute callback answers "not found" for these. | |
| 414 | + return true; | |
| 415 | + } | |
| 416 | + | |
| 417 | + $parent_id = (int) $attachment->post_parent; | |
| 418 | + if ( $parent_id <= 0 || $parent_id === $attachment_id ) { | |
| 419 | + return true; | |
| 420 | + } | |
| 421 | + $parent = get_post( $parent_id ); | |
| 422 | + if ( ! ( $parent instanceof WP_Post ) ) { | |
| 423 | + return true; | |
| 424 | + } | |
| 425 | + | |
| 426 | + if ( ! current_user_can( 'read_post', $parent->ID ) ) { | |
| 427 | + return false; | |
| 428 | + } | |
| 429 | + $parent_type = get_post_type_object( $parent->post_type ); | |
| 430 | + if ( ! $parent_type || ! is_post_type_viewable( $parent_type ) ) { | |
| 431 | + return current_user_can( 'edit_post', $parent->ID ); | |
| 432 | + } | |
| 433 | + return true; | |
| 375 | 434 | } |
| 376 | 435 | |
| 377 | 436 | /** |
| 378 | 437 | * `desktop-mode/update-media` execute callback. |