22 Commits

Author SHA1 Message Date
ignacionelson 7a1aa4021b Update the dependencies behind the open security alerts
Composer, each package alone, nothing else in the lock moved:
laravel/framework 12.64.0 -> 12.69.3, league/commonmark 2.10.0 -> 2.10.3,
league/flysystem 3.35.2 -> 3.36.0, phpseclib/phpseclib 3.0.56 -> 3.0.57.
composer audit reports nothing.

npm, within the ranges package.json already allows: axios 1.19.0 ->
1.20.0, and brace-expansion 1.1.18 -> 1.1.21 and 2.1.4 -> 2.1.7 (both
dev-only, under minimatch). No new dependencies or install scripts, and
each lockfile integrity matches the registry. npm audit --omit=dev
reports nothing.
2026-10-04 04:19:11 -03:00
ignacionelson 5ed5719135 Merge branch feat/logo-crop
Crop the logo, keep the upload, and restore it
2026-10-03 23:37:28 -03:00
ignacionelson d3230a4b64 Say what edit_clients and edit_users reach, and document the new refusals
Setting a password and removing a second factor are how an
administrator lets a locked-out person back in, so a token holding
edit_clients or edit_users can sign in as the accounts it may edit. That
stays what those abilities mean; it is now said where it is chosen. The
token form warns when either is ticked, and the API guide says it beside
the abilities, with the three refusals on your own account under "Staff
accounts". The OpenAPI document carries the new 403s, and CHANGELOG.md
an Unreleased entry.

GHSA-j5cp-r8pr-m5cr
2026-10-03 23:09:03 -03:00
ignacionelson 2da341b821 Test that your own credentials stay behind your profile, and resets end tokens
GHSA-j5cp-r8pr-m5cr
2026-10-03 23:05:23 -03:00
ignacionelson db65731c3a Keep your own credentials behind your profile, and end tokens on a reset
Your own email address, password and second factor are changed from your
profile, which asks for your current password. The staff screen and the
API changed the first two with no password at all, and the API removed
the third on your own account without the confirmation the web asks
for. StaffAccounts::ownCredentialChanges is the one rule both now ask:
the staff screen refuses your own email or password with a validation
error and points to the profile, and the API answers 403, as it does for
removing your own second factor.

Changing somebody else's password is unchanged: that is what edit_users
and edit_clients mean, on the screen and over the API. It now also
revokes that account's API tokens. Browser sessions already ended with
the password hash; tokens did not.

GHSA-j5cp-r8pr-m5cr
2026-10-03 23:05:23 -03:00
ignacionelson 2d8562abba Keep a tall logo inside the crop dialog
react-image-crop's stylesheet gives the image max-height: inherit, so the
limit set on the image was overridden: a portrait logo ran past the
dialog and its bottom handles could not be reached. The limit now sits on
the crop wrapper, and the scrolling container is gone.
2026-10-03 12:10:42 -03:00
ignacionelson bbd424a8d1 Crop the logo from the Branding screen, and restore the original
A Crop button opens the uploaded image with a free-shape box, starting
from the last crop; Restore original appears once there is one. The box
is sent in the upload's own pixels and the server cuts the file. The
image is shown with image-orientation: none, the pixels as the server
reads them, since no image here has the exif extension to rotate by an
orientation tag.

Adds react-image-crop 11.1.2: no dependencies, no install scripts, and
its lockfile integrity matches the registry.
2026-10-03 12:07:26 -03:00
ignacionelson ac1093dc8c Test the logo crop: which pixels it keeps, restoring, cleanup and its limits 2026-10-03 12:03:51 -03:00
ignacionelson 4485e36c5c Crop the logo on the server, from the kept upload, and restore it
Cropping is optional: an upload is used whole until somebody crops it.
A crop is a new file cut from the upload with SimpleImage, which the
watermark already uses; the upload is kept (logo_original_path) with
the box (logo_crop), so cropping again starts from the whole picture and
restoring points back at the upload. A box covering the whole image is
a restore. The box must lie inside the image, and an image over 25
million pixels is refused before GD decodes it.

