13 Commits

Author SHA1 Message Date
ignacionelson 7fcfbb5c41 Merge branch feat/api-folders
Folders in API v1: list, read, create, rename, move, delete and share
2026-10-03 02:46:22 -03:00
ignacionelson e9b71993f5 Document the folder endpoints
The OpenAPI document gains the seven folder operations; `ancestors` gets
an explicit type so the schema says what it holds rather than Scramble's
guess. The guide gets a Folders section, the folder abilities, the
idempotent create under "Retries", and public folders under "Not in v1".

The abilities table was split in two by a blank line, with the groups row
left under the paragraph after it; both are back in the table.
2026-10-02 23:46:09 -03:00
ignacionelson 33bc90c9ef Test the folder API: scope, trails, placement, the delete guard and sharing 2026-10-02 23:46:09 -03:00
ignacionelson 70dc725858 Folders in the API: list, read, create, rename, move, delete and share
An integration could put a file into a folder by id but could not see,
make or arrange the folders themselves, so mirroring a directory tree
into ProjectSend was impossible over the API. The hosted AI connector
already creates, lists and shares folders.

GET /folders polls like every list (updated_since, cursor) and filters
on parent_id, top_level and search. Each folder carries its ancestors
and a display path, trimmed for a client-scoped token to the folders it
may see (BreadcrumbBuilder::visible's rule), worked out for a whole page
in two queries by FolderTrails.

POST /folders returns an existing folder of the same name in the same
place with a 200 rather than making a second one, so a retried request
is safe. PATCH renames and moves. DELETE refuses a non-empty folder with
409 unless content_action=cascade_delete is sent, and then asks
UndeletableFiles exactly as the web does. Sharing goes through
FolderSharing.

Every write uses the web's policy, scope and FolderService, and asks
Folder::uploadableBy for every parent it writes, creation included.

Public state stays web-only: the resource reports `public`, nothing here
changes it. A file's `folder` now carries `parent_id` as well.
2026-10-02 23:46:09 -03:00
ignacionelson a5b6538b31 Give folder sharing and the folder-delete guard one definition each
Sharing a folder was four steps written in the web controller: the
assignment row, the activity entry, the in-app notification and the
digest email. The hosted edition's AI connector repeated them, because
there was nothing in the core to call, and the two copies had already
drifted (one re-notifies on a repeated share, the other does not). The
folder API about to land would have been a third copy.

FolderSharing is the folder twin of FileSharing, and the web controller
now calls it. Behaviour on the web is unchanged.

The count of files a staff member may not delete inside a folder's
subtree moves out of FoldersController into UndeletableFiles, for the
same reason: deleting a folder over the API has to ask exactly the
question the web screen asks before the cascade takes files with it.
2026-10-02 23:46:09 -03:00
ignacionelson 48a1c9f227 Trim the staff breadcrumb to the library's reach, and ask before nesting into a public folder
Two edges of the staff folder screens, found while the folder API was
built to answer the same questions.

A client-scoped staff member can hold one of their clients' folders that
sits inside somebody else's tree. The breadcrumb above it named every
folder on the way up, including ones their library does not show them.
It now starts at the first folder they can reach, as the client portal's
already does (BreadcrumbBuilder::visible). Unscoped staff see the whole
trail as before.

Creating a folder did not ask Folder::uploadableBy for its parent, though
every other write of a parent_id does: a folder inside a public one is
public. Files were already refused there by the upload check, so what
this closes is an empty folder's name appearing on a public page without
upload_public. Staff holding upload_public, or creating inside a private
folder, are unaffected.
2026-10-02 23:45:51 -03:00
Ignacio Nelson 185c46fff1 Merge pull request #1807 from projectsend/feat/package-styling-hooks
Let an installed package restyle the staff area and supply its own browser icons
2026-10-02 15:44:52 -03:00
ignacionelson a8adf6f614 Show a custom logo larger again on the sign-in pages
The sign-in, password reset, setup and share-link pages drew a custom logo
in a box 80 pixels tall, up from 48 in 2.6.0. Tested on 2.6.0, a square
logo still read as small on both phone and desktop. The box is now 128
pixels tall and up to 320 wide. The card is 384 wide, so a wide logo still
fits a phone. ProjectSend's own logo is unchanged.

The Branding → Logo hint states the new size. Its existing translations
are carried over with only the numbers changed, rather than left to fall
back to English.

Reported by @jiits (#1798)
2026-10-01 16:48:53 -03:00
ignacionelson f9e08412f2 Still log a failing health check in the production image
#1804 dropped every /up request from the nginx access log, so the
container's health checks stopped flooding `docker logs`. That also hid
the failing ones: when the container goes unhealthy, the 5xx from /up is
the line someone looks for, and Docker's health status alone does not say
why.

Key the map on the status as well as the path, so only a 2xx /up is
dropped. Verified against the 2.6.0 image: a 503 /up logs, a 200 /up does
not, and a 200 /upload still logs.
2026-10-01 15:24:08 -03:00
ignacionelson 9c26d46374 Merge pull request #1804 from 01110111000001/feat/quieter-logs
Quieter logs in docker container
2026-10-01 15:23:37 -03:00
ignacionelson 60c82afe5a Let an installed package restyle the staff area and supply its own browser icons
Core imports any stylesheet a package ships under resources/css after its
own app.css, and marks the pieces worth restyling with data attributes:
the staff shell (data-surface="staff"), the header, cards, buttons with
their variant, list toolbars, table frames and the default logo marks.
The layout takes its icons from projectsend.icons when a package names
some, replacing the defaults as a set.

Core names no package and no style. With nothing installed that ships a
stylesheet or icons, nothing renders differently.
2026-09-29 17:51:47 -03:00
01110111000001 f24a8587b9 feat: disable php-fpm access logs 2026-09-27 03:19:19 +02:00
01110111000001 a640bf81ed feat: ignore nginx logs on /up parh 2026-09-27 03:18:59 +02:00
46 changed files with 2087 additions and 117 deletions
@@ -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,
) {}
/**
@@ -252,7 +254,7 @@ class FoldersController extends Controller
return Inertia::render('files/index', [
'folder' => $current === null ? null : ['id' => $current->id, 'name' => $current->name],
'breadcrumb' => $flat ? [] : $this->breadcrumbs->for($current),
'breadcrumb' => $flat ? [] : $this->breadcrumb($user, $current),
'folders' => $folderRows->map(fn (Folder $folder): array => $this->folderRow($user, $folder))->all(),
'files' => $fileRows->map(fn (File $file): array => $this->fileRow($user, $file, $commentCounts, $pendingCounts, $versions))->all(),
'pagination' => Pagination::meta($sliced['paginator']),
@@ -426,7 +428,7 @@ class FoldersController extends Controller
'public_url' => $folder->public
? $this->publicUrl->for($folder)
: null,
'breadcrumb' => $this->breadcrumbs->for($folder),
'breadcrumb' => $this->breadcrumb($user, $folder),
'can_update' => Gate::forUser($user)->allows('update', $folder),
'can_manage_public' => $user->can('upload_public'),
...$this->shareTargets->forSubject($folder, $user),
@@ -450,6 +452,11 @@ class FoldersController extends Controller
$parent = $this->resolveParent($user, $validated['parent_id'] ?? null);
// A folder inside a public one is public, so creating it there is
// placing content into a public folder: the question every other
// write of a parent_id already asks (Folder::uploadableBy).
abort_unless(Folder::uploadableBy($user, $parent), 403);
$folder = $this->folders->create($validated['name'], $parent);
// Only a user who can manage public state may set it on create —
@@ -558,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(
@@ -588,47 +588,28 @@ class FoldersController extends Controller
}
/**
* How many files in this folder's subtree the viewer may not delete.
* The trail to $folder, trimmed for a client-scoped staff member to
* start at the first folder their library shows them: one of their
* clients' folders can sit inside somebody else's tree, and the names
* above it are not theirs to read. The client portal trims the same way.
*
* 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.
* @return list<array{id: int, name: string}>
*/
private function undeletableFileCount(User $viewer, Folder $folder): int
private function breadcrumb(User $user, ?Folder $folder): array
{
$mayDeleteOwn = $viewer->can('delete_files');
$mayDeleteOthers = $viewer->can('delete_others_files');
$scoped = $viewer->isClientScoped();
if ($mayDeleteOwn && $mayDeleteOthers && ! $scoped) {
return 0;
if ($folder === null || ! $user->isClientScoped()) {
return $this->breadcrumbs->for($folder);
}
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);
}
$visibleIds = array_values(array_map(
'intval',
$this->scope->folders($user)
->whereIn('folders.id', [...$folder->ancestorIds(), $folder->id])
->pluck('folders.id')
->all(),
));
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();
return $this->breadcrumbs->visible($folder, $visibleIds);
}
private function resolveParent(?User $user, ?int $parentId): ?Folder
@@ -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');
});
}
};
+12
View File
@@ -13,12 +13,24 @@
# as the alpine package's `nginx` (uid 100) and cannot read what
# php-fpm just wrote — see the comment on that line.
# The image's HEALTHCHECK hits /up every 30 seconds, which buried
# `docker logs` under two lines per check. Drop a passing check from the
# access log; a failing one (anything but a 2xx) still logs, because that
# is the line someone reads when the container goes unhealthy.
map "$request_uri:$status" $loggable {
~^/up:2 0;
default 1;
}
server {
listen 80 default_server;
server_name _;
root /var/www/html/public;
index index.php;
# Overrides the http-level access_log only to apply $loggable above.
access_log /dev/stdout main if=$loggable;
# Uploads arrive in chunks (Uppy resumable), so this caps a single
# chunk, not a file. Raising it does not raise the maximum file size.
client_max_body_size 100m;
+2
View File
@@ -24,3 +24,5 @@ group = www-data
catch_workers_output = yes
decorate_workers_output = no
access.log = /dev/null
+50 -4
View File
@@ -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
View File
@@ -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": {
+1 -1
View File
@@ -2280,7 +2280,7 @@
"None of the selected files could be deleted.": "No s'ha pogut eliminar cap dels fitxers seleccionats.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Només s'eliminen els fitxers que tens permís per eliminar. Deixaran d'estar disponibles per a tothom amb qui s'havien compartit.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "A les pàgines d'inici de sessió i de baixada. Deixa-ho desactivat si el teu logotip ja mostra el nom.",
"Shown up to 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Es mostra fins a 240 × 80 píxels, de manera que hi caben tant un logotip ample com un de quadrat. PNG, JPG, GIF o WebP, fins a 2 MB.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Es mostra fins a 320 × 128 píxels, de manera que hi caben tant un logotip ample com un de quadrat. PNG, JPG, GIF o WebP, fins a 2 MB.",
"Show the site name under the logo": "Mostra el nom del lloc sota el logotip",
"They will no longer be available to anyone they were shared with.": "Deixaran d'estar disponibles per a tothom amb qui s'havien compartit.",
"Uploading into :folder": "Pujant a :folder"
+1 -1
View File
@@ -2280,7 +2280,7 @@
"None of the selected files could be deleted.": "Žádný z vybraných souborů nebylo možné smazat.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Smažou se jen soubory, ke kterým máte oprávnění. Přestanou být dostupné všem, se kterými byly sdíleny.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "Na přihlašovací stránce a stránce stahování. Nechte vypnuté, pokud logo už název obsahuje.",
"Shown up to 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Zobrazuje se až v rozměru 240 × 80 pixelů, takže se vejde široké i čtvercové logo. PNG, JPG, GIF nebo WebP, do 2 MB.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Zobrazuje se až v rozměru 320 × 128 pixelů, takže se vejde široké i čtvercové logo. PNG, JPG, GIF nebo WebP, do 2 MB.",
"Show the site name under the logo": "Zobrazit název webu pod logem",
"They will no longer be available to anyone they were shared with.": "Přestanou být dostupné všem, se kterými byly sdíleny.",
"Uploading into :folder": "Nahrávání do: :folder"
+1 -1
View File
@@ -2280,7 +2280,7 @@
"None of the selected files could be deleted.": "Keine der ausgewählten Dateien konnte gelöscht werden.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Es werden nur die Dateien gelöscht, die Sie löschen dürfen. Sie stehen dann niemandem mehr zur Verfügung, mit dem sie geteilt wurden.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "Auf der Anmelde- und der Download-Seite. Lassen Sie es aus, wenn Ihr Logo den Namen bereits zeigt.",
"Shown up to 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Wird bis zu 240 × 80 Pixel groß angezeigt, sodass breite und quadratische Logos gleichermaßen passen. PNG, JPG, GIF oder WebP, bis 2 MB.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Wird bis zu 320 × 128 Pixel groß angezeigt, sodass breite und quadratische Logos gleichermaßen passen. PNG, JPG, GIF oder WebP, bis 2 MB.",
"Show the site name under the logo": "Seitennamen unter dem Logo anzeigen",
"They will no longer be available to anyone they were shared with.": "Sie stehen dann niemandem mehr zur Verfügung, mit dem sie geteilt wurden.",
"Uploading into :folder": "Hochladen in :folder"
+1 -1
View File
@@ -2280,7 +2280,7 @@
"None of the selected files could be deleted.": "No se pudo eliminar ninguno de los archivos seleccionados.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Solo se eliminan los archivos que tienes permiso para eliminar. Dejarán de estar disponibles para quienes fueron compartidos.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "En las páginas de inicio de sesión y de descarga. Déjalo desactivado si tu logo ya muestra el nombre.",
"Shown up to 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Se muestra hasta 240 × 80 píxeles, así que entran tanto un logo ancho como uno cuadrado. PNG, JPG, GIF o WebP, hasta 2 MB.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Se muestra hasta 320 × 128 píxeles, así que entran tanto un logo ancho como uno cuadrado. PNG, JPG, GIF o WebP, hasta 2 MB.",
"Show the site name under the logo": "Mostrar el nombre del sitio debajo del logo",
"They will no longer be available to anyone they were shared with.": "Dejarán de estar disponibles para quienes fueron compartidos.",
"Uploading into :folder": "Subiendo a :folder"
+1 -1
View File
@@ -2280,7 +2280,7 @@
"None of the selected files could be deleted.": "Aucun des fichiers sélectionnés n'a pu être supprimé.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Seuls les fichiers que vous avez le droit de supprimer sont supprimés. Ils ne seront plus accessibles aux personnes avec qui ils étaient partagés.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "Sur les pages de connexion et de téléchargement. Laissez désactivé si votre logo affiche déjà le nom.",
"Shown up to 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Affiché jusqu'à 240 × 80 pixels : un logo large comme un logo carré y trouvent leur place. PNG, JPG, GIF ou WebP, jusqu'à 2 Mo.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Affiché jusqu'à 320 × 128 pixels : un logo large comme un logo carré y trouvent leur place. PNG, JPG, GIF ou WebP, jusqu'à 2 Mo.",
"Show the site name under the logo": "Afficher le nom du site sous le logo",
"They will no longer be available to anyone they were shared with.": "Ils ne seront plus accessibles aux personnes avec qui ils étaient partagés.",
"Uploading into :folder": "Envoi dans :folder"
+1 -1
View File
@@ -2280,7 +2280,7 @@
"None of the selected files could be deleted.": "Tidak ada berkas terpilih yang dapat dihapus.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Hanya berkas yang boleh Anda hapus yang akan dihapus. Berkas tersebut tidak akan tersedia lagi bagi siapa pun yang sebelumnya menerimanya.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "Di halaman masuk dan halaman unduhan. Biarkan nonaktif jika logo Anda sudah memuat nama.",
"Shown up to 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Ditampilkan hingga 240 × 80 piksel, jadi logo lebar maupun persegi sama-sama muat. PNG, JPG, GIF, atau WebP, hingga 2 MB.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Ditampilkan hingga 320 × 128 piksel, jadi logo lebar maupun persegi sama-sama muat. PNG, JPG, GIF, atau WebP, hingga 2 MB.",
"Show the site name under the logo": "Tampilkan nama situs di bawah logo",
"They will no longer be available to anyone they were shared with.": "Berkas tersebut tidak akan tersedia lagi bagi siapa pun yang sebelumnya menerimanya.",
"Uploading into :folder": "Mengunggah ke :folder"
+1 -1
View File
@@ -2280,7 +2280,7 @@
"None of the selected files could be deleted.": "Non è stato possibile eliminare nessuno dei file selezionati.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Vengono eliminati solo i file che hai il permesso di eliminare. Non saranno più disponibili per le persone con cui erano condivisi.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "Nelle pagine di accesso e di download. Lascialo disattivato se il tuo logo mostra già il nome.",
"Shown up to 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Mostrato fino a 240 × 80 pixel, così ci stanno sia un logo largo sia uno quadrato. PNG, JPG, GIF o WebP, fino a 2 MB.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Mostrato fino a 320 × 128 pixel, così ci stanno sia un logo largo sia uno quadrato. PNG, JPG, GIF o WebP, fino a 2 MB.",
"Show the site name under the logo": "Mostra il nome del sito sotto il logo",
"They will no longer be available to anyone they were shared with.": "Non saranno più disponibili per le persone con cui erano condivisi.",
"Uploading into :folder": "Caricamento in :folder"
+1 -1
View File
@@ -2280,7 +2280,7 @@
"None of the selected files could be deleted.": "選択したファイルはどれも削除できませんでした。",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "削除する権限のあるファイルだけが削除されます。共有していた相手全員から利用できなくなります。",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "サインインページとダウンロードページに表示されます。ロゴに名前が入っている場合はオフのままにしてください。",
"Shown up to 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "最大 240 × 80 ピクセルで表示されるため、横長のロゴも正方形のロゴも収まります。PNG、JPG、GIF、WebP、2 MB まで。",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "最大 320 × 128 ピクセルで表示されるため、横長のロゴも正方形のロゴも収まります。PNG、JPG、GIF、WebP、2 MB まで。",
"Show the site name under the logo": "ロゴの下にサイト名を表示する",
"They will no longer be available to anyone they were shared with.": "共有していた相手全員から利用できなくなります。",
"Uploading into :folder": "アップロード先: :folder"
+1 -1
View File
@@ -2280,7 +2280,7 @@
"None of the selected files could be deleted.": "Geen van de geselecteerde bestanden kon worden verwijderd.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Alleen de bestanden die je mag verwijderen worden verwijderd. Ze zijn niet langer beschikbaar voor iedereen met wie ze gedeeld waren.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "Op de inlog- en downloadpagina's. Laat het uit als je logo de naam al toont.",
"Shown up to 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Getoond tot 240 × 80 pixels, dus zowel een breed als een vierkant logo past. PNG, JPG, GIF of WebP, tot 2 MB.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Getoond tot 320 × 128 pixels, dus zowel een breed als een vierkant logo past. PNG, JPG, GIF of WebP, tot 2 MB.",
"Show the site name under the logo": "Sitenaam onder het logo tonen",
"They will no longer be available to anyone they were shared with.": "Ze zijn niet langer beschikbaar voor iedereen met wie ze gedeeld waren.",
"Uploading into :folder": "Uploaden naar :folder"
+1 -1
View File
@@ -2280,7 +2280,7 @@
"None of the selected files could be deleted.": "Nie udało się usunąć żadnego z zaznaczonych plików.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Usuwane są tylko pliki, które możesz usunąć. Przestaną być dostępne dla wszystkich, którym je udostępniono.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "Na stronach logowania i pobierania. Zostaw wyłączone, jeśli logo już zawiera nazwę.",
"Shown up to 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Wyświetlane w rozmiarze do 240 × 80 pikseli, więc zmieści się zarówno szerokie, jak i kwadratowe logo. PNG, JPG, GIF lub WebP, do 2 MB.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Wyświetlane w rozmiarze do 320 × 128 pikseli, więc zmieści się zarówno szerokie, jak i kwadratowe logo. PNG, JPG, GIF lub WebP, do 2 MB.",
"Show the site name under the logo": "Pokaż nazwę witryny pod logo",
"They will no longer be available to anyone they were shared with.": "Przestaną być dostępne dla wszystkich, którym je udostępniono.",
"Uploading into :folder": "Przesyłanie do: :folder"
+1 -1
View File
@@ -2280,7 +2280,7 @@
"None of the selected files could be deleted.": "Não foi possível excluir nenhum dos arquivos selecionados.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Só são excluídos os arquivos que você tem permissão para excluir. Eles deixarão de estar disponíveis para todos com quem foram compartilhados.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "Nas páginas de login e de download. Deixe desativado se o seu logo já mostra o nome.",
"Shown up to 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Exibido em até 240 × 80 pixels, então cabem tanto um logo largo quanto um quadrado. PNG, JPG, GIF ou WebP, até 2 MB.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Exibido em até 320 × 128 pixels, então cabem tanto um logo largo quanto um quadrado. PNG, JPG, GIF ou WebP, até 2 MB.",
"Show the site name under the logo": "Mostrar o nome do site abaixo do logo",
"They will no longer be available to anyone they were shared with.": "Eles deixarão de estar disponíveis para todos com quem foram compartilhados.",
"Uploading into :folder": "Enviando para :folder"
+1 -1
View File
@@ -2280,7 +2280,7 @@
"None of the selected files could be deleted.": "Ни один из выбранных файлов не удалось удалить.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Удаляются только файлы, которые вам разрешено удалять. Они больше не будут доступны никому, кому были открыты.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "На страницах входа и загрузки. Оставьте выключенным, если на логотипе уже есть название.",
"Shown up to 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Показывается размером до 240 × 80 пикселей, поэтому поместится и широкий, и квадратный логотип. PNG, JPG, GIF или WebP, до 2 МБ.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Показывается размером до 320 × 128 пикселей, поэтому поместится и широкий, и квадратный логотип. PNG, JPG, GIF или WebP, до 2 МБ.",
"Show the site name under the logo": "Показывать название сайта под логотипом",
"They will no longer be available to anyone they were shared with.": "Они больше не будут доступны никому, кому были открыты.",
"Uploading into :folder": "Загрузка в: :folder"
+1 -1
View File
@@ -2280,7 +2280,7 @@
"None of the selected files could be deleted.": "Hakuna faili lililochaguliwa lililoweza kufutwa.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Mafaili unayoruhusiwa kufuta pekee ndiyo yanayofutwa. Hayatapatikana tena kwa yeyote aliyeshirikishwa nayo.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "Kwenye ukurasa wa kuingia na ukurasa wa kupakua. Iache imezimwa ikiwa nembo yako tayari ina jina.",
"Shown up to 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Huonyeshwa hadi pikseli 240 × 80, kwa hivyo nembo pana na ya mraba zote zinatosha. PNG, JPG, GIF au WebP, hadi MB 2.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Huonyeshwa hadi pikseli 320 × 128, kwa hivyo nembo pana na ya mraba zote zinatosha. PNG, JPG, GIF au WebP, hadi MB 2.",
"Show the site name under the logo": "Onyesha jina la tovuti chini ya nembo",
"They will no longer be available to anyone they were shared with.": "Hayatapatikana tena kwa yeyote aliyeshirikishwa nayo.",
"Uploading into :folder": "Inapakia kwenye :folder"
+1 -1
View File
@@ -2280,7 +2280,7 @@
"None of the selected files could be deleted.": "Seçili dosyaların hiçbiri silinemedi.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Yalnızca silme izniniz olan dosyalar silinir. Paylaşıldıkları hiç kimse için artık erişilebilir olmayacaklar.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "Giriş ve indirme sayfalarında. Logonuz adı zaten gösteriyorsa kapalı bırakın.",
"Shown up to 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "En fazla 240 × 80 piksel olarak gösterilir; böylece hem geniş hem kare logolar sığar. PNG, JPG, GIF veya WebP, en fazla 2 MB.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "En fazla 320 × 128 piksel olarak gösterilir; böylece hem geniş hem kare logolar sığar. PNG, JPG, GIF veya WebP, en fazla 2 MB.",
"Show the site name under the logo": "Site adını logonun altında göster",
"They will no longer be available to anyone they were shared with.": "Paylaşıldıkları hiç kimse için artık erişilebilir olmayacaklar.",
"Uploading into :folder": "Yükleme hedefi: :folder"
+1 -1
View File
@@ -2280,7 +2280,7 @@
"None of the selected files could be deleted.": "Không xóa được tệp nào trong số các tệp đã chọn.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Chỉ những tệp bạn được phép xóa mới bị xóa. Chúng sẽ không còn khả dụng với bất kỳ ai từng được chia sẻ.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "Trên trang đăng nhập và trang tải xuống. Hãy để tắt nếu logo của bạn đã có tên.",
"Shown up to 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Hiển thị tối đa 240 × 80 pixel, nên cả logo ngang lẫn logo vuông đều vừa. PNG, JPG, GIF hoặc WebP, tối đa 2 MB.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Hiển thị tối đa 320 × 128 pixel, nên cả logo ngang lẫn logo vuông đều vừa. PNG, JPG, GIF hoặc WebP, tối đa 2 MB.",
"Show the site name under the logo": "Hiển thị tên trang dưới logo",
"They will no longer be available to anyone they were shared with.": "Chúng sẽ không còn khả dụng với bất kỳ ai từng được chia sẻ.",
"Uploading into :folder": "Đang tải lên vào :folder"
+1 -1
View File
@@ -2280,7 +2280,7 @@
"None of the selected files could be deleted.": "所选文件均无法删除。",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "只会删除你有权删除的文件。这些文件将不再对任何已共享的人开放。",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "显示在登录页和下载页上。如果你的标志已包含名称,请保持关闭。",
"Shown up to 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "最大按 240 × 80 像素显示,宽标志和方形标志都能放下。PNG、JPG、GIF 或 WebP,最大 2 MB。",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "最大按 320 × 128 像素显示,宽标志和方形标志都能放下。PNG、JPG、GIF 或 WebP,最大 2 MB。",
"Show the site name under the logo": "在标志下方显示站点名称",
"They will no longer be available to anyone they were shared with.": "这些文件将不再对任何已共享的人开放。",
"Uploading into :folder": "上传到 :folder"
+5
View File
@@ -1,5 +1,10 @@
import '../css/app.css';
// Stylesheets an installed package ships under resources/css, the styling
// counterpart of the package pages resolved below. Imported after app.css so
// a package can restyle what core draws; core names no package and no style.
import.meta.glob('../../vendor/*/*/resources/css/*.css', { eager: true });
import { createInertiaApp, router } from '@inertiajs/react';
import axios from 'axios';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
+3 -1
View File
@@ -7,7 +7,9 @@ export default function AppLogoIcon(props: SVGAttributes<SVGElement>) {
const gradientId = useId();
return (
<svg {...props} viewBox="0 0 234.26482 252.25172" xmlns="http://www.w3.org/2000/svg">
// Marked so an installed package can restyle the default mark; only
// drawn when no logo was uploaded in Branding, which always wins.
<svg data-slot="app-logo-default" {...props} viewBox="0 0 234.26482 252.25172" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id={gradientId} gradientUnits="userSpaceOnUse" x1="31.5263" y1="211.0108" x2="197.4637" y2="50.766899">
<stop offset="0.1675" stopColor="#5219B3" />
+6 -2
View File
@@ -18,11 +18,15 @@ export function AppShell({ children, variant = 'header' }: AppShellProps) {
};
if (variant === 'header') {
return <div className="flex min-h-screen w-full flex-col">{children}</div>;
return (
<div data-surface="staff" className="flex min-h-screen w-full flex-col">
{children}
</div>
);
}
return (
<SidebarProvider defaultOpen={isOpen} open={isOpen} onOpenChange={handleSidebarChange}>
<SidebarProvider data-surface="staff" defaultOpen={isOpen} open={isOpen} onOpenChange={handleSidebarChange}>
{children}
</SidebarProvider>
);
@@ -9,7 +9,10 @@ import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';
export function AppSidebarHeader({ breadcrumbs = [] }: { breadcrumbs?: BreadcrumbItemType[] }) {
return (
<header className="border-sidebar-border/50 flex h-16 shrink-0 items-center gap-2 border-b px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4">
<header
data-slot="app-header"
className="border-sidebar-border/50 flex h-16 shrink-0 items-center gap-2 border-b px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4"
>
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Breadcrumbs breadcrumbs={breadcrumbs} />
@@ -23,6 +23,7 @@ export function WidgetBox({ id, title, headerExtra, children }: { id: string; ti
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
data-slot="card"
className={`bg-card rounded-lg border p-4 ${isDragging ? 'z-10 opacity-50' : ''}`}
>
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
+1 -1
View File
@@ -12,7 +12,7 @@ export function ListToolbar({ children, showClear, onClear }: { children: ReactN
const { t } = useTranslation();
return (
<div className="mb-4 flex flex-wrap items-end gap-3 rounded-lg border p-4">
<div data-slot="list-toolbar" className="mb-4 flex flex-wrap items-end gap-3 rounded-lg border p-4">
{children}
{showClear && (
<Button type="button" variant="ghost" onClick={onClear}>
+3 -1
View File
@@ -9,7 +9,9 @@ export default function ProjectSendLogo(props: SVGAttributes<SVGElement>) {
const gradientId = useId();
return (
<svg {...props} viewBox="0 0 1046.5 257" xmlns="http://www.w3.org/2000/svg">
// Marked so an installed package can restyle the default wordmark;
// only drawn when no logo was uploaded in Branding.
<svg data-slot="app-wordmark-default" {...props} viewBox="0 0 1046.5 257" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id={gradientId} gradientUnits="userSpaceOnUse" x1="31.5263" y1="211.0108" x2="197.4637" y2="50.766899">
<stop offset="0.1675" stopColor="#5219B3" />
+1 -1
View File
@@ -24,7 +24,7 @@ interface TableShellProps {
*/
export function TableShell({ columns, emptyMessage, isEmpty, children }: TableShellProps) {
return (
<div className="overflow-x-auto rounded-lg border">
<div data-slot="table-shell" className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b text-left">
+1 -1
View File
@@ -36,7 +36,7 @@ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElemen
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
return <Comp data-slot="button" data-variant={variant ?? 'default'} className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
});
Button.displayName = 'Button';
+1 -1
View File
@@ -3,7 +3,7 @@ import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('rounded-lg border bg-card text-card-foreground shadow-xs', className)} {...props} />
<div ref={ref} data-slot="card" className={cn('rounded-lg border bg-card text-card-foreground shadow-xs', className)} {...props} />
));
Card.displayName = 'Card';
@@ -35,12 +35,13 @@ export default function AuthSimpleLayout({ children, title, description }: AuthL
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-4">
<Link href={route('home')} className="flex flex-col items-center gap-2 font-medium">
{/* A box rather than a height: 80px tall and up to
240px wide, so a square logo is shown at a size
that reads (it was 48px) and a wide one still
{/* A box rather than a height: 128px tall and up
to 320px wide, so a square logo is shown at a
size that reads (48px, then 80px, were both
reported as too small) and a wide one still
fits a phone. */}
{branding?.logo_url ? (
<img src={branding.logo_url} alt={name} className="mb-1 h-20 w-auto max-w-60 object-contain" />
<img src={branding.logo_url} alt={name} className="mb-1 h-32 w-auto max-w-80 object-contain" />
) : (
<ProjectSendLogo className="text-foreground mb-1 h-12 w-auto" />
)}
+1 -1
View File
@@ -204,7 +204,7 @@ export default function BrandingEdit({ logo_url, hide_attribution, show_site_nam
onChange={(e) => uploadForm.setData('logo', e.target.files?.[0] ?? null)}
/>
<p className="text-muted-foreground text-sm">
{t('Shown up to 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.')}
{t('Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.')}
</p>
<InputError message={uploadForm.errors.logo} />
<Button type="submit" disabled={uploadForm.processing || uploadForm.data.logo === null}>
+10 -3
View File
@@ -29,9 +29,16 @@
there is nothing on the client that could work the name out. --}}
<meta name="xsrf-cookie" content="{{ \App\Http\Middleware\ValidateCsrfToken::cookieName() }}">
<link rel="icon" href="/favicon.ico" sizes="48x48">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
{{-- An installed package may name its own icons in
projectsend.icons; they then replace these as a set, so a
stray default never outranks one of them in some browser. --}}
@forelse (config('projectsend.icons', []) as $icon)
<link rel="{{ $icon['rel'] }}" href="{{ $icon['href'] }}"@isset($icon['type']) type="{{ $icon['type'] }}"@endisset @isset($icon['sizes']) sizes="{{ $icon['sizes'] }}"@endisset>
@empty
<link rel="icon" href="/favicon.ico" sizes="48x48">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
@endforelse
@routes
@viteReactRefresh
+36
View File
@@ -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
+355
View File
@@ -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);
});
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Files\Models\Folder;
use App\Modules\Identity\Permissions\SystemRole;
use Inertia\Testing\AssertableInertia;
/**
* Two edges of the staff folder screens that the folder API made visible,
* because the API had to answer the same questions and answered them more
* strictly.
*/
beforeEach(function () {
$this->admin = User::factory()->create();
});
/*
* A client-scoped staff member can hold a client's folder that sits inside
* somebody else's tree. The trail above it named every folder on the way,
* including the ones their library does not show them. The client portal
* already trims the same trail (BreadcrumbBuilder::visible).
*/
test('a client-scoped staff member is not told the names of folders above their reach', function () {
$client = User::factory()->client()->create();
$manager = User::factory()->role(SystemRole::ClientManager)->create();
$manager->assignedClients()->sync([$client->id]);
$secret = makeFolder('Board minutes');
$acme = makeFolder('Acme', $secret);
$year = makeFolder('2026', $acme);
$this->actingAs($this->admin)->post("/folders/{$acme->id}/assignments", ['type' => 'client', 'id' => $client->id]);
$this->actingAs($manager)->get("/files?folder={$year->id}")
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page->where('breadcrumb', [
['id' => $acme->id, 'name' => 'Acme'],
['id' => $year->id, 'name' => '2026'],
]));
$this->actingAs($manager)->get("/folders/{$acme->id}")
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page->where('breadcrumb', [
['id' => $acme->id, 'name' => 'Acme'],
]));
});
test('an unscoped staff member still sees the whole trail', function () {
$secret = makeFolder('Board minutes');
$acme = makeFolder('Acme', $secret);
$this->actingAs($this->admin)->get("/files?folder={$acme->id}")
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page->where('breadcrumb', [
['id' => $secret->id, 'name' => 'Board minutes'],
['id' => $acme->id, 'name' => 'Acme'],
]));
});
/*
* A folder inside a public folder is public, so creating one there is
* placing something into a public folder — the question
* Folder::uploadableBy answers for every other write of a parent_id.
* Creation was the one write that did not ask it.
*/
test('a folder cannot be created inside a public folder without permission to publish', function () {
$public = makeFolder('Press kit');
$public->update(['public' => true]);
$staff = staffWithPermissions(['create_own_folders', 'upload', 'edit_others_files']);
$this->actingAs($staff)->post('/folders', ['name' => 'Drafts', 'parent_id' => $public->id])->assertForbidden();
expect(Folder::query()->where('name', 'Drafts')->exists())->toBeFalse();
});
test('with upload_public, creating inside a public folder still works', function () {
$public = makeFolder('Press kit');
$public->update(['public' => true]);
$staff = staffWithPermissions(['create_own_folders', 'upload', 'edit_others_files', 'upload_public']);
$this->actingAs($staff)->post('/folders', ['name' => 'Drafts', 'parent_id' => $public->id])->assertRedirect();
expect(Folder::query()->where('name', 'Drafts')->value('parent_id'))->toBe($public->id);
});
test('creating inside a private folder needs nothing extra', function () {
$private = makeFolder('Internal');
$staff = staffWithPermissions(['create_own_folders', 'upload', 'edit_others_files']);
$this->actingAs($staff)->post('/folders', ['name' => 'Drafts', 'parent_id' => $private->id])->assertRedirect();
expect(Folder::query()->where('name', 'Drafts')->value('parent_id'))->toBe($private->id);
});