From 70dc7258586bd02e9ce0959be893b564d3c3c46d Mon Sep 17 00:00:00 2001 From: ignacionelson Date: Fri, 2 Oct 2026 17:35:04 -0300 Subject: [PATCH] Folders in the API: list, read, create, rename, move, delete and share An integration could put a file into a folder by id but could not see, make or arrange the folders themselves, so mirroring a directory tree into ProjectSend was impossible over the API. The hosted AI connector already creates, lists and shares folders. GET /folders polls like every list (updated_since, cursor) and filters on parent_id, top_level and search. Each folder carries its ancestors and a display path, trimmed for a client-scoped token to the folders it may see (BreadcrumbBuilder::visible's rule), worked out for a whole page in two queries by FolderTrails. POST /folders returns an existing folder of the same name in the same place with a 200 rather than making a second one, so a retried request is safe. PATCH renames and moves. DELETE refuses a non-empty folder with 409 unless content_action=cascade_delete is sent, and then asks UndeletableFiles exactly as the web does. Sharing goes through FolderSharing. Every write uses the web's policy, scope and FolderService, and asks Folder::uploadableBy for every parent it writes, creation included. Public state stays web-only: the resource reports `public`, nothing here changes it. A file's `folder` now carries `parent_id` as well. --- app/Modules/Files/Folders/FolderTrails.php | 78 +++++ .../Api/FolderAssignmentsController.php | 87 ++++++ .../Controllers/Api/FoldersController.php | 282 ++++++++++++++++++ .../Files/Http/Resources/Api/FileResource.php | 2 + .../Http/Resources/Api/FolderResource.php | 68 +++++ ...000_add_polling_index_to_folders_table.php | 29 ++ routes/api.php | 36 +++ 7 files changed, 582 insertions(+) create mode 100644 app/Modules/Files/Folders/FolderTrails.php create mode 100644 app/Modules/Files/Http/Controllers/Api/FolderAssignmentsController.php create mode 100644 app/Modules/Files/Http/Controllers/Api/FoldersController.php create mode 100644 app/Modules/Files/Http/Resources/Api/FolderResource.php create mode 100644 database/migrations/2026_10_02_090000_add_polling_index_to_folders_table.php diff --git a/app/Modules/Files/Folders/FolderTrails.php b/app/Modules/Files/Folders/FolderTrails.php new file mode 100644 index 00000000..d48e88db --- /dev/null +++ b/app/Modules/Files/Folders/FolderTrails.php @@ -0,0 +1,78 @@ + $folders + * @return array> folder id => its visible ancestors, root first, itself excluded + */ + public function ancestors(iterable $folders, User $viewer): array + { + $chains = []; + $allIds = []; + + foreach ($folders as $folder) { + $ids = $folder->ancestorIds(); + $chains[$folder->id] = $ids; + array_push($allIds, ...$ids); + } + + $allIds = array_values(array_unique($allIds)); + + if ($allIds === []) { + return array_map(fn (): array => [], $chains); + } + + $names = Folder::query()->whereIn('id', $allIds)->pluck('name', 'id')->all(); + + $visible = $viewer->isClientScoped() + ? array_flip($this->scope->folders($viewer)->whereIn('folders.id', $allIds)->pluck('folders.id')->all()) + : array_flip($allIds); + + $out = []; + + foreach ($chains as $folderId => $ids) { + $trail = []; + $reached = false; + + foreach ($ids as $id) { + $reached = $reached || isset($visible[$id]); + + if ($reached && isset($names[$id])) { + $trail[] = ['id' => $id, 'name' => (string) $names[$id]]; + } + } + + $out[$folderId] = $trail; + } + + return $out; + } +} diff --git a/app/Modules/Files/Http/Controllers/Api/FolderAssignmentsController.php b/app/Modules/Files/Http/Controllers/Api/FolderAssignmentsController.php new file mode 100644 index 00000000..a1e38ae3 --- /dev/null +++ b/app/Modules/Files/Http/Controllers/Api/FolderAssignmentsController.php @@ -0,0 +1,87 @@ +resolveRequestedTarget( + $request, + __('Folders can only be shared with clients or groups.'), + ); + + $this->sharing->assign($folder, $assignable, $targetName); + + return $this->resource($request, $folder); + } + + /** + * Stop sharing a folder. + */ + public function destroy(Request $request, Folder $folder): FolderResource + { + Gate::authorize('update', $folder); + + [$assignable, $targetName] = $this->resolveRequestedTarget( + $request, + __('Folders can only be shared with clients or groups.'), + ); + + $this->sharing->unassign($folder, $assignable, $targetName); + + return $this->resource($request, $folder); + } + + private function resource(Request $request, Folder $folder): FolderResource + { + $folder = $folder->fresh() ?? $folder; + $folder->load('assignments.assignable'); + + $user = $request->user(); + + if ($user !== null) { + $folder->setRelation('trail', collect($this->trails->ancestors([$folder], $user)[$folder->id] ?? [])); + } + + return new FolderResource($folder); + } +} diff --git a/app/Modules/Files/Http/Controllers/Api/FoldersController.php b/app/Modules/Files/Http/Controllers/Api/FoldersController.php new file mode 100644 index 00000000..84618199 --- /dev/null +++ b/app/Modules/Files/Http/Controllers/Api/FoldersController.php @@ -0,0 +1,282 @@ +user(); + assert($user !== null); + + $filters = $request->validate($this->polling->rules() + [ + 'parent_id' => ['nullable', 'integer'], + 'top_level' => ['nullable', 'boolean'], + 'search' => ['nullable', 'string', 'max:255'], + ]); + + $query = $this->scope->folders($user); + + if (($filters['parent_id'] ?? null) !== null) { + $query->where('folders.parent_id', (int) $filters['parent_id']); + } + + if ($request->boolean('top_level')) { + $query->whereNull('folders.parent_id'); + } + + if (($filters['search'] ?? null) !== null) { + $query->where('folders.name', 'like', '%'.$filters['search'].'%'); + } + + $page = $this->polling->paginate($request, $query, 'folders'); + + /** @var Collection $items */ + $items = collect($page->items()); + $this->attachTrails($items, $user); + + return FolderResource::collection($page); + } + + /** + * Show a folder, with the clients and groups it is shared with. + */ + public function show(Request $request, Folder $folder): FolderResource + { + Gate::authorize('view', $folder); + + return $this->resource($folder, $request->user()); + } + + /** + * Create a folder. + * + * At the top of the library, or inside `parent_id`. Requires the + * `create_own_folders` ability, and `upload` with it. + * + * If a folder with the same name already exists in the same place, that + * folder is returned with a 200 instead of a second one being made, so + * retrying a request is safe. A new folder answers 201. + */ + public function store(Request $request): JsonResponse + { + $user = $request->user(); + assert($user !== null); + + // The same pair FoldersController::store asks on the web: a folder + // nobody can put anything into is no use. + abort_unless($user->can('create_own_folders') && $user->can('upload'), 403); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'parent_id' => Rules::folderId(), + ]); + + $parent = $this->resolveParent($user, $validated['parent_id'] ?? null); + + // A folder inside a public one is public, so creating one there is + // placing content into it (Folder::uploadableBy). + abort_unless(Folder::uploadableBy($user, $parent), 403); + + $existing = $this->scope->folders($user) + ->where('folders.parent_id', $parent?->id) + ->where('folders.name', $validated['name']) + ->orderBy('folders.id') + ->first(); + + if ($existing instanceof Folder) { + return $this->resource($existing, $user)->response()->setStatusCode(200); + } + + $folder = $this->folders->create($validated['name'], $parent); + + $this->activity->log(Action::FolderCreated, subject: $folder); + + return $this->resource($folder, $user)->response()->setStatusCode(201); + } + + /** + * Rename or move a folder. + * + * Only the fields you send change. `parent_id: null` moves the folder to + * the top of the library. A folder moves with everything inside it, and + * cannot be moved into itself or one of its own subfolders. + */ + public function update(Request $request, Folder $folder): FolderResource + { + $user = $request->user(); + assert($user !== null); + + Gate::authorize('update', $folder); + + $validated = $request->validate([ + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'parent_id' => ['sometimes', ...Rules::folderId()], + ]); + + if (array_key_exists('name', $validated) && $validated['name'] !== $folder->name) { + $folder->update(['name' => $validated['name']]); + $this->activity->log(Action::FolderRenamed, subject: $folder); + } + + if (array_key_exists('parent_id', $validated)) { + $newParentId = $validated['parent_id'] === null ? null : (int) $validated['parent_id']; + + if ($newParentId !== $folder->parent_id) { + $newParent = $this->resolveParent($user, $newParentId); + + // Dropping a folder into a public parent publishes its whole + // subtree, the act FoldersController::move refuses without + // `upload_public` (GHSA-rxf8-wh8v-jm9j). + abort_unless(Folder::uploadableBy($user, $newParent), 403); + + $this->folders->move($folder, $newParent); + $this->activity->log(Action::FolderMoved, subject: $folder); + } + } + + return $this->resource($folder->fresh() ?? $folder, $user); + } + + /** + * Delete a folder. + * + * An empty folder is deleted straight away. A folder holding files or + * other folders answers 409 unless you send + * `content_action=cascade_delete`, which deletes the folder, every folder + * under it and every file inside them, as the web screen does. There is + * no restore. + * + * A cascade is refused with 403 if the folder holds any file this token + * may not delete itself. + */ + public function destroy(Request $request, Folder $folder): JsonResponse + { + $user = $request->user(); + assert($user !== null); + + Gate::authorize('delete', $folder); + + $validated = $request->validate([ + 'content_action' => ['nullable', Rule::in(['cascade_delete'])], + ]); + + $subtree = $folder->subtreeFolderIds(); + $hasContent = count($subtree) > 1 + || File::query()->whereIn('folder_id', $subtree)->exists(); + + // A sync job with a bug in it must not be one request away from + // emptying a client's folder: the cascade has to be asked for. + abort_if( + $hasContent && ($validated['content_action'] ?? null) !== 'cascade_delete', + 409, + __('This folder is not empty. Send content_action=cascade_delete to delete it with everything inside it.'), + ); + + $blocked = $this->undeletable->count($user, $folder); + + abort_if($blocked > 0, 403, trans_choice( + 'This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.', + $blocked, + ['count' => (string) $blocked], + )); + + $name = $folder->name; + + $this->folders->delete($folder); + + $this->activity->log(Action::FolderDeleted, context: ['name' => $name]); + + return response()->json(status: 204); + } + + private function resource(Folder $folder, ?User $user): FolderResource + { + $folder->load('assignments.assignable'); + + if ($user !== null) { + $this->attachTrails(collect([$folder]), $user); + } + + return new FolderResource($folder); + } + + /** + * @param Collection $folders + */ + private function attachTrails(Collection $folders, User $user): void + { + $trails = $this->trails->ancestors($folders, $user); + + foreach ($folders as $folder) { + $folder->setRelation('trail', collect($trails[$folder->id] ?? [])); + } + } + + /** + * The parent must be a folder this caller's library shows them — the + * same lookup the web screen makes, answering 404 otherwise. + */ + private function resolveParent(User $user, ?int $parentId): ?Folder + { + if ($parentId === null) { + return null; + } + + /** @var Builder $folders */ + $folders = $this->scope->folders($user); + + return $folders->findOrFail($parentId); + } +} diff --git a/app/Modules/Files/Http/Resources/Api/FileResource.php b/app/Modules/Files/Http/Resources/Api/FileResource.php index 2a8fdcc3..11de6259 100644 --- a/app/Modules/Files/Http/Resources/Api/FileResource.php +++ b/app/Modules/Files/Http/Resources/Api/FileResource.php @@ -129,9 +129,11 @@ class FileResource extends JsonResource 'name' => $this->nextVersion->name, ]), + // GET /folders/{id} has the rest, its place in the tree included. 'folder' => $this->whenLoaded('folder', fn (): ?array => $this->folder === null ? null : [ 'id' => $this->folder->id, 'name' => $this->folder->name, + 'parent_id' => $this->folder->parent_id, ]), // Name only. The uploader is a user record; their email address diff --git a/app/Modules/Files/Http/Resources/Api/FolderResource.php b/app/Modules/Files/Http/Resources/Api/FolderResource.php new file mode 100644 index 00000000..45aa2b6a --- /dev/null +++ b/app/Modules/Files/Http/Resources/Api/FolderResource.php @@ -0,0 +1,68 @@ +toArray(), for the same + * reason as FileResource: the next migration must not publish itself. + * + * `ancestors` and `path` come from FolderTrails, loaded by the controller + * for a whole page at once, and are trimmed to the folders the caller may + * see. The assignment list is narrowed per entry by ClientIdentityScope, + * exactly as FileResource narrows a file's. + */ +class FolderResource extends JsonResource +{ + /** + * @return array + */ + public function toArray(Request $request): array + { + $viewer = $request->user(); + $identity = app(ClientIdentityScope::class); + $groupMorph = (new Group)->getMorphClass(); + + /** @var list $ancestors */ + $ancestors = $this->relationLoaded('trail') ? $this->getRelation('trail')->all() : []; + + return [ + 'id' => $this->id, + 'name' => $this->name, + 'parent_id' => $this->parent_id, + // The folders above this one, root first, as far up as the + // caller may see. Empty for a folder at the top of the library. + 'ancestors' => $ancestors, + // The same trail as one string, this folder included: + // "Clients / Acme / 2026". For display; match on ids, since a + // folder name may itself contain " / ". + 'path' => implode(' / ', [...array_column($ancestors, 'name'), $this->name]), + // Read-only here. Making a folder public publishes everything + // inside it, and is done on the web. + 'public' => (bool) $this->public, + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + 'assignments' => $this->whenLoaded('assignments', fn (): array => $this->assignments + ->filter(fn (FolderAssignment $assignment): bool => $assignment->assignable_type === $groupMorph + ? $identity->permitsGroupId($viewer, (int) $assignment->assignable_id) + : $identity->permitsClientId($viewer, (int) $assignment->assignable_id)) + ->map(fn (FolderAssignment $assignment): array => [ + 'type' => $assignment->assignable_type === $groupMorph ? 'group' : 'client', + 'id' => $assignment->assignable_id, + 'name' => $assignment->assignable?->getAttribute('name'), + ]) + ->values() + ->all()), + ]; + } +} diff --git a/database/migrations/2026_10_02_090000_add_polling_index_to_folders_table.php b/database/migrations/2026_10_02_090000_add_polling_index_to_folders_table.php new file mode 100644 index 00000000..67deb349 --- /dev/null +++ b/database/migrations/2026_10_02_090000_add_polling_index_to_folders_table.php @@ -0,0 +1,29 @@ +index(['updated_at', 'id'], 'folders_updated_at_id_index'); + }); + } + + public function down(): void + { + Schema::table('folders', function (Blueprint $table) { + $table->dropIndex('folders_updated_at_id_index'); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index 5172a3bd..b504d366 100644 --- a/routes/api.php +++ b/routes/api.php @@ -13,6 +13,8 @@ use App\Modules\Comments\Http\Controllers\Api\CommentModerationController; use App\Modules\Comments\Http\Controllers\Api\FileCommentsController; use App\Modules\Files\Http\Controllers\Api\FileAssignmentsController; use App\Modules\Files\Http\Controllers\Api\FilesController; +use App\Modules\Files\Http\Controllers\Api\FolderAssignmentsController as ApiFolderAssignmentsController; +use App\Modules\Files\Http\Controllers\Api\FoldersController; use App\Modules\Files\Http\Controllers\Api\FileVersionsController as ApiFileVersionsController; use App\Modules\Files\Http\Controllers\ChunkedUploadsController; use App\Modules\Files\Http\Controllers\FileDownloadController; @@ -160,6 +162,40 @@ Route::middleware(['auth:sanctum', 'api-active', 'staff-token'])->group(function ->name('api.files.version.destroy'); }); + /* + |---------------------------------------------------------------------- + | Folders + |---------------------------------------------------------------------- + | + | Reading is FolderPolicy::view()'s staff branch, the same three keys + | as reading files. Creating is `create_own_folders`, as on the web + | (the controller asks for `upload` with it, as the web does). Renaming, + | moving and sharing are "may edit", deleting is "may delete": both + | keys of each pair appear, and FolderPolicy decides which one applies + | to a given folder. + | + */ + Route::middleware('token-can:upload,edit_files,edit_others_files')->group(function () { + Route::get('folders', [FoldersController::class, 'index'])->name('api.folders.index'); + Route::get('folders/{folder}', [FoldersController::class, 'show'])->name('api.folders.show'); + }); + + Route::post('folders', [FoldersController::class, 'store']) + ->middleware('token-can:create_own_folders') + ->name('api.folders.store'); + + Route::middleware('token-can:edit_files,edit_others_files')->group(function () { + Route::patch('folders/{folder}', [FoldersController::class, 'update'])->name('api.folders.update'); + Route::post('folders/{folder}/assignments', [ApiFolderAssignmentsController::class, 'store']) + ->name('api.folders.assignments.store'); + Route::delete('folders/{folder}/assignments', [ApiFolderAssignmentsController::class, 'destroy']) + ->name('api.folders.assignments.destroy'); + }); + + Route::delete('folders/{folder}', [FoldersController::class, 'destroy']) + ->middleware('token-can:delete_files,delete_others_files') + ->name('api.folders.destroy'); + /* |---------------------------------------------------------------------- | Comments