From a5b6538b31be9d475931a8139c5a91c9c603ed3d Mon Sep 17 00:00:00 2001 From: ignacionelson Date: Fri, 2 Oct 2026 17:32:20 -0300 Subject: [PATCH 1/4] Give folder sharing and the folder-delete guard one definition each Sharing a folder was four steps written in the web controller: the assignment row, the activity entry, the in-app notification and the digest email. The hosted edition's AI connector repeated them, because there was nothing in the core to call, and the two copies had already drifted (one re-notifies on a repeated share, the other does not). The folder API about to land would have been a third copy. FolderSharing is the folder twin of FileSharing, and the web controller now calls it. Behaviour on the web is unchanged. The count of files a staff member may not delete inside a folder's subtree moves out of FoldersController into UndeletableFiles, for the same reason: deleting a folder over the API has to ask exactly the question the web screen asks before the cascade takes files with it. --- .../Files/Folders/UndeletableFiles.php | 71 ++++++++++++++ .../FolderAssignmentsController.php | 38 ++------ .../Http/Controllers/FoldersController.php | 59 +----------- app/Modules/Files/Sharing/FolderSharing.php | 95 +++++++++++++++++++ 4 files changed, 177 insertions(+), 86 deletions(-) create mode 100644 app/Modules/Files/Folders/UndeletableFiles.php create mode 100644 app/Modules/Files/Sharing/FolderSharing.php diff --git a/app/Modules/Files/Folders/UndeletableFiles.php b/app/Modules/Files/Folders/UndeletableFiles.php new file mode 100644 index 00000000..9dfd1d9b --- /dev/null +++ b/app/Modules/Files/Folders/UndeletableFiles.php @@ -0,0 +1,71 @@ +can('delete_files'); + $mayDeleteOthers = $viewer->can('delete_others_files'); + $scoped = $viewer->isClientScoped(); + + if ($mayDeleteOwn && $mayDeleteOthers && ! $scoped) { + return 0; + } + + return File::query() + ->whereIn('folder_id', $folder->subtreeFolderIds()) + ->where(function (Builder $outer) use ($viewer, $mayDeleteOwn, $mayDeleteOthers, $scoped): void { + if (! $mayDeleteOwn) { + $outer->orWhere('uploaded_by', $viewer->id); + } + + if (! $mayDeleteOthers) { + $outer->orWhere(fn (Builder $others): Builder => $others + ->whereNull('uploaded_by')->orWhere('uploaded_by', '!=', $viewer->id)); + } + + if ($scoped) { + $outer->orWhereNotIn('id', $this->scope->files($viewer)->select('id')); + } + }) + ->count(); + } +} diff --git a/app/Modules/Files/Http/Controllers/FolderAssignmentsController.php b/app/Modules/Files/Http/Controllers/FolderAssignmentsController.php index 0c16e66c..ffcdf42f 100644 --- a/app/Modules/Files/Http/Controllers/FolderAssignmentsController.php +++ b/app/Modules/Files/Http/Controllers/FolderAssignmentsController.php @@ -5,31 +5,26 @@ declare(strict_types=1); namespace App\Modules\Files\Http\Controllers; use App\Http\Controllers\Controller; -use App\Modules\Audit\Action; -use App\Modules\Audit\ActivityLogger; use App\Modules\Files\Access\StaffLibraryScope; use App\Modules\Files\Http\Controllers\Concerns\ResolvesShareTargets; use App\Modules\Files\Models\Folder; -use App\Modules\Files\Models\FolderAssignment; -use App\Modules\Notifications\NotificationDigester; -use App\Modules\Notifications\Notifier; +use App\Modules\Files\Sharing\FolderSharing; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; /** * Sharing a folder with a client or group grants live access to its - * whole subtree. Mirrors FileAssignmentsController. + * whole subtree. Mirrors FileAssignmentsController; the effects live in + * FolderSharing, shared with the API. */ class FolderAssignmentsController extends Controller { use ResolvesShareTargets; public function __construct( - private readonly ActivityLogger $activity, private readonly StaffLibraryScope $scope, - private readonly NotificationDigester $digester, - private readonly Notifier $notifier, + private readonly FolderSharing $sharing, ) {} public function store(Request $request, Folder $folder): RedirectResponse @@ -41,20 +36,7 @@ class FolderAssignmentsController extends Controller __('Folders can only be shared with clients or groups.'), ); - FolderAssignment::query()->firstOrCreate([ - 'folder_id' => $folder->id, - 'assignable_type' => $this->assignableType($assignable), - 'assignable_id' => $assignable->getKey(), - ]); - - $this->activity->log(Action::FolderShared, subject: $folder, context: ['target' => $targetName]); - - $recipients = $this->shareRecipients($assignable); - $this->notifier->send('file_shared', $recipients, subject: $folder, data: ['itemName' => $folder->name]); - - // The master switch and each recipient's own preference are the - // digester's job now — every caller was repeating them. - $this->digester->queue('file_shared', $recipients, $folder->name, ['is_folder' => true]); + $this->sharing->assign($folder, $assignable, $targetName); return back(); } @@ -68,15 +50,7 @@ class FolderAssignmentsController extends Controller __('Folders can only be shared with clients or groups.'), ); - $deleted = FolderAssignment::query() - ->where('folder_id', $folder->id) - ->where('assignable_type', $this->assignableType($assignable)) - ->where('assignable_id', $assignable->getKey()) - ->delete(); - - if ($deleted > 0) { - $this->activity->log(Action::FolderUnshared, subject: $folder, context: ['target' => $targetName]); - } + $this->sharing->unassign($folder, $assignable, $targetName); return back(); } diff --git a/app/Modules/Files/Http/Controllers/FoldersController.php b/app/Modules/Files/Http/Controllers/FoldersController.php index f6a4e10e..0e508e23 100644 --- a/app/Modules/Files/Http/Controllers/FoldersController.php +++ b/app/Modules/Files/Http/Controllers/FoldersController.php @@ -16,6 +16,7 @@ use App\Modules\Files\Access\ShareTargets; use App\Modules\Files\Access\StaffLibraryScope; use App\Modules\Files\Folders\BreadcrumbBuilder; use App\Modules\Files\Folders\FolderService; +use App\Modules\Files\Folders\UndeletableFiles; use App\Modules\Files\Models\Category; use App\Modules\Files\Models\File; use App\Modules\Files\Scanning\NotScannedReason; @@ -65,6 +66,7 @@ class FoldersController extends Controller private readonly VisibleCommentScope $comments, private readonly FileVersionLinks $versionLinks, private readonly DownloadAllowance $allowance, + private readonly UndeletableFiles $undeletable, ) {} /** @@ -563,16 +565,9 @@ class FoldersController extends Controller $viewer = $request->user(); assert($viewer !== null); - // Deleting a folder cascades to every file in its subtree, and a - // File's `deleted` hook removes the bytes from disk — there is no - // restore. Authorizing the folder is not authorizing its contents: - // FilePolicy::delete asks for `delete_others_files` on somebody - // else's upload, and for the library boundary on top of that, and - // neither question is asked anywhere on this path. - // - // MyFoldersController::destroy already refuses for the client half - // of the same cascade, in the same words. This is the staff half. - $blocked = $this->undeletableFileCount($viewer, $folder); + // Authorizing the folder is not authorizing the files the cascade + // takes with it — see UndeletableFiles, which the API asks too. + $blocked = $this->undeletable->count($viewer, $folder); if ($blocked > 0) { return back()->with('error', trans_choice( @@ -592,50 +587,6 @@ class FoldersController extends Controller return redirect()->route('files.index', $parentId !== null ? ['folder' => $parentId] : [])->with('success', __('Folder deleted.')); } - /** - * How many files in this folder's subtree the viewer may not delete. - * - * Asked as one count rather than FilePolicy::delete per file: a folder - * can hold thousands, Gate resolves a fresh policy for every check, and - * a per-row policy check on a listing is the cost 0a8b609e went to - * some trouble to remove. The two halves of FilePolicy::delete are - * expressible in SQL — the permission half is constant for this - * viewer, and the library half is the query StaffLibraryScope already - * memoises per request. - * - * Somebody holding both delete permissions and no library scope can - * delete anything in the subtree by construction, so they never pay for - * the query at all. - */ - private function undeletableFileCount(User $viewer, Folder $folder): int - { - $mayDeleteOwn = $viewer->can('delete_files'); - $mayDeleteOthers = $viewer->can('delete_others_files'); - $scoped = $viewer->isClientScoped(); - - if ($mayDeleteOwn && $mayDeleteOthers && ! $scoped) { - return 0; - } - - return File::query() - ->whereIn('folder_id', $folder->subtreeFolderIds()) - ->where(function (Builder $outer) use ($viewer, $mayDeleteOwn, $mayDeleteOthers, $scoped): void { - if (! $mayDeleteOwn) { - $outer->orWhere('uploaded_by', $viewer->id); - } - - if (! $mayDeleteOthers) { - $outer->orWhere(fn (Builder $others): Builder => $others - ->whereNull('uploaded_by')->orWhere('uploaded_by', '!=', $viewer->id)); - } - - if ($scoped) { - $outer->orWhereNotIn('id', $this->scope->files($viewer)->select('id')); - } - }) - ->count(); - } - /** * The trail to $folder, trimmed for a client-scoped staff member to * start at the first folder their library shows them: one of their diff --git a/app/Modules/Files/Sharing/FolderSharing.php b/app/Modules/Files/Sharing/FolderSharing.php new file mode 100644 index 00000000..592f44ca --- /dev/null +++ b/app/Modules/Files/Sharing/FolderSharing.php @@ -0,0 +1,95 @@ +firstOrCreate([ + 'folder_id' => $folder->id, + 'assignable_type' => $assignable->getMorphClass(), + 'assignable_id' => $assignable->getKey(), + ]); + + $this->activity->log(Action::FolderShared, subject: $folder, context: ['target' => $targetName]); + + $recipients = $this->recipients($assignable); + + $this->notifier->send('file_shared', $recipients, subject: $folder, data: ['itemName' => $folder->name]); + + // The master switch and each recipient's own preference are the + // digester's job now — every caller was repeating them. + $this->digester->queue('file_shared', $recipients, $folder->name, ['is_folder' => true]); + } + + /** + * @return bool whether an assignment was actually removed + */ + public function unassign(Folder $folder, User|Group $assignable, string $targetName): bool + { + $deleted = FolderAssignment::query() + ->where('folder_id', $folder->id) + ->where('assignable_type', $assignable->getMorphClass()) + ->where('assignable_id', $assignable->getKey()) + ->delete(); + + if ($deleted > 0) { + $this->activity->log(Action::FolderUnshared, subject: $folder, context: ['target' => $targetName]); + } + + return $deleted > 0; + } + + /** + * Notifier performs no authorization of its own — see its SECURITY + * CONTRACT docblock — so the recipient list is resolved here, from the + * assignment itself. + * + * @return iterable + */ + private function recipients(User|Group $assignable): iterable + { + return $assignable instanceof Group ? $assignable->members : [$assignable]; + } +} From 70dc7258586bd02e9ce0959be893b564d3c3c46d Mon Sep 17 00:00:00 2001 From: ignacionelson Date: Fri, 2 Oct 2026 17:35:04 -0300 Subject: [PATCH 2/4] 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 From 33bc90c9efbcc60c21ea8b0401dd2bc234e5f7d9 Mon Sep 17 00:00:00 2001 From: ignacionelson Date: Fri, 2 Oct 2026 17:36:37 -0300 Subject: [PATCH 3/4] Test the folder API: scope, trails, placement, the delete guard and sharing --- tests/Feature/Api/FoldersTest.php | 355 ++++++++++++++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 tests/Feature/Api/FoldersTest.php diff --git a/tests/Feature/Api/FoldersTest.php b/tests/Feature/Api/FoldersTest.php new file mode 100644 index 00000000..28ce8464 --- /dev/null +++ b/tests/Feature/Api/FoldersTest.php @@ -0,0 +1,355 @@ +admin = User::factory()->create(); + $this->token = $this->admin->createToken('t', [ + Permission::Upload->value, + Permission::EditFiles->value, + Permission::EditOthersFiles->value, + Permission::DeleteFiles->value, + Permission::DeleteOthersFiles->value, + Permission::CreateOwnFolders->value, + Permission::UploadPublic->value, + ])->plainTextToken; +}); + +/** A token for a staff member whose role holds exactly these permissions. */ +function folderApiToken(array $permissions): string +{ + $user = staffWithPermissions(array_map(fn (Permission $p): string => $p->value, $permissions)); + + return $user->createToken('t', array_map(fn (Permission $p): string => $p->value, $permissions))->plainTextToken; +} + +/** A client manager scoped to one client, and that client. */ +function scopedFolderManager(): array +{ + $client = User::factory()->client()->create(); + $manager = User::factory()->role(SystemRole::ClientManager)->create(); + $manager->assignedClients()->sync([$client->id]); + + return [$manager, $client]; +} + +test('folders list with their place in the tree', function () { + $clients = makeFolder('Clients'); + $acme = makeFolder('Acme', $clients); + $year = makeFolder('2026', $acme); + + $rows = collect($this->withToken($this->token)->getJson('/api/v1/folders')->assertOk()->json('data'))->keyBy('id'); + + expect($rows[$year->id]['parent_id'])->toBe($acme->id) + ->and($rows[$year->id]['path'])->toBe('Clients / Acme / 2026') + ->and($rows[$year->id]['ancestors'])->toBe([ + ['id' => $clients->id, 'name' => 'Clients'], + ['id' => $acme->id, 'name' => 'Acme'], + ]) + ->and($rows[$clients->id]['ancestors'])->toBe([]) + ->and($rows[$clients->id]['path'])->toBe('Clients'); +}); + +test('filters narrow the listing', function () { + $top = makeFolder('Projects'); + $child = makeFolder('Invoices', $top); + $other = makeFolder('Archive'); + + $ids = fn (string $query) => $this->withToken($this->token)->getJson("/api/v1/folders?{$query}")->assertOk()->json('data.*.id'); + + expect($ids("parent_id={$top->id}"))->toBe([$child->id]) + ->and($ids('top_level=1'))->toEqualCanonicalizing([$top->id, $other->id]) + ->and($ids('search=voice'))->toBe([$child->id]); +}); + +test('polling with updated_since returns what changed, oldest first', function () { + $this->travelTo(now()->subDay()); + $old = makeFolder('Old'); + $this->travelBack(); + + $since = now()->subMinute()->toIso8601String(); + $new = makeFolder('New'); + + $ids = $this->withToken($this->token) + ->getJson('/api/v1/folders?updated_since='.urlencode($since)) + ->assertOk()->json('data.*.id'); + + expect($ids)->toBe([$new->id])->not->toContain($old->id); +}); + +test('a token without a file ability cannot list folders', function () { + $token = folderApiToken([Permission::ViewNews]); + + $this->withToken($token)->getJson('/api/v1/folders')->assertForbidden(); +}); + +/* + * The listing is the library screen's own scope, and the trail above a + * folder must not name folders the caller cannot reach. + */ +test('a client-scoped token sees only its folders, and not the names above them', function () { + [$manager, $client] = scopedFolderManager(); + + $secret = makeFolder('Board minutes'); + $shared = makeFolder('Acme', $secret); + $unrelated = makeFolder('Somebody else'); + $this->actingAs($this->admin)->post("/folders/{$shared->id}/assignments", ['type' => 'client', 'id' => $client->id]); + + $token = $manager->createToken('t', [Permission::Upload->value])->plainTextToken; + + $rows = collect($this->withToken($token)->getJson('/api/v1/folders')->assertOk()->json('data'))->keyBy('id'); + + expect($rows->keys()->all())->toContain($shared->id) + ->not->toContain($unrelated->id) + ->not->toContain($secret->id) + ->and($rows[$shared->id]['ancestors'])->toBe([]) + ->and($rows[$shared->id]['path'])->toBe('Acme'); + + $this->withToken($token)->getJson("/api/v1/folders/{$unrelated->id}")->assertForbidden(); + $this->withToken($token)->getJson("/api/v1/folders/{$shared->id}")->assertOk()->assertJsonPath('data.path', 'Acme'); +}); + +test('an unscoped token sees the whole trail of the same folder', function () { + $secret = makeFolder('Board minutes'); + $shared = makeFolder('Acme', $secret); + + $this->withToken($this->token)->getJson("/api/v1/folders/{$shared->id}") + ->assertOk() + ->assertJsonPath('data.path', 'Board minutes / Acme'); +}); + +test('a folder can be created at the top or inside another', function () { + $response = $this->withToken($this->token)->postJson('/api/v1/folders', ['name' => 'Clients']) + ->assertStatus(201) + ->assertJsonPath('data.name', 'Clients') + ->assertJsonPath('data.parent_id', null); + + $parentId = $response->json('data.id'); + + $this->withToken($this->token)->postJson('/api/v1/folders', ['name' => 'Acme', 'parent_id' => $parentId]) + ->assertStatus(201) + ->assertJsonPath('data.parent_id', $parentId) + ->assertJsonPath('data.path', 'Clients / Acme'); + + $folder = Folder::query()->where('name', 'Acme')->firstOrFail(); + expect($folder->created_by)->toBe($this->admin->id) + ->and(ActivityLog::query()->where('action', Action::FolderCreated)->count())->toBe(2); +}); + +test('creating a folder that already exists returns it instead of a second one', function () { + $parent = makeFolder('Clients'); + $existing = makeFolder('Acme', $parent); + + $this->withToken($this->token)->postJson('/api/v1/folders', ['name' => 'Acme', 'parent_id' => $parent->id]) + ->assertStatus(200) + ->assertJsonPath('data.id', $existing->id); + + // Same name somewhere else is a different folder. + $this->withToken($this->token)->postJson('/api/v1/folders', ['name' => 'Acme'])->assertStatus(201); + + expect(Folder::query()->where('name', 'Acme')->count())->toBe(2); +}); + +test('creating needs upload as well as create_own_folders', function () { + $token = folderApiToken([Permission::CreateOwnFolders]); + + $this->withToken($token)->postJson('/api/v1/folders', ['name' => 'Nope'])->assertForbidden(); + + expect(Folder::query()->where('name', 'Nope')->exists())->toBeFalse(); +}); + +test('a folder cannot be created inside a public folder without upload_public', function () { + $public = makeFolder('Press kit'); + $public->update(['public' => true]); + + $token = folderApiToken([Permission::CreateOwnFolders, Permission::Upload, Permission::EditOthersFiles]); + + $this->withToken($token)->postJson('/api/v1/folders', ['name' => 'Drafts', 'parent_id' => $public->id])->assertForbidden(); + + expect(Folder::query()->where('name', 'Drafts')->exists())->toBeFalse(); +}); + +test('a client-scoped token cannot create inside a folder it cannot see', function () { + [$manager] = scopedFolderManager(); + $hidden = makeFolder('Somebody else'); + + $token = $manager->createToken('t', [Permission::CreateOwnFolders->value, Permission::Upload->value])->plainTextToken; + + $this->withToken($token)->postJson('/api/v1/folders', ['name' => 'Sneaky', 'parent_id' => $hidden->id])->assertNotFound(); + + expect(Folder::query()->where('name', 'Sneaky')->exists())->toBeFalse(); +}); + +test('the depth cap applies', function () { + $parent = null; + + // The deepest folder allowed: one more level is refused. + for ($i = 0; $i < Folder::MAX_DEPTH; $i++) { + $parent = makeFolder("Level {$i}", $parent); + } + + $this->withToken($this->token)->postJson('/api/v1/folders', ['name' => 'Too deep', 'parent_id' => $parent?->id]) + ->assertStatus(422) + ->assertJsonValidationErrors('parent_id'); +}); + +test('a folder can be renamed and moved, carrying its subtree', function () { + $from = makeFolder('From'); + $to = makeFolder('To'); + $folder = makeFolder('Acme', $from); + $child = makeFolder('2026', $folder); + + $this->withToken($this->token)->patchJson("/api/v1/folders/{$folder->id}", ['name' => 'Acme Inc', 'parent_id' => $to->id]) + ->assertOk() + ->assertJsonPath('data.name', 'Acme Inc') + ->assertJsonPath('data.parent_id', $to->id) + ->assertJsonPath('data.path', 'To / Acme Inc'); + + $this->withToken($this->token)->getJson("/api/v1/folders/{$child->id}") + ->assertJsonPath('data.path', 'To / Acme Inc / 2026'); + + $this->withToken($this->token)->patchJson("/api/v1/folders/{$folder->id}", ['parent_id' => null]) + ->assertOk() + ->assertJsonPath('data.parent_id', null); + + expect(ActivityLog::query()->where('action', Action::FolderRenamed)->count())->toBe(1) + ->and(ActivityLog::query()->where('action', Action::FolderMoved)->count())->toBe(2); +}); + +test('only the fields sent change', function () { + $parent = makeFolder('Parent'); + $folder = makeFolder('Acme', $parent); + + $this->withToken($this->token)->patchJson("/api/v1/folders/{$folder->id}", ['name' => 'Renamed']) + ->assertOk() + ->assertJsonPath('data.parent_id', $parent->id); +}); + +test('a folder cannot be moved into itself or below itself', function () { + $folder = makeFolder('Acme'); + $child = makeFolder('2026', $folder); + + $this->withToken($this->token)->patchJson("/api/v1/folders/{$folder->id}", ['parent_id' => $child->id]) + ->assertStatus(422) + ->assertJsonValidationErrors('parent_id'); +}); + +test('a folder cannot be moved into a public folder without upload_public', function () { + $public = makeFolder('Press kit'); + $public->update(['public' => true]); + $folder = makeFolder('Private drafts'); + + $token = folderApiToken([Permission::Upload, Permission::EditFiles, Permission::EditOthersFiles]); + + $this->withToken($token)->patchJson("/api/v1/folders/{$folder->id}", ['parent_id' => $public->id])->assertForbidden(); + + expect($folder->fresh()?->parent_id)->toBeNull(); +}); + +test('an empty folder is deleted', function () { + $folder = makeFolder('Empty'); + + $this->withToken($this->token)->deleteJson("/api/v1/folders/{$folder->id}")->assertNoContent(); + + expect(Folder::query()->whereKey($folder->id)->exists())->toBeFalse(); +}); + +test('a folder with content is refused unless the cascade is asked for', function () { + $folder = makeFolder('Acme'); + $file = File::factory()->create(['uploaded_by' => $this->admin->id, 'folder_id' => $folder->id]); + + $this->withToken($this->token)->deleteJson("/api/v1/folders/{$folder->id}") + ->assertStatus(409) + ->assertJsonPath('type', 'conflict'); + + expect(Folder::query()->whereKey($folder->id)->exists())->toBeTrue() + ->and(File::query()->whereKey($file->id)->exists())->toBeTrue(); + + $this->withToken($this->token)->deleteJson("/api/v1/folders/{$folder->id}", ['content_action' => 'cascade_delete']) + ->assertNoContent(); + + expect(Folder::query()->whereKey($folder->id)->exists())->toBeFalse() + ->and(File::query()->whereKey($file->id)->exists())->toBeFalse(); +}); + +test('a folder holding only a subfolder counts as not empty', function () { + $folder = makeFolder('Acme'); + makeFolder('2026', $folder); + + $this->withToken($this->token)->deleteJson("/api/v1/folders/{$folder->id}")->assertStatus(409); +}); + +test('the cascade is refused when it would take a file the token may not delete', function () { + $staff = staffWithPermissions([ + Permission::Upload->value, Permission::EditFiles->value, + Permission::DeleteFiles->value, Permission::CreateOwnFolders->value, + ]); + $token = $staff->createToken('t', [Permission::DeleteFiles->value])->plainTextToken; + + $folder = Folder::query()->create(['name' => 'Reports', 'created_by' => $staff->id]); + $foreign = File::factory()->create(['uploaded_by' => $this->admin->id, 'folder_id' => $folder->id]); + + $this->withToken($token)->deleteJson("/api/v1/folders/{$folder->id}", ['content_action' => 'cascade_delete']) + ->assertForbidden(); + + expect(Folder::query()->whereKey($folder->id)->exists())->toBeTrue() + ->and(File::query()->whereKey($foreign->id)->exists())->toBeTrue(); +}); + +test('a folder can be shared with a client and unshared', function () { + $folder = makeFolder('Acme'); + $client = User::factory()->client()->create(); + + $this->withToken($this->token)->postJson("/api/v1/folders/{$folder->id}/assignments", ['type' => 'client', 'id' => $client->id]) + ->assertOk() + ->assertJsonPath('data.assignments.0.type', 'client') + ->assertJsonPath('data.assignments.0.id', $client->id); + + // Again: still one share. + $this->withToken($this->token)->postJson("/api/v1/folders/{$folder->id}/assignments", ['type' => 'client', 'id' => $client->id]) + ->assertOk(); + + expect(FolderAssignment::query()->where('folder_id', $folder->id)->count())->toBe(1) + ->and(ActivityLog::query()->where('action', Action::FolderShared)->exists())->toBeTrue(); + + $this->withToken($this->token)->deleteJson("/api/v1/folders/{$folder->id}/assignments", ['type' => 'client', 'id' => $client->id]) + ->assertOk() + ->assertJsonPath('data.assignments', []); + + expect(FolderAssignment::query()->where('folder_id', $folder->id)->exists())->toBeFalse(); +}); + +test('a client-scoped token cannot share with somebody else\'s client', function () { + [$manager] = scopedFolderManager(); + $stranger = User::factory()->client()->create(); + $folder = Folder::query()->create(['name' => 'Mine', 'created_by' => $manager->id]); + + $token = $manager->createToken('t', [Permission::EditFiles->value])->plainTextToken; + + $this->withToken($token)->postJson("/api/v1/folders/{$folder->id}/assignments", ['type' => 'client', 'id' => $stranger->id]) + ->assertStatus(422) + ->assertJsonValidationErrors('id'); + + expect(FolderAssignment::query()->where('folder_id', $folder->id)->exists())->toBeFalse(); +}); + +test('a file reports its folder\'s parent', function () { + $parent = makeFolder('Clients'); + $folder = makeFolder('Acme', $parent); + $file = File::factory()->create(['uploaded_by' => $this->admin->id, 'folder_id' => $folder->id]); + + $this->withToken($this->token)->getJson("/api/v1/files/{$file->id}") + ->assertOk() + ->assertJsonPath('data.folder.parent_id', $parent->id); +}); From e9b71993f56b0f638f12f02ce0dcfab76ae1b13e Mon Sep 17 00:00:00 2001 From: ignacionelson Date: Fri, 2 Oct 2026 17:38:49 -0300 Subject: [PATCH 4/4] Document the folder endpoints The OpenAPI document gains the seven folder operations; `ancestors` gets an explicit type so the schema says what it holds rather than Scramble's guess. The guide gets a Folders section, the folder abilities, the idempotent create under "Retries", and public folders under "Not in v1". The abilities table was split in two by a blank line, with the groups row left under the paragraph after it; both are back in the table. --- .../Http/Resources/Api/FolderResource.php | 18 +- docs/api-guide.md | 54 +- docs/api/openapi.json | 719 +++++++++++++++++- 3 files changed, 784 insertions(+), 7 deletions(-) diff --git a/app/Modules/Files/Http/Resources/Api/FolderResource.php b/app/Modules/Files/Http/Resources/Api/FolderResource.php index 45aa2b6a..38570e89 100644 --- a/app/Modules/Files/Http/Resources/Api/FolderResource.php +++ b/app/Modules/Files/Http/Resources/Api/FolderResource.php @@ -33,8 +33,7 @@ class FolderResource extends JsonResource $identity = app(ClientIdentityScope::class); $groupMorph = (new Group)->getMorphClass(); - /** @var list $ancestors */ - $ancestors = $this->relationLoaded('trail') ? $this->getRelation('trail')->all() : []; + $ancestors = $this->ancestors(); return [ 'id' => $this->id, @@ -65,4 +64,19 @@ class FolderResource extends JsonResource ->all()), ]; } + + /** + * @return list + */ + private function ancestors(): array + { + if (! $this->resource->relationLoaded('trail')) { + return []; + } + + /** @var list $trail */ + $trail = $this->resource->getRelation('trail')->all(); + + return $trail; + } } diff --git a/docs/api-guide.md b/docs/api-guide.md index f4b475dc..ede2da43 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -80,6 +80,10 @@ list for your account. | `upload` | list files, upload | | `edit_files` / `edit_others_files` | read and edit file metadata, share files | | `delete_files` / `delete_others_files` | delete files | +| `upload` / `edit_files` / `edit_others_files` | list and read folders | +| `create_own_folders` | create folders (with `upload`, as on the web) | +| `edit_files` / `edit_others_files` | rename, move and share folders | +| `delete_files` / `delete_others_files` | delete folders | | `set_file_expiration_date` | set `expires_at` when editing | | `set_file_categories` | set `categories` when editing | | `limit_downloads` | set `download_limit` and `download_limit_scope` when editing | @@ -88,7 +92,7 @@ list for your account. | `manage_clients` | list clients | | `create_clients` / `edit_clients` / `delete_clients` | create, read and edit, delete clients; `edit_clients` also removes a client's two-factor authentication | | `manage_groups` | list groups | - +| `create_groups` / `edit_groups` / `delete_groups` | create, read and edit (including membership), delete groups | | `moderate_comments` | list what is awaiting approval, and approve it | | `manage_users` | list staff accounts and the roles you may assign | | `create_users` / `edit_users` / `delete_users` | create, read and edit, delete staff accounts; `edit_users` also removes an account's two-factor authentication | @@ -99,10 +103,10 @@ a per-role permission, so the file abilities are the gate — the same question endpoint also lets an author remove their own within the editing window and that is not moderation; it additionally requires the token's owner to hold `moderate_comments`, checked live against the account rather than carried by the token. -| `create_groups` / `edit_groups` / `delete_groups` | create, read and edit (including membership), delete groups | Where an endpoint accepts several — `edit_files` *or* `edit_others_files` — holding either is enough, -and which one applies to a given file depends on whether you uploaded it. +and which one applies to a given file depends on whether you uploaded it. For a folder, it depends +on whether you created it. Every operation in the OpenAPI document names its own requirement. @@ -359,6 +363,46 @@ two are narrowed to what your token may see: a counterpart outside your reach re --- +## Folders + +`GET /folders` lists the folders you can see in the library, and polls like every other list. +`parent_id=12` lists the folders directly inside folder 12, and `top_level=1` the folders at the top. + +Each folder carries `parent_id`, and its place in the tree as `ancestors` (root first, as +`{id, name}`) and as a display `path` such as `Clients / Acme / 2026`. Match on ids rather than on +`path`: a folder's name may itself contain ` / `. If your token is limited to some clients, the +trail starts at the first folder you can see. + +Creating a folder: + +```bash +curl -X POST -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name":"Acme","parent_id":12}' \ + https://your-install.example.com/api/v1/folders +``` + +A new folder answers `201`. If a folder with that name already exists in the same place, you get +that folder back with a `200` instead, so a sync job can create a folder without looking first. + +`PATCH /folders/{id}` takes `name`, `parent_id`, or both. `parent_id: null` moves the folder to the +top. A folder moves with everything inside it, and cannot go into itself or one of its own +subfolders. + +**Deleting a folder that is not empty must be asked for.** `DELETE /folders/{id}` deletes an empty +folder. A folder holding files or other folders answers `409` unless you send +`content_action=cascade_delete`, which deletes it with every folder and file inside it, as the web +screen does. There is no restore. The cascade is refused with `403` if the folder holds a file your +token may not delete. + +Sharing works as it does for a file, at `/folders/{id}/assignments`. A client a folder is shared +with sees everything inside it, including what is added later. + +A folder's `public` flag is reported but cannot be changed here: making a folder public publishes +everything in it, and is done on the web. + +--- + ## Staff accounts `/users` manages the people who administer the installation, and the role assigned to each of them. @@ -443,7 +487,8 @@ sign-in — this un-sticks an account, it does not exempt one. ## Retries and duplicate requests -Assignments and group membership are idempotent. **Creating a file or a client is not** — a retried +Assignments and group membership are idempotent, and so is creating a folder (see above). +**Creating a file or a client is not** — a retried `POST` that actually succeeded the first time creates a second one. Until idempotency keys exist, check before retrying a create you are unsure about. @@ -506,6 +551,7 @@ Recorded so they read as decisions rather than gaps: - **Webhooks.** Poll instead; see above. - **Idempotency keys.** See "Retries" above. - **Share links, notifications, thumbnails, settings.** +- **Making a folder public**, or changing its public page. See "Folders" above. - **Creating and deleting roles.** `GET /roles` reads them and `role_id` assigns one; defining a role's permission set stays in the UI. diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 5d7b024b..d6baa23a 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -2328,6 +2328,624 @@ } } }, + "/folders/{folder}/assignments": { + "post": { + "operationId": "folders.assignments.store", + "description": "Sharing it again with the same client or group leaves one share.\n\nRequires a token with any of these abilities: `edit_files`, `edit_others_files`.", + "summary": "Share a folder", + "tags": [ + "FolderAssignments" + ], + "parameters": [ + { + "name": "folder", + "in": "path", + "required": true, + "description": "The folder ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "`FolderResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/FolderResource" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + }, + "delete": { + "operationId": "folders.assignments.destroy", + "description": "Requires a token with any of these abilities: `edit_files`, `edit_others_files`.", + "summary": "Stop sharing a folder", + "tags": [ + "FolderAssignments" + ], + "parameters": [ + { + "name": "folder", + "in": "path", + "required": true, + "description": "The folder ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "`FolderResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/FolderResource" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/folders": { + "get": { + "operationId": "folders.index", + "description": "Cursor paginated, like every list. Pass `updated_since` to poll for\nfolders created, renamed or moved since a point in time. `parent_id`\nlists the folders directly inside one folder, and `top_level=1` the\nfolders at the top of the library.\n\nMoving a folder updates the folder itself and every folder under it,\nso a poll sees the whole moved subtree.\n\nRequires a token with any of these abilities: `upload`, `edit_files`, `edit_others_files`.", + "summary": "List folders", + "tags": [ + "Folders" + ], + "parameters": [ + { + "name": "updated_since", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + }, + { + "name": "per_page", + "in": "query", + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "parent_id", + "in": "query", + "schema": { + "type": [ + "integer", + "null" + ] + } + }, + { + "name": "top_level", + "in": "query", + "schema": { + "type": [ + "boolean", + "null" + ] + } + }, + { + "name": "search", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ], + "maxLength": 255 + } + } + ], + "responses": { + "200": { + "description": "Paginated set of `FolderResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FolderResource" + } + }, + "links": { + "type": "object", + "properties": { + "first": { + "type": [ + "string", + "null" + ] + }, + "last": { + "type": [ + "string", + "null" + ] + }, + "prev": { + "type": [ + "string", + "null" + ] + }, + "next": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "first", + "last", + "prev", + "next" + ] + }, + "meta": { + "type": "object", + "properties": { + "path": { + "type": [ + "string", + "null" + ], + "description": "Base path for paginator generated URLs." + }, + "per_page": { + "type": "integer", + "description": "Number of items shown per page.", + "minimum": 0 + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "The \"cursor\" that points to the next set of items." + }, + "prev_cursor": { + "type": [ + "string", + "null" + ], + "description": "The \"cursor\" that points to the previous set of items." + } + }, + "required": [ + "path", + "per_page", + "next_cursor", + "prev_cursor" + ] + } + }, + "required": [ + "data", + "links", + "meta" + ] + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + }, + "post": { + "operationId": "folders.store", + "description": "At the top of the library, or inside `parent_id`. Requires the\n`create_own_folders` ability, and `upload` with it.\n\nIf a folder with the same name already exists in the same place, that\nfolder is returned with a 200 instead of a second one being made, so\nretrying a request is safe. A new folder answers 201.\n\nRequires a token with the ability: `create_own_folders`.", + "summary": "Create a folder", + "tags": [ + "Folders" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 255 + }, + "parent_id": { + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "name" + ] + } + } + } + }, + "responses": { + "201": { + "description": "`FolderResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/FolderResource" + }, + { + "type": "object", + "required": [ + "assignments" + ] + } + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "200": { + "description": "`FolderResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/FolderResource" + }, + { + "type": "object", + "required": [ + "assignments" + ] + } + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "403": { + "description": "An error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Error overview.", + "examples": [ + "" + ] + } + }, + "required": [ + "message" + ] + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/folders/{folder}": { + "get": { + "operationId": "folders.show", + "description": "Requires a token with any of these abilities: `upload`, `edit_files`, `edit_others_files`.", + "summary": "Show a folder, with the clients and groups it is shared with", + "tags": [ + "Folders" + ], + "parameters": [ + { + "name": "folder", + "in": "path", + "required": true, + "description": "The folder ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "`FolderResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/FolderResource" + }, + { + "type": "object", + "required": [ + "assignments" + ] + } + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + }, + "patch": { + "operationId": "folders.update", + "description": "Only the fields you send change. `parent_id: null` moves the folder to\nthe top of the library. A folder moves with everything inside it, and\ncannot be moved into itself or one of its own subfolders.\n\nRequires a token with any of these abilities: `edit_files`, `edit_others_files`.", + "summary": "Rename or move a folder", + "tags": [ + "Folders" + ], + "parameters": [ + { + "name": "folder", + "in": "path", + "required": true, + "description": "The folder ID", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 255 + }, + "parent_id": { + "type": [ + "integer", + "null" + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "`FolderResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/FolderResource" + }, + { + "type": "object", + "required": [ + "assignments" + ] + } + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + }, + "delete": { + "operationId": "folders.destroy", + "description": "An empty folder is deleted straight away. A folder holding files or\nother folders answers 409 unless you send\n`content_action=cascade_delete`, which deletes the folder, every folder\nunder it and every file inside them, as the web screen does. There is\nno restore.\n\nA cascade is refused with 403 if the folder holds any file this token\nmay not delete itself.\n\nRequires a token with any of these abilities: `delete_files`, `delete_others_files`.", + "summary": "Delete a folder", + "tags": [ + "Folders" + ], + "parameters": [ + { + "name": "folder", + "in": "path", + "required": true, + "description": "The folder ID", + "schema": { + "type": "integer" + } + }, + { + "name": "content_action", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ], + "enum": [ + "cascade_delete", + null + ] + } + } + ], + "responses": { + "204": { + "description": "No content", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {} + } + } + } + }, + "409": { + "description": "An error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Error overview.", + "examples": [ + "This folder is not empty. Send content_action=cascade_delete to delete it with everything inside it." + ] + } + }, + "required": [ + "message" + ] + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, "/groups/{group}/members": { "post": { "operationId": "groups.members.store", @@ -4189,17 +4807,25 @@ "object", "null" ], + "description": "GET /folders/{id} has the rest, its place in the tree included.", "properties": { "id": { "type": "integer" }, "name": { "type": "string" + }, + "parent_id": { + "type": [ + "integer", + "null" + ] } }, "required": [ "id", - "name" + "name", + "parent_id" ] }, "uploaded_by": { @@ -4303,6 +4929,97 @@ ], "title": "FileResource" }, + "FolderResource": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "parent_id": { + "type": [ + "integer", + "null" + ] + }, + "ancestors": { + "type": "array", + "description": "The folders above this one, root first, as far up as the\ncaller may see. Empty for a folder at the top of the library.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + } + }, + "path": { + "type": "string", + "description": "The same trail as one string, this folder included:\n\"Clients / Acme / 2026\". For display; match on ids, since a\nfolder name may itself contain \" / \"." + }, + "public": { + "type": "boolean", + "description": "Read-only here. Making a folder public publishes everything\ninside it, and is done on the web." + }, + "created_at": { + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "type": [ + "string", + "null" + ] + }, + "assignments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "group", + "client" + ] + }, + "id": { + "type": "integer" + }, + "name": {} + }, + "required": [ + "type", + "id", + "name" + ] + } + } + }, + "required": [ + "id", + "name", + "parent_id", + "ancestors", + "path", + "public", + "created_at", + "updated_at" + ], + "title": "FolderResource" + }, "GroupResource": { "type": "object", "properties": {