mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-04 05:25:51 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 62245befc7 | |||
| 50a6a19455 | |||
| 8de28059db | |||
| ea214fc27e | |||
| 922be7226c | |||
| 1e30e83f11 | |||
| d32788e4a1 | |||
| c3503a0651 | |||
| 51477cbd02 | |||
| 7c9847981a | |||
| 7da4635f13 | |||
| ddf09677f0 | |||
| 9b2aea4812 |
@@ -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,28 @@ 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.
|
||||
|
||||
**Closed holes in who can see what**
|
||||
|
||||
- A staff member limited to their own assigned clients could read the names of other clients out of
|
||||
file details. Sharing means a file can reach somebody through one client while it was uploaded by
|
||||
another, or while it is also shared with another. That is normal and the file is theirs to open —
|
||||
but the uploader's name, the other recipient's name, and both their ID numbers were being sent
|
||||
along with it, on the library list, the file's edit page, the details panel, the per-client file
|
||||
list, and the matching API responses. A group holding none of their clients was named the same
|
||||
way. The file list could also be filtered by uploader, which answered "does this client of yours
|
||||
share files with that client of mine" without naming anybody.
|
||||
|
||||
Those names are now left out for a limited staff member, and the uploader filter no longer answers
|
||||
for a client they are not assigned to. Administrators and any unrestricted role see exactly what
|
||||
they saw before.
|
||||
|
||||
**Who this affected.** Only installations using the Client Manager role, or a custom role with
|
||||
"Limit to assigned clients" switched on, and only where files are shared with more than one client
|
||||
or through groups. No files, downloads or credentials were reachable this way — a file belonging
|
||||
to a client outside the roster was refused before, and still is.
|
||||
|
||||
Reported by [@Noorkhalel](https://github.com/Noorkhalel) (GHSA-whmp-p9hv-r7j7).
|
||||
|
||||
## 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**
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -79,11 +79,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
|
||||
@@ -129,6 +154,7 @@ enum Capability: string
|
||||
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
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
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",
|
||||
|
||||
+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": {
|
||||
|
||||
+9
-1
@@ -2005,5 +2005,13 @@
|
||||
"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."
|
||||
}
|
||||
|
||||
+9
-1
@@ -2005,5 +2005,13 @@
|
||||
"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í."
|
||||
}
|
||||
|
||||
+9
-1
@@ -2005,5 +2005,13 @@
|
||||
"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."
|
||||
}
|
||||
|
||||
+9
-1
@@ -2005,5 +2005,13 @@
|
||||
"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."
|
||||
}
|
||||
|
||||
+9
-1
@@ -2005,5 +2005,13 @@
|
||||
"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."
|
||||
}
|
||||
|
||||
+9
-1
@@ -2005,5 +2005,13 @@
|
||||
"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."
|
||||
}
|
||||
|
||||
+9
-1
@@ -2005,5 +2005,13 @@
|
||||
"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."
|
||||
}
|
||||
|
||||
+9
-1
@@ -2005,5 +2005,13 @@
|
||||
"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.": "このサイトにはまだ公開ページが設定されていないため、管理者が設定するまで何も表示されません。"
|
||||
}
|
||||
|
||||
+9
-1
@@ -2005,5 +2005,13 @@
|
||||
"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."
|
||||
}
|
||||
|
||||
+9
-1
@@ -2005,5 +2005,13 @@
|
||||
"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."
|
||||
}
|
||||
|
||||
+9
-1
@@ -2005,5 +2005,13 @@
|
||||
"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."
|
||||
}
|
||||
|
||||
+9
-1
@@ -2005,5 +2005,13 @@
|
||||
"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.": "На этом сайте ещё не настроена публичная страница, поэтому ничего не будет видно, пока администратор её не настроит."
|
||||
}
|
||||
|
||||
+9
-1
@@ -2005,5 +2005,13 @@
|
||||
"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."
|
||||
}
|
||||
|
||||
+9
-1
@@ -2005,5 +2005,13 @@
|
||||
"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."
|
||||
}
|
||||
|
||||
+9
-1
@@ -2005,5 +2005,13 @@
|
||||
"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."
|
||||
}
|
||||
|
||||
+9
-1
@@ -2005,5 +2005,13 @@
|
||||
"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.": "本站尚未设置公开页面,因此在管理员设置之前不会显示任何内容。"
|
||||
}
|
||||
|
||||
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",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 6.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 15 KiB |
+16
-5
@@ -1,11 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.26482 252.25172">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-9 0 252.3 252.3">
|
||||
<!--
|
||||
The ProjectSend mark: the swoosh in the project gradient (violet to
|
||||
blue). One shape for the project, one gradient per edition — the
|
||||
hosted portal draws the same vector in the Cloud gradient, so the
|
||||
colour is what tells the tabs apart. Same path as
|
||||
resources/js/components/app-logo-icon.tsx; change both together.
|
||||
|
||||
Padded to a square rather than the artwork's own bounds, so the mark
|
||||
fills whatever square slot a browser puts an icon in — a tab, a
|
||||
bookmark bar, a pinned shortcut — instead of being letterboxed.
|
||||
-->
|
||||
<defs>
|
||||
<linearGradient id="ps-icon-gradient" gradientUnits="userSpaceOnUse" x1="31.5263" y1="211.0108" x2="197.4637" y2="50.766899">
|
||||
<stop offset="0.1675" stop-color="#5219B3"/>
|
||||
<stop offset="0.8128" stop-color="#3072BF"/>
|
||||
<linearGradient id="ps-icon-gradient" gradientUnits="userSpaceOnUse" x1="31.5" y1="211" x2="197.5" y2="50.8">
|
||||
<stop offset="0.17" stop-color="#5219B3"/>
|
||||
<stop offset="0.81" stop-color="#3072BF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g transform="translate(-2.5,-2.1253469)">
|
||||
<g transform="translate(-2.5,-2.13)">
|
||||
<path fill="url(#ps-icon-gradient)" d="m 233.21,158.034 c -2.53,0 -4.992,0 -7.522,0 -38.087,-1.299 -74.943,-8.889 -110.774,-21.676 C 87.015,126.375 60.621,113.451 37.441,94.647 29.783,88.493 22.876,81.587 16.585,73.997 15.286,72.356 15.149,71.604 17.2,70.51 58.092,48.285 98.983,26.062 139.873,3.839 c 6.633,-3.556 12.787,-1.504 16.684,5.47 19.283,35.352 38.497,70.772 57.78,106.124 7.043,12.992 14.154,25.984 21.197,38.976 1.916,3.556 1.916,3.556 -2.324,3.625 z M 3.799,130.545 c 21.061,38.702 42.121,77.337 63.182,115.971 4.513,8.274 10.325,9.915 18.667,5.402 48.002,-26.121 95.936,-52.241 143.869,-78.362 0.957,-0.479 2.051,-0.752 2.735,-1.846 C 153.002,171.573 75.939,160.495 2.5,127.947 c 0.547,1.026 0.889,1.846 1.299,2.598 z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -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 },
|
||||
|
||||
@@ -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))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -36,6 +36,7 @@ export type Capability =
|
||||
| 'custom_assets.manage'
|
||||
| 'branding.customize'
|
||||
| 'attribution.hide'
|
||||
| 'captcha.configure'
|
||||
| 'captcha.managed_keys';
|
||||
|
||||
export interface SocialLoginProvider {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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