mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-04 05:25:51 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fba5f30436 | |||
| 0f66f9030c | |||
| 7c16733c16 | |||
| d7d7acce85 | |||
| 334b11d562 | |||
| da1f432d87 | |||
| 82dd475f8f | |||
| b758fca19c | |||
| 02946abf85 | |||
| b7ac44e77b | |||
| 50a6a19455 | |||
| 8de28059db | |||
| ea214fc27e | |||
| 922be7226c | |||
| 1e30e83f11 | |||
| d32788e4a1 | |||
| c3503a0651 | |||
| 51477cbd02 | |||
| 7c9847981a | |||
| 7da4635f13 | |||
| ddf09677f0 | |||
| 9b2aea4812 | |||
| 5a9133bb07 | |||
| f2b705beee |
@@ -39,6 +39,7 @@ yarn-error.log
|
||||
/database/seeders/DevDataSeeder.php
|
||||
/docs/*.md
|
||||
!/docs/api-guide.md
|
||||
!/docs/api-modules.md
|
||||
!/docs/email-oauth.md
|
||||
!/docs/api-zapier.md
|
||||
|
||||
|
||||
@@ -13,6 +13,53 @@ Anything under **Upgrade notes** is something you have to do, not something we d
|
||||
This section collects changes as they land; the release process turns it into a numbered entry
|
||||
when a version is cut.
|
||||
|
||||
|
||||
## 2.4.0 — 8 September 2026
|
||||
|
||||
Clients can now look after the files they uploaded, and this release closes three ways somebody
|
||||
could see a little more than they should.
|
||||
|
||||
**New**
|
||||
|
||||
- **Clients can edit and delete the files they uploaded**, with the name, description, expiry,
|
||||
categories, download limit and public flag each behind the permission that already governs it.
|
||||
A file shared *with* a client is still not theirs to touch.
|
||||
- **A switch to stop this installation fetching the project news**, on Settings → General. On by
|
||||
default; off means the request is never made.
|
||||
|
||||
**Closed holes in who can see what**
|
||||
|
||||
- A staff member limited to their assigned clients could read other clients' names, and their IDs,
|
||||
out of file details and the uploader filter. Reported by
|
||||
[@Noorkhalel](https://github.com/Noorkhalel) (GHSA-whmp-p9hv-r7j7).
|
||||
- Download links to external storage now last a minute instead of an hour. Previews keep the hour.
|
||||
- Eight advisories in bundled dependencies, including an XSS bypass in the markdown renderer that
|
||||
builds your email templates.
|
||||
|
||||
**Fixed**
|
||||
|
||||
- A failed upload keeps its parts, so retrying it works instead of needing the whole file again.
|
||||
- `projectsend:captcha-off` no longer claims success on an installation whose CAPTCHA keys are
|
||||
supplied centrally, where it changed nothing.
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
- **Resuming an interrupted download from external storage more than a minute after it started now
|
||||
fails.** Start it again from ProjectSend. Local-disk installations and zip bundles are unaffected.
|
||||
- **If your temporary directory is on a small or separate volume, allow headroom for twice your
|
||||
largest allowed upload.** Only while a file is being assembled, and nothing needs configuring.
|
||||
|
||||
Thanks to [@Noorkhalel](https://github.com/Noorkhalel), [@denkfabrik-li](https://github.com/denkfabrik-li)
|
||||
and [@mehmedturk](https://github.com/mehmedturk) for reporting and fixing.
|
||||
|
||||
### Issues closed since 2.3.0
|
||||
|
||||
The summary above is what changed. This is the paper trail, for anyone who wants to read the
|
||||
original report.
|
||||
|
||||
- [#1765](https://github.com/projectsend/projectsend/issues/1765) — Projectsend 2.2.1 thumbnail issue after file upload
|
||||
- [#1771](https://github.com/projectsend/projectsend/issues/1771) — Permissions granted to the Client role are not applied to client accounts
|
||||
|
||||
## 2.3.0 — 1 September 2026
|
||||
|
||||
If you run ProjectSend on Apache or LiteSpeed, this is the release to take. It installed fine on
|
||||
|
||||
@@ -23,6 +23,11 @@ page to download it.
|
||||
No public link passed around by email, no third-party service holding your clients' documents, no
|
||||
per-seat pricing. It runs on your server, and the files stay there.
|
||||
|
||||
Prefer not to run the server yourself? [ProjectSend Cloud](https://projectsend.cloud) is the
|
||||
official hosted version of ProjectSend, run by the same team — every subscription funds this free
|
||||
software. The line between the free core and Cloud, and the commitments that go with it, are set
|
||||
out in [LICENSING.md](LICENSING.md).
|
||||
|
||||
## What it does
|
||||
|
||||
**For the people you send to**
|
||||
|
||||
@@ -24,7 +24,10 @@ use App\Modules\Platform\Settings\Settings;
|
||||
use App\Modules\Platform\Updates\LatestReleaseInfo;
|
||||
use App\Modules\Platform\Updates\RunningCodeState;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use App\Modules\Platform\Announcements\Events\ResolvingAnnouncement;
|
||||
use App\Modules\Platform\Navigation\Events\ResolvingNavigationLinks;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Inertia\Middleware;
|
||||
|
||||
class HandleInertiaRequests extends Middleware
|
||||
@@ -84,6 +87,19 @@ class HandleInertiaRequests extends Middleware
|
||||
// ignore this and always show it.
|
||||
'attribution' => app(Attribution::class)->visible(),
|
||||
'capabilities' => $capabilities->enabledKeys(),
|
||||
// Sidebar entries a package asked for. Shared rather than
|
||||
// passed per page because the sidebar is on every page, and
|
||||
// dispatched unconditionally so that with nothing listening
|
||||
// the list is empty and the sidebar is exactly what it was.
|
||||
// See ResolvingNavigationLinks for why core never learns what
|
||||
// is in it.
|
||||
'extra_nav_links' => $this->extraNavLinks($request),
|
||||
// Shared rather than a dashboard prop, because it is shown in
|
||||
// two places — the band on the dashboard and the icon beside
|
||||
// the notification bell everywhere else — and "the same
|
||||
// message" is the requirement. Two props would drift the day
|
||||
// somebody edited one.
|
||||
'announcement' => $this->announcement($request),
|
||||
// Shared rather than passed by each page: the sign-in buttons,
|
||||
// the registration form and the Connected accounts nav entry
|
||||
// all need the same list, and a nav entry to a screen with
|
||||
@@ -301,4 +317,43 @@ class HandleInertiaRequests extends Middleware
|
||||
/** @var array<string, string> */
|
||||
return app('translator')->getLoader()->load($locale, '*', '*');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{title: string, url: string, external: bool, icon: string|null}>
|
||||
*/
|
||||
private function extraNavLinks(Request $request): array
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Staff only, decided here rather than in each listener: these
|
||||
// render in the administration area, and a client's portal shows
|
||||
// their own files and nothing about the installation.
|
||||
$event = new ResolvingNavigationLinks(isStaff: $user !== null && $user->isStaff());
|
||||
|
||||
if (! $event->isStaff) {
|
||||
return [];
|
||||
}
|
||||
|
||||
Event::dispatch($event);
|
||||
|
||||
return $event->links;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{title: string, body: string, action_label: string|null, action_url: string|null, tone: string}|null
|
||||
*/
|
||||
private function announcement(Request $request): ?array
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$event = new ResolvingAnnouncement(isStaff: $user->isStaff());
|
||||
|
||||
Event::dispatch($event);
|
||||
|
||||
return $event->announcement;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Audit\ActivityLogScope;
|
||||
use App\Modules\Audit\ActivityPresenter;
|
||||
use App\Modules\Audit\DashboardWidgetPreferences;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use App\Modules\Clients\ClientStorageUsage;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Delivery\FileDelivery;
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Access;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
|
||||
/**
|
||||
* Whether a viewer may be told who a client is.
|
||||
*
|
||||
* A different question from whether they may read a file, and the gap
|
||||
* between the two is the whole reason this exists. A stranger client's
|
||||
* upload can sit legitimately inside a client-scoped staff member's
|
||||
* library — shared with a group one of their own clients belongs to, or
|
||||
* assigned to one of their clients alongside somebody else's. The file is
|
||||
* theirs to read. The other client's name is not theirs to see.
|
||||
*
|
||||
* Commit 12a8ebe3 said exactly that while fixing one dashboard widget, and
|
||||
* then the rule stayed in that widget. Every other place that serialises a
|
||||
* file went on publishing the uploader and each recipient by name, so a
|
||||
* manager assigned to one client could read the names and ids of clients
|
||||
* on nobody's roster but their own out of ordinary file metadata. That is
|
||||
* what this class ends: one statement of the rule, asked by every surface
|
||||
* that names a client.
|
||||
*
|
||||
* Two things it deliberately is not:
|
||||
*
|
||||
* - It is not a download check. The file boundary is StaffLibraryScope's
|
||||
* and FilePolicy's, and it is already correct — a file belonging only
|
||||
* to a client off the roster is a 403 today. This narrows what a
|
||||
* permitted response is allowed to say, nothing more.
|
||||
* - It is not applied to staff. A colleague's name is not a client
|
||||
* identity, and hiding it would hide who uploaded most of the library
|
||||
* from the people who work in it.
|
||||
*
|
||||
* Unscoped staff are unaffected: they may identify everyone, which is what
|
||||
* `null` means everywhere StaffLibraryScope answers this shape of question.
|
||||
*/
|
||||
class ClientIdentityScope
|
||||
{
|
||||
/**
|
||||
* Memoised per viewer, since the listings ask once per row and each
|
||||
* miss is a roster query. Registered as `scoped`, so this lasts a
|
||||
* request and is dropped between queue jobs — the same lifetime, and
|
||||
* for the same reason, as StaffLibraryScope's own memo.
|
||||
*
|
||||
* @var array<int, list<int>|null>
|
||||
*/
|
||||
private array $clientIds = [];
|
||||
|
||||
/** @var array<int, list<int>|null> */
|
||||
private array $groupIds = [];
|
||||
|
||||
public function __construct(private readonly StaffLibraryScope $scope) {}
|
||||
|
||||
/**
|
||||
* Whether $viewer may be told that $subject exists, and what they are
|
||||
* called.
|
||||
*
|
||||
* A null subject is permitted: there is no identity to leak, and every
|
||||
* caller here is reading an optional relation.
|
||||
*/
|
||||
public function permits(?User $viewer, ?User $subject): bool
|
||||
{
|
||||
if ($subject === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $subject->isClient()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($viewer === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($viewer->is($subject)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$ids = $this->identifiableClientIds($viewer);
|
||||
|
||||
return $ids === null || in_array($subject->id, $ids, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same question about a client known only by id — used where a
|
||||
* caller has a foreign key rather than a loaded model.
|
||||
*
|
||||
* An id that belongs to nobody, or to a staff member, is permitted:
|
||||
* there is no client identity behind it to protect.
|
||||
*/
|
||||
public function permitsClientId(?User $viewer, ?int $id): bool
|
||||
{
|
||||
if ($id === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->permits($viewer, User::query()->find($id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether $viewer may be told a group exists.
|
||||
*
|
||||
* A group is a list of clients wearing one name, so naming one to
|
||||
* somebody who may reach none of its members says the same thing
|
||||
* naming a client would. The set is StaffLibraryScope's
|
||||
* assignableGroupIds — every group holding at least one of the
|
||||
* viewer's own clients.
|
||||
*/
|
||||
public function permitsGroupId(?User $viewer, ?int $id): bool
|
||||
{
|
||||
if ($id === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($viewer === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ids = $this->identifiableGroupIds($viewer);
|
||||
|
||||
return $ids === null || in_array($id, $ids, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* A client's name, or null when this viewer may not be told it.
|
||||
*
|
||||
* Null rather than a placeholder on purpose: every consumer of these
|
||||
* fields already renders "no uploader recorded" for a null, because a
|
||||
* deleted account leaves one behind. Inventing a "Hidden" string would
|
||||
* be a new thing for sixteen locales to translate and would itself
|
||||
* announce that there is somebody there to hide.
|
||||
*/
|
||||
public function nameOf(?User $viewer, ?User $subject): ?string
|
||||
{
|
||||
return $this->permits($viewer, $subject) ? $subject?->name : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the entries this viewer may not be told about from a list of
|
||||
* id/name pairs describing clients.
|
||||
*
|
||||
* @param list<array{id: int, name: string}> $pairs
|
||||
* @return list<array{id: int, name: string}>
|
||||
*/
|
||||
public function filterClientPairs(?User $viewer, array $pairs): array
|
||||
{
|
||||
if ($this->identifiableClientIds($viewer) === null) {
|
||||
return $pairs;
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$pairs,
|
||||
fn (array $pair): bool => $this->permitsClientId($viewer, $pair['id']),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{id: int, name: string}> $pairs
|
||||
* @return list<array{id: int, name: string}>
|
||||
*/
|
||||
public function filterGroupPairs(?User $viewer, array $pairs): array
|
||||
{
|
||||
if ($this->identifiableGroupIds($viewer) === null) {
|
||||
return $pairs;
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$pairs,
|
||||
fn (array $pair): bool => $this->permitsGroupId($viewer, $pair['id']),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Both halves of a `shares` payload at once, since the two lists are
|
||||
* always filtered together.
|
||||
*
|
||||
* @param array{clients: list<array{id: int, name: string}>, groups: list<array{id: int, name: string}>} $shares
|
||||
* @return array{clients: list<array{id: int, name: string}>, groups: list<array{id: int, name: string}>}
|
||||
*/
|
||||
public function filterShares(?User $viewer, array $shares): array
|
||||
{
|
||||
return [
|
||||
'clients' => $this->filterClientPairs($viewer, $shares['clients']),
|
||||
'groups' => $this->filterGroupPairs($viewer, $shares['groups']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this viewer is narrowed at all. Callers use it to skip
|
||||
* per-row work for the common unscoped case.
|
||||
*/
|
||||
public function isNarrowed(?User $viewer): bool
|
||||
{
|
||||
return $viewer === null || $this->identifiableClientIds($viewer) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>|null
|
||||
*/
|
||||
private function identifiableClientIds(?User $viewer): ?array
|
||||
{
|
||||
if ($viewer === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Deliberately the same set as "who may I share with". A client on
|
||||
// the roster is one this viewer already works with by name; a
|
||||
// client off it is one they have no business knowing exists.
|
||||
return $this->clientIds[$viewer->id] ??= $this->scope->assignableClientIds($viewer);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>|null
|
||||
*/
|
||||
private function identifiableGroupIds(?User $viewer): ?array
|
||||
{
|
||||
if ($viewer === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->groupIds[$viewer->id] ??= $this->scope->assignableGroupIds($viewer);
|
||||
}
|
||||
}
|
||||
@@ -28,13 +28,25 @@ use Illuminate\Support\Collection;
|
||||
*/
|
||||
class ShareTargets
|
||||
{
|
||||
public function __construct(private readonly StaffLibraryScope $scope) {}
|
||||
public function __construct(
|
||||
private readonly StaffLibraryScope $scope,
|
||||
private readonly ClientIdentityScope $identity,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The clients and groups a subject is already shared with, as id/name
|
||||
* pairs. Neutral keys, so callers can nest it ('shares' on the details
|
||||
* panel) or flatten it (the edit pages' assigned_* props).
|
||||
*
|
||||
* **This is the unfiltered truth, and it is not what a screen should
|
||||
* show.** Everyone a file is really in front of is the right answer for
|
||||
* deciding something — VisibleCommentScope resolves notification
|
||||
* recipients from it, and a recipient left out of that list is one who
|
||||
* never hears about a message addressed to them. It is the wrong answer
|
||||
* for telling somebody, because a client-scoped viewer may hold a file
|
||||
* that is also shared with a client they have no business knowing
|
||||
* exists. Anything rendering these names wants assignedFor() below.
|
||||
*
|
||||
* @return array{clients: list<array{id: int, name: string}>, groups: list<array{id: int, name: string}>}
|
||||
*/
|
||||
public function assigned(File|Folder $subject): array
|
||||
@@ -47,6 +59,17 @@ class ShareTargets
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* assigned(), narrowed to the recipients this viewer may be told
|
||||
* about. The display half of the pair — see the warning above.
|
||||
*
|
||||
* @return array{clients: list<array{id: int, name: string}>, groups: list<array{id: int, name: string}>}
|
||||
*/
|
||||
public function assignedFor(File|Folder $subject, ?User $viewer): array
|
||||
{
|
||||
return $this->identity->filterShares($viewer, $this->assigned($subject));
|
||||
}
|
||||
|
||||
/**
|
||||
* The assigned lists plus everything still available to share with,
|
||||
* narrowed to what this viewer is allowed to reach.
|
||||
@@ -76,7 +99,12 @@ class ShareTargets
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$assigned = $this->assigned($subject);
|
||||
// assignedFor, not assigned: an edit page listing a recipient this
|
||||
// viewer may not identify would both name them and offer a control
|
||||
// for a share the viewer cannot otherwise reach. available_* below
|
||||
// was already narrowed this way; assigned_* was not, which is the
|
||||
// asymmetry that made the whole panel a roster listing.
|
||||
$assigned = $this->assignedFor($subject, $viewer);
|
||||
|
||||
return [
|
||||
'assigned_clients' => $assigned['clients'],
|
||||
|
||||
@@ -31,32 +31,65 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
* path, BinaryFileResponse when PHP is streaming — each dropping the
|
||||
* Content-Length passed here in favour of the range actually served.
|
||||
*
|
||||
* The two paths are not equally revocable, which is why the lifetimes
|
||||
* below differ. Every local delivery method authorises one response and
|
||||
* no more — nginx's X-Accel-Redirect, Apache's X-Sendfile, or PHP
|
||||
* streaming the bytes itself: these bytes, now, to this request, and
|
||||
* nothing that outlives it. A presigned URL is a bearer
|
||||
* credential — whoever holds it can fetch the file without passing the
|
||||
* caller's checks again, and it outlives them: a download cap that is
|
||||
* spent in the meantime, an expires_at that falls in between, an
|
||||
* assignment that is withdrawn. Nothing here can revoke one, so the only
|
||||
* dial is how long it lasts.
|
||||
*
|
||||
* A download needs to survive being followed, which is a redirect and a
|
||||
* request: a minute is generous. A preview is held by the player for as
|
||||
* long as somebody watches, and each seek outside the buffer is a fresh
|
||||
* Range request against the same URL, so it keeps the hour. That is the
|
||||
* trade, stated rather than left in a single number.
|
||||
*
|
||||
* Callers of inline() must have established that the mime type is
|
||||
* inline-safe first; PreviewKind is the allowlist, and the reason there
|
||||
* is one.
|
||||
*/
|
||||
class StoredFileResponse
|
||||
{
|
||||
/**
|
||||
* Long enough for a browser, a download manager or a queued transfer
|
||||
* to follow the redirect and start the request. An object store
|
||||
* checks the signature when the request arrives, not while it runs,
|
||||
* so a transfer that begins inside this window finishes however long
|
||||
* it takes.
|
||||
*/
|
||||
private const DOWNLOAD_LINK_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* A preview is watched, not fetched: the player holds this URL and
|
||||
* issues a Range request every time somebody seeks past the buffer,
|
||||
* so it has to outlive the viewing rather than the redirect.
|
||||
*/
|
||||
private const PREVIEW_LINK_SECONDS = 3600;
|
||||
|
||||
public function __construct(private readonly FileDelivery $delivery) {}
|
||||
|
||||
/** Shown in place — a preview. */
|
||||
public function inline(File $file): Response|RedirectResponse
|
||||
{
|
||||
return $this->make($file, ContentDisposition::inline($file->original_name));
|
||||
return $this->make($file, ContentDisposition::inline($file->original_name), self::PREVIEW_LINK_SECONDS);
|
||||
}
|
||||
|
||||
/** Handed over — a download. */
|
||||
public function attachment(File $file): Response|RedirectResponse
|
||||
{
|
||||
return $this->make($file, ContentDisposition::attachment($file->original_name));
|
||||
return $this->make($file, ContentDisposition::attachment($file->original_name), self::DOWNLOAD_LINK_SECONDS);
|
||||
}
|
||||
|
||||
private function make(File $file, string $disposition): Response|RedirectResponse
|
||||
private function make(File $file, string $disposition, int $linkSeconds): Response|RedirectResponse
|
||||
{
|
||||
if ($file->disk !== 'files') {
|
||||
$url = Storage::disk($file->disk)->temporaryUrl(
|
||||
$file->path,
|
||||
now()->addHour(),
|
||||
now()->addSeconds($linkSeconds),
|
||||
['ResponseContentDisposition' => $disposition],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Editing;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Comments\CommentingRules;
|
||||
use App\Modules\Comments\CommentScope;
|
||||
use App\Modules\Files\Models\File;
|
||||
|
||||
/**
|
||||
* The one place that decides which fields an editor may actually write.
|
||||
*
|
||||
* Three surfaces edit a file — the staff editor, `/api/v1/files/{file}`,
|
||||
* and now a client's own uploads in the portal — and they had grown two
|
||||
* copies of the same eight permission checks with a third about to be
|
||||
* written. The checks are not hard; the problem is that they are *easy*,
|
||||
* so a new field gets added to one caller and the drift is invisible until
|
||||
* somebody finds the surface where the gate is missing.
|
||||
*
|
||||
* The split is deliberate: **callers normalise, this gates.** A caller
|
||||
* turns its own request shape into `$changes` — form semantics versus the
|
||||
* API's `sometimes`, a date string versus an instant — and this decides
|
||||
* what the actor is allowed to write, writes it, and records what happened.
|
||||
*
|
||||
* `$changes` uses array_key_exists semantics throughout: a key that is
|
||||
* absent is left alone, a key present with `null` is written as null. That
|
||||
* is the API's existing contract, and the web forms post every field they
|
||||
* own, so it is also the forms'.
|
||||
*
|
||||
* Two things deliberately do NOT live here, because they are the caller's
|
||||
* and getting them wrong is how a boundary breaks:
|
||||
*
|
||||
* - **Whether this actor may edit this file at all.** That is
|
||||
* `Gate::authorize('update', $file)` and FilePolicy. Nothing below
|
||||
* re-checks it.
|
||||
* - **Whether a destination folder is reachable.** Staff ask
|
||||
* StaffLibraryScope; a client asks `Folder::uploadableBy()`. Those are
|
||||
* different questions with the same shape, and the staff one answers
|
||||
* `true` for any client — see FilePolicy::update()'s note.
|
||||
*/
|
||||
class ApplyFileEdits
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly CommentingRules $commenting,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $changes only the fields the caller
|
||||
* wants written; absent keys
|
||||
* are left as they are
|
||||
*/
|
||||
public function apply(User $actor, File $file, array $changes): void
|
||||
{
|
||||
$attributes = [];
|
||||
|
||||
// Covered by the permission to edit the file at all, which the
|
||||
// policy has already settled by the time anything reaches here.
|
||||
foreach (['name', 'description', 'folder_id'] as $field) {
|
||||
if (array_key_exists($field, $changes)) {
|
||||
$attributes[$field] = $changes[$field];
|
||||
}
|
||||
}
|
||||
|
||||
// Only meaningful while the comment scope is `selected`, and only
|
||||
// offered by a form then — but a request reaching here directly
|
||||
// must not be able to set a flag the UI is currently hiding.
|
||||
if (array_key_exists('commentable', $changes) && $this->commenting->scope() === CommentScope::SelectedFiles) {
|
||||
$attributes['commentable'] = $changes['commentable'];
|
||||
}
|
||||
|
||||
// From here down, every field has a permission of its own, and the
|
||||
// rule for all of them is the same: lacking it leaves the field
|
||||
// exactly as it was rather than failing the request. An editor who
|
||||
// may rename a file but not publish it saves a rename, and the
|
||||
// public state does not move. The web and the API have always
|
||||
// behaved this way; it is why the portal can reuse both forms.
|
||||
if (array_key_exists('expires_at', $changes) && $actor->can('set_file_expiration_date')) {
|
||||
$attributes['expires_at'] = $changes['expires_at'];
|
||||
}
|
||||
|
||||
if (array_key_exists('download_limit', $changes) && $actor->can('limit_downloads')) {
|
||||
$attributes['download_limit'] = $changes['download_limit'];
|
||||
}
|
||||
|
||||
if (array_key_exists('download_limit_scope', $changes) && $actor->can('limit_downloads')) {
|
||||
$attributes['download_limit_scope'] = $changes['download_limit_scope'];
|
||||
}
|
||||
|
||||
$wasPublic = $file->public;
|
||||
|
||||
if (array_key_exists('public', $changes) && $actor->can('upload_public')) {
|
||||
$attributes['public'] = $changes['public'];
|
||||
|
||||
// A caller that offers the slug passes what was submitted; one
|
||||
// that does not simply omits the key and gets a derived slug.
|
||||
// The client portal is the second kind on purpose — an
|
||||
// installation-wide unique slug chosen by a client is a name to
|
||||
// squat and an existence oracle to probe, for no benefit over a
|
||||
// slug made from the name they already chose.
|
||||
//
|
||||
// Omitting the slug on an update keeps the current one: it must
|
||||
// not silently change just because the name did.
|
||||
$submitted = is_string($changes['slug'] ?? null) ? trim($changes['slug']) : '';
|
||||
|
||||
$attributes['slug'] = $submitted !== ''
|
||||
? $submitted
|
||||
: ($file->slug ?: File::uniqueSlugFrom(
|
||||
is_string($changes['name'] ?? null) ? $changes['name'] : $file->name,
|
||||
$file->id,
|
||||
));
|
||||
}
|
||||
|
||||
$file->update($attributes);
|
||||
|
||||
// After the write, not inside it: categories are a relation, not a
|
||||
// column. Gated by their own key, so an editor who may rename but
|
||||
// not categorise leaves them untouched.
|
||||
if (array_key_exists('categories', $changes) && $actor->can('set_file_categories')) {
|
||||
$file->categories()->sync($changes['categories']);
|
||||
}
|
||||
|
||||
$this->activity->log(Action::FileUpdated, subject: $file);
|
||||
|
||||
// Publishing and unpublishing are their own entries. A file
|
||||
// becoming reachable without a login is not a detail of "file
|
||||
// updated", and it is the line an audit is most likely to be read
|
||||
// for.
|
||||
if (! $wasPublic && $file->public) {
|
||||
$this->activity->log(Action::FileMadePublic, subject: $file, context: ['slug' => $file->slug]);
|
||||
} elseif ($wasPublic && ! $file->public) {
|
||||
$this->activity->log(Action::FileMadePrivate, subject: $file);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Editing;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Platform\Localization\LocalDay;
|
||||
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* Reading and writing a file's expiry in the zone of whoever is looking.
|
||||
*
|
||||
* The stored value is an instant. What a person sets is a calendar day,
|
||||
* and "the 12th" means the end of the 12th where *they* live — otherwise a
|
||||
* file asked to expire on the 12th dies partway through the 11th for
|
||||
* anyone west of Greenwich, and gives anyone east of it most of a day
|
||||
* nobody promised.
|
||||
*
|
||||
* The two halves have to agree, which is the whole reason they sit
|
||||
* together: a form is rendered with asShown() and posts the same string
|
||||
* back untouched with every other edit, so a caller compares against
|
||||
* asShown() to tell "the editor changed the date" from "the editor renamed
|
||||
* the file and the date came along for the ride". Re-deriving on every
|
||||
* save instead moves the expiry by the difference between two people's
|
||||
* zones each time somebody edits anything.
|
||||
*
|
||||
* Was three private copies — the staff editor, the API, and now the client
|
||||
* portal — of which the API's was the only one that could read a
|
||||
* timestamp.
|
||||
*/
|
||||
class FileExpiry
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TimezoneRegistry $timezones,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The stored instant as the calendar day a form should show, in the
|
||||
* viewer's zone. Null when the file never expires.
|
||||
*/
|
||||
public function asShown(File $file, ?User $viewer): ?string
|
||||
{
|
||||
return $file->expires_at?->copy()->setTimezone($this->timezones->resolve($viewer))->toDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* The instant a submitted value actually names.
|
||||
*
|
||||
* A bare `YYYY-MM-DD` is a calendar day and means the end of it where
|
||||
* the setter is — what every date input posts. Anything carrying a
|
||||
* time is an instant somebody named on purpose and is stored as it
|
||||
* arrives: the API can express a moment, and a date input cannot.
|
||||
*/
|
||||
public function instant(?string $value, ?User $setter): ?Carbon
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1
|
||||
? LocalDay::end($value, $this->timezones->resolve($setter))
|
||||
: Carbon::parse($value);
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,15 @@ use App\Modules\Files\Models\File;
|
||||
/**
|
||||
* Ownership rules as policy methods (brief §6.13): "own" versus
|
||||
* "others'" files map onto the v1 permission pairs. Clients may only
|
||||
* view/download what is assigned to them, directly or via a group. For
|
||||
* client-scoped staff, every action is additionally gated by the
|
||||
* StaffLibraryScope, so direct access can't reach out-of-scope files.
|
||||
* view/download what is assigned to them, directly or via a group, and may
|
||||
* edit or delete only what they uploaded themselves. For client-scoped
|
||||
* staff, every action is additionally gated by the StaffLibraryScope, so
|
||||
* direct access can't reach out-of-scope files.
|
||||
*
|
||||
* Every method here branches on isStaff() before it reaches the scope.
|
||||
* That is not stylistic: StaffLibraryScope answers "is this *restricted*
|
||||
* staff member allowed?", and its "no restriction" answer is `true`. A
|
||||
* client falling through to it is handed the whole library. See update().
|
||||
*/
|
||||
class FilePolicy
|
||||
{
|
||||
@@ -33,8 +39,25 @@ class FilePolicy
|
||||
|
||||
public function update(User $user, File $file): bool
|
||||
{
|
||||
// A client edits what they uploaded and nothing else. Deliberately
|
||||
// its own branch rather than a shared one, because the staff branch
|
||||
// below is unsafe for a client in two ways at once.
|
||||
//
|
||||
// First, `edit_others_files` must never be reachable here. It is a
|
||||
// staff key by construction: a client has no "others' files" they
|
||||
// could hold a legitimate claim over, only files somebody shared
|
||||
// with them, and being shown a file is not being given it. Granting
|
||||
// that key to the Client role does nothing, and a test pins that.
|
||||
//
|
||||
// Second, and the trap: StaffLibraryScope::allowsFile() returns
|
||||
// true outright for anyone who is not client-*scoped* staff —
|
||||
// User::isClientScoped() is `isStaff() && role->client_scoped`, so
|
||||
// it is false for every client. That predicate means "this staff
|
||||
// member is unrestricted", and a client reaching it would inherit
|
||||
// "unrestricted" over the whole library. Nothing here may touch the
|
||||
// staff scope.
|
||||
if (! $user->isStaff()) {
|
||||
return false;
|
||||
return $file->isOwnedBy($user) && $user->can('edit_files');
|
||||
}
|
||||
|
||||
$permitted = $file->isOwnedBy($user) ? $user->can('edit_files') : $user->can('edit_others_files');
|
||||
@@ -72,8 +95,11 @@ class FilePolicy
|
||||
|
||||
public function delete(User $user, File $file): bool
|
||||
{
|
||||
// Their own upload, and only with the key — same two reasons as
|
||||
// update() above, `delete_others_files` standing in for
|
||||
// `edit_others_files`.
|
||||
if (! $user->isStaff()) {
|
||||
return false;
|
||||
return $file->isOwnedBy($user) && $user->can('delete_files');
|
||||
}
|
||||
|
||||
$permitted = $file->isOwnedBy($user) ? $user->can('delete_files') : $user->can('delete_others_files');
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files;
|
||||
|
||||
use App\Modules\Files\Access\ClientIdentityScope;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
@@ -30,6 +31,10 @@ class FilesServiceProvider extends ServiceProvider
|
||||
// reached twice. Scoped rather than a singleton so a long-lived
|
||||
// queue worker starts each job with an empty memo.
|
||||
$this->app->scoped(StaffLibraryScope::class);
|
||||
|
||||
// Same lifetime, same reason: the identity rule memoises a roster
|
||||
// per viewer and the file listings ask it once per row.
|
||||
$this->app->scoped(ClientIdentityScope::class);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
|
||||
@@ -10,23 +10,21 @@ use App\Modules\Api\Support\PollingQuery;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Clients\ClientStorageUsage;
|
||||
use App\Modules\Comments\CommentingRules;
|
||||
use App\Modules\Comments\CommentScope;
|
||||
use App\Modules\Files\Access\ClientIdentityScope;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Access\ViewableFileScope;
|
||||
use App\Modules\Files\DownloadLimitScope;
|
||||
use App\Modules\Files\Editing\ApplyFileEdits;
|
||||
use App\Modules\Files\Editing\FileExpiry;
|
||||
use App\Modules\Files\Http\Resources\Api\FileResource;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Files\Storage\ResolvingUploadDisk;
|
||||
use App\Modules\Files\Uploads\StoreUploadedFile;
|
||||
use App\Modules\Files\Uploads\UploadExtensionPolicy;
|
||||
use App\Modules\Platform\Localization\LocalDay;
|
||||
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use App\Support\Rules;
|
||||
use Carbon\Carbon;
|
||||
use Closure;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
@@ -60,9 +58,10 @@ class FilesController extends Controller
|
||||
private readonly UploadExtensionPolicy $extensionPolicy,
|
||||
private readonly ClientStorageUsage $storageUsage,
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly CommentingRules $commenting,
|
||||
private readonly StaffLibraryScope $scope,
|
||||
private readonly TimezoneRegistry $timezones,
|
||||
private readonly ClientIdentityScope $identity,
|
||||
private readonly ApplyFileEdits $fileEdits,
|
||||
private readonly FileExpiry $expiry,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -114,6 +113,17 @@ class FilesController extends Controller
|
||||
}
|
||||
|
||||
if (array_key_exists('uploaded_by', $filters) && $filters['uploaded_by'] !== null) {
|
||||
// A filter is a question, and this one asks "did client N put
|
||||
// anything into my library". Answered plainly it is an oracle:
|
||||
// a client-scoped caller could walk the id space and learn
|
||||
// which clients off their roster share files with clients on
|
||||
// it, without ever reading a name. So an id this caller may
|
||||
// not identify matches nothing — indistinguishable from a
|
||||
// client who has uploaded nothing, which is the point.
|
||||
if (! $this->identity->permitsClientId($user, (int) $filters['uploaded_by'])) {
|
||||
$query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
$query->where('files.uploaded_by', $filters['uploaded_by']);
|
||||
}
|
||||
|
||||
@@ -316,44 +326,32 @@ class FilesController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$attributes = array_intersect_key($validated, array_flip(['name', 'description', 'folder_id']));
|
||||
// `sometimes` throughout the rules above means $validated already
|
||||
// holds exactly the fields the caller sent, which is the same
|
||||
// array_key_exists contract ApplyFileEdits reads — so the payload
|
||||
// passes through almost untouched. Which of them this token's user
|
||||
// may actually write is that class's decision, shared with the
|
||||
// staff editor and the client portal.
|
||||
$changes = array_intersect_key($validated, array_flip([
|
||||
'name',
|
||||
'description',
|
||||
'folder_id',
|
||||
'commentable',
|
||||
'download_limit',
|
||||
'download_limit_scope',
|
||||
'public',
|
||||
'slug',
|
||||
'categories',
|
||||
]));
|
||||
|
||||
if (array_key_exists('expires_at', $validated) && $user->can('set_file_expiration_date')) {
|
||||
$attributes['expires_at'] = $this->expiryInstant($validated['expires_at'], $user);
|
||||
// The one field that needs converting rather than passing along: a
|
||||
// caller may send a calendar day or a full timestamp, and a day
|
||||
// means the end of that day where the caller is.
|
||||
if (array_key_exists('expires_at', $validated)) {
|
||||
$changes['expires_at'] = $this->expiry->instant($validated['expires_at'], $user);
|
||||
}
|
||||
|
||||
if (array_key_exists('download_limit', $validated) && $user->can('limit_downloads')) {
|
||||
$attributes['download_limit'] = $validated['download_limit'];
|
||||
}
|
||||
|
||||
if (array_key_exists('download_limit_scope', $validated) && $user->can('limit_downloads')) {
|
||||
$attributes['download_limit_scope'] = $validated['download_limit_scope'];
|
||||
}
|
||||
|
||||
if (array_key_exists('commentable', $validated) && $this->commenting->scope() === CommentScope::SelectedFiles) {
|
||||
$attributes['commentable'] = $validated['commentable'];
|
||||
}
|
||||
|
||||
$wasPublic = $file->public;
|
||||
|
||||
if (array_key_exists('public', $validated) && $user->can('upload_public')) {
|
||||
$attributes['public'] = $validated['public'];
|
||||
$attributes['slug'] = ($validated['slug'] ?? '') ?: ($file->slug ?: File::uniqueSlugFrom($validated['name'] ?? $file->name, $file->id));
|
||||
}
|
||||
|
||||
$file->update($attributes);
|
||||
|
||||
if (array_key_exists('categories', $validated) && $user->can('set_file_categories')) {
|
||||
$file->categories()->sync($validated['categories']);
|
||||
}
|
||||
|
||||
$this->activity->log(Action::FileUpdated, subject: $file);
|
||||
|
||||
if (! $wasPublic && $file->public) {
|
||||
$this->activity->log(Action::FileMadePublic, subject: $file, context: ['slug' => $file->slug]);
|
||||
} elseif ($wasPublic && ! $file->public) {
|
||||
$this->activity->log(Action::FileMadePrivate, subject: $file);
|
||||
}
|
||||
$this->fileEdits->apply($user, $file, $changes);
|
||||
|
||||
return new FileResource($file->fresh()?->load(['folder', 'uploader', 'categories']) ?? $file);
|
||||
}
|
||||
@@ -369,29 +367,4 @@ class FilesController extends Controller
|
||||
|
||||
return response()->json(status: 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* What an `expires_at` value means.
|
||||
*
|
||||
* A bare `YYYY-MM-DD` is a calendar day, and a calendar day ends where
|
||||
* the person naming it lives — the same rule the web form's date input
|
||||
* gets from FilesController::expiryInstant. Stored as it arrives it
|
||||
* would be midnight UTC instead, so a file asked to expire on the 12th
|
||||
* would die at the *start* of the 12th, and for a caller west of
|
||||
* Greenwich partway through the 11th.
|
||||
*
|
||||
* Anything carrying a time is an instant the caller named on purpose
|
||||
* and is stored as it arrives, unchanged from before: the API can
|
||||
* express a moment, and a date input cannot.
|
||||
*/
|
||||
private function expiryInstant(?string $value, User $setter): ?Carbon
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1
|
||||
? LocalDay::end($value, $this->timezones->resolve($setter))
|
||||
: Carbon::parse($value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Modules\Files\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Access\ClientIdentityScope;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Models\Category;
|
||||
use App\Modules\Files\Models\File;
|
||||
@@ -28,6 +29,7 @@ class ClientFilesController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StaffLibraryScope $scope,
|
||||
private readonly ClientIdentityScope $identity,
|
||||
) {}
|
||||
|
||||
public function index(Request $request, User $client): Response
|
||||
@@ -66,7 +68,11 @@ class ClientFilesController extends Controller
|
||||
'size' => $file->size,
|
||||
'created_at' => $file->created_at?->toIso8601String(),
|
||||
'uploaded_by_client' => $file->uploaded_by === $client->id,
|
||||
'uploader' => $file->uploader?->name,
|
||||
// Being allowed to browse this client's files does not
|
||||
// extend to the other clients who shared files with them:
|
||||
// a file reaches this listing through the client in the
|
||||
// URL, and its uploader can be somebody else entirely.
|
||||
'uploader' => $this->identity->nameOf($viewer, $file->uploader),
|
||||
'downloads_count' => $file->downloads_count,
|
||||
'can_download' => Gate::forUser($viewer)->allows('view', $file),
|
||||
'categories' => $file->categories->map(fn (Category $category): array => [
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Audit\ActivityPresenter;
|
||||
use App\Modules\Audit\DownloadPresenter;
|
||||
use App\Modules\Comments\CommentingRules;
|
||||
use App\Modules\Files\Access\ClientIdentityScope;
|
||||
use App\Modules\Files\Access\DownloadAllowance;
|
||||
use App\Modules\Files\Access\ShareTargets;
|
||||
use App\Modules\Files\DownloadLimitScope;
|
||||
@@ -79,6 +80,7 @@ class FileDetailsController extends Controller
|
||||
private readonly ActivityPresenter $presenter,
|
||||
private readonly DownloadPresenter $downloadPresenter,
|
||||
private readonly ShareTargets $shareTargets,
|
||||
private readonly ClientIdentityScope $identity,
|
||||
private readonly CommentingRules $commenting,
|
||||
private readonly FileVersionLinks $versionLinks,
|
||||
private readonly DownloadAllowance $allowance,
|
||||
@@ -100,7 +102,10 @@ class FileDetailsController extends Controller
|
||||
'size' => $file->size,
|
||||
'mime_type' => $file->mime_type,
|
||||
'checksum' => $file->checksum,
|
||||
'uploader' => $file->uploader?->name,
|
||||
// Null when the uploader is a client this viewer may not
|
||||
// be told about, which reads the same as an uploader whose
|
||||
// account has since been deleted.
|
||||
'uploader' => $this->identity->nameOf($viewer, $file->uploader),
|
||||
'folder' => $file->folder?->only('id', 'name'),
|
||||
'categories' => $file->categories()->orderBy('name')->get()
|
||||
->map(fn (Category $category): array => ['id' => $category->id, 'name' => $category->name, 'color' => $category->color])
|
||||
@@ -140,7 +145,7 @@ class FileDetailsController extends Controller
|
||||
// Resolved from the chain root for a revision (ShareTargets
|
||||
// does that), so this names who really has the file. The panel
|
||||
// says where those recipients are set.
|
||||
'shares' => $this->shareTargets->assigned($file),
|
||||
'shares' => $this->shareTargets->assignedFor($file, $viewer),
|
||||
'sharing_root' => $file->isRevision()
|
||||
? File::query()->find($file->sharingOwnerId())?->only('id', 'name')
|
||||
: null,
|
||||
@@ -368,7 +373,7 @@ class FileDetailsController extends Controller
|
||||
'name' => $folder->name,
|
||||
'files_count' => $folder->files()->count(),
|
||||
'children_count' => $folder->children()->count(),
|
||||
'creator' => $folder->creator?->name,
|
||||
'creator' => $this->identity->nameOf($viewer, $folder->creator),
|
||||
'created_at' => $folder->created_at?->toIso8601String(),
|
||||
'open_url' => route('files.index', ['folder' => $folder->id], false),
|
||||
// Read-only here, same as a file's shares — sharing (and every
|
||||
@@ -377,7 +382,7 @@ class FileDetailsController extends Controller
|
||||
'edit_url' => route('folders.share', $folder, false),
|
||||
'can_update' => Gate::forUser($viewer)->allows('update', $folder),
|
||||
'can_view_activity' => $viewer->can('view_actions_log'),
|
||||
'shares' => $this->shareTargets->assigned($folder),
|
||||
'shares' => $this->shareTargets->assignedFor($folder, $viewer),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,12 @@ use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Comments\CommentingRules;
|
||||
use App\Modules\Comments\CommentScope;
|
||||
use App\Modules\Files\Access\ClientIdentityScope;
|
||||
use App\Modules\Files\Access\ShareTargets;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\DownloadLimitScope;
|
||||
use App\Modules\Files\Editing\ApplyFileEdits;
|
||||
use App\Modules\Files\Editing\FileExpiry;
|
||||
use App\Modules\Files\Models\Category;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
@@ -22,13 +25,10 @@ use App\Modules\Files\Uploads\StoreUploadedFile;
|
||||
use App\Modules\Files\Uploads\UploadExtensionPolicy;
|
||||
use App\Modules\Files\Versions\FileVersionLinks;
|
||||
use App\Modules\Files\Versions\FileVersions;
|
||||
use App\Modules\Platform\Localization\LocalDay;
|
||||
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use App\Support\PublicUrl;
|
||||
use App\Support\Rules;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
@@ -49,10 +49,12 @@ class FilesController extends Controller
|
||||
private readonly StaffLibraryScope $scope,
|
||||
private readonly PublicUrl $publicUrl,
|
||||
private readonly ShareTargets $shareTargets,
|
||||
private readonly ClientIdentityScope $identity,
|
||||
private readonly CommentingRules $commenting,
|
||||
private readonly FileVersions $versions,
|
||||
private readonly FileVersionLinks $versionLinks,
|
||||
private readonly TimezoneRegistry $timezones,
|
||||
private readonly ApplyFileEdits $fileEdits,
|
||||
private readonly FileExpiry $expiry,
|
||||
) {}
|
||||
|
||||
public function create(Request $request): Response
|
||||
@@ -164,7 +166,7 @@ class FilesController extends Controller
|
||||
'original_name' => $file->original_name,
|
||||
'size' => $file->size,
|
||||
'mime_type' => $file->mime_type,
|
||||
'uploader' => $file->uploader?->name,
|
||||
'uploader' => $this->identity->nameOf($viewer, $file->uploader),
|
||||
'folder_id' => $file->folder_id,
|
||||
'public' => $file->public,
|
||||
'commentable' => $file->commentable,
|
||||
@@ -173,7 +175,7 @@ class FilesController extends Controller
|
||||
// calendar date the editor typed — read back in their
|
||||
// zone, not the server's, or a file set to expire on the
|
||||
// 12th reopens showing the 11th.
|
||||
'expires_at' => $this->expiryDateFor($file, $request->user()),
|
||||
'expires_at' => $this->expiry->asShown($file, $request->user()),
|
||||
'expired' => $file->isExpired(),
|
||||
'download_limit' => $file->download_limit,
|
||||
'download_limit_scope' => ($file->download_limit_scope ?? DownloadLimitScope::Total)->value,
|
||||
@@ -274,6 +276,8 @@ class FilesController extends Controller
|
||||
// change comparison below matches the model's int.
|
||||
$folderId = isset($validated['folder_id']) ? (int) $validated['folder_id'] : null;
|
||||
$user = $request->user();
|
||||
// Gate::authorize above cannot pass without one.
|
||||
assert($user !== null);
|
||||
|
||||
// Reparenting through update() is the same privileged write as
|
||||
// move()/bulkUpdate(), so it needs the same guard: the destination
|
||||
@@ -281,80 +285,45 @@ class FilesController extends Controller
|
||||
// folder actually changes, so re-saving a file that already sits in
|
||||
// an out-of-scope folder (reachable via a direct client share) still
|
||||
// works.
|
||||
if ($folderId !== null && $folderId !== $file->folder_id && $user !== null) {
|
||||
if ($folderId !== null && $folderId !== $file->folder_id) {
|
||||
$this->scope->folders($user)->findOrFail($folderId);
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
// Normalised into the shape ApplyFileEdits reads, then handed
|
||||
// over: which of these the actor may actually write is that
|
||||
// class's decision, and it is the same decision the API and the
|
||||
// client portal get. See its docblock for why the split is here.
|
||||
$changes = [
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'folder_id' => $folderId,
|
||||
// Present unconditionally; the comment scope decides whether it
|
||||
// is honoured. Defaulted to the stored value so a form that
|
||||
// does not render the field cannot clear it.
|
||||
'commentable' => $validated['commentable'] ?? $file->commentable,
|
||||
'download_limit' => $validated['download_limit'] ?? null,
|
||||
'download_limit_scope' => $validated['download_limit_scope'] ?? DownloadLimitScope::Total->value,
|
||||
'public' => $validated['public'] ?? $file->public,
|
||||
'slug' => $validated['slug'] ?? '',
|
||||
'categories' => $validated['categories'] ?? [],
|
||||
];
|
||||
|
||||
// Only meaningful while the comment scope is `selected`, and only
|
||||
// offered by the page then — but a request reaching here directly
|
||||
// must not be able to set a flag the UI is currently hiding, the
|
||||
// same shape as the upload_public gate below.
|
||||
if ($this->commenting->scope() === CommentScope::SelectedFiles) {
|
||||
$attributes['commentable'] = $validated['commentable'] ?? $file->commentable;
|
||||
// The one field that is conditionally *present* rather than
|
||||
// conditionally honoured, and the reason it cannot move into
|
||||
// ApplyFileEdits: the form was rendered with the stored instant
|
||||
// read back as a date in this viewer's zone, and posts it again
|
||||
// untouched with every other edit. Re-deriving it unconditionally
|
||||
// would move the expiry by the difference between two people's
|
||||
// zones each time somebody merely renamed the file. Compared
|
||||
// against the same string the form was given, so "unchanged" means
|
||||
// what the editor actually saw.
|
||||
$posted = $validated['expires_at'] ?? null;
|
||||
|
||||
if ($posted !== $this->expiry->asShown($file, $user)) {
|
||||
$changes['expires_at'] = $this->expiry->instant($posted, $user);
|
||||
}
|
||||
|
||||
// Only a user who can set expiration dates may change this file's
|
||||
// own expiry — same "leave it alone if you lack the permission"
|
||||
// rule as the upload_public gate below.
|
||||
if ($request->user()?->can('set_file_expiration_date') === true) {
|
||||
$posted = $validated['expires_at'] ?? null;
|
||||
|
||||
// Re-derived only when the date actually changed. The form was
|
||||
// rendered with the stored instant read back as a date in *this*
|
||||
// viewer's zone, and posts it again untouched with every other
|
||||
// edit — so deriving it unconditionally moves the expiry by the
|
||||
// difference between two people's zones each time somebody
|
||||
// merely renames the file. Compared against the same string the
|
||||
// form was given, above, so "unchanged" means what the editor
|
||||
// saw.
|
||||
if ($posted !== $this->expiryDateFor($file, $request->user())) {
|
||||
$attributes['expires_at'] = $this->expiryInstant($posted, $request->user());
|
||||
}
|
||||
}
|
||||
|
||||
// Same rule again for the download cap, behind its own
|
||||
// permission — the one that already gates a share link's
|
||||
// max_downloads, since both are the same question asked about
|
||||
// different objects.
|
||||
if ($request->user()?->can('limit_downloads') === true) {
|
||||
$attributes['download_limit'] = $validated['download_limit'] ?? null;
|
||||
$attributes['download_limit_scope'] = $validated['download_limit_scope'] ?? DownloadLimitScope::Total->value;
|
||||
}
|
||||
|
||||
$wasPublic = $file->public;
|
||||
|
||||
// Only a user who can manage public state may change it — a user
|
||||
// who can edit a file but lacks upload_public leaves its public
|
||||
// state exactly as it was, same rule as FoldersController::update.
|
||||
if ($request->user()?->can('upload_public') === true) {
|
||||
$attributes['public'] = $validated['public'] ?? $file->public;
|
||||
// Omitting the field on an update leaves the current slug
|
||||
// alone — it must not silently change just because the name
|
||||
// did.
|
||||
$attributes['slug'] = ($validated['slug'] ?? '') ?: ($file->slug ?: File::uniqueSlugFrom($validated['name'], $file->id));
|
||||
}
|
||||
|
||||
$file->update($attributes);
|
||||
|
||||
// Categories are gated by their own permission; leave them untouched
|
||||
// for a user who can edit the file but not set categories.
|
||||
if ($request->user()?->can('set_file_categories') === true) {
|
||||
$file->categories()->sync($validated['categories'] ?? []);
|
||||
}
|
||||
|
||||
$this->activity->log(Action::FileUpdated, subject: $file);
|
||||
|
||||
if (! $wasPublic && $file->public) {
|
||||
$this->activity->log(Action::FileMadePublic, subject: $file, context: ['slug' => $file->slug]);
|
||||
} elseif ($wasPublic && ! $file->public) {
|
||||
$this->activity->log(Action::FileMadePrivate, subject: $file);
|
||||
}
|
||||
$this->fileEdits->apply($user, $file, $changes);
|
||||
|
||||
return back()->with('success', __('File updated.'));
|
||||
}
|
||||
@@ -475,7 +444,7 @@ class FilesController extends Controller
|
||||
// update()'s expires_at handling.
|
||||
if ($validated['expiration_action'] !== 'no_change' && $canSetExpiration) {
|
||||
$attributes['expires_at'] = $validated['expiration_action'] === 'set'
|
||||
? $this->expiryInstant($validated['expires_at'], $user)
|
||||
? $this->expiry->instant($validated['expires_at'], $user)
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -542,40 +511,17 @@ class FilesController extends Controller
|
||||
Gate::authorize('delete', $file);
|
||||
|
||||
$name = $file->name;
|
||||
// Soft delete; the bytes stay on disk until a purge policy
|
||||
// lands with the retention work.
|
||||
// Soft delete of the row — but not of the bytes. File::booted()'s
|
||||
// `deleted` hook runs FileDiskCleanup on commit, so the upload and
|
||||
// every cached rendition of it are gone from disk by the time this
|
||||
// returns. The row is kept because version chains, the activity
|
||||
// log and the erasure grace period all still point at it; nothing
|
||||
// serves it (route-model binding 404s), and nothing ever
|
||||
// forceDelete()s it either.
|
||||
$file->delete();
|
||||
|
||||
$this->activity->log(Action::FileDeleted, context: ['name' => $name]);
|
||||
|
||||
return redirect()->route('files.index')->with('success', __('File deleted.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The instant a `<input type="date">` expiry actually falls on.
|
||||
*
|
||||
* The form posts a bare `YYYY-MM-DD`, which Eloquent would otherwise
|
||||
* store as midnight UTC — so "expires on the 12th" would cut the file
|
||||
* off partway through the 11th for anyone in the Americas, and give
|
||||
* anyone east of Greenwich most of a day they were not promised. It
|
||||
* means the end of the 12th where the person setting it lives.
|
||||
*/
|
||||
private function expiryInstant(?string $date, ?User $setter): ?Carbon
|
||||
{
|
||||
return $date === null
|
||||
? null
|
||||
: LocalDay::end($date, $this->timezones->resolve($setter));
|
||||
}
|
||||
|
||||
/**
|
||||
* The inverse: the calendar date a stored expiry falls on for this
|
||||
* viewer, which is what the date input is given and what it posts back.
|
||||
*
|
||||
* The pair has to agree, or a re-save reads one date and writes
|
||||
* another.
|
||||
*/
|
||||
private function expiryDateFor(File $file, ?User $viewer): ?string
|
||||
{
|
||||
return $file->expires_at?->copy()->setTimezone($this->timezones->resolve($viewer))->toDateString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Comments\Access\VisibleCommentScope;
|
||||
use App\Modules\Comments\CommentingRules;
|
||||
use App\Modules\Files\Access\ClientIdentityScope;
|
||||
use App\Modules\Files\Access\DownloadAllowance;
|
||||
use App\Modules\Files\Access\ShareTargets;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
@@ -54,6 +55,7 @@ class FoldersController extends Controller
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly PublicUrl $publicUrl,
|
||||
private readonly ShareTargets $shareTargets,
|
||||
private readonly ClientIdentityScope $identity,
|
||||
private readonly BreadcrumbBuilder $breadcrumbs,
|
||||
private readonly CommentingRules $commenting,
|
||||
private readonly VisibleCommentScope $comments,
|
||||
@@ -240,7 +242,11 @@ class FoldersController extends Controller
|
||||
'original_name' => $file->original_name,
|
||||
'mime_type' => $file->mime_type,
|
||||
'size' => $file->size,
|
||||
'uploader' => $file->uploader ? [
|
||||
// The whole block goes, not just the name: type and role
|
||||
// describe the same person, and "a client uploaded this" on a
|
||||
// row whose uploader is off this viewer's roster narrows who
|
||||
// it could be just as effectively as naming them.
|
||||
'uploader' => ($file->uploader !== null && $this->identity->permits($user, $file->uploader)) ? [
|
||||
'name' => $file->uploader->name,
|
||||
'type' => $file->uploader->type->value,
|
||||
'role' => $file->uploader->role?->name,
|
||||
|
||||
@@ -5,10 +5,16 @@ declare(strict_types=1);
|
||||
namespace App\Modules\Files\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Clients\ClientStorageUsage;
|
||||
use App\Modules\Comments\Access\VisibleCommentScope;
|
||||
use App\Modules\Comments\CommentingRules;
|
||||
use App\Modules\Comments\CommentScope;
|
||||
use App\Modules\Files\Access\DownloadAllowance;
|
||||
use App\Modules\Files\DownloadLimitScope;
|
||||
use App\Modules\Files\Editing\ApplyFileEdits;
|
||||
use App\Modules\Files\Editing\FileExpiry;
|
||||
use App\Modules\Files\Folders\BreadcrumbBuilder;
|
||||
use App\Modules\Files\Models\Category;
|
||||
use App\Modules\Files\Models\File;
|
||||
@@ -22,6 +28,7 @@ use App\Modules\Platform\Settings\Settings;
|
||||
use App\Modules\Platform\Theming\PublicThemeRegistry;
|
||||
use App\Support\ConcatenatedPagination;
|
||||
use App\Support\Pagination;
|
||||
use App\Support\Rules;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -66,6 +73,9 @@ class MyFilesController extends Controller
|
||||
private readonly DownloadAllowance $allowance,
|
||||
private readonly FileVersions $versions,
|
||||
private readonly FileVersionLinks $versionLinks,
|
||||
private readonly ApplyFileEdits $fileEdits,
|
||||
private readonly FileExpiry $expiry,
|
||||
private readonly ActivityLogger $activity,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response|RedirectResponse
|
||||
@@ -207,9 +217,11 @@ class MyFilesController extends Controller
|
||||
$fileRows = $sliced['items']['files'];
|
||||
|
||||
$commentCounts = $this->comments->countsFor($client, $fileRows);
|
||||
// Two queries for the page, not two per row. No URL resolver: the
|
||||
// portal has no per-file page to link to, so a counterpart is named
|
||||
// and not linked (see docs/theming-files-checklist.md).
|
||||
// Two queries for the page, not two per row. Still no URL resolver:
|
||||
// the portal's per-file page is an *editor* for a client's own
|
||||
// uploads, and a version counterpart is frequently neither theirs
|
||||
// nor editable — so a counterpart stays named and not linked (see
|
||||
// docs/theming-files-checklist.md).
|
||||
$versions = $this->versionLinks->forMany($fileRows, $client);
|
||||
$unreadComments = $this->comments->unreadCountsFor($client, array_values(array_map(intval(...), $fileRows->pluck('id')->all())));
|
||||
|
||||
@@ -237,6 +249,14 @@ class MyFilesController extends Controller
|
||||
'size' => $file->size,
|
||||
'created_at' => $file->created_at?->toIso8601String(),
|
||||
'is_mine' => $file->uploaded_by === $client->id,
|
||||
// Decided per row by FilePolicy, exactly as the folder rows
|
||||
// above are: a client's own uploads are theirs to manage
|
||||
// and files shared with them are not, and both kinds sit in
|
||||
// the same list. A theme reads these and never works them
|
||||
// out from is_mine — holding the file is only half of it,
|
||||
// the role's keys are the other half.
|
||||
'can_update' => Gate::forUser($client)->allows('update', $file),
|
||||
'can_delete' => Gate::forUser($client)->allows('delete', $file),
|
||||
// Effective status (own flag or inherited from a public
|
||||
// folder) — same "will visitors on the public site see
|
||||
// this" badge as the staff library shows.
|
||||
@@ -306,6 +326,200 @@ class MyFilesController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor page for a file this client uploaded.
|
||||
*
|
||||
* One page for every theme, not one per theme — the same shape
|
||||
* `upload()` uses, and for the same reason: this is a form, and a form
|
||||
* rebuilt four times is four places for a field to go missing. The
|
||||
* `theme` prop picks the shell (see portal/edit-file.tsx), which is the
|
||||
* only part that differs.
|
||||
*
|
||||
* Every `can_*` prop below is the *same* question ApplyFileEdits will
|
||||
* ask when the form posts. A control this page hides is not a control
|
||||
* the server then trusts: hiding it is a courtesy so a client is not
|
||||
* shown a switch that will silently do nothing, and the refusal is
|
||||
* server-side either way.
|
||||
*/
|
||||
public function edit(Request $request, File $file): Response
|
||||
{
|
||||
$client = $request->user();
|
||||
abort_unless($client !== null && $client->isClient(), 404);
|
||||
|
||||
Gate::authorize('update', $file);
|
||||
|
||||
$file->loadMissing('categories');
|
||||
|
||||
return Inertia::render('portal/edit-file', [
|
||||
'theme' => $this->themeKey(),
|
||||
'file' => [
|
||||
'id' => $file->id,
|
||||
'name' => $file->name,
|
||||
'description' => $file->description,
|
||||
'original_name' => $file->original_name,
|
||||
'size' => $file->size,
|
||||
'public' => $file->public,
|
||||
'commentable' => $file->commentable,
|
||||
// The stored instant as the calendar day this client's own
|
||||
// zone shows — the value the form posts back untouched, and
|
||||
// the one update() compares against to tell a real change
|
||||
// from a date that merely came along with a rename.
|
||||
'expires_at' => $this->expiry->asShown($file, $client),
|
||||
'download_limit' => $file->download_limit,
|
||||
'download_limit_scope' => ($file->download_limit_scope ?? DownloadLimitScope::Total)->value,
|
||||
'folder_id' => $file->folder_id,
|
||||
'categories' => $file->categories->pluck('id')->all(),
|
||||
],
|
||||
'can_delete' => Gate::forUser($client)->allows('delete', $file),
|
||||
'can_publish' => $client->can('upload_public'),
|
||||
'can_set_expiration' => $client->can('set_file_expiration_date'),
|
||||
'can_set_categories' => $client->can('set_file_categories'),
|
||||
'can_limit_downloads' => $client->can('limit_downloads'),
|
||||
// Only while the installation asks per file; otherwise the
|
||||
// setting decides and the switch would be a lie.
|
||||
'can_set_commentable' => $this->commenting->scope() === CommentScope::SelectedFiles,
|
||||
'categories' => Category::query()->orderBy('name')->get(['id', 'name', 'color'])
|
||||
->map(fn (Category $category): array => [
|
||||
'id' => $category->id, 'name' => $category->name, 'color' => $category->color,
|
||||
])->all(),
|
||||
// Somewhere this client could have uploaded it in the first
|
||||
// place — the same rule update() enforces, so the picker cannot
|
||||
// offer a destination the save would refuse.
|
||||
'folders' => Folder::query()->visibleToClient($client)->orderBy('name')->get()
|
||||
->filter(fn (Folder $folder): bool => Folder::uploadableBy($client, $folder))
|
||||
->map(fn (Folder $folder): array => [
|
||||
'id' => $folder->id,
|
||||
'name' => $folder->name,
|
||||
// A destination can publish the file without the public
|
||||
// switch being touched: File::isEffectivelyPublic() is
|
||||
// "my own flag OR my folder's", and a client holding
|
||||
// upload_to_public_folders may move into a public
|
||||
// folder without holding upload_public. That is the
|
||||
// established meaning of the two keys, and it is what
|
||||
// uploading there has always done — but in a picker of
|
||||
// bare names it would be invisible, so the name carries
|
||||
// the consequence with it.
|
||||
'public' => $folder->isEffectivelyPublic(),
|
||||
])
|
||||
->values()->all(),
|
||||
// Public files are reachable at the installation's one public
|
||||
// slug; without it configured, publishing shows nowhere and the
|
||||
// page says so rather than offering a switch that does nothing
|
||||
// visible.
|
||||
'public_listing_slug' => $this->settings->get(Setting::PublicListingSlug),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a file this client uploaded.
|
||||
*
|
||||
* The client portal's counterpart to the staff file editor, and
|
||||
* deliberately a separate route rather than the staff one opened up:
|
||||
* `files.*` renders assignments, share links, activity and download
|
||||
* history, which are staff surfaces, and its folder guard asks
|
||||
* StaffLibraryScope — which answers "allowed" for every client (see
|
||||
* FilePolicy::update()).
|
||||
*
|
||||
* Who may edit at all is FilePolicy: the file must be this client's own
|
||||
* upload and they must hold `edit_files`. Which *fields* they may
|
||||
* write is ApplyFileEdits, the same decision the staff editor and the
|
||||
* API get, so a client holding `set_file_categories` but not
|
||||
* `upload_public` gets exactly what those keys say and nothing is
|
||||
* decided twice.
|
||||
*/
|
||||
public function update(Request $request, File $file): RedirectResponse
|
||||
{
|
||||
$client = $request->user();
|
||||
abort_unless($client !== null && $client->isClient(), 404);
|
||||
|
||||
Gate::authorize('update', $file);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:2000'],
|
||||
'folder_id' => Rules::folderId(),
|
||||
'public' => ['sometimes', 'boolean'],
|
||||
'commentable' => ['sometimes', 'boolean'],
|
||||
'categories' => ['array'],
|
||||
'categories.*' => ['integer', 'exists:categories,id'],
|
||||
'expires_at' => ['nullable', 'date'],
|
||||
'download_limit' => ['nullable', 'integer', 'min:1'],
|
||||
'download_limit_scope' => ['nullable', Rule::enum(DownloadLimitScope::class)],
|
||||
]);
|
||||
|
||||
// No `slug`, on purpose, and its absence is what makes
|
||||
// ApplyFileEdits derive one from the name. An installation-wide
|
||||
// unique slug that a client picks is a name to squat and an
|
||||
// existence oracle to probe against every file on the
|
||||
// installation, for nothing a derived slug does not already give
|
||||
// them.
|
||||
|
||||
$folderId = isset($validated['folder_id']) ? (int) $validated['folder_id'] : null;
|
||||
|
||||
// The client rule, not the staff one: somewhere they could have
|
||||
// uploaded it in the first place. Same check the upload path makes,
|
||||
// so moving a file cannot reach a folder that uploading it could
|
||||
// not. Only when the folder actually changes, so re-saving a file
|
||||
// that already sits somewhere unusual still works.
|
||||
if ($folderId !== null && $folderId !== $file->folder_id) {
|
||||
$folder = Folder::query()->visibleToClient($client)->find($folderId);
|
||||
|
||||
abort_unless($folder !== null && Folder::uploadableBy($client, $folder), 403);
|
||||
}
|
||||
|
||||
$changes = [
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'folder_id' => $folderId,
|
||||
'commentable' => $validated['commentable'] ?? $file->commentable,
|
||||
'download_limit' => $validated['download_limit'] ?? null,
|
||||
'download_limit_scope' => $validated['download_limit_scope'] ?? DownloadLimitScope::Total->value,
|
||||
'public' => $validated['public'] ?? $file->public,
|
||||
'categories' => $validated['categories'] ?? [],
|
||||
];
|
||||
|
||||
// Only when the date actually moved — the form posts back what it
|
||||
// was rendered with, and re-deriving it on every save would shift
|
||||
// the expiry by a timezone difference each time somebody renamed
|
||||
// the file. See FileExpiry.
|
||||
$posted = $validated['expires_at'] ?? null;
|
||||
|
||||
if ($posted !== $this->expiry->asShown($file, $client)) {
|
||||
$changes['expires_at'] = $this->expiry->instant($posted, $client);
|
||||
}
|
||||
|
||||
$this->fileEdits->apply($client, $file, $changes);
|
||||
|
||||
return back()->with('success', __('File updated.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file this client uploaded.
|
||||
*
|
||||
* Their own upload and `delete_files`, both settled by
|
||||
* FilePolicy::delete(). A file merely shared with them is not theirs to
|
||||
* remove, and no permission changes that.
|
||||
*
|
||||
* The row is soft-deleted and the bytes are not: File::booted()'s
|
||||
* `deleted` hook removes the upload and every cached rendition on
|
||||
* commit, so the client's storage quota — which sums untrashed rows —
|
||||
* frees up by exactly what the disk does.
|
||||
*/
|
||||
public function destroy(Request $request, File $file): RedirectResponse
|
||||
{
|
||||
$client = $request->user();
|
||||
abort_unless($client !== null && $client->isClient(), 404);
|
||||
|
||||
Gate::authorize('delete', $file);
|
||||
|
||||
$name = $file->name;
|
||||
$file->delete();
|
||||
|
||||
$this->activity->log(Action::FileDeleted, context: ['name' => $name]);
|
||||
|
||||
return redirect()->route('my-files.index')->with('success', __('File deleted.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Files this client may name as the previous version of what they are
|
||||
* uploading — THEIR OWN UPLOADS ONLY.
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Http\Resources\Api;
|
||||
|
||||
use App\Modules\Files\Access\ClientIdentityScope;
|
||||
use App\Modules\Files\DownloadLimitScope;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\FileAssignment;
|
||||
@@ -26,6 +27,23 @@ use Illuminate\Http\Resources\Json\JsonResource;
|
||||
* - `checksum` is included deliberately, since verifying an integration's
|
||||
* own download is a real use case, and it reveals nothing about
|
||||
* location.
|
||||
*
|
||||
* Two fields are narrowed to the caller: the uploader and the assignment
|
||||
* list both name clients, and a client-scoped account may hold a file whose
|
||||
* uploader or co-recipients are clients off their own roster — the file is
|
||||
* theirs to read, those names are not theirs to see. ClientIdentityScope is
|
||||
* the rule; a name dropped here is dropped to null or out of the list, and
|
||||
* an unscoped account is unaffected.
|
||||
*
|
||||
* That narrowing happens here rather than in the controllers, which is the opposite of how the version counterparts are
|
||||
* handled a few files over — and deliberately so. Whether a counterpart may
|
||||
* be named is a set-shaped question with a query to express it, so it is
|
||||
* asked once in the caller's eager load. Whether a client may be named is a
|
||||
* per-row check against the viewer's roster with no query to fold it into,
|
||||
* and this resource is built at eight call sites across four controllers,
|
||||
* two of them re-loading `assignments.assignable` after a write. Asking at
|
||||
* the point of serialisation is the only version of this rule that cannot
|
||||
* be forgotten by the ninth caller.
|
||||
*/
|
||||
class FileResource extends JsonResource
|
||||
{
|
||||
@@ -34,6 +52,15 @@ class FileResource extends JsonResource
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$viewer = $request->user();
|
||||
$identity = app(ClientIdentityScope::class);
|
||||
|
||||
// The morph class rather than ::class, matching ShareTargets: with
|
||||
// a morph map registered the two disagree, and this line now
|
||||
// decides which roster an entry is checked against, so getting it
|
||||
// wrong would mean checking a group id against the client list.
|
||||
$groupMorph = (new Group)->getMorphClass();
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
@@ -96,11 +123,16 @@ class FileResource extends JsonResource
|
||||
]),
|
||||
|
||||
// Name only. The uploader is a user record; their email address
|
||||
// is not part of what "this file exists" needs to say.
|
||||
'uploaded_by' => $this->whenLoaded('uploader', fn (): ?array => $this->uploader === null ? null : [
|
||||
'id' => $this->uploader->id,
|
||||
'name' => $this->uploader->name,
|
||||
]),
|
||||
// is not part of what "this file exists" needs to say. Null
|
||||
// when the uploader is a client the token's owner is not
|
||||
// scoped to; an unscoped account always gets the name.
|
||||
'uploaded_by' => $this->whenLoaded(
|
||||
'uploader',
|
||||
fn (): ?array => $identity->permits($viewer, $this->uploader) && $this->uploader !== null ? [
|
||||
'id' => $this->uploader->id,
|
||||
'name' => $this->uploader->name,
|
||||
] : null,
|
||||
),
|
||||
|
||||
'categories' => $this->whenLoaded('categories', fn (): array => $this->categories
|
||||
->map(fn ($category): array => [
|
||||
@@ -109,15 +141,22 @@ class FileResource extends JsonResource
|
||||
])
|
||||
->all()),
|
||||
|
||||
// Who the file is shared with, as far as this caller is
|
||||
// concerned: a recipient the token's owner is not scoped to is
|
||||
// left out rather than returned without a name.
|
||||
'assignments' => $this->whenLoaded('assignments', fn (): array => $this->assignments
|
||||
->filter(fn (FileAssignment $assignment): bool => $assignment->assignable_type === $groupMorph
|
||||
? $identity->permitsGroupId($viewer, (int) $assignment->assignable_id)
|
||||
: $identity->permitsClientId($viewer, (int) $assignment->assignable_id))
|
||||
->map(fn (FileAssignment $assignment): array => [
|
||||
'type' => $assignment->assignable_type === Group::class ? 'group' : 'client',
|
||||
'type' => $assignment->assignable_type === $groupMorph ? 'group' : 'client',
|
||||
'id' => $assignment->assignable_id,
|
||||
// getAttribute() rather than ->name: the relation is a
|
||||
// MorphTo over User|Group, so the property is only
|
||||
// knowable at runtime. Both targets carry a name.
|
||||
'name' => $assignment->assignable?->getAttribute('name'),
|
||||
])
|
||||
->values()
|
||||
->all()),
|
||||
|
||||
'links' => [
|
||||
|
||||
@@ -27,6 +27,12 @@ use Throwable;
|
||||
*/
|
||||
class LocalPartStore
|
||||
{
|
||||
/**
|
||||
* Said twice, because a full temp volume can announce itself in the
|
||||
* middle of the copy or only when the last buffer is flushed.
|
||||
*/
|
||||
private const WRITE_FAILED = 'Could not assemble the upload: writing to the temporary directory failed.';
|
||||
|
||||
/**
|
||||
* The route name is a parameter because the same flow is mounted twice:
|
||||
* once on the session-authenticated web routes for the browser, once on
|
||||
@@ -140,9 +146,21 @@ class LocalPartStore
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream-append parts in order onto the files disk, hashing as we
|
||||
* go. Peak temp usage ≈ file size + one part (parts are unlinked
|
||||
* as they are consumed).
|
||||
* Stream-append parts in order onto the files disk, hashing as we go.
|
||||
*
|
||||
* The parts stay on disk until the assembled bytes are safely on the
|
||||
* target disk. ChunkedUploadsController's completion lock promises that
|
||||
* "a later retry still works", and everything that can fail after the
|
||||
* concatenation — reopening the copy, a disk refusing the write, the
|
||||
* File row itself — happens while the client has nothing but this
|
||||
* session to retry with. Unlinking each part as it was consumed left
|
||||
* listParts() empty, so every later complete() answered "Upload is
|
||||
* incomplete: missing parts" for good.
|
||||
*
|
||||
* The cost is temp space: peak usage is the whole file twice over
|
||||
* (every part, plus the assembled copy) rather than the file plus one
|
||||
* part. Both are freed by the abort() below the moment the write lands,
|
||||
* and by the failure path the moment it does not.
|
||||
*
|
||||
* @return array{path: string, disk: string, size: int, checksum: string}
|
||||
*/
|
||||
@@ -158,6 +176,93 @@ class LocalPartStore
|
||||
}
|
||||
|
||||
$assembledPath = $this->directory($session).'/assembled';
|
||||
|
||||
try {
|
||||
[$size, $checksum] = $this->concatenate($session, $parts, $assembledPath);
|
||||
|
||||
$readStream = fopen($assembledPath, 'rb');
|
||||
|
||||
if ($readStream === false) {
|
||||
throw new RuntimeException('Could not reopen assembled file.');
|
||||
}
|
||||
|
||||
$diskEvent = new ResolvingUploadDisk($session->user);
|
||||
Event::dispatch($diskEvent);
|
||||
$disk = $diskEvent->disk;
|
||||
|
||||
$written = Storage::disk($disk)->writeStream($targetPath, $readStream);
|
||||
|
||||
if (is_resource($readStream)) {
|
||||
fclose($readStream);
|
||||
}
|
||||
|
||||
// The disks are configured with 'throw' => false, so a refused
|
||||
// write is a `false` return rather than an exception — and the
|
||||
// caller goes on to record a File row for bytes that were never
|
||||
// stored. Losing an upload silently is worse than failing it, and
|
||||
// this is the only place that can tell the difference: a real
|
||||
// instance of it was a GCS bucket rejecting the adapter's ACL,
|
||||
// which looked exactly like a successful upload.
|
||||
if ($written === false) {
|
||||
// The reason is lost by the time it gets here — 'throw' => false
|
||||
// means Flysystem swallowed the exception rather than passing it
|
||||
// on — so log what was attempted. Which bucket it was is the
|
||||
// difference between reading this as "my credentials expired"
|
||||
// and "I typed the wrong bucket name", and only the log can say
|
||||
// it: the message below is shown to whoever was uploading, which
|
||||
// includes clients, and a bucket name is not theirs to see.
|
||||
Log::error('Upload could not be written to storage.', [
|
||||
'disk' => $disk,
|
||||
'bucket' => config('filesystems.disks.'.$disk.'.bucket'),
|
||||
'driver' => config('filesystems.disks.'.$disk.'.driver'),
|
||||
'path' => $targetPath,
|
||||
]);
|
||||
|
||||
throw new RuntimeException(
|
||||
'Could not write the assembled upload to the "'.$disk.'" disk. '
|
||||
.'Check the storage backend is reachable and its credentials are still valid.'
|
||||
);
|
||||
}
|
||||
} catch (Throwable $failure) {
|
||||
// The half-written copy belongs to this attempt and the next one
|
||||
// makes its own; the parts belong to the client, and they are
|
||||
// what a retry needs. Deleting the copy here is also the only
|
||||
// thing that removes it at all on this path — it used to sit in
|
||||
// the session directory until the sweeper came round.
|
||||
FileSystem::delete($assembledPath);
|
||||
|
||||
throw $failure;
|
||||
}
|
||||
|
||||
$this->abort($session);
|
||||
|
||||
return [
|
||||
'path' => $targetPath,
|
||||
'disk' => $disk,
|
||||
'size' => $size,
|
||||
'checksum' => $checksum,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate the parts into $assembledPath, returning the byte count
|
||||
* and the sha256 of what was written.
|
||||
*
|
||||
* Every read and every write is checked. They were not, and while a
|
||||
* failing fwrite on a full volume is loud in practice — Laravel's
|
||||
* error handler turns the warning into an ErrorException — loud there
|
||||
* means a 500 carrying a PHP message, where the disk-refused-the-write
|
||||
* case a few lines above becomes a sentence the person uploading can
|
||||
* act on. A short write arriving without a warning would be worse
|
||||
* still: $size and the hash describe the buffer that was read, so an
|
||||
* unchecked one yields a truncated file with a checksum matching bytes
|
||||
* that were never stored.
|
||||
*
|
||||
* @param list<array{PartNumber: int, Size: int, ETag: string}> $parts
|
||||
* @return array{0: int, 1: string}
|
||||
*/
|
||||
private function concatenate(UploadSession $session, array $parts, string $assembledPath): array
|
||||
{
|
||||
$out = fopen($assembledPath, 'wb');
|
||||
|
||||
if ($out === false) {
|
||||
@@ -167,85 +272,46 @@ class LocalPartStore
|
||||
$hash = hash_init('sha256');
|
||||
$size = 0;
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$partPath = $this->partPath($session, $part['PartNumber']);
|
||||
$in = fopen($partPath, 'rb');
|
||||
try {
|
||||
foreach ($parts as $part) {
|
||||
$in = fopen($this->partPath($session, $part['PartNumber']), 'rb');
|
||||
|
||||
if ($in === false) {
|
||||
fclose($out);
|
||||
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
|
||||
}
|
||||
|
||||
while (! feof($in)) {
|
||||
$buffer = fread($in, 1024 * 1024);
|
||||
|
||||
if ($buffer === false) {
|
||||
break;
|
||||
if ($in === false) {
|
||||
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
|
||||
}
|
||||
|
||||
fwrite($out, $buffer);
|
||||
hash_update($hash, $buffer);
|
||||
$size += strlen($buffer);
|
||||
try {
|
||||
while (! feof($in)) {
|
||||
$buffer = fread($in, 1024 * 1024);
|
||||
|
||||
if ($buffer === false) {
|
||||
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
|
||||
}
|
||||
|
||||
if ($buffer !== '' && @fwrite($out, $buffer) !== strlen($buffer)) {
|
||||
throw new RuntimeException(self::WRITE_FAILED);
|
||||
}
|
||||
|
||||
hash_update($hash, $buffer);
|
||||
$size += strlen($buffer);
|
||||
}
|
||||
} finally {
|
||||
fclose($in);
|
||||
}
|
||||
}
|
||||
} catch (Throwable $failure) {
|
||||
fclose($out);
|
||||
|
||||
fclose($in);
|
||||
unlink($partPath);
|
||||
throw $failure;
|
||||
}
|
||||
|
||||
fclose($out);
|
||||
|
||||
$readStream = fopen($assembledPath, 'rb');
|
||||
|
||||
if ($readStream === false) {
|
||||
throw new RuntimeException('Could not reopen assembled file.');
|
||||
// fclose flushes, so a volume that filled up on the last buffer
|
||||
// fails here rather than in the loop.
|
||||
if (! fclose($out)) {
|
||||
throw new RuntimeException(self::WRITE_FAILED);
|
||||
}
|
||||
|
||||
$diskEvent = new ResolvingUploadDisk($session->user);
|
||||
Event::dispatch($diskEvent);
|
||||
$disk = $diskEvent->disk;
|
||||
|
||||
$written = Storage::disk($disk)->writeStream($targetPath, $readStream);
|
||||
|
||||
if (is_resource($readStream)) {
|
||||
fclose($readStream);
|
||||
}
|
||||
|
||||
// The disks are configured with 'throw' => false, so a refused
|
||||
// write is a `false` return rather than an exception — and the
|
||||
// caller goes on to record a File row for bytes that were never
|
||||
// stored. Losing an upload silently is worse than failing it, and
|
||||
// this is the only place that can tell the difference: a real
|
||||
// instance of it was a GCS bucket rejecting the adapter's ACL,
|
||||
// which looked exactly like a successful upload.
|
||||
if ($written === false) {
|
||||
// The reason is lost by the time it gets here — 'throw' => false
|
||||
// means Flysystem swallowed the exception rather than passing it
|
||||
// on — so log what was attempted. Which bucket it was is the
|
||||
// difference between reading this as "my credentials expired"
|
||||
// and "I typed the wrong bucket name", and only the log can say
|
||||
// it: the message below is shown to whoever was uploading, which
|
||||
// includes clients, and a bucket name is not theirs to see.
|
||||
Log::error('Upload could not be written to storage.', [
|
||||
'disk' => $disk,
|
||||
'bucket' => config('filesystems.disks.'.$disk.'.bucket'),
|
||||
'driver' => config('filesystems.disks.'.$disk.'.driver'),
|
||||
'path' => $targetPath,
|
||||
]);
|
||||
|
||||
throw new RuntimeException(
|
||||
'Could not write the assembled upload to the "'.$disk.'" disk. '
|
||||
.'Check the storage backend is reachable and its credentials are still valid.'
|
||||
);
|
||||
}
|
||||
|
||||
$this->abort($session);
|
||||
|
||||
return [
|
||||
'path' => $targetPath,
|
||||
'disk' => $disk,
|
||||
'size' => $size,
|
||||
'checksum' => hash_final($hash),
|
||||
];
|
||||
return [$size, hash_final($hash)];
|
||||
}
|
||||
|
||||
public function abort(UploadSession $session): void
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Announcements\Events;
|
||||
|
||||
/**
|
||||
* A single message a package wants put in front of staff.
|
||||
*
|
||||
* Shown twice, from one source: a band across the top of the dashboard,
|
||||
* and an icon beside the notification bell that opens the same words on
|
||||
* every other page. One event rather than two because "the same message"
|
||||
* is the requirement — two props would drift the day somebody edits one.
|
||||
*
|
||||
* Not a widget, on purpose. The widget grid is a closed list of keys that
|
||||
* dashboard.tsx renders one by one, and each viewer arranges it — so a
|
||||
* message that matters would sit wherever somebody happened to drag it,
|
||||
* or under a fold, or switched off. A band above the grid is seen without
|
||||
* competing with the columns for space.
|
||||
*
|
||||
* **Core knows nothing about what it says.** Title, body, the label on the
|
||||
* button and where the button goes all come from the listener. The first
|
||||
* caller is the hosted edition telling a free instance what a paid plan
|
||||
* would give it, which is commercial copy belonging to one offering and
|
||||
* has no place in the public repository.
|
||||
*
|
||||
* One at a time, deliberately. A dashboard that can accumulate banners
|
||||
* accumulates them, and the second one is what teaches people to skip the
|
||||
* first. A listener that finds one already set should leave it alone
|
||||
* rather than overwrite it.
|
||||
*/
|
||||
class ResolvingAnnouncement
|
||||
{
|
||||
/**
|
||||
* @var array{title: string, body: string, action_label: string|null, action_url: string|null, tone: string}|null
|
||||
*/
|
||||
public ?array $announcement = null;
|
||||
|
||||
public function __construct(
|
||||
/** Whether the viewer is a staff account. */
|
||||
public readonly bool $isStaff,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* `tone` picks the accent the band is drawn in. Two values, because
|
||||
* two is what the difference is worth: `info` for something worth
|
||||
* knowing, `warning` for something worth acting on. Anything else
|
||||
* falls back to `info` rather than rendering unstyled.
|
||||
*/
|
||||
public function show(string $title, string $body, ?string $actionLabel = null, ?string $actionUrl = null, string $tone = 'info'): void
|
||||
{
|
||||
if ($this->announcement !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->announcement = [
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'action_label' => $actionLabel,
|
||||
'action_url' => $actionUrl,
|
||||
'tone' => in_array($tone, ['info', 'warning'], true) ? $tone : 'info',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,24 @@ enum Capability: string
|
||||
case EmailTransportConfigure = 'email.transport.configure';
|
||||
case SystemUpdates = 'system.updates';
|
||||
|
||||
// Community-only — whether this installation may switch off the
|
||||
// project news on its dashboard.
|
||||
//
|
||||
// Note what is Community-only: the *choice*, not the news. A managed
|
||||
// instance still fetches and still shows it, and cannot be made to
|
||||
// stop. That is the difference from SystemUpdates beside it, and it
|
||||
// is worth stating because the two look alike and are opposites. An
|
||||
// update notice is useless on a hosted tenant — they cannot act on
|
||||
// it, the image is ours — so the check does not run at all there.
|
||||
// News is the reverse: announcements about the product are exactly
|
||||
// what a hosted customer should be told, and an administrator
|
||||
// switching them off for everybody on that instance is not a
|
||||
// preference we meant to hand over.
|
||||
//
|
||||
// A self-hosted operator keeps the switch, because there nobody else
|
||||
// decides what their installation reaches out for.
|
||||
case NewsConfigure = 'news.configure';
|
||||
|
||||
// Community-only — scheduled-task run history and failed-queue-job
|
||||
// visibility. Cut on managed installations, where infrastructure
|
||||
// monitoring happens outside this application; a transient failure
|
||||
@@ -79,11 +97,36 @@ enum Capability: string
|
||||
// simply inert and files stay on local disk.
|
||||
case StorageManaged = 'storage.managed';
|
||||
|
||||
// Both editions, and present by default: a self-hosted installation
|
||||
// has this screen today and needs it, because nobody else is going to
|
||||
// supply its keys. It exists as a key so a managed platform can
|
||||
// subtract it, and the reason to subtract it is narrower than the
|
||||
// reason LDAP and social login stayed ungated.
|
||||
//
|
||||
// On a managed installation the administrator and the host are the
|
||||
// same person, but the *reputation* is not theirs. Every tenant is a
|
||||
// name under one shared domain, sending mail from one shared pool. An
|
||||
// administrator who sets the provider to none, or who leaves the keys
|
||||
// alone and just unticks the four per-form switches, turns their own
|
||||
// public forms into an open door and spends everybody else's
|
||||
// deliverability doing it. That is the same shape as Storage: not a
|
||||
// feature somebody paid for, but a setting whose blast radius reaches
|
||||
// past the installation that holds it.
|
||||
//
|
||||
// All-or-nothing on the route, read included, exactly as Storage and
|
||||
// Branding are. Per-field gating in the controller would not do:
|
||||
// switching the CAPTCHA off does not need the key fields at all, so
|
||||
// the PATCH has to be closed too, and the middleware closes both
|
||||
// verbs at once.
|
||||
case CaptchaConfigure = 'captcha.configure';
|
||||
|
||||
// Cloud-only — managed installations supply CAPTCHA keys centrally, so
|
||||
// protection is on before anybody finds the settings screen. The
|
||||
// feature itself is in both editions and behind no capability: this
|
||||
// covers only the option of using *our* credentials, which cannot ship
|
||||
// inside a self-hosted package.
|
||||
// feature itself is in both editions: this covers only the option of
|
||||
// using *our* credentials, which cannot ship inside a self-hosted
|
||||
// package. Distinct from CaptchaConfigure above — that one says
|
||||
// whether the screen opens at all, this one says what it may offer
|
||||
// once it does.
|
||||
case CaptchaManagedKeys = 'captcha.managed_keys';
|
||||
|
||||
// Cloud-only — letting an AI assistant act on this installation on
|
||||
@@ -125,10 +168,12 @@ enum Capability: string
|
||||
self::StorageConfigure,
|
||||
self::EmailTransportConfigure,
|
||||
self::SystemUpdates,
|
||||
self::NewsConfigure,
|
||||
self::SchedulerMonitoring,
|
||||
self::CustomAssets => [Edition::Community],
|
||||
|
||||
self::UsersManage,
|
||||
self::CaptchaConfigure,
|
||||
self::Branding => [Edition::Community, Edition::Cloud],
|
||||
|
||||
self::AttributionHide,
|
||||
|
||||
@@ -20,8 +20,12 @@ use Illuminate\Console\Command;
|
||||
* administrator editing a database table by hand, guessing which of
|
||||
* several rows matters.
|
||||
*
|
||||
* PROJECTSEND_CAPTCHA_DISABLED does the same thing for anyone who would
|
||||
* rather touch .env than run artisan.
|
||||
* PROJECTSEND_CAPTCHA_DISABLED is the other half of the same escape
|
||||
* hatch, and not merely the .env spelling of this one: it is checked
|
||||
* first, ahead of the key source, so it is the only one of the two that
|
||||
* works on an installation running the platform's managed keys. This
|
||||
* command writes a setting those installations never read, and says so
|
||||
* rather than reporting a success it did not have.
|
||||
*/
|
||||
class DisableCaptchaCommand extends Command
|
||||
{
|
||||
@@ -29,7 +33,7 @@ class DisableCaptchaCommand extends Command
|
||||
|
||||
protected $description = 'Switch off the CAPTCHA on public forms';
|
||||
|
||||
public function handle(Settings $settings): int
|
||||
public function handle(Settings $settings, Captcha $captcha): int
|
||||
{
|
||||
$settings->set(Setting::CaptchaProvider, 'none');
|
||||
|
||||
@@ -38,6 +42,24 @@ class DisableCaptchaCommand extends Command
|
||||
Captcha::forgetDisplayCache();
|
||||
CaptchaVerifier::forgetOutage();
|
||||
|
||||
// Managed keys are not this setting. Captcha::resolve() reaches
|
||||
// them from config and returns before it ever looks at
|
||||
// Setting::CaptchaProvider, so on an installation using them the
|
||||
// write above changed a value nothing reads. Saying "CAPTCHA is
|
||||
// off" there would be false, and false in the worst direction: an
|
||||
// operator who is still being challenged would stop looking,
|
||||
// having just been told the thing challenging them is gone.
|
||||
//
|
||||
// Read after the write rather than before it, because the write is
|
||||
// what makes the answer meaningful — if this still resolves to
|
||||
// something, the something is not ours to switch off.
|
||||
if ($captcha->managedKeysSelected()) {
|
||||
$this->warn('Nothing changed. This installation uses CAPTCHA keys supplied by the platform, and those do not come from the setting this command writes.');
|
||||
$this->line('Set PROJECTSEND_CAPTCHA_DISABLED=true in the environment and restart to switch it off.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info('CAPTCHA is off. Your keys are still stored — switch it back on at /system/settings/captcha.');
|
||||
|
||||
return self::SUCCESS;
|
||||
|
||||
@@ -24,13 +24,18 @@ use Inertia\Response;
|
||||
/**
|
||||
* Configuring the CAPTCHA on public forms.
|
||||
*
|
||||
* Available in **both** editions and behind no capability, for the reason
|
||||
* LDAP settled and social login repeated: this is an administrator's
|
||||
* setting, not an edition difference. What *is* an edition difference is
|
||||
* the option of using the platform's own keys, and that is enforced per
|
||||
* field rather than on the route — the shape EmailSettingsController uses
|
||||
* for SMTP, so a hand-crafted PATCH cannot select a key source this
|
||||
* installation has no keys for.
|
||||
* Available in **both** editions, and behind Capability::CaptchaConfigure
|
||||
* — present by default, so a self-hosted installation keeps the screen,
|
||||
* and removable by an operator whose tenants share a domain and a sending
|
||||
* reputation. Enforced entirely by the `capability:captcha.configure`
|
||||
* route middleware, which covers the PATCH as well as the GET: turning
|
||||
* the CAPTCHA off needs no gated field at all, so nothing short of
|
||||
* closing the write would have closed it.
|
||||
*
|
||||
* Which keys this installation may point at is a second question, and
|
||||
* that one is still enforced per field below rather than on the route —
|
||||
* the shape EmailSettingsController uses for SMTP, so a hand-crafted
|
||||
* PATCH cannot select a key source this installation has no keys for.
|
||||
*
|
||||
* The secret key follows the pattern MailProviderSettings established and
|
||||
* LdapSettings and SocialSettings repeated: it is never sent to the
|
||||
|
||||
@@ -45,6 +45,10 @@ class SystemSettingsController extends Controller
|
||||
{
|
||||
$canManageUpdates = $this->capabilities->has(Capability::SystemUpdates)
|
||||
&& $request->user()?->can('manage_updates') === true;
|
||||
// No permission beside it, unlike updates: turning the news card
|
||||
// off is an ordinary settings change, and edit_settings already
|
||||
// gates this whole screen.
|
||||
$canConfigureNews = $this->capabilities->has(Capability::NewsConfigure);
|
||||
|
||||
return Inertia::render('system/settings/general', [
|
||||
'site_name' => $this->settings->get(Setting::SiteName),
|
||||
@@ -62,6 +66,15 @@ class SystemSettingsController extends Controller
|
||||
'viewer_timezone' => $request->user()?->timezone,
|
||||
'can_manage_updates' => $canManageUpdates,
|
||||
'check_for_updates' => $canManageUpdates ? $this->settings->get(Setting::CheckForUpdates) : null,
|
||||
// Its own capability, and deliberately not $canManageUpdates:
|
||||
// the update block disappears on a managed instance because
|
||||
// nobody there can act on it, while this one disappears
|
||||
// because the news must keep arriving whether or not the
|
||||
// instance's administrator would have chosen it. Null where
|
||||
// the choice is not theirs, so the page renders no switch
|
||||
// rather than a switch that would do nothing.
|
||||
'can_configure_news' => $canConfigureNews,
|
||||
'fetch_news' => $canConfigureNews ? $this->settings->get(Setting::FetchNews) : null,
|
||||
'last_checked_at' => $canManageUpdates ? $this->lastCheckedAt()?->toIso8601String() : null,
|
||||
'check_result' => $request->session()->get('update_check_result'),
|
||||
]);
|
||||
@@ -123,6 +136,10 @@ class SystemSettingsController extends Controller
|
||||
{
|
||||
$canManageUpdates = $this->capabilities->has(Capability::SystemUpdates)
|
||||
&& $request->user()?->can('manage_updates') === true;
|
||||
// No permission beside it, unlike updates: turning the news card
|
||||
// off is an ordinary settings change, and edit_settings already
|
||||
// gates this whole screen.
|
||||
$canConfigureNews = $this->capabilities->has(Capability::NewsConfigure);
|
||||
|
||||
$rules = [
|
||||
'site_name' => ['required', 'string', 'max:255'],
|
||||
@@ -133,6 +150,10 @@ class SystemSettingsController extends Controller
|
||||
// "follow APP_TIMEZONE", and only a fresh install has that.
|
||||
'timezone' => ['sometimes', 'string', 'timezone', Rule::in($this->timezones->all())],
|
||||
];
|
||||
|
||||
if ($canConfigureNews) {
|
||||
$rules['fetch_news'] = ['sometimes', 'boolean'];
|
||||
}
|
||||
if ($canManageUpdates) {
|
||||
// Omitting the field (any caller not sending it, not just this
|
||||
// page's own form) leaves the current value alone rather than
|
||||
@@ -156,6 +177,13 @@ class SystemSettingsController extends Controller
|
||||
$this->settings->set(Setting::CheckForUpdates, $validated['check_for_updates']);
|
||||
}
|
||||
|
||||
// Never read where the choice is not this installation's, so a
|
||||
// hand-crafted PATCH cannot switch off the news on a managed
|
||||
// instance any more than the absent checkbox could.
|
||||
if ($canConfigureNews && array_key_exists('fetch_news', $validated)) {
|
||||
$this->settings->set(Setting::FetchNews, $validated['fetch_news']);
|
||||
}
|
||||
|
||||
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'general']);
|
||||
|
||||
return back();
|
||||
|
||||
@@ -450,6 +450,16 @@ class StatusCommand extends Command
|
||||
// on the day it happens to be empty rather than on the day it
|
||||
// is written. It cannot be empty today, but the allowlist is
|
||||
// meant to be edited.
|
||||
//
|
||||
// What that costs, confirmed against the reader rather than
|
||||
// guessed at: the hosted platform's probe decodes this block
|
||||
// into a typed struct and discards a block it cannot read, and
|
||||
// Go refuses a JSON list into a map outright. So a `[]` here
|
||||
// would not lose `actions` — it would lose the whole `usage`
|
||||
// block, downloads and uploads with it, on the day a tenant
|
||||
// happened to have no counted activity. The quietest
|
||||
// installations would stop reporting and nothing would log a
|
||||
// fault. Both shapes are pinned by tests on that side too.
|
||||
'actions' => (object) $this->usageActions($since),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Navigation\Events;
|
||||
|
||||
/**
|
||||
* Extra links a package wants in the sidebar.
|
||||
*
|
||||
* The sidebar is built from a hardcoded array in app-sidebar.tsx, which
|
||||
* means a package could not contribute to it at all — the nav link was a
|
||||
* separate manual edit every time a package grew a screen, and being
|
||||
* manual it was forgotten. This is the seam that fixes that, in the shape
|
||||
* the extension-points document settles on: core dispatches
|
||||
* unconditionally, listeners add or do not, and with no listener the
|
||||
* default (no extra links) holds.
|
||||
*
|
||||
* **Core deliberately learns nothing about what is added.** A link's
|
||||
* label, its URL and the reason it exists all arrive from whoever
|
||||
* registers it. That is not fastidiousness: the first caller is the
|
||||
* hosted edition's link to its own customer portal, and a product URL
|
||||
* belonging to one commercial offering has no business sitting in the
|
||||
* public repository just because the sidebar happens to live here.
|
||||
*
|
||||
* Staff only, and enforced here rather than trusted to each listener:
|
||||
* these appear in the administration area, and a client's portal shows
|
||||
* only their own files.
|
||||
*/
|
||||
class ResolvingNavigationLinks
|
||||
{
|
||||
/**
|
||||
* @var list<array{title: string, url: string, external: bool, icon: string|null}>
|
||||
*/
|
||||
public array $links = [];
|
||||
|
||||
public function __construct(
|
||||
/** Whether the viewer is a staff account. Listeners that only make
|
||||
* sense for staff should check this rather than assume. */
|
||||
public readonly bool $isStaff,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* `external` opens in a new tab and marks the link as leaving this
|
||||
* installation — a link that navigates a person away from the app
|
||||
* they are working in should say so before they click it, not after.
|
||||
*/
|
||||
public function add(string $title, string $url, bool $external = false, ?string $icon = null): void
|
||||
{
|
||||
$this->links[] = [
|
||||
'title' => $title,
|
||||
'url' => $url,
|
||||
'external' => $external,
|
||||
'icon' => $icon,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\News\Console;
|
||||
|
||||
use App\Modules\Platform\Capabilities\Capability;
|
||||
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Console\Command;
|
||||
@@ -12,9 +14,13 @@ use Illuminate\Support\Facades\Http;
|
||||
use Stevebauman\Purify\Facades\Purify;
|
||||
|
||||
/**
|
||||
* Both editions — unlike CheckForUpdatesCommand, this isn't gated on any
|
||||
* Capability: dashboard news is informational content, not an update
|
||||
* action, so Cloud tenants see it too.
|
||||
* Both editions, and on a managed instance not switchable off — unlike
|
||||
* CheckForUpdatesCommand, which does not run there at all. Dashboard news
|
||||
* is informational content rather than an update action, so hosted
|
||||
* customers see it too, and see it whether their administrator would have
|
||||
* chosen to or not. Capability::NewsConfigure is what a self-hosted
|
||||
* installation holds and a managed one does not: the choice is the
|
||||
* edition difference, not the news.
|
||||
*
|
||||
* The feed returns raw HTML in `content` (links, paragraphs) — sanitized
|
||||
* here, once, before it's ever cached or sent to the frontend, so the
|
||||
@@ -34,12 +40,36 @@ class FetchNewsCommand extends Command
|
||||
|
||||
public function __construct(
|
||||
private readonly Settings $settings,
|
||||
private readonly CapabilityRegistry $capabilities,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
// The setting only decides where the installation is allowed to
|
||||
// make that choice. On a managed instance it is not: announcements
|
||||
// about the product are what a hosted customer should be told, and
|
||||
// one administrator switching them off for everybody on that
|
||||
// instance is not a decision the platform hands over. So the news
|
||||
// runs there regardless of what any row says — including a row
|
||||
// left behind by an instance that used to be self-hosted.
|
||||
//
|
||||
// The opposite of the update check, which does not run on a
|
||||
// managed instance at all because nobody there could act on it.
|
||||
// The two look alike and point in different directions.
|
||||
//
|
||||
// Returns success rather than failure: a scheduled task that was
|
||||
// asked not to run has not failed, and reporting it as a failure
|
||||
// would put a red line in the scheduler history every night for
|
||||
// an installation that is behaving exactly as configured.
|
||||
if ($this->capabilities->has(Capability::NewsConfigure)
|
||||
&& $this->settings->get(Setting::FetchNews) !== true) {
|
||||
$this->info('The news feed is switched off for this installation.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$response = Http::withHeaders(['User-Agent' => 'ProjectSend'])
|
||||
->timeout(10)
|
||||
->get(self::FEED_URL);
|
||||
|
||||
@@ -249,6 +249,17 @@ enum Setting: string
|
||||
// is allowed to tell the admin a newer release exists.
|
||||
case CheckForUpdates = 'check_for_updates';
|
||||
|
||||
// Whether this installation fetches the project's news feed for the
|
||||
// dashboard card. Its own key rather than riding on CheckForUpdates
|
||||
// above, because they are two different wants: "do not tell me about
|
||||
// releases" and "do not show me the project's news" are asked
|
||||
// separately, and an installation with no outbound access at all
|
||||
// wants both off while an ordinary one may want updates and no feed.
|
||||
//
|
||||
// On by default, so nothing changes for an installation that has
|
||||
// never seen this switch.
|
||||
case FetchNews = 'fetch_news';
|
||||
|
||||
// Cached result of the last update check — never written directly by
|
||||
// a settings form, only by CheckForUpdatesCommand. Empty string means
|
||||
// "no successful check yet" (fresh install, or checks disabled).
|
||||
@@ -367,6 +378,7 @@ enum Setting: string
|
||||
self::ClientsCanPreviewFiles,
|
||||
self::PublicListingPreviewEnabled,
|
||||
self::CheckForUpdates,
|
||||
self::FetchNews,
|
||||
self::ExpiredFilesAutoDeleteEnabled,
|
||||
self::PublicCommentsEnabled,
|
||||
self::CommentsGuestModeration,
|
||||
@@ -433,6 +445,7 @@ enum Setting: string
|
||||
self::OrphanFilesAutoDeleteEnabled => false,
|
||||
|
||||
self::CheckForUpdates,
|
||||
self::FetchNews,
|
||||
self::CommentsGuestModeration,
|
||||
// On, so that an installation updating into these switches
|
||||
// keeps the preview it already had rather than losing it to a
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Updates\Console;
|
||||
|
||||
use App\Modules\Platform\Capabilities\Capability;
|
||||
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use App\Modules\Platform\Updates\CheckForUpdates;
|
||||
@@ -32,12 +34,37 @@ class CheckForUpdatesCommand extends Command
|
||||
public function __construct(
|
||||
private readonly Settings $settings,
|
||||
private readonly CheckForUpdates $check,
|
||||
private readonly CapabilityRegistry $capabilities,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
// Ahead of the setting, and deliberately not a setting itself.
|
||||
//
|
||||
// A Setting says "the operator does not want this". The true
|
||||
// statement on a managed installation is "there is nowhere for
|
||||
// this to appear and nothing they could do about it": the
|
||||
// dashboard's System card is gated on Capability::SystemUpdates
|
||||
// (DashboardController), which is Community-only, so the answer
|
||||
// this command fetches cannot be drawn on any screen — and the
|
||||
// update UI is closed by the same capability, so it could not be
|
||||
// acted on if it were. The image is chosen by whoever provisioned
|
||||
// the instance.
|
||||
//
|
||||
// Encoding that as a preference would leave it switchable back on
|
||||
// per tenant, which buys a nightly call to GitHub for a number
|
||||
// nobody can see, and would leave the reason in a provisioning
|
||||
// script rather than beside the code. A self-hosted installation
|
||||
// holds the capability and loses nothing: its own setting below
|
||||
// still decides.
|
||||
if (! $this->capabilities->has(Capability::SystemUpdates)) {
|
||||
$this->info('Update checks do not apply to this installation.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($this->settings->get(Setting::CheckForUpdates) !== true) {
|
||||
$this->info('Update checks are disabled.');
|
||||
|
||||
|
||||
Generated
+12
-12
@@ -2811,16 +2811,16 @@
|
||||
},
|
||||
{
|
||||
"name": "league/commonmark",
|
||||
"version": "2.9.0",
|
||||
"version": "2.10.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/commonmark.git",
|
||||
"reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529"
|
||||
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/5703d83ba3da3b2e356a5fedc848ed6d8ffb6529",
|
||||
"reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529",
|
||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
|
||||
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2857,7 +2857,7 @@
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "2.10-dev"
|
||||
"dev-main": "2.11-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
@@ -2914,7 +2914,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-03T13:42:31+00:00"
|
||||
"time": "2026-08-11T16:06:25+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/config",
|
||||
@@ -3883,16 +3883,16 @@
|
||||
},
|
||||
{
|
||||
"name": "nette/schema",
|
||||
"version": "v1.3.5",
|
||||
"version": "v1.3.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nette/schema.git",
|
||||
"reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002"
|
||||
"reference": "c54350438cd6914616f790a49cb424605f421562"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002",
|
||||
"reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002",
|
||||
"url": "https://api.github.com/repos/nette/schema/zipball/c54350438cd6914616f790a49cb424605f421562",
|
||||
"reference": "c54350438cd6914616f790a49cb424605f421562",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3944,9 +3944,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/nette/schema/issues",
|
||||
"source": "https://github.com/nette/schema/tree/v1.3.5"
|
||||
"source": "https://github.com/nette/schema/tree/v1.3.6"
|
||||
},
|
||||
"time": "2026-02-23T03:47:12+00:00"
|
||||
"time": "2026-08-16T21:58:41+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nette/utils",
|
||||
|
||||
@@ -161,7 +161,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'version' => '2.3.0',
|
||||
'version' => '2.4.0',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
+4
-2
@@ -449,8 +449,10 @@ the core vocabulary. A slug must be unique and lowercase; a clash throws at boot
|
||||
silently shadowing.
|
||||
|
||||
Module endpoints are deliberately absent from the document above — `OpenApiContractTest` skips
|
||||
`api/v1/modules/*` — so each package documents its own surface in its own repository. The
|
||||
`branding` module's endpoints are in `packages/cloud-modules/docs/api.md`.
|
||||
`api/v1/modules/*` — because that document is served unauthenticated and has to be identical on
|
||||
every installation. The modules that ship with the application document their endpoints in
|
||||
[`api-modules.md`](api-modules.md); a module living in its own package documents its surface in its
|
||||
own repository.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# API — module endpoints
|
||||
|
||||
Optional modules add endpoints under `/api/v1/modules/{module}/…`. They are **not** in the committed
|
||||
[`api/openapi.json`](api/openapi.json): `OpenApiContractTest` skips `api/v1/modules/*`, because that
|
||||
document is served unauthenticated and has to be identical on every installation, while a module's
|
||||
paths exist only where the module does. This file is the documentation for the modules that ship
|
||||
with the application; a module living in its own package documents its surface in its own repository.
|
||||
|
||||
Everything [`api-guide.md`](api-guide.md) describes — bearer-token authentication, ability checks,
|
||||
RFC 7807 errors, rate limits — applies unchanged. The core supplies all of it; none of it is
|
||||
restated per module.
|
||||
|
||||
`GET /api/v1/me` lists the modules an installation actually carries, so an integration can check for
|
||||
`branding` before calling anything below rather than guessing from a 404.
|
||||
|
||||
---
|
||||
|
||||
## Branding
|
||||
|
||||
Mounted at `/api/v1/modules/branding`, behind `capability:branding.customize`. Every edition has
|
||||
that capability; a hosted plan can have it subtracted from its environment, in which case these
|
||||
paths answer 403 like any other gated route.
|
||||
|
||||
Both endpoints are read-only. Uploading either image is a multipart flow whose content-sniffing
|
||||
rules only make sense behind a file picker, and settings writes follow the rule that there is never
|
||||
a generic `PATCH /settings`.
|
||||
|
||||
Hiding the attribution line is not here. That switch is Cloud-only, has no API surface, and its
|
||||
column is written by the `cloud-modules` package.
|
||||
|
||||
### `GET /logo` — ability: `edit_settings`
|
||||
|
||||
The logo shown in place of the default sidebar icon.
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"logo_url": "https://example.test/storage/branding/9f3c….png",
|
||||
"updated_at": "2026-08-07T16:02:30+00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`logo_url` is `null` when no logo has been uploaded, which is the normal state rather than an error.
|
||||
|
||||
### `GET /watermark` — ability: `edit_settings`
|
||||
|
||||
The mark stamped onto the thumbnails and previews clients and anonymous public visitors see. What
|
||||
this installation's own staff see goes unmarked, and the stored files — including every download —
|
||||
are never altered.
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"enabled": true,
|
||||
"image_url": "https://example.test/storage/branding/a68a….png",
|
||||
"position": "bottom-right",
|
||||
"size": 35,
|
||||
"opacity": 55
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `enabled` | Whether client- and public-facing images are *actually* being watermarked. False whenever nothing is drawn — including when the toggle is on but its image has since been removed. It answers "is this installation watermarking?", not "which way is the switch pointing?" What staff see is never marked regardless. |
|
||||
| `image_url` | The artwork, or `null` if none was ever chosen. |
|
||||
| `position` | One of `top-left`, `top-center`, `top-right`, `middle-left`, `center`, `middle-right`, `bottom-left`, `bottom-center`, `bottom-right`. |
|
||||
| `size` | Percentage of the image the mark is scaled to fit inside, keeping its proportions — so a thumbnail and a preview carry the same design at different scales. 5–100. |
|
||||
| `opacity` | Percentage. 1–100. |
|
||||
|
||||
An installation that has never opened the branding screen answers with the defaults it would start
|
||||
from (`enabled: false`, `bottom-right`, `30`, `60`) rather than a payload of nulls.
|
||||
|
||||
---
|
||||
|
||||
## Deferred, on purpose
|
||||
|
||||
- **Writes for either image.** See above.
|
||||
- **Rendered thumbnails themselves.** Already deferred by the host ([`api-todo.md`](api-todo.md));
|
||||
the watermark endpoint exists so an integration generating its own derivative images can reproduce
|
||||
the installation's mark, not as a step toward serving thumbnails over the API.
|
||||
@@ -4075,7 +4075,7 @@
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "Name only. The uploader is a user record; their email address\nis not part of what \"this file exists\" needs to say.",
|
||||
"description": "Name only. The uploader is a user record; their email address\nis not part of what \"this file exists\" needs to say. Null\nwhen the uploader is a client the token's owner is not\nscoped to; an unscoped account always gets the name.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
@@ -4109,6 +4109,7 @@
|
||||
},
|
||||
"assignments": {
|
||||
"type": "array",
|
||||
"description": "Who the file is shared with, as far as this caller is\nconcerned: a recipient the token's owner is not scoped to is\nleft out rather than returned without a name.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
+13
-1
@@ -2005,5 +2005,17 @@
|
||||
"Why it is set this way": "Per què està configurat així",
|
||||
"Why that is worth changing": "Per què val la pena canviar-ho",
|
||||
"Why this matters, and how to change it": "Per què és important i com canviar-ho",
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Els teus fitxers es desen fora de l'arrel web, així que cada descàrrega passa primer per ProjectSend, que comprova que qui la demana hi tingui dret. Després d'aquesta comprovació, el PHP obre el fitxer i l'envia. L'alternativa és que el PHP digui al teu servidor web «envia aquest fitxer» i acabi immediatament."
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Els teus fitxers es desen fora de l'arrel web, així que cada descàrrega passa primer per ProjectSend, que comprova que qui la demana hi tingui dret. Després d'aquesta comprovació, el PHP obre el fitxer i l'envia. L'alternativa és que el PHP digui al teu servidor web «envia aquest fitxer» i acabi immediatament.",
|
||||
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "\":name\" és una carpeta pública: qualsevol podrà obrir aquest fitxer sense iniciar sessió.",
|
||||
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "\":name\" s'eliminarà, juntament amb l'accés de tothom. Un administrador ho pot desfer.",
|
||||
"Anyone with the link will be able to open and download it, without signing in.": "Qualsevol que tingui l'enllaç podrà obrir-lo i descarregar-lo sense iniciar sessió.",
|
||||
"Back to my files": "Torna als meus fitxers",
|
||||
"Edit file": "Edita el fitxer",
|
||||
"Expires on": "Caduca el",
|
||||
"Make this file public": "Fes públic aquest fitxer",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Aquest lloc encara no té cap pàgina pública configurada, així que no es veurà res fins que un administrador en configuri una.",
|
||||
"Show ProjectSend news on the dashboard": "Mostra les novetats de ProjectSend al tauler",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Recupera els anuncis del projecte des de projectsend.org un cop al dia per a la targeta del tauler. Si ho desactives, aquesta instal·lació deixa de contactar amb projectsend.org per a novetats.",
|
||||
"Announcement": "Avís",
|
||||
"More": "Més"
|
||||
}
|
||||
|
||||
+13
-1
@@ -2005,5 +2005,17 @@
|
||||
"Why it is set this way": "Proč je to nastavené takto",
|
||||
"Why that is worth changing": "Proč se to vyplatí změnit",
|
||||
"Why this matters, and how to change it": "Proč na tom záleží a jak to změnit",
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Tvoje soubory jsou uložené mimo webový kořen, takže každé stahování projde nejdřív ProjectSendem, který ověří, že na soubor má daný člověk nárok. Po této kontrole PHP soubor otevře a odešle. Druhá možnost je, že PHP řekne webovému serveru „pošli tenhle soubor“ a hned skončí."
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Tvoje soubory jsou uložené mimo webový kořen, takže každé stahování projde nejdřív ProjectSendem, který ověří, že na soubor má daný člověk nárok. Po této kontrole PHP soubor otevře a odešle. Druhá možnost je, že PHP řekne webovému serveru „pošli tenhle soubor“ a hned skončí.",
|
||||
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "\":name\" je veřejná složka – tento soubor bude moci otevřít kdokoli bez přihlášení.",
|
||||
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "\":name\" bude smazán, včetně přístupu všech ostatních. Administrátor to může vrátit zpět.",
|
||||
"Anyone with the link will be able to open and download it, without signing in.": "Kdokoli s odkazem jej bude moci otevřít a stáhnout bez přihlášení.",
|
||||
"Back to my files": "Zpět na moje soubory",
|
||||
"Edit file": "Upravit soubor",
|
||||
"Expires on": "Vyprší dne",
|
||||
"Make this file public": "Zveřejnit tento soubor",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Tento web zatím nemá nastavenou veřejnou stránku, takže nebude nic vidět, dokud ji administrátor nenastaví.",
|
||||
"Show ProjectSend news on the dashboard": "Zobrazovat novinky ProjectSendu v přehledu",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Jednou denně načte oznámení projektu z projectsend.org pro kartu v přehledu. Když to vypnete, tato instalace se kvůli novinkám na projectsend.org už vůbec nepřipojí.",
|
||||
"Announcement": "Oznámení",
|
||||
"More": "Další"
|
||||
}
|
||||
|
||||
+13
-1
@@ -2005,5 +2005,17 @@
|
||||
"Why it is set this way": "Warum es so eingestellt ist",
|
||||
"Why that is worth changing": "Warum sich eine Änderung lohnt",
|
||||
"Why this matters, and how to change it": "Warum das wichtig ist und wie du es änderst",
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Deine Dateien liegen außerhalb des Web-Roots, also läuft jeder Download zuerst über ProjectSend, das prüft, ob die anfragende Person sie haben darf. Nach dieser Prüfung öffnet PHP die Datei und sendet sie. Die Alternative ist, dass PHP deinem Webserver sagt „sende diese Datei“ und sofort fertig ist."
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Deine Dateien liegen außerhalb des Web-Roots, also läuft jeder Download zuerst über ProjectSend, das prüft, ob die anfragende Person sie haben darf. Nach dieser Prüfung öffnet PHP die Datei und sendet sie. Die Alternative ist, dass PHP deinem Webserver sagt „sende diese Datei“ und sofort fertig ist.",
|
||||
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "\":name\" ist ein öffentlicher Ordner – jeder kann diese Datei dann ohne Anmeldung öffnen.",
|
||||
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "\":name\" wird gelöscht, zusammen mit dem Zugriff aller anderen darauf. Ein Administrator kann das rückgängig machen.",
|
||||
"Anyone with the link will be able to open and download it, without signing in.": "Jeder mit dem Link kann sie ohne Anmeldung öffnen und herunterladen.",
|
||||
"Back to my files": "Zurück zu meinen Dateien",
|
||||
"Edit file": "Datei bearbeiten",
|
||||
"Expires on": "Läuft ab am",
|
||||
"Make this file public": "Diese Datei öffentlich machen",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Für diese Website ist noch keine öffentliche Seite eingerichtet, daher ist nichts sichtbar, bis ein Administrator eine einrichtet.",
|
||||
"Show ProjectSend news on the dashboard": "ProjectSend-Neuigkeiten in der Übersicht anzeigen",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Ruft einmal täglich Projektankündigungen von projectsend.org für die Karte in der Übersicht ab. Ausgeschaltet nimmt diese Installation für Neuigkeiten überhaupt keine Verbindung zu projectsend.org mehr auf.",
|
||||
"Announcement": "Ankündigung",
|
||||
"More": "Mehr"
|
||||
}
|
||||
|
||||
+13
-1
@@ -2005,5 +2005,17 @@
|
||||
"Why it is set this way": "Por qué está configurado así",
|
||||
"Why that is worth changing": "Por qué vale la pena cambiarlo",
|
||||
"Why this matters, and how to change it": "Por qué importa y cómo cambiarlo",
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Tus archivos se guardan fuera de la raíz web, así que cada descarga pasa primero por ProjectSend para comprobar que quien la pide tiene permiso. Después de esa comprobación, PHP abre el archivo y lo envía. La alternativa es que PHP le diga a tu servidor web «envía este archivo» y termine de inmediato."
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Tus archivos se guardan fuera de la raíz web, así que cada descarga pasa primero por ProjectSend para comprobar que quien la pide tiene permiso. Después de esa comprobación, PHP abre el archivo y lo envía. La alternativa es que PHP le diga a tu servidor web «envía este archivo» y termine de inmediato.",
|
||||
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "\":name\" es una carpeta pública: cualquiera podrá abrir este archivo sin iniciar sesión.",
|
||||
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "\":name\" será eliminado, junto con el acceso de todos a él. Un administrador puede deshacerlo.",
|
||||
"Anyone with the link will be able to open and download it, without signing in.": "Cualquiera que tenga el enlace podrá abrirlo y descargarlo sin iniciar sesión.",
|
||||
"Back to my files": "Volver a mis archivos",
|
||||
"Edit file": "Editar archivo",
|
||||
"Expires on": "Vence el",
|
||||
"Make this file public": "Hacer público este archivo",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Este sitio todavía no tiene una página pública configurada, así que no se verá nada hasta que un administrador la configure.",
|
||||
"Show ProjectSend news on the dashboard": "Mostrar las noticias de ProjectSend en el panel de control",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Trae los anuncios del proyecto desde projectsend.org una vez al día para la tarjeta del panel. Si lo desactivas, esta instalación deja de contactar a projectsend.org por noticias.",
|
||||
"Announcement": "Aviso",
|
||||
"More": "Más"
|
||||
}
|
||||
|
||||
+13
-1
@@ -2005,5 +2005,17 @@
|
||||
"Why it is set this way": "Pourquoi c'est réglé ainsi",
|
||||
"Why that is worth changing": "Pourquoi cela vaut la peine d'être changé",
|
||||
"Why this matters, and how to change it": "Pourquoi c'est important, et comment le changer",
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Tes fichiers sont stockés hors de la racine web : chaque téléchargement passe donc d'abord par ProjectSend, qui vérifie que la personne a le droit de l'obtenir. Après cette vérification, PHP ouvre le fichier et l'envoie. L'autre solution est que PHP dise à ton serveur web « envoie ce fichier » et se termine aussitôt."
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Tes fichiers sont stockés hors de la racine web : chaque téléchargement passe donc d'abord par ProjectSend, qui vérifie que la personne a le droit de l'obtenir. Après cette vérification, PHP ouvre le fichier et l'envoie. L'autre solution est que PHP dise à ton serveur web « envoie ce fichier » et se termine aussitôt.",
|
||||
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "\":name\" est un dossier public : n'importe qui pourra ouvrir ce fichier sans se connecter.",
|
||||
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "\":name\" sera supprimé, ainsi que l'accès de tout le monde à ce fichier. Un administrateur peut annuler cette action.",
|
||||
"Anyone with the link will be able to open and download it, without signing in.": "N'importe qui disposant du lien pourra l'ouvrir et le télécharger sans se connecter.",
|
||||
"Back to my files": "Retour à mes fichiers",
|
||||
"Edit file": "Modifier le fichier",
|
||||
"Expires on": "Expire le",
|
||||
"Make this file public": "Rendre ce fichier public",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Ce site n'a pas encore de page publique configurée, donc rien ne sera visible tant qu'un administrateur n'en aura pas configuré une.",
|
||||
"Show ProjectSend news on the dashboard": "Afficher les actualités ProjectSend sur le tableau de bord",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Récupère une fois par jour les annonces du projet depuis projectsend.org pour la carte du tableau de bord. Désactivé, cette installation ne contacte plus du tout projectsend.org pour les actualités.",
|
||||
"Announcement": "Annonce",
|
||||
"More": "Plus"
|
||||
}
|
||||
|
||||
+13
-1
@@ -2005,5 +2005,17 @@
|
||||
"Why it is set this way": "Mengapa disetel seperti ini",
|
||||
"Why that is worth changing": "Mengapa ini layak diubah",
|
||||
"Why this matters, and how to change it": "Mengapa ini penting, dan cara mengubahnya",
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Berkas Anda disimpan di luar akar web, jadi setiap unduhan melewati ProjectSend lebih dulu untuk memastikan orang tersebut memang berhak menerimanya. Setelah pemeriksaan itu, PHP membuka berkas dan mengirimkannya. Alternatifnya adalah PHP memberi tahu server web Anda “kirim berkas ini” lalu langsung selesai."
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Berkas Anda disimpan di luar akar web, jadi setiap unduhan melewati ProjectSend lebih dulu untuk memastikan orang tersebut memang berhak menerimanya. Setelah pemeriksaan itu, PHP membuka berkas dan mengirimkannya. Alternatifnya adalah PHP memberi tahu server web Anda “kirim berkas ini” lalu langsung selesai.",
|
||||
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "Folder \":name\" bersifat publik — siapa pun bisa membuka berkas ini tanpa masuk.",
|
||||
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "\":name\" akan dihapus, beserta akses semua orang ke berkas ini. Administrator bisa membatalkannya.",
|
||||
"Anyone with the link will be able to open and download it, without signing in.": "Siapa pun yang punya tautannya bisa membuka dan mengunduhnya tanpa masuk.",
|
||||
"Back to my files": "Kembali ke berkas saya",
|
||||
"Edit file": "Sunting berkas",
|
||||
"Expires on": "Kedaluwarsa pada",
|
||||
"Make this file public": "Jadikan berkas ini publik",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Situs ini belum punya halaman publik, jadi tidak ada yang terlihat sampai administrator menyiapkannya.",
|
||||
"Show ProjectSend news on the dashboard": "Tampilkan kabar terbaru ProjectSend di dasbor",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Mengambil pengumuman proyek dari projectsend.org sekali sehari untuk kartu di dasbor. Jika dimatikan, instalasi ini sama sekali tidak lagi menghubungi projectsend.org untuk kabar terbaru.",
|
||||
"Announcement": "Pengumuman",
|
||||
"More": "Lainnya"
|
||||
}
|
||||
|
||||
+13
-1
@@ -2005,5 +2005,17 @@
|
||||
"Why it is set this way": "Perché è impostato così",
|
||||
"Why that is worth changing": "Perché vale la pena cambiarlo",
|
||||
"Why this matters, and how to change it": "Perché è importante e come cambiarlo",
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "I tuoi file sono archiviati fuori dalla radice web, quindi ogni download passa prima da ProjectSend, che verifica che chi lo chiede possa averlo. Dopo quel controllo, PHP apre il file e lo invia. L'alternativa è che PHP dica al tuo server web «invia questo file» e concluda subito."
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "I tuoi file sono archiviati fuori dalla radice web, quindi ogni download passa prima da ProjectSend, che verifica che chi lo chiede possa averlo. Dopo quel controllo, PHP apre il file e lo invia. L'alternativa è che PHP dica al tuo server web «invia questo file» e concluda subito.",
|
||||
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "\":name\" è una cartella pubblica: chiunque potrà aprire questo file senza accedere.",
|
||||
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "\":name\" verrà eliminato, insieme all'accesso di tutti al file. Un amministratore può annullare l'operazione.",
|
||||
"Anyone with the link will be able to open and download it, without signing in.": "Chiunque abbia il link potrà aprirlo e scaricarlo senza accedere.",
|
||||
"Back to my files": "Torna ai miei file",
|
||||
"Edit file": "Modifica file",
|
||||
"Expires on": "Scade il",
|
||||
"Make this file public": "Rendi pubblico questo file",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Questo sito non ha ancora una pagina pubblica, quindi non sarà visibile nulla finché un amministratore non ne configura una.",
|
||||
"Show ProjectSend news on the dashboard": "Mostra le novità di ProjectSend nel pannello",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Recupera gli annunci del progetto da projectsend.org una volta al giorno per la scheda del pannello. Disattivandolo, questa installazione smette del tutto di contattare projectsend.org per le novità.",
|
||||
"Announcement": "Avviso",
|
||||
"More": "Altro"
|
||||
}
|
||||
|
||||
+13
-1
@@ -2005,5 +2005,17 @@
|
||||
"Why it is set this way": "なぜこの設定になっているか",
|
||||
"Why that is worth changing": "変更する価値がある理由",
|
||||
"Why this matters, and how to change it": "これが重要な理由と変更方法",
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "ファイルは Web ルートの外に保存されているため、ダウンロードはまず ProjectSend を通り、要求した人に権限があるかを確認します。その確認のあと、PHP がファイルを開いて送信しています。もう一つの方法は、PHP が Web サーバーに「このファイルを送って」と伝えてすぐ処理を終えることです。"
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "ファイルは Web ルートの外に保存されているため、ダウンロードはまず ProjectSend を通り、要求した人に権限があるかを確認します。その確認のあと、PHP がファイルを開いて送信しています。もう一つの方法は、PHP が Web サーバーに「このファイルを送って」と伝えてすぐ処理を終えることです。",
|
||||
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "「:name」は公開フォルダです。ログインしていない人でもこのファイルを開けるようになります。",
|
||||
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "「:name」を削除します。ほかの人のアクセス権もなくなります。管理者なら元に戻せます。",
|
||||
"Anyone with the link will be able to open and download it, without signing in.": "リンクを知っている人なら誰でも、ログインせずに開いてダウンロードできます。",
|
||||
"Back to my files": "マイファイルに戻る",
|
||||
"Edit file": "ファイルを編集",
|
||||
"Expires on": "有効期限",
|
||||
"Make this file public": "このファイルを公開する",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "このサイトにはまだ公開ページが設定されていないため、管理者が設定するまで何も表示されません。",
|
||||
"Show ProjectSend news on the dashboard": "ダッシュボードに ProjectSend のお知らせを表示する",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "ダッシュボードのカード用に、プロジェクトのお知らせを projectsend.org から1日1回取得します。オフにすると、このインストールはお知らせのために projectsend.org へ接続しなくなります。",
|
||||
"Announcement": "お知らせ",
|
||||
"More": "その他"
|
||||
}
|
||||
|
||||
+13
-1
@@ -2005,5 +2005,17 @@
|
||||
"Why it is set this way": "Waarom het zo staat ingesteld",
|
||||
"Why that is worth changing": "Waarom het de moeite waard is dit te veranderen",
|
||||
"Why this matters, and how to change it": "Waarom dit uitmaakt, en hoe je het verandert",
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Je bestanden staan buiten de webroot, dus elke download gaat eerst langs ProjectSend om te controleren of de persoon het bestand mag hebben. Na die controle opent PHP het bestand en verstuurt het. Het alternatief is dat PHP tegen je webserver zegt “stuur dit bestand” en meteen klaar is."
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Je bestanden staan buiten de webroot, dus elke download gaat eerst langs ProjectSend om te controleren of de persoon het bestand mag hebben. Na die controle opent PHP het bestand en verstuurt het. Het alternatief is dat PHP tegen je webserver zegt “stuur dit bestand” en meteen klaar is.",
|
||||
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "\":name\" is een openbare map — iedereen kan dit bestand dan zonder in te loggen openen.",
|
||||
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "\":name\" wordt verwijderd, samen met de toegang van iedereen tot dit bestand. Een beheerder kan dit ongedaan maken.",
|
||||
"Anyone with the link will be able to open and download it, without signing in.": "Iedereen met de link kan het openen en downloaden, zonder in te loggen.",
|
||||
"Back to my files": "Terug naar mijn bestanden",
|
||||
"Edit file": "Bestand bewerken",
|
||||
"Expires on": "Verloopt op",
|
||||
"Make this file public": "Dit bestand openbaar maken",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Deze site heeft nog geen openbare pagina, dus er is niets zichtbaar totdat een beheerder er een instelt.",
|
||||
"Show ProjectSend news on the dashboard": "ProjectSend-nieuws op het overzicht tonen",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Haalt eens per dag projectaankondigingen op van projectsend.org voor de kaart op het overzicht. Uitgeschakeld neemt deze installatie voor nieuws helemaal geen contact meer op met projectsend.org.",
|
||||
"Announcement": "Mededeling",
|
||||
"More": "Meer"
|
||||
}
|
||||
|
||||
+13
-1
@@ -2005,5 +2005,17 @@
|
||||
"Why it is set this way": "Dlaczego jest tak ustawione",
|
||||
"Why that is worth changing": "Dlaczego warto to zmienić",
|
||||
"Why this matters, and how to change it": "Dlaczego to ma znaczenie i jak to zmienić",
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Twoje pliki są przechowywane poza katalogiem publicznym, więc każde pobranie przechodzi najpierw przez ProjectSend, który sprawdza, czy dana osoba ma do niego prawo. Po tym sprawdzeniu PHP otwiera plik i go wysyła. Alternatywą jest, by PHP powiedziało serwerowi WWW „wyślij ten plik” i od razu zakończyło pracę."
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Twoje pliki są przechowywane poza katalogiem publicznym, więc każde pobranie przechodzi najpierw przez ProjectSend, który sprawdza, czy dana osoba ma do niego prawo. Po tym sprawdzeniu PHP otwiera plik i go wysyła. Alternatywą jest, by PHP powiedziało serwerowi WWW „wyślij ten plik” i od razu zakończyło pracę.",
|
||||
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "\":name\" to folder publiczny — każdy będzie mógł otworzyć ten plik bez logowania.",
|
||||
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "\":name\" zostanie usunięty, razem z dostępem wszystkich osób do niego. Administrator może to cofnąć.",
|
||||
"Anyone with the link will be able to open and download it, without signing in.": "Każdy, kto ma link, będzie mógł go otworzyć i pobrać bez logowania.",
|
||||
"Back to my files": "Wróć do moich plików",
|
||||
"Edit file": "Edytuj plik",
|
||||
"Expires on": "Wygasa",
|
||||
"Make this file public": "Ustaw ten plik jako publiczny",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Ta witryna nie ma jeszcze skonfigurowanej strony publicznej, więc nic nie będzie widoczne, dopóki administrator jej nie ustawi.",
|
||||
"Show ProjectSend news on the dashboard": "Pokazuj aktualności ProjectSend na pulpicie",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Raz dziennie pobiera ogłoszenia projektu z projectsend.org na kartę pulpitu. Po wyłączeniu ta instalacja w ogóle przestaje łączyć się z projectsend.org po aktualności.",
|
||||
"Announcement": "Ogłoszenie",
|
||||
"More": "Więcej"
|
||||
}
|
||||
|
||||
+13
-1
@@ -2005,5 +2005,17 @@
|
||||
"Why it is set this way": "Por que está assim",
|
||||
"Why that is worth changing": "Por que vale a pena mudar",
|
||||
"Why this matters, and how to change it": "Por que isso importa e como mudar",
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Seus arquivos ficam fora da raiz web, então todo download passa primeiro pelo ProjectSend, que confere se a pessoa pode recebê-lo. Depois dessa checagem, o PHP abre o arquivo e o envia. A alternativa é o PHP dizer ao seu servidor web “envie este arquivo” e terminar na hora."
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Seus arquivos ficam fora da raiz web, então todo download passa primeiro pelo ProjectSend, que confere se a pessoa pode recebê-lo. Depois dessa checagem, o PHP abre o arquivo e o envia. A alternativa é o PHP dizer ao seu servidor web “envie este arquivo” e terminar na hora.",
|
||||
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "\":name\" é uma pasta pública: qualquer pessoa poderá abrir este arquivo sem entrar na conta.",
|
||||
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "\":name\" será excluído, junto com o acesso de todos a ele. Um administrador pode desfazer isso.",
|
||||
"Anyone with the link will be able to open and download it, without signing in.": "Qualquer pessoa com o link poderá abri-lo e baixá-lo sem entrar na conta.",
|
||||
"Back to my files": "Voltar para meus arquivos",
|
||||
"Edit file": "Editar arquivo",
|
||||
"Expires on": "Expira em",
|
||||
"Make this file public": "Tornar este arquivo público",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Este site ainda não tem uma página pública configurada, então nada ficará visível até que um administrador configure uma.",
|
||||
"Show ProjectSend news on the dashboard": "Mostrar novidades do ProjectSend no painel",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Busca os anúncios do projeto em projectsend.org uma vez por dia para o cartão do painel. Se você desativar, esta instalação para de contatar o projectsend.org por novidades.",
|
||||
"Announcement": "Aviso",
|
||||
"More": "Mais"
|
||||
}
|
||||
|
||||
+13
-1
@@ -2005,5 +2005,17 @@
|
||||
"Why it is set this way": "Почему выбран этот способ",
|
||||
"Why that is worth changing": "Почему это стоит изменить",
|
||||
"Why this matters, and how to change it": "Почему это важно и как это изменить",
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Ваши файлы хранятся вне корня сайта, поэтому каждая загрузка сначала проходит через ProjectSend, который проверяет, имеет ли человек на неё право. После проверки PHP открывает файл и отправляет его. Другой вариант — PHP говорит веб-серверу «отправь этот файл» и сразу завершает работу."
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Ваши файлы хранятся вне корня сайта, поэтому каждая загрузка сначала проходит через ProjectSend, который проверяет, имеет ли человек на неё право. После проверки PHP открывает файл и отправляет его. Другой вариант — PHP говорит веб-серверу «отправь этот файл» и сразу завершает работу.",
|
||||
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "«:name» — публичная папка: любой сможет открыть этот файл без входа в систему.",
|
||||
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "«:name» будет удалён вместе с доступом к нему у всех остальных. Администратор может это отменить.",
|
||||
"Anyone with the link will be able to open and download it, without signing in.": "Любой, у кого есть ссылка, сможет открыть и скачать файл без входа в систему.",
|
||||
"Back to my files": "Назад к моим файлам",
|
||||
"Edit file": "Редактировать файл",
|
||||
"Expires on": "Срок действия до",
|
||||
"Make this file public": "Сделать этот файл публичным",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "На этом сайте ещё не настроена публичная страница, поэтому ничего не будет видно, пока администратор её не настроит.",
|
||||
"Show ProjectSend news on the dashboard": "Показывать новости ProjectSend на панели",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Раз в день загружает анонсы проекта с projectsend.org для карточки на панели. Если выключить, эта установка вообще перестанет обращаться к projectsend.org за новостями.",
|
||||
"Announcement": "Объявление",
|
||||
"More": "Ещё"
|
||||
}
|
||||
|
||||
+13
-1
@@ -2005,5 +2005,17 @@
|
||||
"Why it is set this way": "Kwa nini imewekwa hivi",
|
||||
"Why that is worth changing": "Kwa nini inafaa kubadilishwa",
|
||||
"Why this matters, and how to change it": "Kwa nini hili ni muhimu, na jinsi ya kulibadilisha",
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Faili zako huhifadhiwa nje ya mzizi wa wavuti, kwa hivyo kila upakuaji hupitia ProjectSend kwanza ili kuthibitisha kuwa mtu anayeomba anaruhusiwa kuipata. Baada ya ukaguzi huo, PHP hufungua faili na kuituma. Njia mbadala ni PHP kuiambia seva yako ya wavuti “tuma faili hii” kisha imalize mara moja."
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Faili zako huhifadhiwa nje ya mzizi wa wavuti, kwa hivyo kila upakuaji hupitia ProjectSend kwanza ili kuthibitisha kuwa mtu anayeomba anaruhusiwa kuipata. Baada ya ukaguzi huo, PHP hufungua faili na kuituma. Njia mbadala ni PHP kuiambia seva yako ya wavuti “tuma faili hii” kisha imalize mara moja.",
|
||||
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "\":name\" ni folda ya umma — mtu yeyote ataweza kufungua faili hili bila kuingia.",
|
||||
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "\":name\" litafutwa, pamoja na ufikiaji wa kila mtu kwake. Msimamizi anaweza kutendua hatua hii.",
|
||||
"Anyone with the link will be able to open and download it, without signing in.": "Mtu yeyote mwenye kiungo ataweza kulifungua na kulipakua bila kuingia.",
|
||||
"Back to my files": "Rudi kwenye mafaili yangu",
|
||||
"Edit file": "Hariri faili",
|
||||
"Expires on": "Litaisha muda tarehe",
|
||||
"Make this file public": "Fanya faili hili liwe la umma",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Tovuti hii bado haina ukurasa wa umma, kwa hivyo hakuna kitakachoonekana hadi msimamizi aweke mmoja.",
|
||||
"Show ProjectSend news on the dashboard": "Onyesha habari za ProjectSend kwenye dashibodi",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Huleta matangazo ya mradi kutoka projectsend.org mara moja kwa siku kwa ajili ya kadi ya dashibodi. Ukiizima, usakinishaji huu hautawasiliana kabisa na projectsend.org kwa habari.",
|
||||
"Announcement": "Tangazo",
|
||||
"More": "Zaidi"
|
||||
}
|
||||
|
||||
+13
-1
@@ -2005,5 +2005,17 @@
|
||||
"Why it is set this way": "Neden böyle ayarlı",
|
||||
"Why that is worth changing": "Değiştirmeye neden değer",
|
||||
"Why this matters, and how to change it": "Bu neden önemli ve nasıl değiştirilir",
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Dosyaların web kök dizininin dışında saklanır; bu yüzden her indirme önce ProjectSend'den geçer ve isteyen kişinin o dosyayı alma hakkı olup olmadığı denetlenir. Bu denetimden sonra dosyayı PHP açıp gönderir. Diğer seçenek, PHP'nin web sunucuna “bu dosyayı gönder” deyip hemen işini bitirmesidir."
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Dosyaların web kök dizininin dışında saklanır; bu yüzden her indirme önce ProjectSend'den geçer ve isteyen kişinin o dosyayı alma hakkı olup olmadığı denetlenir. Bu denetimden sonra dosyayı PHP açıp gönderir. Diğer seçenek, PHP'nin web sunucuna “bu dosyayı gönder” deyip hemen işini bitirmesidir.",
|
||||
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "\":name\" herkese açık bir klasör — bu dosyayı oturum açmadan herkes açabilecek.",
|
||||
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "\":name\" silinecek, herkesin ona erişimiyle birlikte. Bir yönetici bunu geri alabilir.",
|
||||
"Anyone with the link will be able to open and download it, without signing in.": "Bağlantıya sahip herkes oturum açmadan dosyayı açabilir ve indirebilir.",
|
||||
"Back to my files": "Dosyalarıma dön",
|
||||
"Edit file": "Dosyayı düzenle",
|
||||
"Expires on": "Sona erme tarihi",
|
||||
"Make this file public": "Bu dosyayı herkese açık yap",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Bu sitede henüz herkese açık bir sayfa ayarlanmamış, bu yüzden bir yönetici ayarlayana kadar hiçbir şey görünmeyecek.",
|
||||
"Show ProjectSend news on the dashboard": "ProjectSend haberlerini panelde göster",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Panel kartı için proje duyurularını günde bir kez projectsend.org adresinden alır. Kapatıldığında bu kurulum haberler için projectsend.org ile hiç bağlantı kurmaz.",
|
||||
"Announcement": "Duyuru",
|
||||
"More": "Daha fazla"
|
||||
}
|
||||
|
||||
+13
-1
@@ -2005,5 +2005,17 @@
|
||||
"Why it is set this way": "Vì sao lại được đặt như vậy",
|
||||
"Why that is worth changing": "Vì sao nên thay đổi",
|
||||
"Why this matters, and how to change it": "Vì sao điều này quan trọng và cách thay đổi",
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Tệp của bạn được lưu ngoài thư mục gốc web, nên mỗi lượt tải xuống đều đi qua ProjectSend trước để kiểm tra người yêu cầu có quyền nhận tệp hay không. Sau bước kiểm tra đó, PHP mở tệp và gửi đi. Cách còn lại là PHP báo cho máy chủ web «gửi tệp này» rồi kết thúc ngay."
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Tệp của bạn được lưu ngoài thư mục gốc web, nên mỗi lượt tải xuống đều đi qua ProjectSend trước để kiểm tra người yêu cầu có quyền nhận tệp hay không. Sau bước kiểm tra đó, PHP mở tệp và gửi đi. Cách còn lại là PHP báo cho máy chủ web «gửi tệp này» rồi kết thúc ngay.",
|
||||
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "“:name” là thư mục công khai — bất kỳ ai cũng sẽ mở được tệp này mà không cần đăng nhập.",
|
||||
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "“:name” sẽ bị xóa, cùng với quyền truy cập của mọi người vào tệp. Quản trị viên có thể hoàn tác việc này.",
|
||||
"Anyone with the link will be able to open and download it, without signing in.": "Bất kỳ ai có liên kết đều có thể mở và tải tệp xuống mà không cần đăng nhập.",
|
||||
"Back to my files": "Quay lại tệp của tôi",
|
||||
"Edit file": "Sửa tệp",
|
||||
"Expires on": "Hết hạn vào",
|
||||
"Make this file public": "Công khai tệp này",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Trang web này chưa thiết lập trang công khai, nên sẽ không có gì hiển thị cho đến khi quản trị viên thiết lập.",
|
||||
"Show ProjectSend news on the dashboard": "Hiển thị tin tức ProjectSend trên bảng điều khiển",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Tải thông báo của dự án từ projectsend.org mỗi ngày một lần cho thẻ trên bảng điều khiển. Khi tắt, bản cài đặt này sẽ hoàn toàn không liên hệ với projectsend.org để lấy tin tức.",
|
||||
"Announcement": "Thông báo",
|
||||
"More": "Thêm"
|
||||
}
|
||||
|
||||
+13
-1
@@ -2005,5 +2005,17 @@
|
||||
"Why it is set this way": "为什么是这样设置的",
|
||||
"Why that is worth changing": "为什么值得更改",
|
||||
"Why this matters, and how to change it": "为什么这很重要,以及如何更改",
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "你的文件存放在 Web 根目录之外,因此每次下载都会先经过 ProjectSend,确认请求者有权获取该文件。通过检查之后,由 PHP 打开文件并发送。另一种方式是 PHP 告诉 Web 服务器“发送这个文件”,然后立即结束。"
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "你的文件存放在 Web 根目录之外,因此每次下载都会先经过 ProjectSend,确认请求者有权获取该文件。通过检查之后,由 PHP 打开文件并发送。另一种方式是 PHP 告诉 Web 服务器“发送这个文件”,然后立即结束。",
|
||||
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "“:name”是公开文件夹——任何人无需登录即可打开此文件。",
|
||||
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "“:name”将被删除,所有人对它的访问权限也会一并移除。管理员可以撤销此操作。",
|
||||
"Anyone with the link will be able to open and download it, without signing in.": "任何拿到链接的人都可以无需登录即可打开并下载。",
|
||||
"Back to my files": "返回我的文件",
|
||||
"Edit file": "编辑文件",
|
||||
"Expires on": "到期日",
|
||||
"Make this file public": "将此文件设为公开",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "本站尚未设置公开页面,因此在管理员设置之前不会显示任何内容。",
|
||||
"Show ProjectSend news on the dashboard": "在仪表板上显示 ProjectSend 动态",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "每天一次从 projectsend.org 获取项目公告,用于仪表板卡片。关闭后,本安装将完全不再因动态而连接 projectsend.org。",
|
||||
"Announcement": "公告",
|
||||
"More": "更多"
|
||||
}
|
||||
|
||||
Generated
+27
-26
@@ -4,6 +4,7 @@
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cloud",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
@@ -1190,41 +1191,41 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@humanfs/types": "^0.15.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/node": {
|
||||
"version": "0.16.6",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz",
|
||||
"integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==",
|
||||
"version": "0.16.8",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
|
||||
"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@humanfs/core": "^0.19.1",
|
||||
"@humanwhocodes/retry": "^0.3.0"
|
||||
"@humanfs/core": "^0.19.2",
|
||||
"@humanfs/types": "^0.15.0",
|
||||
"@humanwhocodes/retry": "^0.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz",
|
||||
"integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==",
|
||||
"node_modules/@humanfs/types": {
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
|
||||
"integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nzakas"
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/module-importer": {
|
||||
@@ -4057,9 +4058,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6688,9 +6689,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.17",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
|
||||
"integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -7220,9 +7221,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
|
||||
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.1",
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { type SharedData } from '@/types';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
import { ExternalLink, Megaphone } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
|
||||
export interface Announcement {
|
||||
title: string;
|
||||
body: string;
|
||||
action_label: string | null;
|
||||
action_url: string | null;
|
||||
tone: 'info' | 'warning' | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One message, shown two ways, from one shared prop — see
|
||||
* ResolvingAnnouncement. The band is the dashboard; the icon beside the
|
||||
* notification bell carries the same words to every other page, so
|
||||
* somebody who never opens the dashboard still meets it once.
|
||||
*
|
||||
* Both live in this file deliberately. They have to say the same thing,
|
||||
* and the reliable way to keep two renderings of one message identical is
|
||||
* for them to share the component that renders it.
|
||||
*
|
||||
* Nothing here knows what it is saying. Title, body and button all arrive
|
||||
* from whatever listened.
|
||||
*
|
||||
* Coloured enough to be read and not enough to alarm: a tinted left edge
|
||||
* and a matching wash, rather than a saturated block. The first caller
|
||||
* tells a hosted customer their plan has limits and a bigger one exists,
|
||||
* which is worth noticing and not worth interrupting for — so it must not
|
||||
* look like an outage. Both palettes are declared per tone rather than
|
||||
* derived, so the dark variant is a deliberate colour rather than
|
||||
* whatever the light one happens to become.
|
||||
*/
|
||||
const TONES: Record<string, string> = {
|
||||
info: 'border-l-sky-500 bg-sky-50 dark:bg-sky-950/40',
|
||||
warning: 'border-l-amber-500 bg-amber-50 dark:bg-amber-950/40',
|
||||
};
|
||||
|
||||
const DOT_TONES: Record<string, string> = {
|
||||
info: 'bg-sky-500',
|
||||
warning: 'bg-amber-500',
|
||||
};
|
||||
|
||||
/** The words, and the button if there is one. Shared by both surfaces. */
|
||||
function AnnouncementBody({ announcement, compact = false }: { announcement: Announcement; compact?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold">{announcement.title}</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">{announcement.body}</p>
|
||||
</div>
|
||||
|
||||
{announcement.action_label && announcement.action_url && (
|
||||
<Button asChild variant="outline" size="sm" className={compact ? 'w-full bg-transparent' : 'shrink-0 bg-transparent'}>
|
||||
{/* Always a new tab: the destination is outside this
|
||||
installation, and taking somebody out of the app
|
||||
they are working in is not what this should do. */}
|
||||
<a href={announcement.action_url} target="_blank" rel="noopener noreferrer">
|
||||
{announcement.action_label}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The dashboard band. */
|
||||
export function AnnouncementBand({ announcement }: { announcement: Announcement }) {
|
||||
const tone = TONES[announcement.tone] ?? TONES.info;
|
||||
|
||||
return (
|
||||
<div className={`mb-6 flex flex-col gap-3 rounded-lg border border-l-4 p-4 sm:flex-row sm:items-center sm:justify-between ${tone}`}>
|
||||
<AnnouncementBody announcement={announcement} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The header icon, beside the notification bell.
|
||||
*
|
||||
* Same shape as UpdateAvailableIcon next to it: absent entirely when
|
||||
* there is nothing to say, rather than a dead control. The dot marks it
|
||||
* without a count — there is only ever one of these, and "1" on a badge
|
||||
* would invite somebody to look for the second.
|
||||
*/
|
||||
export function AnnouncementIcon() {
|
||||
const { t } = useTranslation();
|
||||
const { announcement } = usePage<SharedData>().props;
|
||||
|
||||
if (!announcement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dot = DOT_TONES[announcement.tone] ?? DOT_TONES.info;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="relative" aria-label={announcement.title || t('Announcement')}>
|
||||
<Megaphone className="size-5" />
|
||||
<span className={`absolute top-0.5 right-0.5 size-2.5 rounded-full ${dot}`} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="flex w-80 flex-col gap-3 p-4">
|
||||
<AnnouncementBody announcement={announcement} compact />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AnnouncementIcon } from '@/components/announcement';
|
||||
import { AppearanceSwitcher } from '@/components/appearance-switcher';
|
||||
import { Breadcrumbs } from '@/components/breadcrumbs';
|
||||
import { IconLocaleSwitcher } from '@/components/locale-switcher';
|
||||
@@ -15,6 +16,7 @@ export function AppSidebarHeader({ breadcrumbs = [] }: { breadcrumbs?: Breadcrum
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<UpdateAvailableIcon />
|
||||
<AnnouncementIcon />
|
||||
<NotificationBell />
|
||||
<AppearanceSwitcher />
|
||||
<IconLocaleSwitcher />
|
||||
|
||||
@@ -32,7 +32,7 @@ import AppLogo from './app-logo';
|
||||
|
||||
export function AppSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const { auth, capabilities, pending, version } = usePage<SharedData>().props;
|
||||
const { auth, capabilities, extra_nav_links, pending, version } = usePage<SharedData>().props;
|
||||
|
||||
// Modules (Files, Clients, Groups…) add their own groups here as
|
||||
// they land, mirroring v1's grouped admin menu.
|
||||
@@ -226,7 +226,7 @@ export function AppSidebar() {
|
||||
{ title: t('Security'), url: '/system/settings/security', when: settings },
|
||||
{ title: t('LDAP'), url: '/system/settings/ldap', when: settings },
|
||||
{ title: t('Social login'), url: '/system/settings/social-login', when: settings },
|
||||
{ title: t('CAPTCHA'), url: '/system/settings/captcha', when: settings },
|
||||
{ title: t('CAPTCHA'), url: '/system/settings/captcha', when: settings && capabilities.includes('captcha.configure') },
|
||||
|
||||
// Files: where they land, how long they stay, who can see them.
|
||||
{ title: t('Uploads'), url: '/system/settings/uploads', when: settings },
|
||||
@@ -262,6 +262,23 @@ export function AppSidebar() {
|
||||
});
|
||||
}
|
||||
|
||||
// Contributed by whatever is installed — see ResolvingNavigationLinks.
|
||||
// Their own group at the end rather than mixed into Administration:
|
||||
// these leave the installation, and a link that takes somebody out of
|
||||
// the app should not sit between two that do not. Empty on every
|
||||
// installation with nothing listening, and the group disappears with
|
||||
// it rather than rendering a heading over nothing.
|
||||
if (extra_nav_links.length > 0) {
|
||||
groups.push({
|
||||
title: t('More'),
|
||||
items: extra_nav_links.map((link) => ({
|
||||
title: link.title,
|
||||
url: link.url,
|
||||
external: link.external,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" variant="inset">
|
||||
<SidebarHeader>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@/components/ui/sidebar';
|
||||
import { type NavGroup } from '@/types';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { ChevronRight, ExternalLink } from 'lucide-react';
|
||||
|
||||
export function NavMain({ groups = [] }: { groups: NavGroup[] }) {
|
||||
const page = usePage();
|
||||
@@ -58,11 +58,31 @@ export function NavMain({ groups = [] }: { groups: NavGroup[] }) {
|
||||
</Collapsible>
|
||||
) : (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild isActive={isActive(item.url)} tooltip={item.title}>
|
||||
<Link href={item.url ?? '#'}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={item.external ? false : isActive(item.url)}
|
||||
tooltip={item.title}
|
||||
>
|
||||
{/* An external destination is a plain
|
||||
anchor, never an Inertia <Link>:
|
||||
Link expects a page component back
|
||||
and another origin will not give it
|
||||
one, so it fails without saying so.
|
||||
It is also never "active" — nothing
|
||||
outside this app is the page you
|
||||
are on. */}
|
||||
{item.external ? (
|
||||
<a href={item.url ?? '#'} target="_blank" rel="noopener noreferrer">
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
<ExternalLink className="ml-auto size-3.5 opacity-60" />
|
||||
</a>
|
||||
) : (
|
||||
<Link href={item.url ?? '#'}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
{item.badge !== undefined && item.badge > 0 && (
|
||||
<SidebarMenuBadge className="bg-primary text-primary-foreground peer-hover/menu-button:text-primary-foreground peer-data-[active=true]/menu-button:text-primary-foreground rounded-full">
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Link, router } from '@inertiajs/react';
|
||||
import { Pencil, X } from 'lucide-react';
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
import { type FileRow } from '@/types/portal';
|
||||
|
||||
interface FileRowActionsProps {
|
||||
file: FileRow;
|
||||
size?: 'default' | 'sm' | 'icon';
|
||||
}
|
||||
|
||||
/**
|
||||
* The edit and delete controls that sit on a file row.
|
||||
*
|
||||
* The file twin of FolderRowActions, and gated the same way: on the row's
|
||||
* own `can_update`/`can_delete`, which the server decides per file. A
|
||||
* client manages what they uploaded and not what was shared with them, and
|
||||
* both kinds sit in the same list — so this is never `is_mine`, which
|
||||
* answers only half the question. The role's keys are the other half.
|
||||
*
|
||||
* Deliberately no props for the handlers. Renaming a folder is a one-field
|
||||
* dialog and each theme owns its own; a file has eight fields behind five
|
||||
* separate permissions, so it gets a page (portal/edit-file.tsx) that every
|
||||
* theme shares rather than a form each theme would have to carry.
|
||||
*/
|
||||
export function FileRowActions({ file, size = 'sm' }: FileRowActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
{file.can_update && (
|
||||
<Button variant="ghost" size={size} asChild>
|
||||
<Link href={route('my-files.edit', file.id)}>
|
||||
<Pencil className="size-4" />
|
||||
<span className="sr-only">{t('Edit')}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{file.can_delete && (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size={size} className="text-destructive hover:text-destructive">
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">{t('Delete')}</span>
|
||||
</Button>
|
||||
}
|
||||
title={t('Delete file?')}
|
||||
description={t('":name" will be deleted, along with everyone\'s access to it. This can be undone by an administrator.', {
|
||||
name: file.name,
|
||||
})}
|
||||
confirmLabel={t('Delete file')}
|
||||
onConfirm={() => router.delete(route('my-files.destroy', file.id))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { TopClientsWidget, type TopClient } from '@/components/dashboard-widgets
|
||||
import { TransfersRangeControls, TransfersWidget, type TransferPoint, type TransfersRange } from '@/components/dashboard-widgets/transfers-widget';
|
||||
import { WidgetBox } from '@/components/dashboard-widgets/widget-box';
|
||||
import { WidgetsDialog } from '@/components/dashboard-widgets/widgets-dialog';
|
||||
import { AnnouncementBand } from '@/components/announcement';
|
||||
import Heading from '@/components/heading';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -110,7 +111,7 @@ export default function Dashboard({
|
||||
dashboard_columns,
|
||||
}: DashboardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { update_notice } = usePage<SharedData>().props;
|
||||
const { announcement, update_notice } = usePage<SharedData>().props;
|
||||
const [releaseDialogOpen, setReleaseDialogOpen] = useState(false);
|
||||
const [widgetsDialogOpen, setWidgetsDialogOpen] = useState(false);
|
||||
const [layout, setLayout] = useState<WidgetLayout>(widget_layout);
|
||||
@@ -336,6 +337,12 @@ export default function Dashboard({
|
||||
<Head title={t('Dashboard')} />
|
||||
|
||||
<div className="space-y-6 px-4 py-6">
|
||||
{/* Above the heading, not inside the grid: whoever asked
|
||||
for this wants it read, and the grid is arranged by
|
||||
each viewer. Read from shared props, the same source
|
||||
the header icon uses, so the two cannot disagree. */}
|
||||
{announcement && <AnnouncementBand announcement={announcement} />}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Heading title={t('Dashboard')} description={t('An overview of this installation')} />
|
||||
<Button variant="outline" size="sm" onClick={() => setWidgetsDialogOpen(true)}>
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import { Head, Link, router, useForm } from '@inertiajs/react';
|
||||
import { ArrowLeft, Globe } from 'lucide-react';
|
||||
import { type FormEventHandler } from 'react';
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import Heading from '@/components/heading';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import PortalLayout from '@/layouts/portal-layout';
|
||||
import { categoryColor } from '@/lib/category-colors';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { type CategoryTag } from '@/types/portal';
|
||||
|
||||
interface FolderOption {
|
||||
id: number;
|
||||
name: string;
|
||||
/** Files in here are readable by anyone, with or without the public switch. */
|
||||
public: boolean;
|
||||
}
|
||||
|
||||
interface PortalEditFileProps {
|
||||
theme: string;
|
||||
file: {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
original_name: string;
|
||||
size: number;
|
||||
public: boolean;
|
||||
commentable: boolean;
|
||||
expires_at: string | null;
|
||||
download_limit: number | null;
|
||||
download_limit_scope: string;
|
||||
folder_id: number | null;
|
||||
categories: number[];
|
||||
};
|
||||
can_delete: boolean;
|
||||
can_publish: boolean;
|
||||
can_set_expiration: boolean;
|
||||
can_set_categories: boolean;
|
||||
can_limit_downloads: boolean;
|
||||
can_set_commentable: boolean;
|
||||
categories: CategoryTag[];
|
||||
folders: FolderOption[];
|
||||
public_listing_slug: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The client's editor for a file they uploaded.
|
||||
*
|
||||
* One page for every theme, like portal/upload.tsx and for the same
|
||||
* reason — a form rebuilt per theme is four places for a field to go
|
||||
* missing. Only the shell differs, and the `theme` prop picks it.
|
||||
*
|
||||
* Every field here is behind the same permission the server will check
|
||||
* when this posts. Hiding a control the client cannot use is a courtesy,
|
||||
* not the enforcement: ApplyFileEdits leaves an ungranted field exactly as
|
||||
* it was regardless of what arrives.
|
||||
*/
|
||||
export default function PortalEditFile({
|
||||
theme,
|
||||
file,
|
||||
can_delete,
|
||||
can_publish,
|
||||
can_set_expiration,
|
||||
can_set_categories,
|
||||
can_limit_downloads,
|
||||
can_set_commentable,
|
||||
categories,
|
||||
folders,
|
||||
public_listing_slug,
|
||||
}: PortalEditFileProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const form = useForm({
|
||||
name: file.name,
|
||||
description: file.description ?? '',
|
||||
folder_id: file.folder_id === null ? 'root' : String(file.folder_id),
|
||||
public: file.public,
|
||||
commentable: file.commentable,
|
||||
expires_at: file.expires_at ?? '',
|
||||
download_limit: file.download_limit === null ? '' : String(file.download_limit),
|
||||
download_limit_scope: file.download_limit_scope,
|
||||
categories: file.categories,
|
||||
});
|
||||
const { data, setData, processing, errors, recentlySuccessful } = form;
|
||||
|
||||
const submit: FormEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// 'root' means no folder, and an empty box means no limit — the
|
||||
// same transforms the staff editor makes, because the server reads
|
||||
// one payload shape from both.
|
||||
form.transform((payload) => ({
|
||||
...payload,
|
||||
folder_id: payload.folder_id === 'root' ? null : payload.folder_id,
|
||||
expires_at: payload.expires_at || null,
|
||||
download_limit: payload.download_limit === '' ? null : Number(payload.download_limit),
|
||||
}));
|
||||
|
||||
form.patch(route('my-files.update', file.id), { preserveScroll: true });
|
||||
};
|
||||
|
||||
const unassigned = categories.filter((category) => !data.categories.includes(category.id));
|
||||
const selectedFolder = folders.find((folder) => String(folder.id) === data.folder_id) ?? null;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<Head title={t('Edit :name', { name: file.name })} />
|
||||
|
||||
<div className="px-4 py-6">
|
||||
<Heading title={t('Edit file')} description={file.original_name} />
|
||||
|
||||
<Button variant="ghost" size="sm" asChild className="mb-4 -ml-2">
|
||||
<Link href={route('my-files.index')}>
|
||||
<ArrowLeft className="size-4" />
|
||||
{t('Back to my files')}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<form onSubmit={submit} className="grid max-w-2xl gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">{t('Name')}</Label>
|
||||
<Input id="name" value={data.name} onChange={(e) => setData('name', e.target.value)} required />
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">{t('Description')}</Label>
|
||||
<Textarea id="description" value={data.description} onChange={(e) => setData('description', e.target.value)} rows={3} />
|
||||
<InputError message={errors.description} />
|
||||
</div>
|
||||
|
||||
{folders.length > 0 && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="folder_id">{t('Folder')}</Label>
|
||||
<Select value={data.folder_id} onValueChange={(value) => setData('folder_id', value)}>
|
||||
<SelectTrigger id="folder_id">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="root">{t('No folder')}</SelectItem>
|
||||
{folders.map((folder) => (
|
||||
<SelectItem key={folder.id} value={String(folder.id)}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{folder.name}
|
||||
{folder.public && <Globe className="text-muted-foreground size-3.5 shrink-0" />}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={errors.folder_id} />
|
||||
{/* Moving into a public folder publishes the file
|
||||
whether or not the public switch below is even
|
||||
offered — so the warning belongs here, next to
|
||||
the choice, not only next to that switch. */}
|
||||
{selectedFolder?.public && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('":name" is a public folder — anyone will be able to open this file, without signing in.', {
|
||||
name: selectedFolder.name,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{can_set_expiration && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="expires_at">{t('Expires on')}</Label>
|
||||
<Input id="expires_at" type="date" value={data.expires_at} onChange={(e) => setData('expires_at', e.target.value)} />
|
||||
<InputError message={errors.expires_at} />
|
||||
<p className="text-muted-foreground text-xs">{t('Leave empty for a file that never expires.')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{can_limit_downloads && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="download_limit">{t('Download limit')}</Label>
|
||||
<Input
|
||||
id="download_limit"
|
||||
type="number"
|
||||
min={1}
|
||||
value={data.download_limit}
|
||||
onChange={(e) => setData('download_limit', e.target.value)}
|
||||
/>
|
||||
<InputError message={errors.download_limit} />
|
||||
|
||||
{data.download_limit !== '' && (
|
||||
<Select value={data.download_limit_scope} onValueChange={(value) => setData('download_limit_scope', value)}>
|
||||
<SelectTrigger id="download_limit_scope">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="total">{t('In total, across everyone')}</SelectItem>
|
||||
<SelectItem value="per_user">{t('Each person separately')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('Leave empty for a file that can be downloaded any number of times.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{can_set_categories && (
|
||||
<div className="grid gap-2">
|
||||
<Label>{t('Categories')}</Label>
|
||||
|
||||
{data.categories.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{data.categories.map((id) => {
|
||||
const category = categories.find((c) => c.id === id);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm ${category ? categoryColor(category.color).badge : 'bg-muted'}`}
|
||||
onClick={() =>
|
||||
setData(
|
||||
'categories',
|
||||
data.categories.filter((current) => current !== id),
|
||||
)
|
||||
}
|
||||
>
|
||||
{category?.name ?? id}
|
||||
<span aria-hidden>×</span>
|
||||
<span className="sr-only">{t('Remove')}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unassigned.length > 0 && (
|
||||
<Select value="" onValueChange={(value) => setData('categories', [...data.categories, Number(value)])}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('Add a category')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{unassigned.map((category) => (
|
||||
<SelectItem key={category.id} value={String(category.id)}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className={`size-2 shrink-0 rounded-full ${categoryColor(category.color).swatch}`} />
|
||||
{category.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{can_set_commentable && (
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="commentable"
|
||||
checked={data.commentable}
|
||||
onCheckedChange={(checked) => setData('commentable', checked === true)}
|
||||
/>
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="commentable">{t('Allow comments on this file')}</Label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{can_publish && (
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="public"
|
||||
checked={data.public}
|
||||
onCheckedChange={(checked) => setData('public', checked === true)}
|
||||
/>
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="public" className="flex items-center gap-1.5">
|
||||
<Globe className="size-4" />
|
||||
{t('Make this file public')}
|
||||
</Label>
|
||||
{/* Said plainly, because it is the one switch
|
||||
here that reaches past the people this
|
||||
file was shared with. */}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{public_listing_slug
|
||||
? t('Anyone with the link will be able to open and download it, without signing in.')
|
||||
: t('This site has no public page set up yet, so nothing will be visible until an administrator sets one.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="submit" disabled={processing}>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
|
||||
{recentlySuccessful && <p className="text-muted-foreground text-sm">{t('Saved.')}</p>}
|
||||
|
||||
{can_delete && (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button type="button" variant="ghost" className="text-destructive hover:text-destructive ml-auto">
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
}
|
||||
title={t('Delete file?')}
|
||||
description={t('":name" will be deleted, along with everyone\'s access to it. This can be undone by an administrator.', {
|
||||
name: file.name,
|
||||
})}
|
||||
confirmLabel={t('Delete file')}
|
||||
onConfirm={() => router.delete(route('my-files.destroy', file.id))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
// Same shell dispatch as portal/upload.tsx: the "default" theme uses
|
||||
// the app's own sidebar, every other portal theme uses PortalLayout.
|
||||
if (theme === 'default') {
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{ title: t('My files'), href: '/my-files' },
|
||||
{ title: file.name, href: route('my-files.edit', file.id) },
|
||||
];
|
||||
|
||||
return <AppLayout breadcrumbs={breadcrumbs}>{content}</AppLayout>;
|
||||
}
|
||||
|
||||
return <PortalLayout>{content}</PortalLayout>;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { CategoryBadges } from '@/components/files/category-badges';
|
||||
import { VersionBadge } from '@/components/files/version-badge';
|
||||
import Heading from '@/components/heading';
|
||||
import { Pagination } from '@/components/pagination';
|
||||
import { FileRowActions } from '@/components/portal/file-row-actions';
|
||||
import { FolderRowActions } from '@/components/portal/folder-row-actions';
|
||||
import { NewFolderButton } from '@/components/portal/new-folder-button';
|
||||
import { PortalBreadcrumb } from '@/components/portal/portal-breadcrumb';
|
||||
@@ -257,6 +258,7 @@ export default function MyFilesCompact(props: MyFilesFolderManagementProps) {
|
||||
iconClassName="size-3.5"
|
||||
iconOnly
|
||||
/>
|
||||
<FileRowActions file={file} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { CategoryBadges } from '@/components/files/category-badges';
|
||||
import { VersionBadge } from '@/components/files/version-badge';
|
||||
import Heading from '@/components/heading';
|
||||
import { Pagination } from '@/components/pagination';
|
||||
import { FileRowActions } from '@/components/portal/file-row-actions';
|
||||
import { FolderRowActions } from '@/components/portal/folder-row-actions';
|
||||
import { NewFolderButton } from '@/components/portal/new-folder-button';
|
||||
import { PortalBreadcrumb } from '@/components/portal/portal-breadcrumb';
|
||||
@@ -232,6 +233,7 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
|
||||
size="sm"
|
||||
/>
|
||||
<DownloadAction href={route('files.download', file.id)} limit={file.download_limit} variant="outline" size="sm" />
|
||||
<FileRowActions file={file} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -356,6 +358,7 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
|
||||
size="sm"
|
||||
iconOnly
|
||||
/>
|
||||
<FileRowActions file={file} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CategoryBadges } from '@/components/files/category-badges';
|
||||
import { VersionBadge } from '@/components/files/version-badge';
|
||||
import Heading from '@/components/heading';
|
||||
import { Pagination } from '@/components/pagination';
|
||||
import { FileRowActions } from '@/components/portal/file-row-actions';
|
||||
import { FolderRowActions } from '@/components/portal/folder-row-actions';
|
||||
import { NewFolderButton } from '@/components/portal/new-folder-button';
|
||||
import { PortalBreadcrumb } from '@/components/portal/portal-breadcrumb';
|
||||
@@ -262,6 +263,7 @@ export default function MyFilesDrive(props: MyFilesFolderManagementProps) {
|
||||
iconClassName="size-4 text-blue-600"
|
||||
iconOnly
|
||||
/>
|
||||
<FileRowActions file={file} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CategoryBadges } from '@/components/files/category-badges';
|
||||
import { VersionBadge } from '@/components/files/version-badge';
|
||||
import Heading from '@/components/heading';
|
||||
import { Pagination } from '@/components/pagination';
|
||||
import { FileRowActions } from '@/components/portal/file-row-actions';
|
||||
import { FolderRowActions } from '@/components/portal/folder-row-actions';
|
||||
import { NewFolderButton } from '@/components/portal/new-folder-button';
|
||||
import { PortalBreadcrumb } from '@/components/portal/portal-breadcrumb';
|
||||
@@ -257,6 +258,7 @@ export default function MyFilesGallery(props: MyFilesFolderManagementProps) {
|
||||
size="sm"
|
||||
iconOnly
|
||||
/>
|
||||
<FileRowActions file={file} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,8 @@ interface SystemSettingsProps {
|
||||
viewer_timezone: string | null;
|
||||
can_manage_updates: boolean;
|
||||
check_for_updates: boolean | null;
|
||||
fetch_news: boolean | null;
|
||||
can_configure_news: boolean;
|
||||
/** When the release feed was last asked, by anybody. Null until it has been. */
|
||||
last_checked_at: string | null;
|
||||
/** The answer to a "check now" press, for the one render after it. */
|
||||
@@ -36,6 +38,8 @@ export default function SystemSettings({
|
||||
viewer_timezone,
|
||||
can_manage_updates,
|
||||
check_for_updates,
|
||||
fetch_news,
|
||||
can_configure_news,
|
||||
last_checked_at,
|
||||
check_result,
|
||||
}: SystemSettingsProps) {
|
||||
@@ -75,6 +79,7 @@ export default function SystemSettings({
|
||||
site_name: site_name,
|
||||
timezone: timezone,
|
||||
check_for_updates: check_for_updates ?? false,
|
||||
fetch_news: fetch_news ?? true,
|
||||
});
|
||||
|
||||
const submit: FormEventHandler = (e) => {
|
||||
@@ -132,6 +137,30 @@ export default function SystemSettings({
|
||||
<InputError className="mt-2" message={errors.timezone} />
|
||||
</div>
|
||||
|
||||
{/* Its own capability, not can_manage_updates: that block
|
||||
disappears on a managed installation because nobody
|
||||
there can act on an update notice, while this one
|
||||
disappears because the news has to keep arriving
|
||||
whether or not that installation's administrator
|
||||
would have chosen it. */}
|
||||
{can_configure_news && (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="fetch_news"
|
||||
checked={data.fetch_news}
|
||||
onCheckedChange={(checked) => setData('fetch_news', checked === true)}
|
||||
/>
|
||||
<Label htmlFor="fetch_news">{t('Show ProjectSend news on the dashboard')}</Label>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
'Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{can_manage_updates && (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { type Announcement } from '@/components/announcement';
|
||||
import { type InstallKind } from '@/components/update-instructions';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
@@ -23,6 +24,12 @@ export interface NavItem {
|
||||
isActive?: boolean;
|
||||
items?: NavItem[];
|
||||
badge?: number;
|
||||
/**
|
||||
* Leaves this installation. Rendered as a plain anchor opening in a
|
||||
* new tab rather than an Inertia <Link>, which would try to fetch a
|
||||
* page component from another origin and fail silently.
|
||||
*/
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
export type Edition = 'community' | 'cloud';
|
||||
@@ -32,12 +39,27 @@ export type Capability =
|
||||
| 'storage.configure'
|
||||
| 'email.transport.configure'
|
||||
| 'system.updates'
|
||||
| 'news.configure'
|
||||
| 'scheduler.monitoring'
|
||||
| 'custom_assets.manage'
|
||||
| 'branding.customize'
|
||||
| 'attribution.hide'
|
||||
| 'captcha.configure'
|
||||
| 'captcha.managed_keys';
|
||||
|
||||
/**
|
||||
* A sidebar entry contributed by a package — see
|
||||
* ResolvingNavigationLinks. Staff-only and already filtered server-side,
|
||||
* so the sidebar renders these without re-deciding who may see them.
|
||||
*/
|
||||
export interface ExtraNavLink {
|
||||
title: string;
|
||||
url: string;
|
||||
/** Opens in a new tab and shows that it leaves this installation. */
|
||||
external: boolean;
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
export interface SocialLoginProvider {
|
||||
provider: string;
|
||||
label: string;
|
||||
@@ -89,6 +111,14 @@ export interface SharedData {
|
||||
*/
|
||||
attribution: boolean;
|
||||
capabilities: Capability[];
|
||||
/** Sidebar entries contributed by packages, already staff-filtered. */
|
||||
extra_nav_links: ExtraNavLink[];
|
||||
/**
|
||||
* One message to put in front of staff, or null. Rendered as a band
|
||||
* on the dashboard and behind the header icon everywhere else — see
|
||||
* ResolvingAnnouncement. Shared so both say the same thing.
|
||||
*/
|
||||
announcement: Announcement | null;
|
||||
/** Identity providers that are switched on and fully configured. */
|
||||
social_login: SocialLoginProvider[];
|
||||
/** The CAPTCHA in force, or null when this installation has none. */
|
||||
|
||||
@@ -38,6 +38,14 @@ export interface FileRow {
|
||||
created_at: string | null;
|
||||
is_mine: boolean;
|
||||
public: boolean;
|
||||
/**
|
||||
* Whether this client may edit / delete this row, decided per file by
|
||||
* FilePolicy. Not the same question as `is_mine`: holding the file is
|
||||
* half of it and the role's keys are the other half, so a theme reads
|
||||
* these and never derives them.
|
||||
*/
|
||||
can_update: boolean;
|
||||
can_delete: boolean;
|
||||
/** Comments this client can see on the file — the number on its row. */
|
||||
comments_count: number;
|
||||
/** How many of those they have not read yet. */
|
||||
|
||||
+24
-6
@@ -215,12 +215,30 @@ Route::middleware('auth')->group(function () {
|
||||
Route::patch('system/settings/social-login/{provider}', [SocialLoginSettingsController::class, 'update'])
|
||||
->name('system-settings.social-login.update');
|
||||
|
||||
// Outside any capability: group for the same reason as LDAP above.
|
||||
// Only the option of using the platform's own keys is an edition
|
||||
// difference, and that is gated per field inside the controller.
|
||||
Route::get('system/settings/captcha', [CaptchaSettingsController::class, 'edit'])->name('system-settings.captcha.edit');
|
||||
Route::patch('system/settings/captcha', [CaptchaSettingsController::class, 'update'])->name('system-settings.captcha.update');
|
||||
Route::post('system/settings/captcha/test', [CaptchaSettingsController::class, 'test'])->name('system-settings.captcha.test');
|
||||
// Unlike LDAP and social login above, this one does get a
|
||||
// capability: group — present in both editions, so a self-hosted
|
||||
// installation keeps the screen it has always had, and only
|
||||
// removed when an operator names captcha.configure in
|
||||
// PROJECTSEND_CAPABILITIES_DISABLED.
|
||||
//
|
||||
// The reason a managed platform would: its tenants share one
|
||||
// parent domain and one sending reputation, so an administrator
|
||||
// switching their own CAPTCHA off spends everybody else's. Same
|
||||
// shape as Storage below — all-or-nothing on the route, read
|
||||
// included. Per-field gating in the controller would leave the
|
||||
// hole open, because turning the CAPTCHA off (provider `none`, or
|
||||
// just unticking the four per-form switches) needs none of the
|
||||
// gated fields. The middleware covers the PATCH as well as the
|
||||
// GET, which is what closes it.
|
||||
//
|
||||
// Which keys the screen may offer is a second, narrower question,
|
||||
// still answered per field inside the controller by
|
||||
// Capability::CaptchaManagedKeys.
|
||||
Route::middleware('capability:captcha.configure')->group(function () {
|
||||
Route::get('system/settings/captcha', [CaptchaSettingsController::class, 'edit'])->name('system-settings.captcha.edit');
|
||||
Route::patch('system/settings/captcha', [CaptchaSettingsController::class, 'update'])->name('system-settings.captcha.update');
|
||||
Route::post('system/settings/captcha/test', [CaptchaSettingsController::class, 'test'])->name('system-settings.captcha.test');
|
||||
});
|
||||
|
||||
Route::get('system/settings/privacy', [PrivacySettingsController::class, 'edit'])->name('system-settings.privacy.edit');
|
||||
Route::patch('system/settings/privacy', [PrivacySettingsController::class, 'update'])->name('system-settings.privacy.update');
|
||||
|
||||
@@ -237,6 +237,24 @@ Route::middleware(['auth'])->group(function () {
|
||||
Route::get('my-files/upload', [MyFilesController::class, 'upload'])->middleware('can:upload')->name('my-files.upload.create');
|
||||
Route::get('my-files/version-candidates', [MyFilesController::class, 'versionCandidates'])->middleware('can:upload')->name('my-files.version-candidates');
|
||||
|
||||
// A client editing and deleting their OWN uploads. Deliberately not the
|
||||
// staff files.* routes, which are `staff`-gated because they carry
|
||||
// assignments, share links and activity — and whose folder guard asks
|
||||
// StaffLibraryScope, which answers "allowed" for any client (see
|
||||
// FilePolicy::update()).
|
||||
//
|
||||
// No `can:` middleware here on purpose: `edit_files` and `delete_files`
|
||||
// mean "your own" for a client and "anyone's, if you also hold the
|
||||
// others_ key" for staff, and only FilePolicy knows which. A route-level
|
||||
// gate would let a client through on the key alone, before anything had
|
||||
// asked whose file it is. MyFilesController authorizes both.
|
||||
// Registered after the literal my-files/* GETs above, which would
|
||||
// otherwise be swallowed by {file} — the same ordering rule as
|
||||
// files/orphans.
|
||||
Route::get('my-files/{file}/edit', [MyFilesController::class, 'edit'])->name('my-files.edit');
|
||||
Route::patch('my-files/{file}', [MyFilesController::class, 'update'])->name('my-files.update');
|
||||
Route::delete('my-files/{file}', [MyFilesController::class, 'destroy'])->name('my-files.destroy');
|
||||
|
||||
// Client-created folders — MyFoldersController double-checks isClient()
|
||||
// and create_own_folders itself; the route-level gate here just keeps
|
||||
// staff from ever hitting these (they use folders.* instead).
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Identity\Permissions\Permission;
|
||||
|
||||
/*
|
||||
@@ -38,6 +40,45 @@ test('a client token is refused everywhere', function () {
|
||||
$this->withToken($token)->getJson('/api/v1/me')->assertForbidden();
|
||||
});
|
||||
|
||||
/*
|
||||
* The write half of the same boundary, and it needs its own test now that
|
||||
* FilePolicy has a client branch.
|
||||
*
|
||||
* Since clients may edit and delete their own uploads in the portal,
|
||||
* `Gate::authorize('update', $file)` inside Api\FilesController *passes*
|
||||
* for a client holding the key on a file they uploaded. The only thing
|
||||
* standing between a client token and the API's write endpoints is the
|
||||
* `staff-token` middleware. That was always true, but until the portal
|
||||
* work it was belt-and-braces: the policy refused as well. It no longer
|
||||
* does, so this pins the one remaining door rather than leaving the whole
|
||||
* boundary resting on a middleware nothing tests against a *passing*
|
||||
* policy.
|
||||
*
|
||||
* If client tokens are ever issued (see docs/api-todo.md), this test is
|
||||
* where that decision has to be made deliberately.
|
||||
*/
|
||||
test('a client token cannot write through the API even to its own file', function () {
|
||||
$client = User::factory()->client()->create();
|
||||
|
||||
foreach (['edit_files', 'delete_files'] as $permission) {
|
||||
$client->role->permissions()->create(['permission' => $permission]);
|
||||
}
|
||||
|
||||
$file = File::factory()->create(['uploaded_by' => $client->id]);
|
||||
|
||||
// The policy itself now says yes — this is the premise, not an aside.
|
||||
expect(Gate::forUser($client)->allows('update', $file))->toBeTrue()
|
||||
->and(Gate::forUser($client)->allows('delete', $file))->toBeTrue();
|
||||
|
||||
$token = $client->createToken('t', ['edit_files', 'delete_files'])->plainTextToken;
|
||||
|
||||
$this->withToken($token)->patchJson("/api/v1/files/{$file->id}", ['name' => 'taken'])->assertForbidden();
|
||||
$this->withToken($token)->deleteJson("/api/v1/files/{$file->id}")->assertForbidden();
|
||||
|
||||
expect($file->refresh()->name)->not->toBe('taken')
|
||||
->and($file->trashed())->toBeFalse();
|
||||
});
|
||||
|
||||
test('a deactivated account loses API access on the next request', function () {
|
||||
$token = $this->staff->createToken('t', [Permission::Upload->value])->plainTextToken;
|
||||
|
||||
|
||||
@@ -329,6 +329,61 @@ test('a storage backend that refuses the write fails the upload instead of recor
|
||||
expect(File::query()->count())->toBe($before);
|
||||
});
|
||||
|
||||
// What survives a completion that failed. The lock in complete() promises
|
||||
// the client may try again once whatever went wrong is fixed -- "the
|
||||
// lock's TTL releases the claim if a completion dies mid-flight, so a
|
||||
// later retry still works" -- and a retry has nothing to work from but
|
||||
// the parts.
|
||||
test('a refused write leaves the parts for the retry the lock promises', function () {
|
||||
$this->actingAs($this->admin);
|
||||
$sessionId = createSession(11, 'assembled.txt');
|
||||
|
||||
putPart($sessionId, 1, 'hello-');
|
||||
putPart($sessionId, 2, 'world');
|
||||
|
||||
$refusing = Mockery::mock(Illuminate\Contracts\Filesystem\Filesystem::class);
|
||||
$refusing->shouldReceive('writeStream')->once()->andReturnFalse();
|
||||
Storage::set('files', $refusing);
|
||||
|
||||
$this->postJson("/uploads/{$sessionId}/complete")->assertStatus(422);
|
||||
|
||||
// Both parts are still there, and the half-written copy is not.
|
||||
expect($this->getJson("/uploads/{$sessionId}/parts")->json())->toHaveCount(2)
|
||||
->and(file_exists(partsRoot().'/'.$sessionId.'/assembled'))->toBeFalse();
|
||||
|
||||
// The operator fixes the bucket; the same session completes.
|
||||
Storage::fake('files');
|
||||
|
||||
$this->postJson("/uploads/{$sessionId}/complete")->assertOk();
|
||||
|
||||
$file = File::query()->latest('id')->firstOrFail();
|
||||
expect(Storage::disk('files')->get($file->path))->toBe('hello-world')
|
||||
->and($file->size)->toBe(11);
|
||||
});
|
||||
|
||||
test('a temporary directory that cannot be written fails the upload, and says so', function () {
|
||||
// /dev/full accepts an open and refuses every write with ENOSPC, which
|
||||
// is the failure this guards against without needing a full volume.
|
||||
// Unchecked, the byte count and the checksum describe what was read
|
||||
// rather than what landed; checked, it reads like the other storage
|
||||
// failure a few lines below it in the same method.
|
||||
$this->actingAs($this->admin);
|
||||
$sessionId = createSession(11, 'assembled.txt');
|
||||
|
||||
putPart($sessionId, 1, 'hello-');
|
||||
putPart($sessionId, 2, 'world');
|
||||
|
||||
symlink('/dev/full', partsRoot().'/'.$sessionId.'/assembled');
|
||||
|
||||
$this->postJson("/uploads/{$sessionId}/complete")
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('errors.parts.0', fn (string $message): bool => str_contains($message, 'Could not assemble the upload'));
|
||||
|
||||
// Nothing recorded, and the parts are still the client's to retry with.
|
||||
expect(File::query()->count())->toBe(0)
|
||||
->and($this->getJson("/uploads/{$sessionId}/parts")->json())->toHaveCount(2);
|
||||
})->skip(! file_exists('/dev/full'), 'needs /dev/full, which only exists on Linux');
|
||||
|
||||
// A chunked upload is two requests, and store()'s rule only ever sees the
|
||||
// first one. Delete the folder while the bytes are in flight and the
|
||||
// session still names it -- the version of this that nobody can ask for
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Clients\ClientStorageUsage;
|
||||
use App\Modules\Files\Models\Category;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use App\Modules\Identity\Models\RolePermission;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake('files');
|
||||
$this->admin = User::factory()->create();
|
||||
});
|
||||
|
||||
/** A client whose role carries exactly the given permission keys. */
|
||||
function clientWithPermissions(array $permissions): User
|
||||
{
|
||||
$role = Role::query()->create(['name' => 'Client Role '.Str::random(6)]);
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
RolePermission::query()->create(['role_id' => $role->id, 'permission' => $permission]);
|
||||
}
|
||||
|
||||
return User::factory()->client()->create(['role_id' => $role->id]);
|
||||
}
|
||||
|
||||
/** A stored file owned by $owner, with real bytes on the fake disk. */
|
||||
function ownedFile(User $owner, array $overrides = []): File
|
||||
{
|
||||
$path = 'uploads/'.Str::uuid()->toString().'.pdf';
|
||||
Storage::disk('files')->put($path, 'hello-world');
|
||||
|
||||
return File::factory()->create([
|
||||
'uploaded_by' => $owner->id,
|
||||
'name' => 'report',
|
||||
'original_name' => 'report.pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size' => 11,
|
||||
...$overrides,
|
||||
'path' => $path,
|
||||
'disk' => 'files',
|
||||
]);
|
||||
}
|
||||
|
||||
/** The full form payload the portal editor posts, overridable per test. */
|
||||
function clientEditPayload(array $overrides = []): array
|
||||
{
|
||||
return array_merge(['name' => 'renamed'], $overrides);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| The rule
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| A client owns what they uploaded, and owning it is what lets them edit
|
||||
| and delete it — subject to the same per-field keys staff are subject to.
|
||||
*/
|
||||
|
||||
test('a client with edit_files can rename their own upload', function () {
|
||||
$client = clientWithPermissions(['edit_files']);
|
||||
$file = ownedFile($client);
|
||||
|
||||
$this->actingAs($client)
|
||||
->patch("/my-files/{$file->id}", clientEditPayload(['description' => 'now with a description']))
|
||||
->assertRedirect();
|
||||
|
||||
expect($file->refresh()->name)->toBe('renamed')
|
||||
->and($file->description)->toBe('now with a description');
|
||||
});
|
||||
|
||||
test('a client with delete_files can delete their own upload, and the bytes go with it', function () {
|
||||
$client = clientWithPermissions(['delete_files']);
|
||||
$file = ownedFile($client);
|
||||
|
||||
expect(app(ClientStorageUsage::class)->usedBytes($client))->toBe(11);
|
||||
|
||||
$this->actingAs($client)->delete("/my-files/{$file->id}")->assertRedirect('/my-files');
|
||||
|
||||
expect(File::withTrashed()->findOrFail($file->id)->trashed())->toBeTrue();
|
||||
Storage::disk('files')->assertMissing($file->path);
|
||||
|
||||
// The quota frees by exactly what the disk did. These two have to agree
|
||||
// or a client pays rent on bytes that are gone — nothing ever
|
||||
// forceDelete()s a File row, so "temporarily" would mean forever.
|
||||
expect(app(ClientStorageUsage::class)->usedBytes($client))->toBe(0);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Ownership is the boundary
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('a client cannot edit or delete another client\'s file', function () {
|
||||
$client = clientWithPermissions(['edit_files', 'delete_files']);
|
||||
$stranger = User::factory()->client()->create();
|
||||
$file = ownedFile($stranger);
|
||||
|
||||
$this->actingAs($client)->patch("/my-files/{$file->id}", clientEditPayload())->assertForbidden();
|
||||
$this->actingAs($client)->delete("/my-files/{$file->id}")->assertForbidden();
|
||||
|
||||
expect($file->refresh()->name)->toBe('report')
|
||||
->and($file->trashed())->toBeFalse();
|
||||
});
|
||||
|
||||
// The keys exist for staff, where "others' files" is a real category. A
|
||||
// client has no others' files — only files somebody showed them — so these
|
||||
// two must buy nothing at all. FilePolicy's client branch never reads them.
|
||||
test('edit_others_files and delete_others_files buy a client nothing', function () {
|
||||
$client = clientWithPermissions([
|
||||
'edit_files', 'delete_files', 'edit_others_files', 'delete_others_files',
|
||||
]);
|
||||
$stranger = User::factory()->client()->create();
|
||||
$file = ownedFile($stranger);
|
||||
|
||||
$this->actingAs($client)->patch("/my-files/{$file->id}", clientEditPayload())->assertForbidden();
|
||||
$this->actingAs($client)->delete("/my-files/{$file->id}")->assertForbidden();
|
||||
});
|
||||
|
||||
// Being shown a file is not being given it. This is the case a client is
|
||||
// most likely to try, because the file is sitting right there in their list.
|
||||
test('a client cannot edit a file staff merely shared with them', function () {
|
||||
$client = clientWithPermissions(['edit_files', 'delete_files']);
|
||||
$file = ownedFile($this->admin);
|
||||
|
||||
$this->actingAs($this->admin)
|
||||
->post("/files/{$file->id}/assignments", ['type' => 'client', 'id' => $client->id])
|
||||
->assertRedirect();
|
||||
|
||||
// Visible to them...
|
||||
$this->actingAs($client)->get('/my-files')->assertOk();
|
||||
|
||||
// ...and still not theirs.
|
||||
$this->actingAs($client)->patch("/my-files/{$file->id}", clientEditPayload())->assertForbidden();
|
||||
$this->actingAs($client)->delete("/my-files/{$file->id}")->assertForbidden();
|
||||
});
|
||||
|
||||
test('a client without edit_files cannot edit their own upload', function () {
|
||||
$client = clientWithPermissions([]);
|
||||
$file = ownedFile($client);
|
||||
|
||||
$this->actingAs($client)->patch("/my-files/{$file->id}", clientEditPayload())->assertForbidden();
|
||||
|
||||
expect($file->refresh()->name)->toBe('report');
|
||||
});
|
||||
|
||||
test('a client without delete_files cannot delete their own upload', function () {
|
||||
$client = clientWithPermissions(['edit_files']);
|
||||
$file = ownedFile($client);
|
||||
|
||||
$this->actingAs($client)->delete("/my-files/{$file->id}")->assertForbidden();
|
||||
|
||||
expect($file->refresh()->trashed())->toBeFalse();
|
||||
});
|
||||
|
||||
test('staff cannot reach the portal routes, and a client cannot reach the staff ones', function () {
|
||||
$client = clientWithPermissions(['edit_files', 'delete_files']);
|
||||
$file = ownedFile($client);
|
||||
|
||||
// The staff editor is `staff` middleware, not a permission — so holding
|
||||
// the key changes nothing. A GET is sent home rather than refused
|
||||
// (EnsureStaff: staff pages are not part of a client's world); the
|
||||
// writes are a hard 403.
|
||||
$this->actingAs($client)->get("/files/{$file->id}")->assertRedirect(route('dashboard'));
|
||||
$this->actingAs($client)->patch("/files/{$file->id}", clientEditPayload())->assertForbidden();
|
||||
$this->actingAs($client)->delete("/files/{$file->id}")->assertForbidden();
|
||||
|
||||
// And the portal route refuses a staff account rather than quietly
|
||||
// giving it a second way to edit.
|
||||
$this->actingAs($this->admin)->patch("/my-files/{$file->id}", clientEditPayload())->assertNotFound();
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Per-field keys
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Lacking the key leaves the field alone and the rest of the edit still
|
||||
| saves — the rule the staff editor and the API already follow. A client
|
||||
| must not be the one surface where a missing key fails the request.
|
||||
*/
|
||||
|
||||
test('a client without upload_public cannot publish, and the rename still saves', function () {
|
||||
$client = clientWithPermissions(['edit_files']);
|
||||
$file = ownedFile($client);
|
||||
|
||||
$this->actingAs($client)
|
||||
->patch("/my-files/{$file->id}", clientEditPayload(['public' => true]))
|
||||
->assertRedirect();
|
||||
|
||||
expect($file->refresh()->public)->toBeFalse()
|
||||
->and($file->name)->toBe('renamed');
|
||||
});
|
||||
|
||||
test('a client with upload_public can publish their own upload', function () {
|
||||
$client = clientWithPermissions(['edit_files', 'upload_public']);
|
||||
$file = ownedFile($client);
|
||||
|
||||
$this->actingAs($client)
|
||||
->patch("/my-files/{$file->id}", clientEditPayload(['public' => true]))
|
||||
->assertRedirect();
|
||||
|
||||
expect($file->refresh()->public)->toBeTrue()
|
||||
->and($file->slug)->not->toBe('');
|
||||
});
|
||||
|
||||
// The slug is derived, never chosen. An installation-wide unique slug a
|
||||
// client picks is a name to squat and an oracle to probe with.
|
||||
test('a client cannot choose the public slug', function () {
|
||||
$client = clientWithPermissions(['edit_files', 'upload_public']);
|
||||
$file = ownedFile($client);
|
||||
|
||||
$this->actingAs($client)
|
||||
->patch("/my-files/{$file->id}", clientEditPayload([
|
||||
'public' => true,
|
||||
'slug' => 'front-page',
|
||||
]))
|
||||
->assertRedirect();
|
||||
|
||||
expect($file->refresh()->slug)->not->toBe('front-page');
|
||||
});
|
||||
|
||||
test('a client without set_file_categories cannot categorise', function () {
|
||||
$client = clientWithPermissions(['edit_files']);
|
||||
$file = ownedFile($client);
|
||||
$category = Category::query()->create(['name' => 'Docs']);
|
||||
|
||||
$this->actingAs($client)
|
||||
->patch("/my-files/{$file->id}", clientEditPayload(['categories' => [$category->id]]))
|
||||
->assertRedirect();
|
||||
|
||||
expect($file->refresh()->categories)->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a client without set_file_expiration_date cannot set an expiry', function () {
|
||||
$client = clientWithPermissions(['edit_files']);
|
||||
$file = ownedFile($client);
|
||||
|
||||
$this->actingAs($client)
|
||||
->patch("/my-files/{$file->id}", clientEditPayload(['expires_at' => now()->addWeek()->toDateString()]))
|
||||
->assertRedirect();
|
||||
|
||||
expect($file->refresh()->expires_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('a client without limit_downloads cannot cap downloads', function () {
|
||||
$client = clientWithPermissions(['edit_files']);
|
||||
$file = ownedFile($client);
|
||||
|
||||
$this->actingAs($client)
|
||||
->patch("/my-files/{$file->id}", clientEditPayload(['download_limit' => 3]))
|
||||
->assertRedirect();
|
||||
|
||||
expect($file->refresh()->download_limit)->toBeNull();
|
||||
});
|
||||
|
||||
test('a client holding the keys can set an expiry, categories and a download cap', function () {
|
||||
$client = clientWithPermissions([
|
||||
'edit_files', 'set_file_expiration_date', 'set_file_categories', 'limit_downloads',
|
||||
]);
|
||||
$file = ownedFile($client);
|
||||
$category = Category::query()->create(['name' => 'Docs']);
|
||||
|
||||
$this->actingAs($client)
|
||||
->patch("/my-files/{$file->id}", clientEditPayload([
|
||||
'expires_at' => now()->addWeek()->toDateString(),
|
||||
'categories' => [$category->id],
|
||||
'download_limit' => 3,
|
||||
]))
|
||||
->assertRedirect();
|
||||
|
||||
$file->refresh();
|
||||
|
||||
expect($file->expires_at)->not->toBeNull()
|
||||
->and($file->categories)->toHaveCount(1)
|
||||
->and($file->download_limit)->toBe(3);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| The folder trap
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| StaffLibraryScope::allowsFolder() returns true for anyone who is not
|
||||
| client-*scoped* staff, and User::isClientScoped() is false for every
|
||||
| client. A client reaching the staff guard would be handed every folder on
|
||||
| the installation. These pin that they never do.
|
||||
*/
|
||||
|
||||
test('a client cannot move their file into a folder they could not upload to', function () {
|
||||
$client = clientWithPermissions(['edit_files']);
|
||||
$file = ownedFile($client);
|
||||
$staffOnly = makeFolder('Internal');
|
||||
|
||||
$this->actingAs($client)
|
||||
->patch("/my-files/{$file->id}", clientEditPayload(['folder_id' => $staffOnly->id]))
|
||||
->assertForbidden();
|
||||
|
||||
expect($file->refresh()->folder_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('a client cannot move their file into another client\'s folder', function () {
|
||||
$client = clientWithPermissions(['edit_files']);
|
||||
$stranger = User::factory()->client()->create();
|
||||
$file = ownedFile($client);
|
||||
|
||||
$this->actingAs($stranger)->post('/my-folders', ['name' => 'Theirs'])->assertRedirect();
|
||||
$theirs = Folder::query()->where('name', 'Theirs')->sole();
|
||||
|
||||
$this->actingAs($client)
|
||||
->patch("/my-files/{$file->id}", clientEditPayload(['folder_id' => $theirs->id]))
|
||||
->assertForbidden();
|
||||
|
||||
expect($file->refresh()->folder_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('a client can move their file into a folder they created', function () {
|
||||
$client = clientWithPermissions(['edit_files', 'create_own_folders', 'upload']);
|
||||
$file = ownedFile($client);
|
||||
|
||||
$this->actingAs($client)->post('/my-folders', ['name' => 'Mine'])->assertRedirect();
|
||||
$mine = Folder::query()->where('name', 'Mine')->sole();
|
||||
|
||||
$this->actingAs($client)
|
||||
->patch("/my-files/{$file->id}", clientEditPayload(['folder_id' => $mine->id]))
|
||||
->assertRedirect();
|
||||
|
||||
expect($file->refresh()->folder_id)->toBe($mine->id);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| What the payload must not reach
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('a client cannot hand their file to somebody else, or repoint its bytes', function () {
|
||||
$client = clientWithPermissions(['edit_files']);
|
||||
$stranger = User::factory()->client()->create();
|
||||
$file = ownedFile($client);
|
||||
$originalPath = $file->path;
|
||||
|
||||
$this->actingAs($client)
|
||||
->patch("/my-files/{$file->id}", clientEditPayload([
|
||||
'uploaded_by' => $stranger->id,
|
||||
'path' => 'uploads/somebody-elses-file.pdf',
|
||||
'disk' => 'nonexistent-disk',
|
||||
'size' => 999999999,
|
||||
]))
|
||||
->assertRedirect();
|
||||
|
||||
$file->refresh();
|
||||
|
||||
expect($file->uploaded_by)->toBe($client->id)
|
||||
->and($file->path)->toBe($originalPath)
|
||||
->and($file->disk)->toBe('files')
|
||||
->and($file->size)->toBe(11);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| The page and the rows
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Hiding a control is a courtesy, never the enforcement — every assertion
|
||||
| above already proves the server refuses. These pin that a client is not
|
||||
| shown a switch that would silently do nothing.
|
||||
*/
|
||||
|
||||
test('the editor opens for an owner and refuses everyone else', function () {
|
||||
$client = clientWithPermissions(['edit_files']);
|
||||
$stranger = User::factory()->client()->create();
|
||||
$file = ownedFile($client);
|
||||
|
||||
$this->actingAs($client)->get("/my-files/{$file->id}/edit")
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('portal/edit-file')
|
||||
->where('file.name', 'report'));
|
||||
|
||||
$this->actingAs($stranger)->get("/my-files/{$file->id}/edit")->assertForbidden();
|
||||
|
||||
// And a staff account gets the staff editor, not this one.
|
||||
$this->actingAs($this->admin)->get("/my-files/{$file->id}/edit")->assertNotFound();
|
||||
});
|
||||
|
||||
test('the editor offers only the fields the role actually grants', function () {
|
||||
$bare = clientWithPermissions(['edit_files']);
|
||||
$file = ownedFile($bare);
|
||||
|
||||
$this->actingAs($bare)->get("/my-files/{$file->id}/edit")->assertInertia(
|
||||
fn (AssertableInertia $page) => $page
|
||||
->where('can_publish', false)
|
||||
->where('can_set_expiration', false)
|
||||
->where('can_set_categories', false)
|
||||
->where('can_limit_downloads', false)
|
||||
->where('can_delete', false),
|
||||
);
|
||||
|
||||
$full = clientWithPermissions([
|
||||
'edit_files', 'delete_files', 'upload_public',
|
||||
'set_file_expiration_date', 'set_file_categories', 'limit_downloads',
|
||||
]);
|
||||
$theirs = ownedFile($full);
|
||||
|
||||
$this->actingAs($full)->get("/my-files/{$theirs->id}/edit")->assertInertia(
|
||||
fn (AssertableInertia $page) => $page
|
||||
->where('can_publish', true)
|
||||
->where('can_set_expiration', true)
|
||||
->where('can_set_categories', true)
|
||||
->where('can_limit_downloads', true)
|
||||
->where('can_delete', true),
|
||||
);
|
||||
});
|
||||
|
||||
// The folder picker must not offer a destination the save would refuse —
|
||||
// otherwise a client picks a folder, saves, and gets a 403 for choosing
|
||||
// something they were shown.
|
||||
test('the folder picker offers only folders the client could upload to', function () {
|
||||
$client = clientWithPermissions(['edit_files', 'create_own_folders', 'upload']);
|
||||
$file = ownedFile($client);
|
||||
makeFolder('Internal');
|
||||
|
||||
$this->actingAs($client)->post('/my-folders', ['name' => 'Mine'])->assertRedirect();
|
||||
|
||||
$this->actingAs($client)->get("/my-files/{$file->id}/edit")->assertInertia(
|
||||
fn (AssertableInertia $page) => $page
|
||||
->has('folders', 1)
|
||||
->where('folders.0.name', 'Mine'),
|
||||
);
|
||||
});
|
||||
|
||||
test('file rows carry the same answer the server will give', function () {
|
||||
$client = clientWithPermissions(['edit_files', 'delete_files']);
|
||||
$own = ownedFile($client, ['name' => 'mine']);
|
||||
$shared = ownedFile($this->admin, ['name' => 'theirs']);
|
||||
|
||||
$this->actingAs($this->admin)
|
||||
->post("/files/{$shared->id}/assignments", ['type' => 'client', 'id' => $client->id])
|
||||
->assertRedirect();
|
||||
|
||||
$this->actingAs($client)->get('/my-files')->assertInertia(function (AssertableInertia $page) {
|
||||
$files = collect($page->toArray()['props']['files'])->keyBy('name');
|
||||
|
||||
expect($files['mine']['can_update'])->toBeTrue()
|
||||
->and($files['mine']['can_delete'])->toBeTrue()
|
||||
// Shared with them, and still not theirs — the same answer the
|
||||
// PATCH gives, so the row never offers what the save refuses.
|
||||
->and($files['theirs']['can_update'])->toBeFalse()
|
||||
->and($files['theirs']['can_delete'])->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
test('a client without the keys sees no controls on their own rows', function () {
|
||||
$client = clientWithPermissions([]);
|
||||
ownedFile($client, ['name' => 'mine']);
|
||||
|
||||
$this->actingAs($client)->get('/my-files')->assertInertia(
|
||||
fn (AssertableInertia $page) => $page
|
||||
->where('files.0.can_update', false)
|
||||
->where('files.0.can_delete', false),
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Publishing by the side door
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| File::isEffectivelyPublic() is "my own flag OR my folder's", and
|
||||
| Folder::uploadableBy() lets a client into a public folder on
|
||||
| upload_to_public_folders — a different key from upload_public. So a
|
||||
| client can make a file world-readable without ever touching the public
|
||||
| switch, and without holding the key that switch is behind.
|
||||
|
|
||||
| That is the established meaning of the two keys and exactly what
|
||||
| uploading into such a folder has always done, so the editor does not
|
||||
| refuse it. What it must not do is let it happen silently: in a picker of
|
||||
| bare folder names the consequence would be invisible, which is the one
|
||||
| thing that would be new here.
|
||||
*/
|
||||
test('moving into a public folder publishes the file, and the picker says so', function () {
|
||||
$client = clientWithPermissions(['edit_files', 'upload_to_public_folders']);
|
||||
$file = ownedFile($client);
|
||||
|
||||
$open = makeFolder('Open Drop Box');
|
||||
$open->forceFill(['public' => true, 'allow_client_uploads' => true, 'slug' => 'open-drop-box'])->save();
|
||||
|
||||
$this->actingAs($this->admin)
|
||||
->post("/folders/{$open->id}/assignments", ['type' => 'client', 'id' => $client->id])
|
||||
->assertRedirect();
|
||||
|
||||
// The picker offers it — and carries the consequence with the name.
|
||||
$this->actingAs($client)->get("/my-files/{$file->id}/edit")->assertInertia(
|
||||
fn (AssertableInertia $page) => $page
|
||||
->where('can_publish', false)
|
||||
->has('folders', 1)
|
||||
->where('folders.0.name', 'Open Drop Box')
|
||||
->where('folders.0.public', true),
|
||||
);
|
||||
|
||||
$this->actingAs($client)
|
||||
->patch("/my-files/{$file->id}", clientEditPayload(['folder_id' => $open->id]))
|
||||
->assertRedirect();
|
||||
|
||||
$file->refresh();
|
||||
|
||||
// The file's own flag never moved — the client does not hold the key
|
||||
// for that — but the folder makes it readable all the same.
|
||||
expect($file->public)->toBeFalse()
|
||||
->and($file->isEffectivelyPublic())->toBeTrue();
|
||||
});
|
||||
|
||||
// The other half: a private folder must never be labelled public, or the
|
||||
// warning becomes noise people learn to ignore.
|
||||
test('a private folder is not flagged public in the picker', function () {
|
||||
$client = clientWithPermissions(['edit_files', 'create_own_folders', 'upload']);
|
||||
$file = ownedFile($client);
|
||||
|
||||
$this->actingAs($client)->post('/my-folders', ['name' => 'Mine'])->assertRedirect();
|
||||
|
||||
$this->actingAs($client)->get("/my-files/{$file->id}/edit")->assertInertia(
|
||||
fn (AssertableInertia $page) => $page
|
||||
->where('folders.0.name', 'Mine')
|
||||
->where('folders.0.public', false),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Access\ClientIdentityScope;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use App\Modules\Identity\Models\RolePermission;
|
||||
use App\Modules\Identity\Permissions\Permission;
|
||||
use App\Modules\Identity\Permissions\SystemRole;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* A client-scoped staff member may hold a file whose uploader, or whose
|
||||
* other recipients, are clients off their roster — that is a legitimate
|
||||
* consequence of group and folder sharing, and the file boundary is right
|
||||
* to allow it. What is not legitimate is learning those clients' names and
|
||||
* ids from the file's metadata.
|
||||
*
|
||||
* Every one of these asserts on the rendered response body rather than on a
|
||||
* particular key, because the leak was never in one field: the same client
|
||||
* name arrived through the uploader, through the recipient list, and
|
||||
* through four different screens. A body that does not contain the name
|
||||
* anywhere is the only assertion that would have caught all of them.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Storage::fake('files');
|
||||
|
||||
$this->admin = User::factory()->create();
|
||||
$this->onRoster = User::factory()->client()->create(['name' => 'Roster Client']);
|
||||
$this->offRoster = User::factory()->client()->create(['name' => 'Offroster Client']);
|
||||
|
||||
$this->manager = User::factory()->role(SystemRole::ClientManager)->create();
|
||||
$this->manager->assignedClients()->sync([$this->onRoster->id]);
|
||||
|
||||
$this->token = $this->manager->createToken('t', [Permission::Upload->value])->plainTextToken;
|
||||
});
|
||||
|
||||
/** A file the manager may read, uploaded by a client they may not identify. */
|
||||
function fileFromStranger(): File
|
||||
{
|
||||
$file = File::factory()->create([
|
||||
'uploaded_by' => test()->offRoster->id,
|
||||
'name' => 'shared-onward',
|
||||
]);
|
||||
shareFileWith($file, test()->onRoster);
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
/**
|
||||
* A client-scoped staff member holding wider permissions than the built-in
|
||||
* Client Manager — the roles that can open a colleague's file for editing
|
||||
* and browse a client's own library. Scoping and permissions are separate
|
||||
* axes, and the leak is a property of the scoping.
|
||||
*
|
||||
* @param list<string> $permissions
|
||||
*/
|
||||
function scopedStaffWith(array $permissions): User
|
||||
{
|
||||
$role = Role::query()->create(['name' => 'Scoped '.uniqid(), 'client_scoped' => true]);
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
RolePermission::query()->create(['role_id' => $role->id, 'permission' => $permission]);
|
||||
}
|
||||
|
||||
$staff = User::factory()->create(['role_id' => $role->id]);
|
||||
$staff->assignedClients()->sync([test()->onRoster->id]);
|
||||
|
||||
return $staff;
|
||||
}
|
||||
|
||||
/** A file the manager may read that is also shared with a stranger client. */
|
||||
function fileWithStrangerCoRecipient(): File
|
||||
{
|
||||
$file = File::factory()->create(['uploaded_by' => test()->admin->id, 'name' => 'shared-both']);
|
||||
shareFileWith($file, test()->onRoster);
|
||||
shareFileWith($file, test()->offRoster);
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
test('the file boundary itself is unchanged: a stranger-only file is still refused', function () {
|
||||
$file = File::factory()->create(['uploaded_by' => $this->offRoster->id]);
|
||||
shareFileWith($file, $this->offRoster);
|
||||
|
||||
$this->withToken($this->token)->getJson("/api/v1/files/{$file->id}")->assertForbidden();
|
||||
});
|
||||
|
||||
test('the file boundary itself is unchanged on the web', function () {
|
||||
$file = File::factory()->create(['uploaded_by' => $this->offRoster->id]);
|
||||
shareFileWith($file, $this->offRoster);
|
||||
|
||||
$this->actingAs($this->manager)->get("/files/{$file->id}/details")->assertForbidden();
|
||||
});
|
||||
|
||||
test('the API does not name a stranger uploader of a file the caller may read', function () {
|
||||
$file = fileFromStranger();
|
||||
|
||||
$show = $this->withToken($this->token)->getJson("/api/v1/files/{$file->id}")->assertOk();
|
||||
$index = $this->withToken($this->token)->getJson('/api/v1/files')->assertOk();
|
||||
|
||||
expect($show->getContent())->not->toContain('Offroster Client')
|
||||
->and($index->getContent())->not->toContain('Offroster Client')
|
||||
->and($show->json('data.uploaded_by'))->toBeNull()
|
||||
// The file is still readable — this narrows the answer, it does
|
||||
// not withdraw it.
|
||||
->and($show->json('data.id'))->toBe($file->id);
|
||||
});
|
||||
|
||||
test('the API does not list a stranger co-recipient', function () {
|
||||
$file = fileWithStrangerCoRecipient();
|
||||
|
||||
$show = $this->withToken($this->token)->getJson("/api/v1/files/{$file->id}")->assertOk();
|
||||
|
||||
expect($show->getContent())->not->toContain('Offroster Client')
|
||||
->and($show->json('data.assignments'))->toHaveCount(1)
|
||||
->and($show->json('data.assignments.0.name'))->toBe('Roster Client');
|
||||
});
|
||||
|
||||
test('a stranger co-recipient is not named in the reply to a write', function () {
|
||||
$file = fileWithStrangerCoRecipient();
|
||||
|
||||
// The assignment endpoints re-load assignments.assignable and hand the
|
||||
// result straight back, which is a second serialisation path — and one
|
||||
// that a fix applied only to the read controllers would have missed.
|
||||
// The 200 is asserted on purpose: without it this passes on a 403,
|
||||
// whose body names nobody either.
|
||||
$writer = scopedStaffWith([Permission::Upload->value, Permission::EditFiles->value, Permission::EditOthersFiles->value]);
|
||||
$token = $writer->createToken('w', [Permission::EditFiles->value, Permission::EditOthersFiles->value])->plainTextToken;
|
||||
|
||||
$response = $this->withToken($token)
|
||||
->postJson("/api/v1/files/{$file->id}/assignments", ['type' => 'client', 'id' => $this->onRoster->id])
|
||||
->assertOk();
|
||||
|
||||
expect($response->getContent())->not->toContain('Offroster Client')
|
||||
->and($response->json('data.assignments'))->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('uploaded_by cannot be used to probe for a client the caller may not identify', function () {
|
||||
fileFromStranger();
|
||||
|
||||
$probe = $this->withToken($this->token)->getJson("/api/v1/files?uploaded_by={$this->offRoster->id}")->assertOk();
|
||||
|
||||
expect($probe->json('data'))->toBeEmpty();
|
||||
});
|
||||
|
||||
test('uploaded_by still filters by a client on the roster', function () {
|
||||
$own = File::factory()->create(['uploaded_by' => $this->onRoster->id, 'name' => 'theirs']);
|
||||
shareFileWith($own, $this->onRoster);
|
||||
fileFromStranger();
|
||||
|
||||
$hit = $this->withToken($this->token)->getJson("/api/v1/files?uploaded_by={$this->onRoster->id}")->assertOk();
|
||||
|
||||
expect($hit->json('data'))->toHaveCount(1)
|
||||
->and($hit->json('data.0.id'))->toBe($own->id);
|
||||
});
|
||||
|
||||
test('uploaded_by still filters by a staff member', function () {
|
||||
$file = fileWithStrangerCoRecipient();
|
||||
|
||||
$hit = $this->withToken($this->token)->getJson("/api/v1/files?uploaded_by={$this->admin->id}")->assertOk();
|
||||
|
||||
expect($hit->json('data'))->toHaveCount(1)
|
||||
->and($hit->json('data.0.id'))->toBe($file->id);
|
||||
});
|
||||
|
||||
test('the details panel names neither a stranger uploader nor a stranger recipient', function () {
|
||||
$stranger = fileFromStranger();
|
||||
$both = fileWithStrangerCoRecipient();
|
||||
|
||||
$one = $this->actingAs($this->manager)->get("/files/{$stranger->id}/details")->assertOk();
|
||||
$two = $this->actingAs($this->manager)->get("/files/{$both->id}/details")->assertOk();
|
||||
|
||||
expect($one->getContent())->not->toContain('Offroster Client')
|
||||
->and($one->json('uploader'))->toBeNull()
|
||||
->and($two->getContent())->not->toContain('Offroster Client')
|
||||
->and($two->json('shares.clients'))->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('the library listing does not describe a stranger uploader', function () {
|
||||
fileFromStranger();
|
||||
|
||||
$body = $this->actingAs($this->manager)->get('/files')->assertOk()->getContent();
|
||||
|
||||
// Not the name, and not the "a client uploaded this" shape either.
|
||||
expect($body)->not->toContain('Offroster Client');
|
||||
});
|
||||
|
||||
test('the edit page does not name a stranger uploader or recipient', function () {
|
||||
$file = fileWithStrangerCoRecipient();
|
||||
$file->update(['uploaded_by' => $this->offRoster->id]);
|
||||
|
||||
$editor = scopedStaffWith([Permission::Upload->value, Permission::EditFiles->value, Permission::EditOthersFiles->value]);
|
||||
|
||||
// route() rather than a built path: File binds by slug, so an id in
|
||||
// the URL is a 404 rather than the page under test.
|
||||
$body = $this->actingAs($editor)->get(route('files.edit', $file))->assertOk()->getContent();
|
||||
|
||||
expect($body)->not->toContain('Offroster Client');
|
||||
});
|
||||
|
||||
test('the per-client file listing does not name a stranger uploader', function () {
|
||||
fileFromStranger();
|
||||
|
||||
$browser = scopedStaffWith([Permission::Upload->value, Permission::EditClients->value]);
|
||||
|
||||
$body = $this->actingAs($browser)
|
||||
->get("/clients/{$this->onRoster->id}/files")->assertOk()->getContent();
|
||||
|
||||
expect($body)->not->toContain('Offroster Client');
|
||||
});
|
||||
|
||||
test('a group holding none of the viewer clients is not named', function () {
|
||||
$group = Group::query()->create(['name' => 'Offroster Group']);
|
||||
$group->members()->sync([$this->offRoster->id]);
|
||||
|
||||
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
|
||||
shareFileWith($file, $this->onRoster);
|
||||
shareFileWithGroup($file, $group);
|
||||
|
||||
$api = $this->withToken($this->token)->getJson("/api/v1/files/{$file->id}")->assertOk();
|
||||
|
||||
expect($api->getContent())->not->toContain('Offroster Group')
|
||||
->and($api->json('data.assignments'))->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('a group holding none of the viewer clients is not named on the web', function () {
|
||||
$group = Group::query()->create(['name' => 'Offroster Group']);
|
||||
$group->members()->sync([$this->offRoster->id]);
|
||||
|
||||
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
|
||||
shareFileWith($file, $this->onRoster);
|
||||
shareFileWithGroup($file, $group);
|
||||
|
||||
$details = $this->actingAs($this->manager)->get("/files/{$file->id}/details")->assertOk();
|
||||
|
||||
expect($details->getContent())->not->toContain('Offroster Group')
|
||||
->and($details->json('shares.groups'))->toBeEmpty();
|
||||
});
|
||||
|
||||
test('an unscoped administrator still sees every name', function () {
|
||||
$file = fileWithStrangerCoRecipient();
|
||||
$file->update(['uploaded_by' => $this->offRoster->id]);
|
||||
|
||||
$token = $this->admin->createToken('a', [Permission::Upload->value])->plainTextToken;
|
||||
|
||||
$api = $this->withToken($token)->getJson("/api/v1/files/{$file->id}")->assertOk();
|
||||
|
||||
expect($api->json('data.uploaded_by.name'))->toBe('Offroster Client')
|
||||
->and($api->json('data.assignments'))->toHaveCount(2);
|
||||
});
|
||||
|
||||
test('an unscoped administrator still sees every name on the web', function () {
|
||||
$file = fileWithStrangerCoRecipient();
|
||||
$file->update(['uploaded_by' => $this->offRoster->id]);
|
||||
|
||||
$details = $this->actingAs($this->admin)->get("/files/{$file->id}/details")->assertOk();
|
||||
|
||||
expect($details->json('uploader'))->toBe('Offroster Client')
|
||||
->and($details->json('shares.clients'))->toHaveCount(2);
|
||||
});
|
||||
|
||||
test('a scoped viewer is still told about their own roster and their own uploads', function () {
|
||||
$mine = File::factory()->create(['uploaded_by' => $this->manager->id, 'name' => 'mine']);
|
||||
shareFileWith($mine, $this->onRoster);
|
||||
|
||||
$api = $this->withToken($this->token)->getJson("/api/v1/files/{$mine->id}")->assertOk();
|
||||
|
||||
expect($api->json('data.uploaded_by.name'))->toBe($this->manager->name)
|
||||
->and($api->json('data.assignments.0.name'))->toBe('Roster Client');
|
||||
});
|
||||
|
||||
test('the rule itself: staff are never hidden, strangers always are', function () {
|
||||
$identity = app(ClientIdentityScope::class);
|
||||
|
||||
expect($identity->permits($this->manager, $this->admin))->toBeTrue()
|
||||
->and($identity->permits($this->manager, $this->onRoster))->toBeTrue()
|
||||
->and($identity->permits($this->manager, $this->offRoster))->toBeFalse()
|
||||
// A client may always be told who they themselves are.
|
||||
->and($identity->permits($this->offRoster, $this->offRoster))->toBeTrue()
|
||||
// An unscoped viewer is narrowed by nothing.
|
||||
->and($identity->permits($this->admin, $this->offRoster))->toBeTrue()
|
||||
->and($identity->isNarrowed($this->admin))->toBeFalse()
|
||||
->and($identity->isNarrowed($this->manager))->toBeTrue()
|
||||
// No viewer at all is the closed case, not the open one.
|
||||
->and($identity->permits(null, $this->offRoster))->toBeFalse()
|
||||
->and($identity->permits(null, $this->admin))->toBeTrue();
|
||||
});
|
||||
@@ -3,7 +3,9 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Support\ContentDisposition;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
beforeEach(function () {
|
||||
@@ -61,3 +63,70 @@ test('downloads of files with non-ascii names send both header forms on the wire
|
||||
->toContain('attachment; filename="')
|
||||
->toContain("filename*=utf-8''".rawurlencode('año contable — resumen.pdf'));
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| How long a presigned URL stays usable
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| On the local disk a delivery is an X-Accel-Redirect: it authorises one
|
||||
| response, to one request. On external storage it is a presigned URL,
|
||||
| which is a bearer credential -- forwardable, and valid whatever happens
|
||||
| to the file or the caller's checks in the meantime. Nothing can revoke
|
||||
| one, so its lifetime is the only dial there is.
|
||||
|
|
||||
*/
|
||||
|
||||
test('a download link outlives the redirect and not much else', function () {
|
||||
$seen = [];
|
||||
|
||||
Storage::fake('files_external');
|
||||
Storage::disk('files_external')->buildTemporaryUrlsUsing(
|
||||
function (string $path, $expiration, array $options) use (&$seen): string {
|
||||
$seen[] = $expiration;
|
||||
|
||||
return 'https://storage.example.test/'.$path;
|
||||
}
|
||||
);
|
||||
|
||||
$file = uploadDocumentFile($this->admin, 'report.pdf');
|
||||
$file->update(['disk' => 'files_external']);
|
||||
|
||||
$this->actingAs($this->admin)->get("/files/{$file->id}/download")->assertRedirect();
|
||||
|
||||
expect($seen)->toHaveCount(1)
|
||||
->and($seen[0]->getTimestamp())->toBeLessThanOrEqual(now()->addSeconds(60)->getTimestamp())
|
||||
->and($seen[0]->getTimestamp())->toBeGreaterThan(now()->addSeconds(30)->getTimestamp());
|
||||
});
|
||||
|
||||
test('a preview link lasts as long as somebody might watch', function () {
|
||||
// The other half of the trade: a player holds this URL and asks it for
|
||||
// ranges every time the viewer seeks past the buffer, so a minute would
|
||||
// break playback of anything longer than a minute.
|
||||
$seen = [];
|
||||
|
||||
Storage::fake('files_external');
|
||||
Storage::disk('files_external')->buildTemporaryUrlsUsing(
|
||||
function (string $path, $expiration, array $options) use (&$seen): string {
|
||||
$seen[] = $expiration;
|
||||
|
||||
return 'https://storage.example.test/'.$path;
|
||||
}
|
||||
);
|
||||
|
||||
// Uploaded rather than factory-made, so it is a real previewable file;
|
||||
// FilePreviewTest's helper is private to that file (Pest globals).
|
||||
$this->actingAs($this->admin)->post('/files', [
|
||||
'file' => UploadedFile::fake()->create('clip.mp4', 16, 'video/mp4'),
|
||||
'name' => '',
|
||||
'description' => '',
|
||||
]);
|
||||
|
||||
$file = File::query()->latest('id')->firstOrFail();
|
||||
$file->update(['disk' => 'files_external']);
|
||||
|
||||
$this->actingAs($this->admin)->get("/files/{$file->id}/preview")->assertRedirect();
|
||||
|
||||
expect($seen)->toHaveCount(1)
|
||||
->and($seen[0]->getTimestamp())->toBeGreaterThan(now()->addMinutes(50)->getTimestamp());
|
||||
});
|
||||
|
||||
@@ -269,3 +269,68 @@ test('saving clears a stale outage warning', function () {
|
||||
|
||||
expect(CaptchaVerifier::lastError())->toBeNull();
|
||||
});
|
||||
|
||||
// Capability::CaptchaConfigure. Present in both editions, so nothing here
|
||||
// changes for anybody until an operator subtracts it — which is the whole
|
||||
// point of the key: a hosted fleet shares one parent domain and one
|
||||
// sending reputation, and a tenant switching its own CAPTCHA off spends
|
||||
// the rest of the fleet's.
|
||||
test('the screen is open by default in both editions', function () {
|
||||
foreach ([Edition::Community, Edition::Cloud] as $edition) {
|
||||
config()->set('projectsend.edition', $edition);
|
||||
|
||||
$this->actingAs($this->admin)->get('/system/settings/captcha')->assertOk();
|
||||
}
|
||||
});
|
||||
|
||||
test('withdrawing the capability closes the screen', function () {
|
||||
config()->set('projectsend.edition', Edition::Cloud);
|
||||
config()->set('projectsend.capabilities_disabled', 'captcha.configure');
|
||||
|
||||
$this->actingAs($this->admin)->get('/system/settings/captcha')->assertNotFound();
|
||||
});
|
||||
|
||||
// The half that actually protects the fleet. Closing the read alone would
|
||||
// leave a hand-crafted PATCH able to do the damage, and turning the
|
||||
// CAPTCHA off needs none of the fields the controller gates per field —
|
||||
// `provider: none` does it, and so does unticking the four form switches
|
||||
// while leaving perfectly good keys in place.
|
||||
test('withdrawing the capability closes the write, keys or no keys', function () {
|
||||
config()->set('projectsend.edition', Edition::Cloud);
|
||||
config()->set('projectsend.capabilities_disabled', 'captcha.configure');
|
||||
|
||||
app(Settings::class)->set(Setting::CaptchaProvider, 'turnstile');
|
||||
|
||||
$this->actingAs($this->admin)
|
||||
->patch('/system/settings/captcha', captchaPayload(['provider' => 'none']))
|
||||
->assertNotFound();
|
||||
|
||||
$this->actingAs($this->admin)
|
||||
->patch('/system/settings/captcha', captchaPayload([
|
||||
'provider' => 'turnstile',
|
||||
'site_key' => 'site-abc',
|
||||
'secret_key' => 'secret-abc',
|
||||
'on_login' => false,
|
||||
'on_registration' => false,
|
||||
'on_password_reset' => false,
|
||||
'on_public_comments' => false,
|
||||
]))
|
||||
->assertNotFound();
|
||||
|
||||
$settings = app(Settings::class);
|
||||
|
||||
expect($settings->get(Setting::CaptchaProvider))->toBe('turnstile')
|
||||
->and($settings->get(Setting::CaptchaOnLogin))->toBeTrue()
|
||||
->and($settings->get(Setting::CaptchaOnRegistration))->toBeTrue()
|
||||
->and($settings->get(Setting::CaptchaOnPasswordReset))->toBeTrue()
|
||||
->and($settings->get(Setting::CaptchaOnPublicComments))->toBeTrue();
|
||||
});
|
||||
|
||||
test('withdrawing the capability closes the test button too', function () {
|
||||
config()->set('projectsend.edition', Edition::Cloud);
|
||||
config()->set('projectsend.capabilities_disabled', 'captcha.configure');
|
||||
|
||||
$this->actingAs($this->admin)
|
||||
->post('/system/settings/captcha/test', ['provider' => 'turnstile', 'secret_key' => 'secret-abc'])
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Modules\Platform\Capabilities\Edition;
|
||||
use App\Modules\Platform\Captcha\Captcha;
|
||||
use App\Modules\Platform\Captcha\CaptchaProvider;
|
||||
use App\Modules\Platform\Captcha\CaptchaSettings;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
|
||||
beforeEach(function () {
|
||||
// Settings outlive the per-test rollback, so nothing here may assume a
|
||||
// default — see the same note in CaptchaSettingsTest.
|
||||
$settings = app(Settings::class);
|
||||
$settings->set(Setting::CaptchaProvider, 'turnstile');
|
||||
$settings->set(Setting::CaptchaKeySource, 'own');
|
||||
|
||||
CaptchaSettings::for(CaptchaProvider::Turnstile)
|
||||
->fill(['site_key' => 'site-abc', 'secret_key' => 'secret-abc'])
|
||||
->save();
|
||||
|
||||
config()->set('projectsend.edition', Edition::Community);
|
||||
config()->set('projectsend.captcha.disabled', false);
|
||||
config()->set('projectsend.captcha.managed', ['provider' => null, 'site_key' => null, 'secret_key' => null, 'score_threshold' => 0.5]);
|
||||
|
||||
Captcha::forgetDisplayCache();
|
||||
});
|
||||
|
||||
test('it switches the captcha off and keeps the keys', function () {
|
||||
expect(app(Captcha::class)->active())->not->toBeNull();
|
||||
|
||||
$this->artisan('projectsend:captcha-off')
|
||||
->expectsOutputToContain('CAPTCHA is off')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(app(Captcha::class)->active())->toBeNull()
|
||||
// The whole point of the command: a way back in, not a way to lose
|
||||
// a credential somebody wants again in ten minutes.
|
||||
->and(CaptchaSettings::for(CaptchaProvider::Turnstile)->secret_key)->toBe('secret-abc');
|
||||
});
|
||||
|
||||
// The setting this command writes is not where managed keys come from.
|
||||
// Captcha::resolve() returns managedConfig() before it ever reads
|
||||
// Setting::CaptchaProvider, so on a managed installation the write lands
|
||||
// somewhere nothing reads — and the old success message sent an operator
|
||||
// who was still being challenged away from the only thing that would have
|
||||
// explained why.
|
||||
test('it says plainly that it changed nothing when the platform supplies the keys', function () {
|
||||
config()->set('projectsend.edition', Edition::Cloud);
|
||||
config()->set('projectsend.captcha.managed', [
|
||||
'provider' => 'turnstile',
|
||||
'site_key' => 'managed-site',
|
||||
'secret_key' => 'managed-secret',
|
||||
'score_threshold' => 0.5,
|
||||
]);
|
||||
app(Settings::class)->set(Setting::CaptchaKeySource, 'managed');
|
||||
Captcha::forgetDisplayCache();
|
||||
|
||||
$this->artisan('projectsend:captcha-off')
|
||||
->expectsOutputToContain('Nothing changed')
|
||||
->expectsOutputToContain('PROJECTSEND_CAPTCHA_DISABLED')
|
||||
->doesntExpectOutputToContain('CAPTCHA is off')
|
||||
->assertSuccessful();
|
||||
|
||||
// And it really did change nothing: the forms are still protected.
|
||||
expect(app(Captcha::class)->active())->not->toBeNull();
|
||||
});
|
||||
|
||||
// The env switch is checked ahead of the key source, which is what makes
|
||||
// it the one that works on a managed installation. If that ordering ever
|
||||
// moves, a locked-out operator loses their last way in.
|
||||
test('the environment switch turns off even the platform keys', function () {
|
||||
config()->set('projectsend.edition', Edition::Cloud);
|
||||
config()->set('projectsend.captcha.managed', [
|
||||
'provider' => 'turnstile',
|
||||
'site_key' => 'managed-site',
|
||||
'secret_key' => 'managed-secret',
|
||||
'score_threshold' => 0.5,
|
||||
]);
|
||||
app(Settings::class)->set(Setting::CaptchaKeySource, 'managed');
|
||||
config()->set('projectsend.captcha.disabled', true);
|
||||
Captcha::forgetDisplayCache();
|
||||
|
||||
expect(app(Captcha::class)->active())->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Platform\Announcements\Events\ResolvingAnnouncement;
|
||||
use App\Modules\Platform\Navigation\Events\ResolvingNavigationLinks;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->admin = User::factory()->create();
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Two seams a package fills, and core does not
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Both are dispatched unconditionally, and with nothing listening the
|
||||
| documented default holds — no links, no callout. That is what makes them
|
||||
| safe to add to a community installation that will never have a listener.
|
||||
*/
|
||||
|
||||
test('with nothing listening the dashboard is exactly what it was', function () {
|
||||
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->where('announcement', null),
|
||||
);
|
||||
|
||||
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->where('extra_nav_links', []),
|
||||
);
|
||||
});
|
||||
|
||||
test('a listener can put a message in front of staff', function () {
|
||||
Event::listen(ResolvingAnnouncement::class, function (ResolvingAnnouncement $event): void {
|
||||
$event->show('Heads up', 'Something worth reading.', 'Do the thing', 'https://example.test/', 'warning');
|
||||
});
|
||||
|
||||
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
|
||||
fn (AssertableInertia $page) => $page
|
||||
->where('announcement.title', 'Heads up')
|
||||
->where('announcement.action_url', 'https://example.test/')
|
||||
->where('announcement.tone', 'warning'),
|
||||
);
|
||||
});
|
||||
|
||||
test('a listener can add a sidebar link', function () {
|
||||
Event::listen(ResolvingNavigationLinks::class, function (ResolvingNavigationLinks $event): void {
|
||||
$event->add('Somewhere else', 'https://example.test/', external: true);
|
||||
});
|
||||
|
||||
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
|
||||
fn (AssertableInertia $page) => $page
|
||||
->where('extra_nav_links.0.title', 'Somewhere else')
|
||||
->where('extra_nav_links.0.external', true),
|
||||
);
|
||||
});
|
||||
|
||||
// The sidebar is the administration area. A client's portal shows their
|
||||
// own files and nothing about the installation, so these must not reach
|
||||
// them however careless a listener is.
|
||||
test('a client gets no contributed links, even from a listener that adds unconditionally', function () {
|
||||
Event::listen(ResolvingNavigationLinks::class, function (ResolvingNavigationLinks $event): void {
|
||||
$event->add('Staff only really', 'https://example.test/');
|
||||
});
|
||||
|
||||
$client = User::factory()->client()->create();
|
||||
|
||||
$this->actingAs($client)->get('/my-files')->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->where('extra_nav_links', []),
|
||||
);
|
||||
});
|
||||
|
||||
// One band. A dashboard that can accumulate banners accumulates them, and
|
||||
// the second is what teaches people to skip the first.
|
||||
test('the first listener to set a callout keeps it', function () {
|
||||
Event::listen(ResolvingAnnouncement::class, function (ResolvingAnnouncement $event): void {
|
||||
$event->show('First', 'Set first.');
|
||||
});
|
||||
Event::listen(ResolvingAnnouncement::class, function (ResolvingAnnouncement $event): void {
|
||||
$event->show('Second', 'Should not win.');
|
||||
});
|
||||
|
||||
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->where('announcement.title', 'First'),
|
||||
);
|
||||
});
|
||||
|
||||
test('an unknown tone falls back rather than rendering unstyled', function () {
|
||||
Event::listen(ResolvingAnnouncement::class, function (ResolvingAnnouncement $event): void {
|
||||
$event->show('T', 'B', tone: 'chartreuse');
|
||||
});
|
||||
|
||||
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->where('announcement.tone', 'info'),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
// The header icon and the dashboard band read one shared prop, so a
|
||||
// message reaches somebody who never opens the dashboard. Two props would
|
||||
// have drifted the first time anybody edited one.
|
||||
test('the same message is available away from the dashboard', function () {
|
||||
Event::listen(ResolvingAnnouncement::class, function (ResolvingAnnouncement $event): void {
|
||||
$event->show('Everywhere', 'Not only on the dashboard.');
|
||||
});
|
||||
|
||||
$this->actingAs($this->admin)->get('/system/settings/general')->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->where('announcement.title', 'Everywhere'),
|
||||
);
|
||||
});
|
||||
|
||||
// A client's header carries the bell too. Nothing addressed to staff may
|
||||
// appear there, however careless the listener.
|
||||
test('a client is never shown one, even from a listener that sets it unconditionally', function () {
|
||||
Event::listen(ResolvingAnnouncement::class, function (ResolvingAnnouncement $event): void {
|
||||
if (! $event->isStaff) {
|
||||
return;
|
||||
}
|
||||
|
||||
$event->show('Staff only', 'Not for clients.');
|
||||
});
|
||||
|
||||
$client = User::factory()->client()->create();
|
||||
|
||||
$this->actingAs($client)->get('/my-files')->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->where('announcement', null),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Platform\Capabilities\Edition;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->admin = User::factory()->create();
|
||||
|
||||
// Settings survive the per-test rollback, so nothing here may assume
|
||||
// a default — see the note in CaptchaSettingsTest.
|
||||
$settings = app(Settings::class);
|
||||
$settings->set(Setting::CheckForUpdates, true);
|
||||
$settings->set(Setting::FetchNews, true);
|
||||
|
||||
config()->set('projectsend.edition', Edition::Community);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Two daily calls out of the container, and who may stop them
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| An operator could stop neither. The news feed had no switch of any kind,
|
||||
| and the update check had one whose default is on — so a managed fleet
|
||||
| believed it had disabled updates through an environment variable that
|
||||
| nothing in this application reads.
|
||||
|
|
||||
| They are not the same case, and are not fixed the same way. Which
|
||||
| mechanism each gets is the point of these tests.
|
||||
*/
|
||||
|
||||
test('the news feed can be switched off, and says so rather than failing', function () {
|
||||
Http::fake();
|
||||
app(Settings::class)->set(Setting::FetchNews, false);
|
||||
|
||||
$this->artisan('projectsend:fetch-news')
|
||||
->expectsOutputToContain('switched off')
|
||||
->assertSuccessful();
|
||||
|
||||
// Not merely "no items stored" — the request never left.
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('the news feed is on by default, so nothing changes for an existing install', function () {
|
||||
Http::fake(['*' => Http::response([])]);
|
||||
|
||||
$this->artisan('projectsend:fetch-news')->assertSuccessful();
|
||||
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
// The news itself is both editions — a Cloud client with view_news sees
|
||||
// that card. What is Community-only is the *choice*: announcements about
|
||||
// the product are what a hosted customer should be told, and one
|
||||
// administrator switching them off for everybody on that instance is not
|
||||
// a decision the platform hands over.
|
||||
//
|
||||
// The exact opposite of the update check below, which does not run on a
|
||||
// managed instance at all. The two look alike and point in different
|
||||
// directions, so both directions are pinned.
|
||||
test('a managed instance fetches the news whatever its setting says', function () {
|
||||
Http::fake(['*' => Http::response([])]);
|
||||
config()->set('projectsend.edition', Edition::Cloud);
|
||||
|
||||
// Off — including a row left behind by an instance that used to be
|
||||
// self-hosted, which is the case that would otherwise go silent.
|
||||
app(Settings::class)->set(Setting::FetchNews, false);
|
||||
|
||||
$this->artisan('projectsend:fetch-news')->assertSuccessful();
|
||||
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
test('a managed instance is not offered the switch, and cannot be sent it', function () {
|
||||
config()->set('projectsend.edition', Edition::Cloud);
|
||||
app(Settings::class)->set(Setting::FetchNews, true);
|
||||
|
||||
$this->actingAs($this->admin)->get('/system/settings/general')->assertInertia(
|
||||
fn (Inertia\Testing\AssertableInertia $page) => $page
|
||||
->where('can_configure_news', false)
|
||||
->where('fetch_news', null),
|
||||
);
|
||||
|
||||
// A hand-crafted PATCH must not do what the absent checkbox could not.
|
||||
$this->actingAs($this->admin)
|
||||
->patch('/system/settings/general', generalPayload(['fetch_news' => false]))
|
||||
->assertRedirect();
|
||||
|
||||
expect(app(Settings::class)->get(Setting::FetchNews))->toBeTrue();
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| The update check is the other kind
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| On a managed installation the result is unreachable rather than
|
||||
| unwanted: the dashboard's System card and the update UI are both gated
|
||||
| on Capability::SystemUpdates, which is Community-only, and the image is
|
||||
| chosen by whoever provisioned the instance. That is a fact about the
|
||||
| edition, not a preference — so it is a capability, not a Setting.
|
||||
*/
|
||||
|
||||
test('the update check does not run where its answer could never be seen', function () {
|
||||
Http::fake();
|
||||
config()->set('projectsend.edition', Edition::Cloud);
|
||||
|
||||
// On, and it still must not call out: the capability decides first.
|
||||
app(Settings::class)->set(Setting::CheckForUpdates, true);
|
||||
|
||||
$this->artisan('projectsend:check-for-updates')
|
||||
->expectsOutputToContain('do not apply')
|
||||
->assertSuccessful();
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('a self-hosted install keeps its own switch, both ways', function () {
|
||||
Http::fake(['*' => Http::response([])]);
|
||||
|
||||
app(Settings::class)->set(Setting::CheckForUpdates, false);
|
||||
|
||||
$this->artisan('projectsend:check-for-updates')
|
||||
->expectsOutputToContain('disabled')
|
||||
->assertSuccessful();
|
||||
|
||||
Http::assertNothingSent();
|
||||
|
||||
app(Settings::class)->set(Setting::CheckForUpdates, true);
|
||||
|
||||
$this->artisan('projectsend:check-for-updates')->assertSuccessful();
|
||||
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Reachable without a shell
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| A setting an operator cannot find is not a switch, it is a row. The
|
||||
| update toggle beside it is hidden where the capability is absent; this
|
||||
| one must not be, because the card it controls is shown in both editions.
|
||||
*/
|
||||
|
||||
test('a self-hosted installation is offered the switch', function () {
|
||||
$this->actingAs($this->admin)->get('/system/settings/general')->assertInertia(
|
||||
fn (Inertia\Testing\AssertableInertia $page) => $page
|
||||
->where('can_configure_news', true)
|
||||
->where('fetch_news', true),
|
||||
);
|
||||
});
|
||||
|
||||
test('saving the settings page can turn the feed off and on', function () {
|
||||
Http::fake();
|
||||
|
||||
$this->actingAs($this->admin)
|
||||
->patch('/system/settings/general', generalPayload(['fetch_news' => false]))
|
||||
->assertRedirect();
|
||||
|
||||
expect(app(Settings::class)->get(Setting::FetchNews))->toBeFalse();
|
||||
|
||||
$this->artisan('projectsend:fetch-news')->assertSuccessful();
|
||||
Http::assertNothingSent();
|
||||
|
||||
$this->actingAs($this->admin)
|
||||
->patch('/system/settings/general', generalPayload(['fetch_news' => true]))
|
||||
->assertRedirect();
|
||||
|
||||
expect(app(Settings::class)->get(Setting::FetchNews))->toBeTrue();
|
||||
});
|
||||
|
||||
/** The general form posts every field it owns; only the interesting one varies. */
|
||||
function generalPayload(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'site_name' => 'ProjectSend',
|
||||
'timezone' => 'UTC',
|
||||
], $overrides);
|
||||
}
|
||||
@@ -31,7 +31,11 @@ test('community edition has the self-management capabilities and no cloud exclus
|
||||
->and($registry->has(Capability::AttributionHide))->toBeFalse()
|
||||
// The counterpart of StorageConfigure above: a self-hosted install
|
||||
// configures its own bucket and is never handed one.
|
||||
->and($registry->has(Capability::StorageManaged))->toBeFalse();
|
||||
->and($registry->has(Capability::StorageManaged))->toBeFalse()
|
||||
// Both editions, and the self-hosted side is the reason it must
|
||||
// stay present by default: nobody else supplies this
|
||||
// installation's CAPTCHA keys.
|
||||
->and($registry->has(Capability::CaptchaConfigure))->toBeTrue();
|
||||
});
|
||||
|
||||
test('cloud edition has cloud exclusives and none of the community-only capabilities', function () {
|
||||
@@ -43,6 +47,9 @@ test('cloud edition has cloud exclusives and none of the community-only capabili
|
||||
// not decide who fills them. See the case's own comment.
|
||||
->and($registry->has(Capability::UsersManage))->toBeTrue()
|
||||
->and($registry->has(Capability::StorageManaged))->toBeTrue()
|
||||
// Granted here too. A hosted platform closes the CAPTCHA screen by
|
||||
// subtracting this key, not by the edition withholding it.
|
||||
->and($registry->has(Capability::CaptchaConfigure))->toBeTrue()
|
||||
->and($registry->has(Capability::StorageConfigure))->toBeFalse()
|
||||
->and($registry->has(Capability::EmailTransportConfigure))->toBeFalse()
|
||||
->and($registry->has(Capability::SystemUpdates))->toBeFalse()
|
||||
@@ -60,6 +67,11 @@ test('enabledKeys returns the string keys of enabled capabilities', function ()
|
||||
'branding.customize',
|
||||
'attribution.hide',
|
||||
'storage.managed',
|
||||
// Both editions, present by default. It appears in a Cloud
|
||||
// instance's keys until the platform names it in
|
||||
// PROJECTSEND_CAPABILITIES_DISABLED, which is how the fleet keeps
|
||||
// one tenant from switching its CAPTCHA off.
|
||||
'captcha.configure',
|
||||
'captcha.managed_keys',
|
||||
'platform.managed',
|
||||
'ai.connector',
|
||||
|
||||
Reference in New Issue
Block a user