Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions inc/classes/class-media-library-ajax.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
defined( 'ABSPATH' ) || exit;

use RTGODAM\Inc\Traits\Singleton;
use RTGODAM\Inc\Media_Library\Media_Folder_Utils;

/**
* Class Media_Library
Expand Down Expand Up @@ -637,6 +638,12 @@ public function add_media_library_taxonomy_on_media_upload( $attachment_id ) {
return;
}

// A locked folder, or one inside a locked folder, takes no new files. Leave the
// upload unfiled, as the assign-folder route refuses the same move.
if ( Media_Folder_Utils::get_instance()->is_folder_locked( $media_folder ) ) {
return;
}

// Assign the existing term.
wp_set_object_terms( $attachment_id, (int) $media_folder, 'media-folder' );
}
Expand Down
39 changes: 39 additions & 0 deletions inc/classes/media-library/class-media-folder-utils.php
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,45 @@ public function is_valid_folder( $folder_id ) {
return ! is_wp_error( $term ) && ! empty( $term );
}

/**
* Whether a media folder is locked, directly or through an ancestor.
*
* Folder locking is protective: a locked folder shields itself AND all of its
* descendants from mutation. So a folder counts as locked if its own `locked`
* term-meta is truthy or if any of its ancestors' is. Client-side gating (React)
* is UX only; this is the authoritative server-side check.
*
* @since 2.5.1
*
* @param int $folder_id Media folder term ID.
* @return bool True if the folder or one of its ancestors is locked.
*/
public function is_folder_locked( $folder_id ) {
$folder_id = (int) $folder_id;

if ( $folder_id <= 0 ) {
return false;
}

// The term itself plus every ancestor — a locked ancestor locks the branch.
$ids = array( $folder_id );
$ancestors = get_ancestors( $folder_id, 'media-folder', 'taxonomy' );

if ( is_array( $ancestors ) ) {
$ids = array_merge( $ids, $ancestors );
}

foreach ( $ids as $id ) {
$locked_raw = get_term_meta( $id, 'locked', true );

if ( '1' === $locked_raw || 1 === $locked_raw || true === $locked_raw || 'true' === $locked_raw ) {
return true;
}
}

return false;
}

/**
* Get folder information with attachment count
*
Expand Down
197 changes: 131 additions & 66 deletions inc/classes/rest-api/class-media-library.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,60 +39,112 @@ class Media_Library extends Base {
/**
* Setup hooks.
*
* Adds the base REST-route registration plus a server-side guard that enforces
* folder "locked" state on the NATIVE wp/v2/media-folder term endpoints (rename,
* delete, re-parent, create-under). Locking was previously enforced only in React,
* so a direct REST call could still mutate a locked folder.
* Adds the base REST-route registration plus the server-side rules for the NATIVE
* wp/v2/media-folder term endpoints: who may use them, and the folder "locked" state
* (rename, delete, re-parent, create-under). Locking was previously enforced only in
* React, so a direct REST call could still mutate a locked folder.
*
* @return void
*/
protected function setup_hooks() {
parent::setup_hooks();

add_filter( 'rest_pre_dispatch', array( $this, 'enforce_locked_media_folder' ), 10, 3 );
add_filter( 'rest_endpoints', array( $this, 'guard_media_folder_routes' ) );
add_filter( 'rest_pre_insert_attachment', array( $this, 'enforce_locked_folder_on_media_update' ), 10, 2 );

// Background builder for folder-ZIP downloads (see download_folder()). Runs via
// Action Scheduler when available, otherwise WP-Cron — both invoke this hook.
add_action( 'godam_build_folder_zip', array( $this, 'run_folder_zip_job' ), 10, 1 );
}

/**
* Reject mutating REST requests against locked media-folder terms.
* Put the media-folder term routes behind check_media_folder_request().
*
* Runs before dispatch on the native taxonomy routes:
* The check is attached to the routes' own permission callbacks rather than matched
* against the requested path, so it runs however the request reaches them (any letter
* case, `?rest_route=`, a batch request, an embed).
*
* @since 2.5.1
*
* @param array $endpoints The registered REST routes, keyed by route.
* @return array The routes, with the media-folder handlers wrapped.
*/
public function guard_media_folder_routes( $endpoints ) {
$base = rest_get_route_for_taxonomy_items( Media_Folders::SLUG );

if ( '' === $base || ! is_array( $endpoints ) ) {
return $endpoints;
}

foreach ( $endpoints as $route => $handlers ) {
// The collection route and the single-term route.
if ( $route !== $base && 0 !== strpos( $route, $base . '/' ) ) {
continue;
}

foreach ( $handlers as $key => $handler ) {
// Route options such as `allow_batch` and `schema` sit beside the handlers.
if ( ! is_int( $key ) || ! is_array( $handler ) ) {
continue;
}

$original = isset( $handler['permission_callback'] ) ? $handler['permission_callback'] : '__return_true';

$endpoints[ $route ][ $key ]['permission_callback'] = function ( $request ) use ( $original ) {
$allowed = $this->check_media_folder_request( $request );

return is_wp_error( $allowed ) ? $allowed : call_user_func( $original, $request );
};
}
}

return $endpoints;
}