A new upload, or removing the logo, deletes both files.
2026-10-03 12:02:41 -03:00
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
64 changed files with 3178 additions and 196 deletions
+16
View File
@@ -10,6 +10,22 @@ Anything under **⚠️ Important — do these yourself** is something you have
we did. It sits at the top of a release for that reason. Older entries call the same section we did. It sits at the top of a release for that reason. Older entries call the same section
**Upgrade notes**. **Upgrade notes**.
## Unreleased
**Fixed**
- **An API token can no longer change its own owner's password, email address or second factor,
and neither can the staff screen without your current password.** Reported by
[@simjiun](https://github.com/simjiun).
*Who this affected:* installations where someone holds an API token with "Manage users" and
"Edit users". Such a token could give its own owner a new password, remove their second factor,
and then sign in as them with everything they can do, including abilities the token was never
given. The same staff screen also let a signed-in administrator change their own email address
or password without the current password the profile asks for. Your own credentials are now
changed only from your profile. Setting *someone else's* password now also revokes their API
tokens. Nothing to do on upgrade.
## 2.6.0 — 25 September 2026 ## 2.6.0 — 25 September 2026
Mostly fixes: files and folders are easier to tidy, the sign-in pages carry your brand better, and Mostly fixes: files and folders are easier to tidy, the sign-in pages carry your brand better, and
@@ -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; namespace App\Modules\Files\Http\Controllers;
use App\Http\Controllers\Controller; 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\Access\StaffLibraryScope;
use App\Modules\Files\Http\Controllers\Concerns\ResolvesShareTargets; use App\Modules\Files\Http\Controllers\Concerns\ResolvesShareTargets;
use App\Modules\Files\Models\Folder; use App\Modules\Files\Models\Folder;
use App\Modules\Files\Models\FolderAssignment; use App\Modules\Files\Sharing\FolderSharing;
use App\Modules\Notifications\NotificationDigester;
use App\Modules\Notifications\Notifier;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
/** /**
* Sharing a folder with a client or group grants live access to its * 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 class FolderAssignmentsController extends Controller
{ {
use ResolvesShareTargets; use ResolvesShareTargets;
public function __construct( public function __construct(
private readonly ActivityLogger $activity,
private readonly StaffLibraryScope $scope, private readonly StaffLibraryScope $scope,
private readonly NotificationDigester $digester, private readonly FolderSharing $sharing,
private readonly Notifier $notifier,
) {} ) {}
public function store(Request $request, Folder $folder): RedirectResponse 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.'), __('Folders can only be shared with clients or groups.'),
); );
FolderAssignment::query()->firstOrCreate([ $this->sharing->assign($folder, $assignable, $targetName);
'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]);
return back(); return back();
} }
@@ -68,15 +50,7 @@ class FolderAssignmentsController extends Controller
__('Folders can only be shared with clients or groups.'), __('Folders can only be shared with clients or groups.'),
); );
$deleted = FolderAssignment::query() $this->sharing->unassign($folder, $assignable, $targetName);
->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]);
}
return back(); return back();
} }
@@ -16,6 +16,7 @@ use App\Modules\Files\Access\ShareTargets;
use App\Modules\Files\Access\StaffLibraryScope; use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Folders\BreadcrumbBuilder; use App\Modules\Files\Folders\BreadcrumbBuilder;
use App\Modules\Files\Folders\FolderService; use App\Modules\Files\Folders\FolderService;
use App\Modules\Files\Folders\UndeletableFiles;
use App\Modules\Files\Models\Category; use App\Modules\Files\Models\Category;
use App\Modules\Files\Models\File; use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\NotScannedReason; use App\Modules\Files\Scanning\NotScannedReason;
@@ -65,6 +66,7 @@ class FoldersController extends Controller
private readonly VisibleCommentScope $comments, private readonly VisibleCommentScope $comments,
private readonly FileVersionLinks $versionLinks, private readonly FileVersionLinks $versionLinks,
private readonly DownloadAllowance $allowance, private readonly DownloadAllowance $allowance,
private readonly UndeletableFiles $undeletable,
) {} ) {}
/** /**
@@ -252,7 +254,7 @@ class FoldersController extends Controller
return Inertia::render('files/index', [ return Inertia::render('files/index', [
'folder' => $current === null ? null : ['id' => $current->id, 'name' => $current->name], '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(), '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(), 'files' => $fileRows->map(fn (File $file): array => $this->fileRow($user, $file, $commentCounts, $pendingCounts, $versions))->all(),
'pagination' => Pagination::meta($sliced['paginator']), 'pagination' => Pagination::meta($sliced['paginator']),
@@ -426,7 +428,7 @@ class FoldersController extends Controller
'public_url' => $folder->public 'public_url' => $folder->public
? $this->publicUrl->for($folder) ? $this->publicUrl->for($folder)
: null, : null,
'breadcrumb' => $this->breadcrumbs->for($folder), 'breadcrumb' => $this->breadcrumb($user, $folder),
'can_update' => Gate::forUser($user)->allows('update', $folder), 'can_update' => Gate::forUser($user)->allows('update', $folder),
'can_manage_public' => $user->can('upload_public'), 'can_manage_public' => $user->can('upload_public'),
...$this->shareTargets->forSubject($folder, $user), ...$this->shareTargets->forSubject($folder, $user),
@@ -450,6 +452,11 @@ class FoldersController extends Controller
$parent = $this->resolveParent($user, $validated['parent_id'] ?? null); $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); $folder = $this->folders->create($validated['name'], $parent);
// Only a user who can manage public state may set it on create — // Only a user who can manage public state may set it on create —
@@ -558,16 +565,9 @@ class FoldersController extends Controller
$viewer = $request->user(); $viewer = $request->user();
assert($viewer !== null); assert($viewer !== null);
// Deleting a folder cascades to every file in its subtree, and a // Authorizing the folder is not authorizing the files the cascade
// File's `deleted` hook removes the bytes from disk — there is no // takes with it — see UndeletableFiles, which the API asks too.
// restore. Authorizing the folder is not authorizing its contents: $blocked = $this->undeletable->count($viewer, $folder);
// 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);
if ($blocked > 0) { if ($blocked > 0) {
return back()->with('error', trans_choice( 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 * @return list<array{id: int, name: string}>
* can hold thousands, Gate resolves a fresh policy for every check, and
* a per-row policy check on a listing is the cost 0a8b609e went to
* some trouble to remove. The two halves of FilePolicy::delete are
* expressible in SQL — the permission half is constant for this
* viewer, and the library half is the query StaffLibraryScope already
* memoises per request.
*
* Somebody holding both delete permissions and no library scope can
* delete anything in the subtree by construction, so they never pay for
* the query at all.
*/ */
private function undeletableFileCount(User $viewer, Folder $folder): int private function breadcrumb(User $user, ?Folder $folder): array
{ {
$mayDeleteOwn = $viewer->can('delete_files'); if ($folder === null || ! $user->isClientScoped()) {
$mayDeleteOthers = $viewer->can('delete_others_files'); return $this->breadcrumbs->for($folder);
$scoped = $viewer->isClientScoped();
if ($mayDeleteOwn && $mayDeleteOthers && ! $scoped) {
return 0;
} }
return File::query() $visibleIds = array_values(array_map(
->whereIn('folder_id', $folder->subtreeFolderIds()) 'intval',
->where(function (Builder $outer) use ($viewer, $mayDeleteOwn, $mayDeleteOthers, $scoped): void { $this->scope->folders($user)
if (! $mayDeleteOwn) { ->whereIn('folders.id', [...$folder->ancestorIds(), $folder->id])
$outer->orWhere('uploaded_by', $viewer->id); ->pluck('folders.id')
} ->all(),
));
if (! $mayDeleteOthers) { return $this->breadcrumbs->visible($folder, $visibleIds);
$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();
} }
private function resolveParent(?User $user, ?int $parentId): ?Folder private function resolveParent(?User $user, ?int $parentId): ?Folder
@@ -129,9 +129,11 @@ class FileResource extends JsonResource
'name' => $this->nextVersion->name, '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 : [ 'folder' => $this->whenLoaded('folder', fn (): ?array => $this->folder === null ? null : [
'id' => $this->folder->id, 'id' => $this->folder->id,
'name' => $this->folder->name, 'name' => $this->folder->name,
'parent_id' => $this->folder->parent_id,
]), ]),
// Name only. The uploader is a user record; their email address // 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];
}
}
@@ -160,6 +160,11 @@ class UsersController extends Controller
* *
* Refused with a 422 if the change would leave the installation with * Refused with a 422 if the change would leave the installation with
* no active administrator, or if you would be deactivating yourself. * no active administrator, or if you would be deactivating yourself.
*
* Your own email address and password cannot be changed here: that is
* a `403`. Change them from your profile in the web interface, which
* asks for your current password. Setting somebody else's password
* signs them out of the API: every token they hold is revoked.
*/ */
public function update(Request $request, User $user): StaffUserResource public function update(Request $request, User $user): StaffUserResource
{ {
@@ -182,6 +187,15 @@ class UsersController extends Controller
'assigned_clients.*' => ['integer', Rule::in($this->accounts->assignableClientIds($actor))], 'assigned_clients.*' => ['integer', Rule::in($this->accounts->assignableClientIds($actor))],
]); ]);
// Your own email address and password are changed from your
// profile, which asks for your current password. A token cannot be
// asked for one, so here the answer is no.
abort_if(
$this->accounts->ownCredentialChanges($actor, $user, $validated['email'] ?? null, $validated['password'] ?? null) !== [],
403,
__('Change your own email address and password from your profile.'),
);
// Read through Request::boolean() rather than off the validated // Read through Request::boolean() rather than off the validated
// array, for the reason RolesController::guardScopeRemoval spells // array, for the reason RolesController::guardScopeRemoval spells
// out: the `boolean` rule accepts 0 and "0" as well as false but // out: the `boolean` rule accepts 0 and "0" as well as false but
@@ -269,12 +283,22 @@ class UsersController extends Controller
* The account holder is emailed that this happened, and the action is * The account holder is emailed that this happened, and the action is
* recorded in the activity log against the caller. Answers 204 whether * recorded in the activity log against the caller. Answers 204 whether
* or not a second factor was actually in force. * or not a second factor was actually in force.
*
* Not for your own account, which is a `403`: remove your own second
* factor from your profile in the web interface.
*/ */
public function destroyTwoFactor(Request $request, User $user, TwoFactorAdministration $twoFactor): JsonResponse public function destroyTwoFactor(Request $request, User $user, TwoFactorAdministration $twoFactor): JsonResponse
{ {
abort_unless($user->isStaff(), 404); abort_unless($user->isStaff(), 404);
$this->accounts->guardTarget($this->actor($request), $user); $actor = $this->actor($request);
$this->accounts->guardTarget($actor, $user);
// The web asks for your password before this (password.confirm),
// and a token cannot give one. On your own account it would let a
// token clear the second factor standing between it and a browser
// session as you.
abort_if($user->is($actor), 403, __('Remove your own two-factor authentication from your profile.'));
$twoFactor->reset($user); $twoFactor->reset($user);
@@ -226,6 +226,23 @@ class UsersController extends Controller
]); ]);
} }
// Your own email address and password are changed from your
// profile, which asks for your current password first; this screen
// does not, so it does not change them.
$ownCredentials = $this->accounts->ownCredentialChanges(
$this->actor(),
$user,
$validated['email'],
is_string($validated['password'] ?? null) ? $validated['password'] : null,
);
if ($ownCredentials !== []) {
throw ValidationException::withMessages(array_fill_keys(
$ownCredentials,
__('Change your own email address and password from your profile.'),
));
}
$this->accounts->update($user, [ $this->accounts->update($user, [
'name' => $validated['name'], 'name' => $validated['name'],
'email' => $validated['email'], 'email' => $validated['email'],
+49 -1
View File
@@ -161,6 +161,42 @@ class StaffAccounts
abort_unless($role === null || $this->mayGrant($actor, $role), 403); abort_unless($role === null || $this->mayGrant($actor, $role), 403);
} }
/**
* Which of your own credentials this change would replace: a different
* email address, or a new password. Empty when the target is somebody
* else, or when nothing that signs the account in is changing.
*
* Your own are changed from your profile, which asks for your current
* password first (GHSA-f32x-fgmp-q353). The staff screen and the API
* must not be a second door to them. Over the API that door was wider
* still: a token limited to manage_users and edit_users could give its
* own owner a password it chose, and then sign in as the owner with
* every ability the token had been denied.
*
* The email address counts because it is how a password is recovered:
* an address you control is a password you can set.
*
* @return list<'email'|'password'>
*/
public function ownCredentialChanges(User $actor, User $target, ?string $email, ?string $password): array
{
if (! $target->is($actor)) {
return [];
}
$fields = [];
if ($email !== null && mb_strtolower($email) !== mb_strtolower($target->email)) {
$fields[] = 'email';
}
if ($password !== null && $password !== '') {
$fields[] = 'password';
}
return $fields;
}
/** /**
* Refuse any change that would leave the installation without an * Refuse any change that would leave the installation without an
* active administrator. * active administrator.
@@ -297,12 +333,24 @@ class StaffAccounts
$user->fill(array_intersect_key($attributes, array_flip(['name', 'email', 'role_id', 'active']))); $user->fill(array_intersect_key($attributes, array_flip(['name', 'email', 'role_id', 'active'])));
if (is_string($attributes['password'] ?? null) && $attributes['password'] !== '') { $passwordReplaced = is_string($attributes['password'] ?? null) && $attributes['password'] !== '';
if ($passwordReplaced) {
$user->password = $attributes['password']; $user->password = $attributes['password'];
} }
$user->save(); $user->save();
// Somebody else gave this account a new password: whatever had been
// holding it, a person or a stolen credential, is ended with it.
// Browser sessions end on their own (AuthenticateSession reads the
// password hash), but API tokens do not, and a reset that left the
// previous holder's token working would not be a reset. Never your
// own password: both callers refuse that (ownCredentialChanges).
if ($passwordReplaced) {
$user->tokens()->delete();
}
if ($assignedClients !== null) { if ($assignedClients !== null) {
$this->syncAssignedClients($user, (int) $user->role_id, $assignedClients); $this->syncAssignedClients($user, (int) $user->role_id, $assignedClients);
} elseif ($oldRoleId !== $user->role_id) { } elseif ($oldRoleId !== $user->role_id) {
@@ -15,6 +15,7 @@ use Illuminate\Support\Str;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
use App\Modules\Platform\Branding\LogoCropper;
use App\Modules\Platform\Branding\Models\BrandingSetting; use App\Modules\Platform\Branding\Models\BrandingSetting;
use App\Modules\Platform\Branding\Watermark\WatermarkPosition; use App\Modules\Platform\Branding\Watermark\WatermarkPosition;
use App\Modules\Platform\Branding\Watermark\WatermarkSample; use App\Modules\Platform\Branding\Watermark\WatermarkSample;
@@ -41,6 +42,12 @@ class BrandingController extends Controller
return Inertia::render('branding/edit', [ return Inertia::render('branding/edit', [
'logo_url' => $setting->logoUrl(), 'logo_url' => $setting->logoUrl(),
// The upload the logo was cut from, and the box it was cut
// with, so the cropper opens on the whole picture with the
// last crop already drawn.
'logo_source_url' => $setting->logoSourceUrl(),
'logo_crop' => $setting->logo_crop,
'logo_cropped' => $setting->logoIsCropped(),
// Read, never written here. Hiding attribution is the // Read, never written here. Hiding attribution is the
// white-label half and stays a hosted feature: the switch is // white-label half and stays a hosted feature: the switch is
// rendered only where Capability::AttributionHide is held, and // rendered only where Capability::AttributionHide is held, and
@@ -60,7 +67,7 @@ class BrandingController extends Controller
]); ]);
} }
public function store(Request $request): RedirectResponse public function store(Request $request, LogoCropper $cropper): RedirectResponse
{ {
$validated = $request->validate([ $validated = $request->validate([
'logo' => ['required', 'image', 'max:2048'], 'logo' => ['required', 'image', 'max:2048'],
@@ -70,16 +77,64 @@ class BrandingController extends Controller
$upload = $validated['logo']; $upload = $validated['logo'];
$setting = BrandingSetting::current(); $setting = BrandingSetting::current();
$previous = $cropper->files($setting);
if ($setting->logo_path !== null) { // A new upload starts uncropped: the old crop's box describes a
Storage::disk('public')->delete($setting->logo_path); // different picture.
} $setting->update([
'logo_path' => $this->storeImage($upload),
'logo_original_path' => null,
'logo_crop' => null,
]);
$setting->update(['logo_path' => $this->storeImage($upload)]); Storage::disk('public')->delete($previous);
return back()->with('success', __('Logo updated.')); return back()->with('success', __('Logo updated.'));
} }
/**
* Cut the logo down to a box drawn on the uploaded image. Optional: an
* uploaded logo is used whole until somebody crops it.
*/
public function cropLogo(Request $request, LogoCropper $cropper): RedirectResponse
{
$validated = $request->validate([
'x' => ['required', 'integer', 'min:0'],
'y' => ['required', 'integer', 'min:0'],
'width' => ['required', 'integer', 'min:1'],
'height' => ['required', 'integer', 'min:1'],
]);
$setting = BrandingSetting::query()->first();
if ($setting === null) {
return back()->withErrors(['logo' => __('Upload a logo before cropping it.')]);
}
$cropper->crop($setting, [
'x' => (int) $validated['x'],
'y' => (int) $validated['y'],
'width' => (int) $validated['width'],
'height' => (int) $validated['height'],
]);
return back()->with('success', __('Logo cropped.'));
}
/**
* Go back to the logo exactly as it was uploaded.
*/
public function restoreLogo(LogoCropper $cropper): RedirectResponse
{
$setting = BrandingSetting::query()->first();
if ($setting !== null) {
$cropper->restore($setting);
}
return back()->with('success', __('Original logo restored.'));
}
/** /**
* Whether the sign-in and download pages print the site name under the * Whether the sign-in and download pages print the site name under the
* logo. Its own action rather than a field on the logo upload, because * logo. Its own action rather than a field on the logo upload, because
@@ -97,13 +152,14 @@ class BrandingController extends Controller
return back(); return back();
} }
public function destroy(): RedirectResponse public function destroy(LogoCropper $cropper): RedirectResponse
{ {
$setting = BrandingSetting::query()->first(); $setting = BrandingSetting::query()->first();
if ($setting?->logo_path !== null) { if ($setting !== null && $setting->logo_path !== null) {
Storage::disk('public')->delete($setting->logo_path); $files = $cropper->files($setting);
$setting->update(['logo_path' => null]); $setting->update(['logo_path' => null, 'logo_original_path' => null, 'logo_crop' => null]);
Storage::disk('public')->delete($files);
} }
return back()->with('success', __('Logo removed.')); return back()->with('success', __('Logo removed.'));
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Branding;
use App\Modules\Platform\Branding\Models\BrandingSetting;
use claviska\SimpleImage;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
/**
* Cutting the logo down to part of itself, and putting the whole of it back.
*
* The uploaded file is never changed. A crop is a new file written from it,
* so cropping again starts from everything that was uploaded rather than
* from the last crop, and restoring is pointing back at the upload.
*
* Coordinates are in the upload's stored pixels, which is what GD reads. The
* cropper shows the image the same way (`image-orientation: none`), because
* the images here have no exif extension to rotate a phone photo by its
* orientation tag: if the browser rotated it and GD did not, the box would
* land on the wrong part of the picture.
*/
class LogoCropper
{
/**
* GD holds four bytes a pixel, and a 2 MB file can still describe a very
* large image if it compresses well. 25 million pixels is about 100 MB of
* memory, and far beyond any logo.
*/
public const MAX_PIXELS = 25_000_000;
/**
* @param array{x: int, y: int, width: int, height: int} $box
*/
public function crop(BrandingSetting $setting, array $box): void
{
$source = $setting->logoSourcePath();
$disk = Storage::disk('public');
if ($source === null || ! $disk->exists($source)) {
throw ValidationException::withMessages(['logo' => __('Upload a logo before cropping it.')]);
}
$absolute = $disk->path($source);
$size = @getimagesize($absolute);
if ($size === false) {
throw ValidationException::withMessages(['logo' => __('This logo cannot be cropped. Upload it again.')]);
}
[$width, $height] = $size;
if ($width * $height > self::MAX_PIXELS) {
throw ValidationException::withMessages(['logo' => __('This image is too large to crop. Upload a smaller one.')]);
}
if ($box['x'] + $box['width'] > $width || $box['y'] + $box['height'] > $height) {
throw ValidationException::withMessages(['width' => __('The crop must stay inside the image.')]);
}
// The whole picture is the upload itself: no second copy of it.
if ($box['x'] === 0 && $box['y'] === 0 && $box['width'] === $width && $box['height'] === $height) {
$this->restore($setting);
return;
}
// Same extension as the upload, which took it from the content
// when it was stored (see BrandingController::storeImage).
$cropped = 'branding/'.Str::uuid().'.'.pathinfo($source, PATHINFO_EXTENSION);
(new SimpleImage($absolute))
->crop($box['x'], $box['y'], $box['x'] + $box['width'], $box['y'] + $box['height'])
->toFile($disk->path($cropped), $size['mime']);
$previous = $setting->logoIsCropped() ? $setting->logo_path : null;
$setting->update([
'logo_original_path' => $source,
'logo_path' => $cropped,
'logo_crop' => $box,
]);
if ($previous !== null) {
$disk->delete($previous);
}
}
public function restore(BrandingSetting $setting): void
{
if (! $setting->logoIsCropped()) {
return;
}
$cropped = $setting->logo_path;
$setting->update([
'logo_path' => $setting->logo_original_path,
'logo_original_path' => null,
'logo_crop' => null,
]);
if ($cropped !== null) {
Storage::disk('public')->delete($cropped);
}
}
/**
* Every file the logo occupies: the one shown, and the upload behind it.
*
* @return list<string>
*/
public function files(BrandingSetting $setting): array
{
return array_values(array_filter([$setting->logo_path, $setting->logo_original_path]));
}
}
@@ -14,6 +14,8 @@ use App\Modules\Platform\Branding\Watermark\WatermarkPosition;
* *
* @property int $id * @property int $id
* @property string|null $logo_path * @property string|null $logo_path
* @property string|null $logo_original_path
* @property array{x: int, y: int, width: int, height: int}|null $logo_crop
* @property bool $watermark_enabled * @property bool $watermark_enabled
* @property string|null $watermark_path * @property string|null $watermark_path
* @property WatermarkPosition $watermark_position * @property WatermarkPosition $watermark_position
@@ -33,6 +35,7 @@ class BrandingSetting extends Model
protected $casts = [ protected $casts = [
'watermark_enabled' => 'boolean', 'watermark_enabled' => 'boolean',
'show_site_name' => 'boolean', 'show_site_name' => 'boolean',
'logo_crop' => 'array',
'watermark_position' => WatermarkPosition::class, 'watermark_position' => WatermarkPosition::class,
'watermark_size' => 'integer', 'watermark_size' => 'integer',
'watermark_opacity' => 'integer', 'watermark_opacity' => 'integer',
@@ -65,6 +68,28 @@ class BrandingSetting extends Model
return $this->logo_path === null ? null : Storage::disk('public')->url($this->logo_path); return $this->logo_path === null ? null : Storage::disk('public')->url($this->logo_path);
} }
/**
* The file the logo was cut from: the upload itself when it was never
* cropped, since then `logo_path` is that upload. A crop always starts
* from here, never from a previous crop.
*/
public function logoSourcePath(): ?string
{
return $this->logo_original_path ?? $this->logo_path;
}
public function logoSourceUrl(): ?string
{
$path = $this->logoSourcePath();
return $path === null ? null : Storage::disk('public')->url($path);
}
public function logoIsCropped(): bool
{
return $this->logo_original_path !== null;
}
public function watermarkUrl(): ?string public function watermarkUrl(): ?string
{ {
return $this->watermark_path === null ? null : Storage::disk('public')->url($this->watermark_path); return $this->watermark_path === null ? null : Storage::disk('public')->url($this->watermark_path);
Generated
+22 -22
View File
@@ -2210,16 +2210,16 @@
}, },
{ {
"name": "laravel/framework", "name": "laravel/framework",
"version": "v12.64.0", "version": "v12.69.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/framework.git", "url": "https://github.com/laravel/framework.git",
"reference": "727a8ea2949c23ca8b5316b86a00984b6017b7a0" "reference": "58ea544a2a80dc03c168e13a5dc9a1d176a88717"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/727a8ea2949c23ca8b5316b86a00984b6017b7a0", "url": "https://api.github.com/repos/laravel/framework/zipball/58ea544a2a80dc03c168e13a5dc9a1d176a88717",
"reference": "727a8ea2949c23ca8b5316b86a00984b6017b7a0", "reference": "58ea544a2a80dc03c168e13a5dc9a1d176a88717",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2428,7 +2428,7 @@
"issues": "https://github.com/laravel/framework/issues", "issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework" "source": "https://github.com/laravel/framework"
}, },
"time": "2026-07-14T14:25:37+00:00" "time": "2026-09-29T12:53:25+00:00"
}, },
{ {
"name": "laravel/prompts", "name": "laravel/prompts",
@@ -2811,16 +2811,16 @@
}, },
{ {
"name": "league/commonmark", "name": "league/commonmark",
"version": "2.10.0", "version": "2.10.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/thephpleague/commonmark.git", "url": "https://github.com/thephpleague/commonmark.git",
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4" "reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4", "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f",
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4", "reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2914,7 +2914,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-08-11T16:06:25+00:00" "time": "2026-09-21T13:07:34+00:00"
}, },
{ {
"name": "league/config", "name": "league/config",
@@ -3000,16 +3000,16 @@
}, },
{ {
"name": "league/flysystem", "name": "league/flysystem",
"version": "3.35.2", "version": "3.36.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/thephpleague/flysystem.git", "url": "https://github.com/thephpleague/flysystem.git",
"reference": "b277b5dc3d56650b68904117124e79c851e12376" "reference": "f7fb152932f30072d573510cbd4dd657d6475b25"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376", "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25",
"reference": "b277b5dc3d56650b68904117124e79c851e12376", "reference": "f7fb152932f30072d573510cbd4dd657d6475b25",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -3077,9 +3077,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/thephpleague/flysystem/issues", "issues": "https://github.com/thephpleague/flysystem/issues",
"source": "https://github.com/thephpleague/flysystem/tree/3.35.2" "source": "https://github.com/thephpleague/flysystem/tree/3.36.0"
}, },
"time": "2026-07-06T14:42:07+00:00" "time": "2026-09-02T08:00:27+00:00"
}, },
{ {
"name": "league/flysystem-aws-s3-v3", "name": "league/flysystem-aws-s3-v3",
@@ -4379,16 +4379,16 @@
}, },
{ {
"name": "phpseclib/phpseclib", "name": "phpseclib/phpseclib",
"version": "3.0.56", "version": "3.0.57",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/phpseclib/phpseclib.git", "url": "https://github.com/phpseclib/phpseclib.git",
"reference": "7adbbe38cde25e2df2116dbf2673c407e24fa305" "reference": "d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/7adbbe38cde25e2df2116dbf2673c407e24fa305", "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4",
"reference": "7adbbe38cde25e2df2116dbf2673c407e24fa305", "reference": "d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4469,7 +4469,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/phpseclib/phpseclib/issues", "issues": "https://github.com/phpseclib/phpseclib/issues",
"source": "https://github.com/phpseclib/phpseclib/tree/3.0.56" "source": "https://github.com/phpseclib/phpseclib/tree/3.0.57"
}, },
"funding": [ "funding": [
{ {
@@ -4485,7 +4485,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-08-03T04:36:50+00:00" "time": "2026-08-26T12:13:21+00:00"
}, },
{ {
"name": "phpstan/phpdoc-parser", "name": "phpstan/phpdoc-parser",
@@ -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');
});
}
};
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Cropping the logo keeps the uploaded file, so the crop can be redone or
* undone. Both columns are null for a logo that was never cropped, which is
* every logo that exists when this runs: `logo_path` keeps meaning "the
* image to show", and nothing about an existing installation changes.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('branding_settings', function (Blueprint $table) {
// The uploaded file, while `logo_path` points at a crop of it.
$table->string('logo_original_path')->nullable()->after('logo_path');
// The box that crop was cut with, in the original's pixels:
// {x, y, width, height}. So the cropper reopens where it was.
$table->json('logo_crop')->nullable()->after('logo_original_path');
});
}
public function down(): void
{
Schema::table('branding_settings', function (Blueprint $table) {
$table->dropColumn(['logo_original_path', 'logo_crop']);
});
}
};
+12
View File
@@ -13,12 +13,24 @@
# as the alpine package's `nginx` (uid 100) and cannot read what # as the alpine package's `nginx` (uid 100) and cannot read what
# php-fpm just wrote — see the comment on that line. # 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 { server {
listen 80 default_server; listen 80 default_server;
server_name _; server_name _;
root /var/www/html/public; root /var/www/html/public;
index index.php; 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 # Uploads arrive in chunks (Uppy resumable), so this caps a single
# chunk, not a file. Raising it does not raise the maximum file size. # chunk, not a file. Raising it does not raise the maximum file size.
client_max_body_size 100m; client_max_body_size 100m;
+2
View File
@@ -24,3 +24,5 @@ group = www-data
catch_workers_output = yes catch_workers_output = yes
decorate_workers_output = no decorate_workers_output = no
access.log = /dev/null
+65 -7
View File
@@ -80,18 +80,22 @@ list for your account.
| `upload` | list files, upload | | `upload` | list files, upload |
| `edit_files` / `edit_others_files` | read and edit file metadata, share files | | `edit_files` / `edit_others_files` | read and edit file metadata, share files |
| `delete_files` / `delete_others_files` | delete 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_expiration_date` | set `expires_at` when editing |
| `set_file_categories` | set `categories` when editing | | `set_file_categories` | set `categories` when editing |
| `limit_downloads` | set `download_limit` and `download_limit_scope` when editing | | `limit_downloads` | set `download_limit` and `download_limit_scope` when editing |
| `upload_public` | set `public` when editing | | `upload_public` | set `public` when editing |
| `upload` / `edit_files` / `edit_others_files` | read and write a file's comments | | `upload` / `edit_files` / `edit_others_files` | read and write a file's comments |
| `manage_clients` | list clients | | `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 | | `create_clients` / `edit_clients` / `delete_clients` | create, read and edit, delete clients; `edit_clients` also sets a client's password and removes their two-factor authentication |
| `manage_groups` | list groups | | `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 | | `moderate_comments` | list what is awaiting approval, and approve it |
| `manage_users` | list staff accounts and the roles you may assign | | `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 | | `create_users` / `edit_users` / `delete_users` | create, read and edit, delete staff accounts; `edit_users` also sets an account's password and removes its two-factor authentication |
There is no ability for *writing* a comment. Who may comment is an installation setting rather than There is no ability for *writing* a comment. Who may comment is an installation setting rather than
a per-role permission, so the file abilities are the gate — the same question the web asks, which is a per-role permission, so the file abilities are the gate — the same question the web asks, which is
@@ -99,10 +103,17 @@ 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; 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 it additionally requires the token's owner to hold `moderate_comments`, checked live against the
account rather than carried by the token. account rather than carried by the token.
| `create_groups` / `edit_groups` / `delete_groups` | create, read and edit (including membership), delete groups |
**`edit_clients` and `edit_users` are control of the accounts they reach.** Setting a password and
removing a second factor are what an administrator does for somebody who is locked out, so a token
holding either ability can sign in as any client, or any staff account below its owner, that it may
edit, and do what that account can do. Give these abilities to a token only when you would trust its
holder with those accounts themselves. Your *own* credentials are never reachable this way (see
"Staff accounts").
Where an endpoint accepts several — `edit_files` *or* `edit_others_files` — holding either is enough, 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. Every operation in the OpenAPI document names its own requirement.
@@ -359,6 +370,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 ## Staff accounts
`/users` manages the people who administer the installation, and the role assigned to each of them. `/users` manages the people who administer the installation, and the role assigned to each of them.
@@ -373,7 +424,7 @@ Two abilities are needed for each call: `manage_users` to reach the area at all,
action (`create_users`, `edit_users`, `delete_users`). That mirrors the web UI, where the whole action (`create_users`, `edit_users`, `delete_users`). That mirrors the web UI, where the whole
section sits behind `manage_users` and each button behind its own key. section sits behind `manage_users` and each button behind its own key.
### Two rules that will refuse you ### Three rules that will refuse you
**You cannot hand out authority you do not hold.** `role_id` must name a role you could grant **You cannot hand out authority you do not hold.** `role_id` must name a role you could grant
yourself: a caller who is not an administrator may not create one, nor assign any role carrying a yourself: a caller who is not an administrator may not create one, nor assign any role carrying a
@@ -384,6 +435,11 @@ guess an id.
**The installation always keeps an active administrator.** Demoting, deactivating or deleting the **The installation always keeps an active administrator.** Demoting, deactivating or deleting the
last one is a `422`. So is deactivating or deleting yourself, from either surface. last one is a `422`. So is deactivating or deleting yourself, from either surface.
**Your own credentials stay behind your profile.** Changing your own email address or password, or
removing your own second factor, is a `403`. Do it from your profile in the web interface, which
asks for your current password; a token cannot be asked for one. Setting *somebody else's* password
revokes every token they hold.
### Changing a role ### Changing a role
The assigned role is a field on the account, so `PATCH /users/{user}` with `role_id` is the whole The assigned role is a field on the account, so `PATCH /users/{user}` with `role_id` is the whole
@@ -443,7 +499,8 @@ sign-in — this un-sticks an account, it does not exempt one.
## Retries and duplicate requests ## 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, `POST` that actually succeeded the first time creates a second one. Until idempotency keys exist,
check before retrying a create you are unsure about. check before retrying a create you are unsure about.
@@ -506,6 +563,7 @@ Recorded so they read as decisions rather than gaps:
- **Webhooks.** Poll instead; see above. - **Webhooks.** Poll instead; see above.
- **Idempotency keys.** See "Retries" above. - **Idempotency keys.** See "Retries" above.
- **Share links, notifications, thumbnails, settings.** - **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 - **Creating and deleting roles.** `GET /roles` reads them and `role_id` assigns one; defining a
role's permission set stays in the UI. role's permission set stays in the UI.
+764 -3
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": { "/groups/{group}/members": {
"post": { "post": {
"operationId": "groups.members.store", "operationId": "groups.members.store",
@@ -3416,7 +4034,7 @@
}, },
"patch": { "patch": {
"operationId": "users.update", "operationId": "users.update",
"description": "PATCH semantics: an absent key means \"leave alone\", not \"clear\".\nSending `assigned_clients` replaces the whole list; omitting it\nleaves it, except that moving to a role which is not client-scoped\nclears it either way.\n\nRefused with a 422 if the change would leave the installation with\nno active administrator, or if you would be deactivating yourself.\n\nRequires a token with the ability: `edit_users`.", "description": "PATCH semantics: an absent key means \"leave alone\", not \"clear\".\nSending `assigned_clients` replaces the whole list; omitting it\nleaves it, except that moving to a role which is not client-scoped\nclears it either way.\n\nRefused with a 422 if the change would leave the installation with\nno active administrator, or if you would be deactivating yourself.\n\nYour own email address and password cannot be changed here: that is\na `403`. Change them from your profile in the web interface, which\nasks for your current password. Setting somebody else's password\nsigns them out of the API: every token they hold is revoked.\n\nRequires a token with the ability: `edit_users`.",
"summary": "Update a staff account, including the role assigned to it", "summary": "Update a staff account, including the role assigned to it",
"tags": [ "tags": [
"Users" "Users"
@@ -3490,6 +4108,28 @@
} }
} }
}, },
"403": {
"description": "An error",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "Error overview.",
"examples": [
"Change your own email address and password from your profile."
]
}
},
"required": [
"message"
]
}
}
}
},
"404": { "404": {
"$ref": "#/components/responses/ModelNotFoundException" "$ref": "#/components/responses/ModelNotFoundException"
}, },
@@ -3543,7 +4183,7 @@
"/users/{user}/two-factor": { "/users/{user}/two-factor": {
"delete": { "delete": {
"operationId": "users.two-factor.destroy", "operationId": "users.two-factor.destroy",
"description": "The remedy for a locked-out account: somebody whose authenticator\napp and recovery codes are both gone cannot sign in, and nobody else\ncan open the account for them either. Afterwards the account signs\nin with its password alone, and \u2014 if this installation enforces\ntwo-factor authentication for staff \u2014 is asked to enrol again on its\nnext request.\n\nThe account holder is emailed that this happened, and the action is\nrecorded in the activity log against the caller. Answers 204 whether\nor not a second factor was actually in force.\n\nRequires a token with the ability: `edit_users`.", "description": "The remedy for a locked-out account: somebody whose authenticator\napp and recovery codes are both gone cannot sign in, and nobody else\ncan open the account for them either. Afterwards the account signs\nin with its password alone, and \u2014 if this installation enforces\ntwo-factor authentication for staff \u2014 is asked to enrol again on its\nnext request.\n\nThe account holder is emailed that this happened, and the action is\nrecorded in the activity log against the caller. Answers 204 whether\nor not a second factor was actually in force.\n\nNot for your own account, which is a `403`: remove your own second\nfactor from your profile in the web interface.\n\nRequires a token with the ability: `edit_users`.",
"summary": "Remove a staff account's two-factor authentication", "summary": "Remove a staff account's two-factor authentication",
"tags": [ "tags": [
"Users" "Users"
@@ -3571,6 +4211,28 @@
} }
} }
}, },
"403": {
"description": "An error",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "Error overview.",
"examples": [
"Remove your own two-factor authentication from your profile."
]
}
},
"required": [
"message"
]
}
}
}
},
"404": { "404": {
"$ref": "#/components/responses/ModelNotFoundException" "$ref": "#/components/responses/ModelNotFoundException"
}, },
@@ -4189,17 +4851,25 @@
"object", "object",
"null" "null"
], ],
"description": "GET /folders/{id} has the rest, its place in the tree included.",
"properties": { "properties": {
"id": { "id": {
"type": "integer" "type": "integer"
}, },
"name": { "name": {
"type": "string" "type": "string"
},
"parent_id": {
"type": [
"integer",
"null"
]
} }
}, },
"required": [ "required": [
"id", "id",
"name" "name",
"parent_id"
] ]
}, },
"uploaded_by": { "uploaded_by": {
@@ -4303,6 +4973,97 @@
], ],
"title": "FileResource" "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": { "GroupResource": {
"type": "object", "type": "object",
"properties": { "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.", "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.", "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.", "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", "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.", "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" "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.", "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.", "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.", "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", "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.", "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" "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.", "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.", "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.", "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", "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.", "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" "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.", "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.", "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.", "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", "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.", "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" "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é.", "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.", "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.", "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", "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.", "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" "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.", "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.", "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.", "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", "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.", "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" "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.", "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.", "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.", "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", "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.", "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" "Uploading into :folder": "Caricamento in :folder"
+1 -1
View File
@@ -2280,7 +2280,7 @@
"None of the selected files could be deleted.": "選択したファイルはどれも削除できませんでした。", "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.": "削除する権限のあるファイルだけが削除されます。共有していた相手全員から利用できなくなります。", "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.": "サインインページとダウンロードページに表示されます。ロゴに名前が入っている場合はオフのままにしてください。", "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": "ロゴの下にサイト名を表示する", "Show the site name under the logo": "ロゴの下にサイト名を表示する",
"They will no longer be available to anyone they were shared with.": "共有していた相手全員から利用できなくなります。", "They will no longer be available to anyone they were shared with.": "共有していた相手全員から利用できなくなります。",
"Uploading into :folder": "アップロード先: :folder" "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.", "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.", "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.", "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", "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.", "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" "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.", "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.", "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ę.", "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", "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.", "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" "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.", "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.", "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.", "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", "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.", "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" "Uploading into :folder": "Enviando para :folder"
+1 -1
View File
@@ -2280,7 +2280,7 @@
"None of the selected files could be deleted.": "Ни один из выбранных файлов не удалось удалить.", "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.": "Удаляются только файлы, которые вам разрешено удалять. Они больше не будут доступны никому, кому были открыты.", "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.": "На страницах входа и загрузки. Оставьте выключенным, если на логотипе уже есть название.", "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": "Показывать название сайта под логотипом", "Show the site name under the logo": "Показывать название сайта под логотипом",
"They will no longer be available to anyone they were shared with.": "Они больше не будут доступны никому, кому были открыты.", "They will no longer be available to anyone they were shared with.": "Они больше не будут доступны никому, кому были открыты.",
"Uploading into :folder": "Загрузка в: :folder" "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.", "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.", "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.", "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", "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.", "They will no longer be available to anyone they were shared with.": "Hayatapatikana tena kwa yeyote aliyeshirikishwa nayo.",
"Uploading into :folder": "Inapakia kwenye :folder" "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.", "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.", "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.", "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", "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.", "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" "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.", "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ẻ.", "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.", "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", "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ẻ.", "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" "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.": "所选文件均无法删除。", "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.": "只会删除你有权删除的文件。这些文件将不再对任何已共享的人开放。", "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.": "显示在登录页和下载页上。如果你的标志已包含名称,请保持关闭。", "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": "在标志下方显示站点名称", "Show the site name under the logo": "在标志下方显示站点名称",
"They will no longer be available to anyone they were shared with.": "这些文件将不再对任何已共享的人开放。", "They will no longer be available to anyone they were shared with.": "这些文件将不再对任何已共享的人开放。",
"Uploading into :folder": "上传到 :folder" "Uploading into :folder": "上传到 :folder"
+19 -9
View File
@@ -47,6 +47,7 @@
"lucide-react": "^0.475.0", "lucide-react": "^0.475.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-image-crop": "^11.1.2",
"recharts": "^3.10.1", "recharts": "^3.10.1",
"tailwind-merge": "^3.0.1", "tailwind-merge": "^3.0.1",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
@@ -3376,9 +3377,9 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.1.4", "version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -4027,9 +4028,9 @@
} }
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.19.0", "version": "1.20.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
"integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.16.0", "follow-redirects": "^1.16.0",
@@ -4058,9 +4059,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.18", "version": "1.1.21",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -7278,6 +7279,15 @@
"react": "^19.0.0" "react": "^19.0.0"
} }
}, },
"node_modules/react-image-crop": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/react-image-crop/-/react-image-crop-11.1.2.tgz",
"integrity": "sha512-+0Pc2fxpwKL4u4oLmdKBw8XSwUceFbXbKEHvFOlsl/MGB1OVNic4uBlAPmEHGXYgoJIq+b63xHbc/aJMG0AVkA==",
"license": "ISC",
"peerDependencies": {
"react": ">=16.13.1"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+1
View File
@@ -65,6 +65,7 @@
"lucide-react": "^0.475.0", "lucide-react": "^0.475.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-image-crop": "^11.1.2",
"recharts": "^3.10.1", "recharts": "^3.10.1",
"tailwind-merge": "^3.0.1", "tailwind-merge": "^3.0.1",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
+5
View File
@@ -1,5 +1,10 @@
import '../css/app.css'; 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 { createInertiaApp, router } from '@inertiajs/react';
import axios from 'axios'; import axios from 'axios';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
@@ -1,4 +1,5 @@
import InputError from '@/components/input-error'; import InputError from '@/components/input-error';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -129,6 +130,20 @@ export function ApiTokenForm({ values, setValue, errors, availableAbilities, max
})} })}
</div> </div>
{/* Both reach other people's sign-in: setting a password and
removing a second factor are how an administrator lets a
locked-out person back in, so a token holding either can
become the accounts it may edit. */}
{(values.abilities.includes('edit_clients') || values.abilities.includes('edit_users')) && (
<Alert>
<AlertDescription>
{t(
'This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.',
)}
</AlertDescription>
</Alert>
)}
<InputError message={errors.abilities ?? errors['abilities.0']} /> <InputError message={errors.abilities ?? errors['abilities.0']} />
</div> </div>
+3 -1
View File
@@ -7,7 +7,9 @@ export default function AppLogoIcon(props: SVGAttributes<SVGElement>) {
const gradientId = useId(); const gradientId = useId();
return ( 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> <defs>
<linearGradient id={gradientId} gradientUnits="userSpaceOnUse" x1="31.5263" y1="211.0108" x2="197.4637" y2="50.766899"> <linearGradient id={gradientId} gradientUnits="userSpaceOnUse" x1="31.5263" y1="211.0108" x2="197.4637" y2="50.766899">
<stop offset="0.1675" stopColor="#5219B3" /> <stop offset="0.1675" stopColor="#5219B3" />
+6 -2
View File
@@ -18,11 +18,15 @@ export function AppShell({ children, variant = 'header' }: AppShellProps) {
}; };
if (variant === 'header') { 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 ( return (
<SidebarProvider defaultOpen={isOpen} open={isOpen} onOpenChange={handleSidebarChange}> <SidebarProvider data-surface="staff" defaultOpen={isOpen} open={isOpen} onOpenChange={handleSidebarChange}>
{children} {children}
</SidebarProvider> </SidebarProvider>
); );
@@ -9,7 +9,10 @@ import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';
export function AppSidebarHeader({ breadcrumbs = [] }: { breadcrumbs?: BreadcrumbItemType[] }) { export function AppSidebarHeader({ breadcrumbs = [] }: { breadcrumbs?: BreadcrumbItemType[] }) {
return ( 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"> <div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" /> <SidebarTrigger className="-ml-1" />
<Breadcrumbs breadcrumbs={breadcrumbs} /> <Breadcrumbs breadcrumbs={breadcrumbs} />
@@ -0,0 +1,154 @@
import { router } from '@inertiajs/react';
import { useState } from 'react';
import ReactCrop, { type PercentCrop } from 'react-image-crop';
import 'react-image-crop/dist/ReactCrop.css';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useTranslation } from '@/hooks/use-translation';
export interface LogoCropBox {
x: number;
y: number;
width: number;
height: number;
}
interface LogoCropDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The uploaded image, never a previous crop of it. */
sourceUrl: string;
/** The last crop, in the upload's pixels, or null to start from the whole image. */
savedCrop: LogoCropBox | null;
}
const WHOLE: PercentCrop = { unit: '%', x: 0, y: 0, width: 100, height: 100 };
/**
* Draw a box on the uploaded logo and keep only that part of it.
*
* The box is held in percent while it is being drawn, so it survives the
* dialog resizing, and turned into the upload's own pixels only when it is
* saved. The server cuts the new file from the upload: nothing is cropped
* in the browser.
*
* `image-orientation: none` shows the stored pixels exactly as the server
* reads them. A browser would otherwise rotate a phone photo by its
* orientation tag, the server cannot, and the box would land on the wrong
* part of the picture.
*/
export default function LogoCropDialog({ open, onOpenChange, sourceUrl, savedCrop }: LogoCropDialogProps) {
const { t } = useTranslation();
const [crop, setCrop] = useState<PercentCrop>(WHOLE);
const [natural, setNatural] = useState<{ width: number; height: number } | null>(null);
const [error, setError] = useState<string | undefined>();
const [saving, setSaving] = useState(false);
const onImageLoad = (image: HTMLImageElement) => {
const width = image.naturalWidth;
const height = image.naturalHeight;
setNatural({ width, height });
setError(undefined);
setCrop(
savedCrop === null
? WHOLE
: {
unit: '%',
x: (savedCrop.x / width) * 100,
y: (savedCrop.y / height) * 100,
width: (savedCrop.width / width) * 100,
height: (savedCrop.height / height) * 100,
},
);
};
const toPixels = (box: PercentCrop, size: { width: number; height: number }): LogoCropBox => {
const x = Math.max(0, Math.round((box.x / 100) * size.width));
const y = Math.max(0, Math.round((box.y / 100) * size.height));
return {
x,
y,
width: Math.max(1, Math.min(size.width - x, Math.round((box.width / 100) * size.width))),
height: Math.max(1, Math.min(size.height - y, Math.round((box.height / 100) * size.height))),
};
};
const save = () => {
if (natural === null || crop.width === 0 || crop.height === 0) {
return;
}
setSaving(true);
router.patch(
route('branding.logo.crop'),
{ ...toPixels(crop, natural) },
{
preserveScroll: true,
onSuccess: () => onOpenChange(false),
onError: (errors) => setError(errors.logo ?? errors.width ?? errors.x ?? Object.values(errors)[0]),
onFinish: () => setSaving(false),
},
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t('Crop logo')}</DialogTitle>
<DialogDescription>
{t(
'Drag the box and its corners to choose the part of the image to show. The uploaded image is kept, so you can change this later.',
)}
</DialogDescription>
</DialogHeader>
<div className="bg-muted/40 flex justify-center rounded border p-2">
{/* The height limit goes on the crop wrapper: the library's
stylesheet gives the image `max-height: inherit`, so a
limit on the image itself is overridden, and a tall logo
would push the bottom handles out of reach. */}
<ReactCrop
crop={crop}
onChange={(_, percent) => setCrop(percent)}
keepSelection
minWidth={8}
minHeight={8}
style={{ maxHeight: '56vh' }}
>
<img src={sourceUrl} alt="" onLoad={(e) => onImageLoad(e.currentTarget)} style={{ imageOrientation: 'none' }} />
</ReactCrop>
</div>
{natural !== null && (
<p className="text-muted-foreground text-sm">
{(() => {
const box = toPixels(crop, natural);
return t(':width × :height pixels', { width: String(box.width), height: String(box.height) });
})()}
</p>
)}
<InputError message={error} />
<DialogFooter>
<Button variant="outline" onClick={() => setCrop(WHOLE)} disabled={saving}>
{t('Select all')}
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
{t('Cancel')}
</Button>
<Button onClick={save} disabled={saving || natural === null}>
{t('Save crop')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -23,6 +23,7 @@ export function WidgetBox({ id, title, headerExtra, children }: { id: string; ti
<div <div
ref={setNodeRef} ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }} style={{ transform: CSS.Transform.toString(transform), transition }}
data-slot="card"
className={`bg-card rounded-lg border p-4 ${isDragging ? 'z-10 opacity-50' : ''}`} 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"> <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(); const { t } = useTranslation();
return ( 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} {children}
{showClear && ( {showClear && (
<Button type="button" variant="ghost" onClick={onClear}> <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(); const gradientId = useId();
return ( 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> <defs>
<linearGradient id={gradientId} gradientUnits="userSpaceOnUse" x1="31.5263" y1="211.0108" x2="197.4637" y2="50.766899"> <linearGradient id={gradientId} gradientUnits="userSpaceOnUse" x1="31.5263" y1="211.0108" x2="197.4637" y2="50.766899">
<stop offset="0.1675" stopColor="#5219B3" /> <stop offset="0.1675" stopColor="#5219B3" />
+1 -1
View File
@@ -24,7 +24,7 @@ interface TableShellProps {
*/ */
export function TableShell({ columns, emptyMessage, isEmpty, children }: TableShellProps) { export function TableShell({ columns, emptyMessage, isEmpty, children }: TableShellProps) {
return ( 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"> <table className="w-full text-sm">
<thead> <thead>
<tr className="bg-muted/50 border-b text-left"> <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 Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'; 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'; Button.displayName = 'Button';
+1 -1
View File
@@ -3,7 +3,7 @@ import * as React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => ( 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'; Card.displayName = 'Card';
+56 -27
View File
@@ -1,10 +1,11 @@
import { Link } from '@inertiajs/react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import InputError from '@/components/input-error'; import InputError from '@/components/input-error';
import { PasswordRequirements } from '@/components/password-requirements';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { PasswordRequirements } from '@/components/password-requirements';
import { useTranslation } from '@/hooks/use-translation'; import { useTranslation } from '@/hooks/use-translation';
export interface AssignableRole { export interface AssignableRole {
@@ -31,6 +32,12 @@ interface UserFormProps {
assignedClients: number[]; assignedClients: number[];
onAssignedClientsChange: (ids: number[]) => void; onAssignedClientsChange: (ids: number[]) => void;
passwordOptional: boolean; passwordOptional: boolean;
/**
* Editing your own account: the email address and password are changed
* from your profile, which asks for your current password first, so
* this form shows the address and leaves both alone.
*/
ownAccount?: boolean;
errors: Partial<Record<string, string>>; errors: Partial<Record<string, string>>;
} }
@@ -46,6 +53,7 @@ export function UserForm({
assignedClients, assignedClients,
onAssignedClientsChange, onAssignedClientsChange,
passwordOptional, passwordOptional,
ownAccount = false,
errors, errors,
}: UserFormProps) { }: UserFormProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -62,7 +70,23 @@ export function UserForm({
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="email">{t('Email address')}</Label> <Label htmlFor="email">{t('Email address')}</Label>
<Input id="email" type="email" value={email} onChange={(e) => onChange('email', e.target.value)} required autoComplete="off" /> <Input
id="email"
type="email"
value={email}
onChange={(e) => onChange('email', e.target.value)}
required
readOnly={ownAccount}
autoComplete="off"
/>
{ownAccount && (
<p className="text-muted-foreground text-sm">
{t('Change your own email address and password from your profile.')}{' '}
<Link href={route('profile.edit')} className="underline underline-offset-4">
{t('Go to your profile')}
</Link>
</p>
)}
<InputError message={errors.email} /> <InputError message={errors.email} />
</div> </div>
@@ -92,32 +116,37 @@ export function UserForm({
/> />
)} )}
<div className="grid gap-2"> {!ownAccount && (
<Label htmlFor="password">{passwordOptional ? t('New password (leave blank to keep current)') : t('Password')}</Label> <>
<Input <div className="grid gap-2">
id="password" <Label htmlFor="password">{passwordOptional ? t('New password (leave blank to keep current)') : t('Password')}</Label>
type="password" <Input
value={password} id="password"
onChange={(e) => onChange('password', e.target.value)} type="password"
required={!passwordOptional} value={password}
autoComplete="new-password" onChange={(e) => onChange('password', e.target.value)}
/> required={!passwordOptional}
<PasswordRequirements /> autoComplete="new-password"
<InputError message={errors.password} /> />
</div> <PasswordRequirements />
<InputError message={errors.password} />
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="password_confirmation">{t('Confirm password')}</Label> <Label htmlFor="password_confirmation">{t('Confirm password')}</Label>
<Input <Input
id="password_confirmation" id="password_confirmation"
type="password" type="password"
value={passwordConfirmation} value={passwordConfirmation}
onChange={(e) => onChange('password_confirmation', e.target.value)} onChange={(e) => onChange('password_confirmation', e.target.value)}
required={!passwordOptional || password !== ''} required={!passwordOptional || password !== ''}
autoComplete="new-password" autoComplete="new-password"
/> />
<InputError message={errors.password_confirmation} /> <InputError message={errors.password_confirmation} />
</div> </div>
</>
)}
{ownAccount && <InputError message={errors.password} />}
</div> </div>
); );
} }
@@ -35,12 +35,13 @@ export default function AuthSimpleLayout({ children, title, description }: AuthL
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<Link href={route('home')} className="flex flex-col items-center gap-2 font-medium"> <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 {/* A box rather than a height: 128px tall and up
240px wide, so a square logo is shown at a size to 320px wide, so a square logo is shown at a
that reads (it was 48px) and a wide one still size that reads (48px, then 80px, were both
reported as too small) and a wide one still
fits a phone. */} fits a phone. */}
{branding?.logo_url ? ( {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" /> <ProjectSendLogo className="text-foreground mb-1 h-12 w-auto" />
)} )}
+39 -6
View File
@@ -2,6 +2,7 @@ import { type BreadcrumbItem, type SharedData } from '@/types';
import { Head, useForm, usePage } from '@inertiajs/react'; import { Head, useForm, usePage } from '@inertiajs/react';
import { FormEventHandler, useEffect, useRef, useState } from 'react'; import { FormEventHandler, useEffect, useRef, useState } from 'react';
import LogoCropDialog, { type LogoCropBox } from '@/components/branding/logo-crop-dialog';
import Heading from '@/components/heading'; import Heading from '@/components/heading';
import InputError from '@/components/input-error'; import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -22,6 +23,9 @@ interface Watermark {
interface BrandingEditProps { interface BrandingEditProps {
logo_url: string | null; logo_url: string | null;
logo_source_url: string | null;
logo_crop: LogoCropBox | null;
logo_cropped: boolean;
hide_attribution: boolean; hide_attribution: boolean;
show_site_name: boolean; show_site_name: boolean;
watermark: Watermark; watermark: Watermark;
@@ -30,7 +34,7 @@ interface BrandingEditProps {
type Tab = 'logo' | 'watermark' | 'attribution'; type Tab = 'logo' | 'watermark' | 'attribution';
export default function BrandingEdit({ logo_url, hide_attribution, show_site_name, watermark, watermark_positions }: BrandingEditProps) { export default function BrandingEdit({ logo_url, logo_source_url, logo_crop, logo_cropped, hide_attribution, show_site_name, watermark, watermark_positions }: BrandingEditProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { capabilities } = usePage<SharedData>().props; const { capabilities } = usePage<SharedData>().props;
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -135,6 +139,14 @@ export default function BrandingEdit({ logo_url, hide_attribution, show_site_nam
removeForm.delete(route('branding.destroy'), { preserveScroll: true, preserveState: true }); removeForm.delete(route('branding.destroy'), { preserveScroll: true, preserveState: true });
}; };
// Cropping is optional: an upload is used whole until somebody crops it.
const [cropping, setCropping] = useState(false);
const restoreForm = useForm({});
const restore = () => {
restoreForm.delete(route('branding.logo.restore'), { preserveScroll: true, preserveState: true });
};
const submitWatermark: FormEventHandler = (e) => { const submitWatermark: FormEventHandler = (e) => {
e.preventDefault(); e.preventDefault();
watermarkForm.post(route('branding.watermark.update'), { watermarkForm.post(route('branding.watermark.update'), {
@@ -204,7 +216,7 @@ export default function BrandingEdit({ logo_url, hide_attribution, show_site_nam
onChange={(e) => uploadForm.setData('logo', e.target.files?.[0] ?? null)} onChange={(e) => uploadForm.setData('logo', e.target.files?.[0] ?? null)}
/> />
<p className="text-muted-foreground text-sm"> <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> </p>
<InputError message={uploadForm.errors.logo} /> <InputError message={uploadForm.errors.logo} />
<Button type="submit" disabled={uploadForm.processing || uploadForm.data.logo === null}> <Button type="submit" disabled={uploadForm.processing || uploadForm.data.logo === null}>
@@ -212,10 +224,31 @@ export default function BrandingEdit({ logo_url, hide_attribution, show_site_nam
</Button> </Button>
</form> </form>
{logo_url && ( {logo_url && logo_source_url && (
<Button variant="outline" onClick={remove} disabled={removeForm.processing}> <div className="flex flex-wrap items-center gap-2">
{t('Remove logo')} <Button variant="outline" onClick={() => setCropping(true)}>
</Button> {t('Crop')}
</Button>
{logo_cropped && (
<Button variant="outline" onClick={restore} disabled={restoreForm.processing}>
{t('Restore original')}
</Button>
)}
<Button variant="outline" onClick={remove} disabled={removeForm.processing}>
{t('Remove logo')}
</Button>
</div>
)}
{logo_source_url && (
// Keyed by the upload so a new logo opens on a fresh box.
<LogoCropDialog
key={logo_source_url}
open={cropping}
onOpenChange={setCropping}
sourceUrl={logo_source_url}
savedCrop={logo_crop}
/>
)} )}
<form onSubmit={submitSiteName} className="space-y-4 border-t pt-6"> <form onSubmit={submitSiteName} className="space-y-4 border-t pt-6">
+1
View File
@@ -128,6 +128,7 @@ export default function UsersEdit({
assignedClients={data.assigned_clients} assignedClients={data.assigned_clients}
onAssignedClientsChange={(ids) => setData('assigned_clients', ids)} onAssignedClientsChange={(ids) => setData('assigned_clients', ids)}
passwordOptional passwordOptional
ownAccount={is_self}
errors={errors} errors={errors}
/> />
+10 -3
View File
@@ -29,9 +29,16 @@
there is nothing on the client that could work the name out. --}} there is nothing on the client that could work the name out. --}}
<meta name="xsrf-cookie" content="{{ \App\Http\Middleware\ValidateCsrfToken::cookieName() }}"> <meta name="xsrf-cookie" content="{{ \App\Http\Middleware\ValidateCsrfToken::cookieName() }}">
<link rel="icon" href="/favicon.ico" sizes="48x48"> {{-- An installed package may name its own icons in
<link rel="icon" href="/favicon.svg" type="image/svg+xml"> projectsend.icons; they then replace these as a set, so a
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> 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 @routes
@viteReactRefresh @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\Comments\Http\Controllers\Api\FileCommentsController;
use App\Modules\Files\Http\Controllers\Api\FileAssignmentsController; use App\Modules\Files\Http\Controllers\Api\FileAssignmentsController;
use App\Modules\Files\Http\Controllers\Api\FilesController; 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\Api\FileVersionsController as ApiFileVersionsController;
use App\Modules\Files\Http\Controllers\ChunkedUploadsController; use App\Modules\Files\Http\Controllers\ChunkedUploadsController;
use App\Modules\Files\Http\Controllers\FileDownloadController; 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'); ->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 | Comments
+2
View File
@@ -292,6 +292,8 @@ Route::middleware('auth')->group(function () {
Route::post('system/settings/branding', [BrandingController::class, 'store'])->name('branding.store'); Route::post('system/settings/branding', [BrandingController::class, 'store'])->name('branding.store');
Route::delete('system/settings/branding', [BrandingController::class, 'destroy'])->name('branding.destroy'); Route::delete('system/settings/branding', [BrandingController::class, 'destroy'])->name('branding.destroy');
Route::patch('system/settings/branding/site-name', [BrandingController::class, 'updateSiteName'])->name('branding.site-name.update'); Route::patch('system/settings/branding/site-name', [BrandingController::class, 'updateSiteName'])->name('branding.site-name.update');
Route::patch('system/settings/branding/logo/crop', [BrandingController::class, 'cropLogo'])->name('branding.logo.crop');
Route::delete('system/settings/branding/logo/crop', [BrandingController::class, 'restoreLogo'])->name('branding.logo.restore');
// POST rather than PATCH: the form carries a file, so it is // POST rather than PATCH: the form carries a file, so it is
// multipart, and PHP only populates $_FILES for POST. // multipart, and PHP only populates $_FILES for POST.
+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);
});
+234
View File
@@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
use App\Modules\Platform\Branding\Models\BrandingSetting;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Inertia\Testing\AssertableInertia;
beforeEach(function () {
Storage::fake('public');
$this->actingAs(staffWithPermissions(['edit_settings']));
});
/**
* A 200 × 100 image, red on the left half and blue on the right, so a test
* can tell which part of it a crop actually kept.
*/
function twoColourLogo(string $format = 'png'): UploadedFile
{
$image = imagecreatetruecolor(200, 100);
imagefilledrectangle($image, 0, 0, 99, 99, imagecolorallocate($image, 255, 0, 0));
imagefilledrectangle($image, 100, 0, 199, 99, imagecolorallocate($image, 0, 0, 255));
$temp = tempnam(sys_get_temp_dir(), 'logo');
$format === 'jpg' ? imagejpeg($image, $temp, 100) : imagepng($image, $temp);
return new UploadedFile($temp, "logo.{$format}", $format === 'jpg' ? 'image/jpeg' : 'image/png', null, true);
}
/** The colour of one pixel of a stored logo, as [r, g, b]. */
function logoPixel(string $path, int $x, int $y): array
{
$image = imagecreatefromstring(Storage::disk('public')->get($path));
$rgb = imagecolorsforindex($image, imagecolorat($image, $x, $y));
return [$rgb['red'], $rgb['green'], $rgb['blue']];
}
function uploadTwoColourLogo(string $format = 'png'): string
{
test()->post(route('branding.store'), ['logo' => twoColourLogo($format)])->assertRedirect();
return BrandingSetting::query()->sole()->logo_path;
}
test('an uploaded logo is used whole until somebody crops it', function () {
$upload = uploadTwoColourLogo();
$setting = BrandingSetting::query()->sole();
expect($setting->logo_original_path)->toBeNull()
->and($setting->logo_crop)->toBeNull()
->and($setting->logoSourcePath())->toBe($upload);
});
test('cropping keeps the part of the picture the box was drawn on', function () {
$upload = uploadTwoColourLogo();
$this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100])
->assertRedirect()
->assertSessionHasNoErrors();
$setting = BrandingSetting::query()->sole();
$size = getimagesizefromstring(Storage::disk('public')->get($setting->logo_path));
expect($setting->logo_path)->not->toBe($upload)
->and($setting->logo_original_path)->toBe($upload)
->and($setting->logo_crop)->toBe(['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100])
->and([$size[0], $size[1]])->toBe([100, 100])
->and(logoPixel($setting->logo_path, 5, 50))->toBe([0, 0, 255])
->and(logoPixel($setting->logo_path, 95, 50))->toBe([0, 0, 255]);
// The upload itself is untouched.
Storage::disk('public')->assertExists($upload);
expect(logoPixel($upload, 5, 50))->toBe([255, 0, 0]);
});
test('cropping again starts from the upload, not from the last crop', function () {
$upload = uploadTwoColourLogo();
$this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100]);
$firstCrop = BrandingSetting::query()->sole()->logo_path;
// The red half is not in the first crop at all; it is in the upload.
$this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 50, 'height' => 50])->assertSessionHasNoErrors();
$setting = BrandingSetting::query()->sole();
expect($setting->logo_original_path)->toBe($upload)
->and(logoPixel($setting->logo_path, 10, 10))->toBe([255, 0, 0]);
Storage::disk('public')->assertMissing($firstCrop);
});
test('restoring puts the upload back and deletes the crop', function () {
$upload = uploadTwoColourLogo();
$this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100]);
$crop = BrandingSetting::query()->sole()->logo_path;
$this->delete(route('branding.logo.restore'))->assertRedirect();
$setting = BrandingSetting::query()->sole();
expect($setting->logo_path)->toBe($upload)
->and($setting->logo_original_path)->toBeNull()
->and($setting->logo_crop)->toBeNull();
Storage::disk('public')->assertMissing($crop);
Storage::disk('public')->assertExists($upload);
});
test('a box covering the whole picture is the same as restoring', function () {
$upload = uploadTwoColourLogo();
$this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100]);
$this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 200, 'height' => 100])->assertSessionHasNoErrors();
expect(BrandingSetting::query()->sole()->logo_path)->toBe($upload)
->and(Storage::disk('public')->allFiles('branding'))->toBe([$upload]);
});
test('a box reaching outside the picture is refused and changes nothing', function () {
$upload = uploadTwoColourLogo();
$this->patch(route('branding.logo.crop'), ['x' => 150, 'y' => 0, 'width' => 100, 'height' => 100])
->assertSessionHasErrors('width');
$this->patch(route('branding.logo.crop'), ['x' => -1, 'y' => 0, 'width' => 10, 'height' => 10])
->assertSessionHasErrors('x');
expect(BrandingSetting::query()->sole()->logo_path)->toBe($upload)
->and(Storage::disk('public')->allFiles('branding'))->toBe([$upload]);
});
test('there is nothing to crop before a logo is uploaded', function () {
$this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 10, 'height' => 10])
->assertSessionHasErrors('logo');
});
test('a crop keeps the format of the upload', function () {
uploadTwoColourLogo('jpg');
$this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 100, 'height' => 100])->assertSessionHasNoErrors();
$path = BrandingSetting::query()->sole()->logo_path;
expect(pathinfo($path, PATHINFO_EXTENSION))->toBe('jpg')
->and(getimagesizefromstring(Storage::disk('public')->get($path))['mime'])->toBe('image/jpeg');
});
/*
* A small file can declare a huge picture. The size is read from the header
* and refused before GD is asked to hold the pixels.
*/
test('an image too large to hold in memory is refused before it is decoded', function () {
$ihdr = pack('NNCCCCC', 6000, 5000, 8, 2, 0, 0, 0);
$png = "\x89PNG\r\n\x1a\n"
.pack('N', 13).'IHDR'.$ihdr.pack('N', crc32('IHDR'.$ihdr))
.pack('N', 0).'IEND'.pack('N', crc32('IEND'));
Storage::disk('public')->put('branding/huge.png', $png);
BrandingSetting::current()->update(['logo_path' => 'branding/huge.png']);
$this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 100, 'height' => 100])
->assertSessionHasErrors('logo');
expect(BrandingSetting::query()->sole()->logo_original_path)->toBeNull();
});
test('a new upload after a crop deletes both files and starts uncropped', function () {
$upload = uploadTwoColourLogo();
$this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100]);
$crop = BrandingSetting::query()->sole()->logo_path;
$this->post(route('branding.store'), ['logo' => UploadedFile::fake()->image('new.png', 80, 80)]);
$setting = BrandingSetting::query()->sole();
expect($setting->logo_original_path)->toBeNull()
->and($setting->logo_crop)->toBeNull()
->and(Storage::disk('public')->allFiles('branding'))->toBe([$setting->logo_path]);
Storage::disk('public')->assertMissing($upload);
Storage::disk('public')->assertMissing($crop);
});
test('removing a cropped logo deletes both files', function () {
uploadTwoColourLogo();
$this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100]);
$this->delete(route('branding.destroy'))->assertRedirect();
$setting = BrandingSetting::query()->sole();
expect($setting->logo_path)->toBeNull()
->and($setting->logo_original_path)->toBeNull()
->and(Storage::disk('public')->allFiles('branding'))->toBe([]);
});
test('the screen opens the cropper on the upload, with the last box drawn', function () {
$upload = uploadTwoColourLogo();
$this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100]);
$this->get(route('branding.edit'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->where('logo_source_url', Storage::disk('public')->url($upload))
->where('logo_crop', ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100])
->where('logo_cropped', true));
});
test('cropping and restoring need what the rest of the screen needs', function () {
uploadTwoColourLogo();
$this->actingAs(staffWithPermissions([]));
$this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 10, 'height' => 10])->assertForbidden();
$this->delete(route('branding.logo.restore'))->assertForbidden();
expect(BrandingSetting::query()->sole()->logo_original_path)->toBeNull();
});
test('cropping and restoring 404 when the capability has been taken away', function () {
// Before any request: the capability set is worked out once, on the
// first one, as the other branding tests rely on.
config(['projectsend.capabilities_disabled' => 'branding.customize']);
Storage::disk('public')->put('branding/logo.png', twoColourLogo()->getContent());
BrandingSetting::current()->update(['logo_path' => 'branding/logo.png']);
$this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 10, 'height' => 10])->assertNotFound();
$this->delete(route('branding.logo.restore'))->assertNotFound();
expect(BrandingSetting::query()->sole()->logo_original_path)->toBeNull();
});
@@ -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);
});
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Identity\Permissions\SystemRole;
use Illuminate\Support\Facades\Hash;
/*
* Your own password, email address and second factor are changed from your
* profile, which asks for your current password first. The staff screens
* and the API must not be a second door to them: a token holding only
* manage_users and edit_users could otherwise reset its own owner and
* become a full browser session with abilities the token was never given,
* and a borrowed browser session could take the account for good without
* the password the profile asks for.
*
* Changing somebody *else's* credentials is what edit_users and
* edit_clients mean, on the web and over the API alike, and stays so.
*/
beforeEach(function () {
$this->admin = User::factory()->role(SystemRole::SystemAdministrator)->create(['password' => 'Original-pass-1!']);
$this->token = $this->admin->createToken('t', ['manage_users', 'edit_users'])->plainTextToken;
});
test('a token cannot set a new password for its own owner', function () {
$this->withToken($this->token)->patchJson("/api/v1/users/{$this->admin->id}", ['password' => 'Picked-by-token-9!'])
->assertForbidden();
expect(Hash::check('Original-pass-1!', $this->admin->refresh()->password))->toBeTrue();
});
test('a token cannot change its own owner\'s email address', function () {
$this->withToken($this->token)->patchJson("/api/v1/users/{$this->admin->id}", ['email' => 'elsewhere@example.test'])
->assertForbidden();
expect($this->admin->refresh()->email)->not->toBe('elsewhere@example.test');
});
test('a token cannot remove its own owner\'s second factor', function () {
// The factory's own password: enableTwoFactor() confirms with it.
$owner = User::factory()->role(SystemRole::SystemAdministrator)->create();
enableTwoFactor($owner);
forgetRequestState();
auth()->logout();
$token = $owner->createToken('t', ['manage_users', 'edit_users'])->plainTextToken;
$this->withToken($token)->deleteJson("/api/v1/users/{$owner->id}/two-factor")->assertForbidden();
expect($owner->refresh()->hasTwoFactorEnabled())->toBeTrue();
});
test('a token can still rename its own owner, and resend the same email address', function () {
$this->withToken($this->token)->patchJson("/api/v1/users/{$this->admin->id}", ['name' => 'Renamed', 'email' => $this->admin->email])
->assertOk()
->assertJsonPath('data.name', 'Renamed');
});
test('a token can still set another staff member\'s password, and that ends their tokens', function () {
$colleague = User::factory()->role(SystemRole::Uploader)->create();
$colleague->createToken('theirs', ['upload']);
$this->withToken($this->token)->patchJson("/api/v1/users/{$colleague->id}", ['password' => 'Reset-by-admin-9!'])
->assertOk();
expect(Hash::check('Reset-by-admin-9!', $colleague->refresh()->password))->toBeTrue()
->and($colleague->tokens()->count())->toBe(0);
});
test('renaming another staff member leaves their tokens alone', function () {
$colleague = User::factory()->role(SystemRole::Uploader)->create();
$colleague->createToken('theirs', ['upload']);
$this->withToken($this->token)->patchJson("/api/v1/users/{$colleague->id}", ['name' => 'New name'])->assertOk();
expect($colleague->tokens()->count())->toBe(1);
});
test('a token holding edit_clients can still reset a client it manages', function () {
$staff = staffWithPermissions(['edit_clients']);
$token = $staff->createToken('t', ['edit_clients'])->plainTextToken;
$client = User::factory()->client()->create();
$this->withToken($token)->patchJson("/api/v1/clients/{$client->id}", ['password' => 'Reset-by-staff-9!'])->assertOk();
expect(Hash::check('Reset-by-staff-9!', $client->refresh()->password))->toBeTrue();
});
/*
* The staff screen, editing yourself.
*/
function ownAccountPayload(User $user, array $overrides = []): array
{
return array_merge([
'name' => $user->name,
'email' => $user->email,
'role_id' => $user->role_id,
'active' => true,
'assigned_clients' => [],
], $overrides);
}
test('the staff screen does not change your own email address', function () {
$this->actingAs($this->admin)
->patch("/users/{$this->admin->id}", ownAccountPayload($this->admin, ['email' => 'elsewhere@example.test']))
->assertSessionHasErrors('email');
expect($this->admin->refresh()->email)->not->toBe('elsewhere@example.test');
});
test('the staff screen does not change your own password', function () {
$this->actingAs($this->admin)
->patch("/users/{$this->admin->id}", ownAccountPayload($this->admin, ['password' => 'Picked-here-9!', 'password_confirmation' => 'Picked-here-9!']))
->assertSessionHasErrors('password');
expect(Hash::check('Original-pass-1!', $this->admin->refresh()->password))->toBeTrue();
});
test('the staff screen still saves the rest of your own account', function () {
$this->actingAs($this->admin)
->patch("/users/{$this->admin->id}", ownAccountPayload($this->admin, ['name' => 'Renamed']))
->assertSessionHasNoErrors();
expect($this->admin->refresh()->name)->toBe('Renamed');
});
test('setting another staff member\'s password on the screen ends their tokens', function () {
$colleague = User::factory()->role(SystemRole::Uploader)->create();
$colleague->createToken('theirs', ['upload']);
$this->actingAs($this->admin)
->patch("/users/{$colleague->id}", ownAccountPayload($colleague, ['password' => 'Reset-by-admin-9!', 'password_confirmation' => 'Reset-by-admin-9!']))
->assertSessionHasNoErrors();
expect($colleague->tokens()->count())->toBe(0);
});