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/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/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/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/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..38570e89 --- /dev/null +++ b/app/Modules/Files/Http/Resources/Api/FolderResource.php @@ -0,0 +1,82 @@ +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(); + + $ancestors = $this->ancestors(); + + 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()), + ]; + } + + /** + * @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/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]; + } +} 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/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": { 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 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); +});