/**
* Check a request to the native media-folder term routes, before the controller's own permission check.
*
* Reading folders needs `edit_posts` (the gallery block's folder picker runs for anyone
* who writes posts); every other verb needs `upload_files`. Then, on the mutating verbs:
* - POST/PUT/PATCH /wp/v2/media-folder/{id} → rename / re-parent an existing folder
* - DELETE /wp/v2/media-folder/{id} → delete an existing folder
* - POST /wp/v2/media-folder → create a folder (blocked only when the
* target parent is locked, i.e. populating it)
* Reads (GET) are always allowed — locking protects against modification, not viewing.
* Reads are always allowed once the user can write posts; locking protects against
* modification, not viewing.
*
* @since 2.5.1
*
* @param mixed $result Pre-dispatch result (non-null short-circuits the request).
* @param \WP_REST_Server $server Server instance (unused).
* @param \WP_REST_Request $request The request being dispatched.
* @return mixed Original $result, or a 403 WP_Error when the target folder is locked.
* @return true|\WP_Error True to carry on to the controller's own check, or a WP_Error
* (401/403) when the user may not use the routes or the target
* folder is locked.
*/
public function enforce_locked_media_folder( $result, $server, $request ) {
// Let an already short-circuited result (e.g. another plugin's error) stand.
if ( null !== $result ) {
return $result;
}
public function check_media_folder_request( $request ) {
$is_write = in_array( $request->get_method(), array( 'POST', 'PUT', 'PATCH', 'DELETE' ), true );

// Anyone who writes posts can read folders (the gallery block's folder picker); changing them needs upload rights.
$can_use_route = $is_write ? current_user_can( 'upload_files' ) : current_user_can( 'edit_posts' );

// Only guard the native media-folder collection or a single term.
if ( ! preg_match( '#^/wp/v2/media-folder(?:/(\d+))?$#', $request->get_route(), $matches ) ) {
return $result;
if ( ! $can_use_route ) {
return new \WP_Error(
'rest_forbidden',
__( 'Sorry, you are not allowed to access media folders.', 'godam' ),
array( 'status' => rest_authorization_required_code() )
);
}

// Only mutating verbs.
if ( ! in_array( $request->get_method(), array( 'POST', 'PUT', 'PATCH', 'DELETE' ), true ) ) {
return $result;
if ( ! $is_write ) {
return true;
}

$term_id = isset( $matches[1] ) ? (int) $matches[1] : 0;
// Read the id the way the controller does: a body or query `id` outranks the one in the URL.
$term_id = (int) $request->get_param( 'id' );

// Renaming / re-parenting / deleting a locked folder (or a child of one). A
// bookmark toggle is a personal flag rather than a modification of the folder's
// protected content, so it is still allowed on a locked folder.
if ( $term_id > 0 && $this->is_folder_locked( $term_id ) && ! $this->is_bookmark_only_folder_update( $request, $term_id ) ) {
if ( $term_id > 0 && Media_Folder_Utils::get_instance()->is_folder_locked( $term_id ) && ! $this->is_bookmark_only_folder_update( $request, $term_id ) ) {
return new \WP_Error(
'godam_folder_locked',
__( 'This folder is locked and cannot be modified.', 'godam' ),
Expand All @@ -104,7 +156,7 @@ public function enforce_locked_media_folder( $result, $server, $request ) {
if ( 'DELETE' !== $request->get_method() ) {
$new_parent = $request->get_param( 'parent' );

if ( ! is_null( $new_parent ) && (int) $new_parent > 0 && $this->is_folder_locked( (int) $new_parent ) ) {
if ( ! is_null( $new_parent ) && (int) $new_parent > 0 && Media_Folder_Utils::get_instance()->is_folder_locked( (int) $new_parent ) ) {
return new \WP_Error(
'godam_folder_locked',
__( 'The destination folder is locked and cannot be modified.', 'godam' ),
Expand All @@ -113,7 +165,57 @@ public function enforce_locked_media_folder( $result, $server, $request ) {
}
}

return $result;
return true;
}

/**
* Refuse a core media update that takes a file out of a locked folder.
*
* Moving a file INTO a locked folder is already refused by the `assign_term`
* capability check (see Media_Folders::lock_folder_assignment()). Moving one OUT is
* not a capability check in core: the media route replaces the file's folders with the
* `media-folder` field it was sent, which empties the locked folder. Same rule as the
* assign-folder route.
*
* @since 2.5.1
*
* @param \stdClass|\WP_Error $prepared_post The attachment about to be saved.
* @param \WP_REST_Request $request The request being dispatched.
* @return \stdClass|\WP_Error The attachment, or a 403 WP_Error when it would leave a locked folder.
*/
public function enforce_locked_folder_on_media_update( $prepared_post, $request ) {
// A new upload has no folder to leave; only an update carries an ID.
if ( is_wp_error( $prepared_post ) || empty( $prepared_post->ID ) ) {
return $prepared_post;
}

$taxonomy = get_taxonomy( Media_Folders::SLUG );
$rest_base = ( $taxonomy && ! empty( $taxonomy->rest_base ) ) ? $taxonomy->rest_base : Media_Folders::SLUG;
$requested = $request->get_param( $rest_base );

// The request does not touch the file's folders.
if ( null === $requested ) {
return $prepared_post;
}

$requested = array_map( 'intval', (array) $requested );
$current = wp_get_object_terms( (int) $prepared_post->ID, Media_Folders::SLUG, array( 'fields' => 'ids' ) );

if ( is_wp_error( $current ) ) {
return $prepared_post;
}

foreach ( $current as $current_folder_id ) {
if ( ! in_array( (int) $current_folder_id, $requested, true ) && Media_Folder_Utils::get_instance()->is_folder_locked( $current_folder_id ) ) {
return new \WP_Error(
'godam_folder_locked',
__( 'One or more attachments belong to a locked folder and cannot be moved.', 'godam' ),
array( 'status' => 403 )
);
}
}

return $prepared_post;
}

/**
Expand Down Expand Up @@ -910,7 +1012,7 @@ public function assign_images_to_folder( $request ) {

if ( is_array( $current_folders ) ) {
foreach ( $current_folders as $current_folder_id ) {
if ( (int) $current_folder_id !== (int) $folder_term_id && $this->is_folder_locked( $current_folder_id ) ) {
if ( (int) $current_folder_id !== (int) $folder_term_id && Media_Folder_Utils::get_instance()->is_folder_locked( $current_folder_id ) ) {
return new \WP_Error(
'godam_folder_locked',
__( 'One or more attachments belong to a locked folder and cannot be moved.', 'godam' ),
Expand Down Expand Up @@ -947,7 +1049,7 @@ public function assign_images_to_folder( $request ) {
}

// Assigning attachments into a locked folder (or a child of one) populates it — reject.
if ( $this->is_folder_locked( $folder_term_id ) ) {
if ( Media_Folder_Utils::get_instance()->is_folder_locked( $folder_term_id ) ) {
return new \WP_Error(
'godam_folder_locked',
__( 'This folder is locked and cannot be modified.', 'godam' ),
Expand Down Expand Up @@ -1752,7 +1854,7 @@ public function bulk_delete_folders( $request ) {
// Reject the whole request if any target is locked (or a child of a locked folder),
// so a direct API call can't delete a protected folder — and we never partially delete.
foreach ( $folder_ids as $folder_id ) {
if ( is_numeric( $folder_id ) && (int) $folder_id > 0 && $this->is_folder_locked( $folder_id ) ) {
if ( is_numeric( $folder_id ) && (int) $folder_id > 0 && Media_Folder_Utils::get_instance()->is_folder_locked( $folder_id ) ) {
return new \WP_Error(
'godam_folder_locked',
__( 'One or more selected folders are locked and cannot be deleted.', 'godam' ),
Expand Down Expand Up @@ -1819,43 +1921,6 @@ public function bulk_delete_folders( $request ) {
}
}

/**
* Whether a media-folder term is locked, directly or through an ancestor.
*
* Folder locking is protective: a locked folder shields itself AND all of its
* descendants from mutation. So a folder counts as locked if its own `locked`
* term-meta is truthy or if any of its ancestors' is. Client-side gating (React)
* is UX only; this is the authoritative server-side check.
*
* @param int $term_id Media-folder term ID.
* @return bool True if the folder or one of its ancestors is locked.
*/
private function is_folder_locked( $term_id ) {
$term_id = (int) $term_id;

if ( $term_id <= 0 ) {
return false;
}

// The term itself plus every ancestor — a locked ancestor locks the branch.
$ids = array( $term_id );
$ancestors = get_ancestors( $term_id, 'media-folder', 'taxonomy' );

if ( is_array( $ancestors ) ) {
$ids = array_merge( $ids, $ancestors );
}

foreach ( $ids as $id ) {
$locked_raw = get_term_meta( $id, 'locked', true );

if ( '1' === $locked_raw || 1 === $locked_raw || true === $locked_raw || 'true' === $locked_raw ) {
return true;
}
}

return false;
}

/**
* Update meta status for multiple folders.
* This is a helper method used by bulk_update_folder_lock_status and bulk_update_folder_bookmark_status.
Expand Down
Loading
Loading