mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-03 12:54:18 +00:00
Merge branch feat/api-folders
Folders in API v1: list, read, create, rename, move, delete and share
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Folders;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
|
||||
/**
|
||||
* The ancestors of a whole page of folders, in two queries however long the
|
||||
* page is, trimmed to what the viewer may see.
|
||||
*
|
||||
* BreadcrumbBuilder answers this for one folder on a screen. A list
|
||||
* endpoint needs it for every row, and a query per row is the cost a
|
||||
* listing must not have.
|
||||
*
|
||||
* Trimmed the way BreadcrumbBuilder::visible() trims the client portal's
|
||||
* trail: the list starts at the first ancestor the viewer can reach, since
|
||||
* a client-scoped staff member holding a client's folder deep in somebody
|
||||
* else's tree has no business reading the names of the folders above it.
|
||||
* An unscoped staff member reaches every folder, so for them nothing is
|
||||
* ever trimmed.
|
||||
*/
|
||||
class FolderTrails
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StaffLibraryScope $scope,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param iterable<Folder> $folders
|
||||
* @return array<int, list<array{id: int, name: string}>> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Folders;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
* How many files in a folder's subtree a staff member may not delete.
|
||||
*
|
||||
* 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 by
|
||||
* FolderPolicy. Every staff path that deletes a folder asks this first, so
|
||||
* the web screen and the API cannot disagree about what a cascade may take.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* The client half of the same rule is MyFoldersController::destroy.
|
||||
*/
|
||||
class UndeletableFiles
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StaffLibraryScope $scope,
|
||||
) {}
|
||||
|
||||
public function count(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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Folders\FolderTrails;
|
||||
use App\Modules\Files\Http\Controllers\Concerns\ResolvesShareTargets;
|
||||
use App\Modules\Files\Http\Resources\Api\FolderResource;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Files\Sharing\FolderSharing;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
/**
|
||||
* Sharing a folder with a client or a group: `{type: client|group, id}`.
|
||||
*
|
||||
* A client a folder is shared with sees everything inside it, including
|
||||
* folders and files added later.
|
||||
*
|
||||
* Both the target resolution (ResolvesShareTargets) and the effects
|
||||
* (FolderSharing — the row, the activity entry, the in-app notification,
|
||||
* the digest email) are shared with the web controller, so the two surfaces
|
||||
* cannot drift. "May share" is "may edit", as on the web.
|
||||
*/
|
||||
class FolderAssignmentsController extends Controller
|
||||
{
|
||||
use ResolvesShareTargets;
|
||||
|
||||
public function __construct(
|
||||
private readonly StaffLibraryScope $scope,
|
||||
private readonly FolderSharing $sharing,
|
||||
private readonly FolderTrails $trails,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Share a folder.
|
||||
*
|
||||
* Sharing it again with the same client or group leaves one share.
|
||||
*/
|
||||
public function store(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->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\Api\Support\PollingQuery;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Folders\FolderService;
|
||||
use App\Modules\Files\Folders\FolderTrails;
|
||||
use App\Modules\Files\Folders\UndeletableFiles;
|
||||
use App\Modules\Files\Http\Resources\Api\FolderResource;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Support\Rules;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* The staff library's folders.
|
||||
*
|
||||
* Which folders a token sees is the same question the library screen
|
||||
* answers, so a staff member limited to their assigned clients gets exactly
|
||||
* the folders they see on the web. Every write goes through the same
|
||||
* service, policy and placement rule as the web screen.
|
||||
*/
|
||||
class FoldersController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StaffLibraryScope $scope,
|
||||
private readonly PollingQuery $polling,
|
||||
private readonly FolderService $folders,
|
||||
private readonly FolderTrails $trails,
|
||||
private readonly UndeletableFiles $undeletable,
|
||||
private readonly ActivityLogger $activity,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* List folders.
|
||||
*
|
||||
* Cursor paginated, like every list. Pass `updated_since` to poll for
|
||||
* folders created, renamed or moved since a point in time. `parent_id`
|
||||
* lists the folders directly inside one folder, and `top_level=1` the
|
||||
* folders at the top of the library.
|
||||
*
|
||||
* Moving a folder updates the folder itself and every folder under it,
|
||||
* so a poll sees the whole moved subtree.
|
||||
*/
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$user = $request->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<int, Folder> $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<int, Folder> $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<Folder> $folders */
|
||||
$folders = $this->scope->folders($user);
|
||||
|
||||
return $folders->findOrFail($parentId);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Http\Resources\Api;
|
||||
|
||||
use App\Modules\Files\Access\ClientIdentityScope;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Files\Models\FolderAssignment;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin Folder
|
||||
*
|
||||
* Every field is listed explicitly, never $folder->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<string, mixed>
|
||||
*/
|
||||
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<array{id: int, name: string}>
|
||||
*/
|
||||
private function ancestors(): array
|
||||
{
|
||||
if (! $this->resource->relationLoaded('trail')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @var list<array{id: int, name: string}> $trail */
|
||||
$trail = $this->resource->getRelation('trail')->all();
|
||||
|
||||
return $trail;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Sharing;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Files\Models\FolderAssignment;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use App\Modules\Notifications\NotificationDigester;
|
||||
use App\Modules\Notifications\Notifier;
|
||||
|
||||
/**
|
||||
* What actually happens when a folder is shared with a client or a group —
|
||||
* the assignment row, the activity entry, the in-app notification and the
|
||||
* debounced digest email, in that order. The folder twin of FileSharing.
|
||||
*
|
||||
* Extracted for the same reason FileSharing was: the web controller and the
|
||||
* API controller must not be able to answer the question differently. The
|
||||
* AI connector in the hosted edition repeated these four steps too, because
|
||||
* there was nothing here to call.
|
||||
*
|
||||
* Unlike a file, a folder has no scan to wait for: the files inside it are
|
||||
* held back individually until they can be had, and sharing the folder
|
||||
* does not change that. So the telling is never deferred here.
|
||||
*
|
||||
* Authorization is the caller's job — both callers reach this after
|
||||
* Gate::authorize('update', $folder), and the target has already been
|
||||
* resolved and scope-checked by ResolvesShareTargets.
|
||||
*/
|
||||
class FolderSharing
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly NotificationDigester $digester,
|
||||
private readonly Notifier $notifier,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Idempotent for the row: sharing the same folder with the same target
|
||||
* twice leaves one assignment, which matters for an API caller retrying
|
||||
* a request.
|
||||
*/
|
||||
public function assign(Folder $folder, User|Group $assignable, string $targetName): void
|
||||
{
|
||||
FolderAssignment::query()->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<User>
|
||||
*/
|
||||
private function recipients(User|Group $assignable): iterable
|
||||
{
|
||||
return $assignable instanceof Group ? $assignable->members : [$assignable];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* GET /api/v1/folders walks folders ordered by (updated_at, id), like every
|
||||
* list endpoint — see App\Modules\Api\Support\PollingQuery. Same reasoning
|
||||
* as the files index: without it, every poll is a filesort over the table.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('folders', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
+50
-4
@@ -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.
|
||||
|
||||
|
||||
+718
-1
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Files\Models\FolderAssignment;
|
||||
use App\Modules\Identity\Permissions\Permission;
|
||||
use App\Modules\Identity\Permissions\SystemRole;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake('files');
|
||||
$this->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);
|
||||
});
|
||||
Reference in New Issue
Block a user