273 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

Core names no package and no style. With nothing installed that ships a
stylesheet or icons, nothing renders differently.
2026-09-29 17:51:47 -03:00
01110111000001 f24a8587b9 feat: disable php-fpm access logs 2026-09-27 03:19:19 +02:00
01110111000001 a640bf81ed feat: ignore nginx logs on /up parh 2026-09-27 03:18:59 +02:00
ignacionelson a9b17ddc1e Release 2.6.0 2026-09-25 15:00:17 -03:00
ignacionelson 24a94d3beb Translate the strings added in the 2026-09-25 issue run
Eight strings, in all sixteen locales: bulk delete's confirmation and its
error, the upload page's "Uploading into", and the Branding page's site-name
switch and logo size hint.
2026-09-25 02:50:18 -03:00
ignacionelson 27f994263f Label the bulk delete confirmation "Delete", not the permission name
"Delete files" is already the label of the delete_files permission, and
several locales translate it as a noun ("deletion of files"), which reads
wrongly on a button. "Delete" is translated as a verb everywhere.
2026-09-25 02:50:18 -03:00
ignacionelson 8180a66243 Drop two nullsafe operators PHPStan flags: ?? already covers a missing branding row 2026-09-25 02:49:03 -03:00
ignacionelson 1d483f6a81 Delete several files at once from the staff selection bar
The selection bar could zip and bulk-edit the ticked files, including
moving them to a folder, but deleting was one file at a time.

A Delete button now appears when at least one ticked file is one this
person may delete. It asks for confirmation and sends only those files.
The server asks each file the same question a single delete asks, through
FilePolicy, and gives each the same soft delete and the same FileDeleted
activity entry. A file the person may not delete is dropped from the
batch rather than failing it, as bulk edit already does. A batch with
nothing left to delete is a 422. The route sits before files/{file},
which would otherwise read "bulk-delete" as a file id.

Reported by @lolgufdHD (#1800)
2026-09-25 02:47:34 -03:00
ignacionelson bd26740390 Upload into the folder you are in, on the staff Files page
Inside a folder, Upload opened the upload page with no folder, so every
file landed at the top of the library and had to be moved. The client
portal already carried the folder; the staff page now does the same.

The upload page takes ?folder=, says "Uploading into <folder>", passes
it to the upload, and sends a multi-file upload back to that folder. The
same two checks as the portal's upload page: a folder outside the staff
member's library is a 404, so the page never confirms it exists, and one
they may not upload into is a 403. ChunkedUploadsController still checks
the destination again when the upload starts. While searching, the
button keeps uploading to the top, since results span folders.

Reported by @lolgufdHD (#1801)
2026-09-25 02:44:54 -03:00
ignacionelson 37c9cb839f Never remember a JSON request as the page to go back to
Saving a settings form could open Inertia's error dialog showing
{"count":0}. back() prefers the Referer and falls back to the URL the
session recorded last. Laravel records every GET not marked as Ajax, and
the notification bell's plain fetch() of its unread count is not marked,
so the poll became "the previous page". Where the Referer did not arrive,
because a proxy or a browser stripped it, the save redirected to
/notifications/unread-count, and Inertia rendered the JSON as an error.

The session middleware is swapped for a subclass that skips recording
when the request asked for JSON. The rule is about the request, not about
that one route: nothing that asked for JSON is a page anybody goes back
to. Inertia visits ask for HTML and are recorded as before, and the
middleware order is unchanged (the sorter matches the subclass by its
parent).

A test reproduces it: open the privacy form, poll the count the way the
bell does, and save with no Referer. It failed with a redirect to
/notifications/unread-count before this change.

Reported by @0xVavaldi (#1799)
2026-09-25 02:40:29 -03:00
ignacionelson 1b3f014f5f Show the logo larger on the sign-in pages, and let the site name appear under it
The sign-in, password reset, setup and share-link pages drew a custom logo
48 pixels tall, so a square logo was a 48x48 stamp. It now gets a box 80
pixels tall and up to 240 wide, so a square logo reads and a wide one still
fits a phone. ProjectSend's own logo is unchanged.

The site name appeared nowhere on those pages except as the image's alt
text. A new switch on Branding → Logo prints it under the logo. It is off
by default, because many logos already say the name and would then say it
twice. It is stored on the branding row, gated like the rest of the
screen (staff, edit_settings, branding.customize), and a withheld
capability takes it off the pages along with the logo. The branding API's
logo endpoint reports it as show_site_name.

The Logo tab now says what size to use and which formats are accepted.
SVG stays refused: it can carry script, and the logo is served from the
site's own origin. The crop tool the issue also asks for is not part of
this.

Reported by @jiits (#1798)
2026-09-25 02:38:47 -03:00
ignacionelson c795c58963 Use Pdo\Mysql::ATTR_SSL_CA, which PHP 8.5 no longer warns about
PHP 8.5 deprecated PDO::MYSQL_ATTR_SSL_CA, so loading config/database.php
printed two "Deprecated" warnings, one for each of the MySQL and MariaDB
connections. On a server that displays warnings, they appeared on every
page.

Pdo\Mysql::ATTR_SSL_CA exists since PHP 8.4, which is our minimum, so no
version check is needed. Checked by loading the real config file under
PHP 8.5 with pdo_mysql: the old file prints both warnings, and the new one
prints none and sets the same option.

Reported by @jiits (#1796)
2026-09-25 01:59:18 -03:00
ignacionelson acab833b72 Put the Docker quick start first, and fill the gaps a first install falls into
The README now opens its instructions before the screenshots, and says
what the quick start assumed: Docker Engine with Compose installed, your
own user in the docker group (so nobody reaches for sudo), and which
directory the commands run from.

The quick start also saved the file as compose.example.yaml and started
it with -f. Every later command in DOCKER.md, UPDATE.md and the migration
guide is a plain `docker compose ...`, which only finds a file called
compose.yaml, so each of them failed with "no configuration file
provided". It is now saved as compose.yaml, as the Docker Hub page
already said, with one line for people who kept the old name.

The migration guide covered "Legacy on this machine, ProjectSend in
Docker" in one sentence. It now has a worked route through a bundle. The
exporter runs with the host's own PHP, where Legacy's `localhost`
database really is local, so no container networking is needed. The
guide also says why Direct is harder there: the database, and hardlinks
that cannot cross a mount. Step 2 was run against a real v1 install on
the host (60 files, 63 MB).

Reported by @lukatong (#1635), with the install and migration gaps
pointed out by @jjoelc.
2026-09-25 01:34:38 -03:00
ignacionelson a150dc4955 Let a disk sign its links with a separate key
Downloads, previews and public links from managed storage have failed in
every browser since late August with the bucket's AccessDenied XML. The
platform pins each instance's R2 key to our servers' IP (portal 0125be7,
2026-08-26). Core started redirecting to signed URLs at about the same
time (57540164, d6fd5a91). A signed URL carries every restriction of the
key that signed it, so it worked from the server and nowhere else.

A disk can now name another disk in `signing_disk`, and links are signed
with that one. The platform gives it a read-only key without the IP pin.
The read-write key stays pinned. A name that points at no configured disk
is ignored, and the file's own disk signs as before. Uploads are
unaffected: they go through the server, and nothing else signs.
2026-09-24 22:56:00 -03:00
ignacionelson bccf3d1f29 Stop serving a self-deleted account's files, and let them go at once
Reported from the shared free instance. A client deleted their own account
on 2026-09-18. Their five files kept serving through share links with no
expiry for the whole 30-day grace period. Somebody who asked to leave
stayed published.

Rule 1: once an account is soft-deleted, its uploads are served to nobody
but staff. That covers the client scope (assignment, group, shared
folder), share links, the public listing, public comments and zips.
Staff keep them, because the grace period exists to undo a mistake.
Nothing is deleted and share links are kept, so a restored account is
served again. A withdrawn share link answers like a token that never
existed. In practice this only meets self-deleted accounts: an
administrator deleting an account that owns anything must already choose
to delete or reassign it.

Rule 2, new in Privacy settings: when someone deletes their own account,
their files are deleted "when the grace period ends" (the default, and
today's behaviour) or "right away". "Right away" uses
DeletedAccountContent's cascade: their own uploads, and their folders only
if nothing else is left inside. It runs in the same transaction as the
account delete. A platform can force "right away" through the new
ResolvingSelfDeletion hook. The screen then shows the choice as set by
the hosting plan instead of offering a switch.

A second setting decides whose deletion both rules apply to: any account
(the default) or clients only. A staff member's uploads are often the
organization's work for its clients.

The delete-account screen now says what happens to the files before the
person confirms. New strings are in all sixteen locales.
2026-09-24 16:58:48 -03:00
ignacionelson 4524b75c9d Let a hosted plan switch zip downloads off with downloads.zip
A new capability, granted by both editions. Self-hosted installs keep zip
downloads as they are. A platform removes it through
PROJECTSEND_CAPABILITIES_DISABLED. The free shared instances do this,
because building an archive holds the zips worker, the disk and a CPU on
a server that thousands of accounts share.

- The three zip routes sit behind capability:downloads.zip, so a
  hand-made request gets a 404, not just a missing button.
- BuildZipDownloadJob refuses a build that was queued before the key went
  away. The row ends failed and is never stamped as started.
  StalledZipBuilds stays quiet when the key is off, so leftover rows
  raise no worker banner.
- The zip buttons are hidden. In the portal, the checkboxes and the
  selection bar are hidden too, since they exist only to pick files for a
  zip. Staff /files keeps its checkboxes, which also drive bulk edit.
- Archives already built are not touched. They expire on the normal
  purge schedule.
- A guard test walks the router. It fails if any route that reaches
  ZipDownloadsController, or dispatches the build job, lacks the
  middleware. No API route builds zips today.

The case goes last in the enum, because the control plane reads keys in
enum order.
2026-09-24 15:39:44 -03:00
ignacionelson ca85c8a7e3 Translate the storage rows and the password dialog's rate-limit message
Eight strings, in all sixteen locales: the seven the System card gained
in #1794 (where files are stored, what is free, the temporary upload
space and why it exists), and the password dialog's "Too many attempts",
which until now was only in Spanish.
2026-09-21 22:47:50 -03:00
ignacionelson 42721b1bab Merge pull request #1794 from JensS/fix/storage-capacity-display
Show object storage and temporary upload capacity separately
2026-09-21 21:49:24 -03:00
ignacionelson ac5803b773 Merge pull request #1792 from JensS/fix/concurrent-upload-reservations
Fix premature 413 errors for concurrent upload chunks
2026-09-21 18:37:45 -03:00
ignacionelson 86edbc640d Remove the duplicate custom-pr-sign-comment that broke the CLA workflow
c9a4b552 added custom-pr-sign-comment to close a hole that was not
there: the key was already set further down the same block, with the
same value, and has been since 2.0.0. The action was already comparing
the whole comment, so a comment wrapping the phrase in other text was
never recorded as a signature, and loosening the job filter in
0a7330d5 opened nothing. The second copy made the file invalid, and
GitHub stopped running the CLA check at all.

This removes the copy and says at the original why it is set, since it
repeats the action's default phrase and looks removable.
2026-09-21 18:26:43 -03:00
ignacionelson c9a4b5520d Only record a CLA signature when the comment is the phrase
The action, left to its default, searches a comment for the signing
phrase, so a single line such as "I LIE, I have read the CLA Document
and I hereby sign the CLA. I do not sign it" was recorded as a
signature. The exact match in the job filter used to block that, but
only by accident, and it also blocked @JensS's real signature, which
had line breaks after it. Loosening that filter in 0a7330d5 let both
through.

custom-pr-sign-comment makes the action compare the whole comment,
trimmed and lowercased, against the phrase. Trailing line breaks still
sign; anything else around the phrase does not. The job filter stays
loose, since it only decides whether a runner starts.
2026-09-21 18:25:14 -03:00
ignacionelson 0a7330d5cd Accept a CLA signature that has line breaks after it
The CLA job only ran for a comment exactly equal to the signing phrase.
@JensS signed on #1792 with the phrase followed by line breaks
("...sign the CLA\r\n\r\n\n"), the comparison failed, the job was
skipped, and the signature was never recorded, so all three of his pull
requests still show the CLA as unsigned.

The action itself matches the phrase loosely. The filter in front of it
now does too: contains() for the signature, startsWith() for recheck,
both case-insensitive. It still keeps the job off ordinary comments,
which is what it is there for.
2026-09-21 18:21:04 -03:00
ignacionelson 3a3fd5358d Let directory accounts confirm their password
The confirm-password screen hid its field from every account that was
not Local, and told it to set a password instead. That is right for an
account a provider created, which has no password anybody has seen. It
is wrong for a directory account: its password is the directory's,
PasswordVerification accepts it, and /settings/password refuses to let
it set another. So an LDAP account could not get past the confirmation
at all, and everything behind it, turning on two-factor included, was
out of reach. The new dialog copied the same question.

Both now ask whether the account came from a provider, and the prop is
called has_password, which is what it means. The dialog also clears
the typed password when it closes or once it has been used, instead of
keeping it in component state.
2026-09-21 18:13:24 -03:00
ignacionelson a45eae315c Ask for the password over the page instead of throwing the form away
password.confirm redirected every write to the confirm-password screen.
A redirect cannot carry a POST body, and Redirector::guest() only
remembers the exact URL of a GET, so after confirming, the user landed
back on an empty form and the action never ran. On the API token forms
that meant typing the name, the scopes and the expiry again.

An Inertia request now gets a 423 marked X-Password-Confirmation. A
dialog mounted around every page catches it, asks for the password over
the current page, and sends the refused request again with the same data
and callbacks, so the form finishes as if nothing happened. The check
itself is still the framework's. Plain form posts and JSON clients are
answered as before, and accounts with no local password are offered a
way to set one, as the confirm screen does.
2026-09-21 18:05:54 -03:00
Jens 5902e7ab32 Report actual file storage and temporary upload capacity separately 2026-09-20 17:58:08 +02:00
Jens d3f213da16 Fix premature 413 responses for concurrent upload chunks 2026-09-20 16:46:00 +02:00
ignacionelson 60171799e7 Keep "No folder" inside the client's own folder, not the library root
Reported by binghuo. With per-client folders switched on, a client editing
one of their own files could choose "No folder" and the file left their
home for the root of the library — beside the staff folders, where the
administrator's own things are. The feature exists precisely to stop that
mess, and the editor was the one door still open to it.

Uploading resolves an absent folder to the client's home, and so does
creating a folder without naming a parent. The portal's file editor did
not, so the rule held on two paths out of three.

Now it holds on all three: update() resolves a null folder to the home
where the installation gives them one, and the editor stops offering "No
folder" at all in that case — there is no such place for this client — and
preselects their home for a file that has none.

An installation with the setting off is unchanged: no folder still means
no folder, because there every client's file sits at the root.
2026-09-20 10:44:25 -03:00
ignacionelson 849ec5f3e8 Release 2.5.0 2026-09-18 16:37:32 -03:00
ignacionelson 4905be8e32 Give the bucket folder only to the driver that reads it
Reported by @veenone (#1788). Setting "folder inside the bucket" on S3 or
an S3-compatible backend made every page that touches storage answer 500,
including the orphan-files screen. Reproduced against MinIO: the disk
resolved into

  Class "League\Flysystem\PathPrefixing\PathPrefixedAdapter" not found

One folder on the screen, two names underneath: Laravel's own drivers
read `root`, and this application's GCS driver reads `prefix` and builds
its adapter with it. Setting both looked like a harmless way to serve
both, and was not — FilesystemManager wraps any disk carrying a non-empty
`prefix` in that adapter, which lives in an optional package nobody
installs. So the key meant for GCS broke S3, and only once somebody set a
folder.

`prefix` now goes to the GCS driver alone, and both keys are written on
every apply rather than only when there is a folder — a process that
switched provider or cleared the field kept a stale one otherwise.

Verified against MinIO with a folder set: the orphans screen loads, and an
upload lands inside the folder rather than at the root of the bucket.
2026-09-18 15:08:42 -03:00
ignacionelson cf1cd3ab9a Say on the Microsoft screen why accounts still wait for approval
Reported by Ricardo Cazati, who ticked "create an account on first
sign-in" and "approve those accounts automatically", named his domain,
and then found every colleague's account sitting in the approval queue
with nothing anywhere saying why.

The rule is right and stays: an address the provider has not confirmed
goes to the queue whatever the box says, because inside one Entra tenant
a colleague can present somebody else's address. What was missing is that
Microsoft confirms nothing until `xms_edov` is added to the app
registration — an administrator configuring their own company directory
reads "verified" as already true of it.

The tenant-ID field mentioned that claim, but only for the half about
never attaching to an existing account. The other half — that this is
also why auto-approval does nothing — now sits under the auto-approve
checkbox itself, where the promise is made, and only when the three
settings that produce the surprise are all on.
2026-09-18 13:49:50 -03:00
ignacionelson 51035994ae Give a client the public link the switch already promised
Reported by Ricardo Cazati. A client with every file permission could
mark their own file public — and was then shown nothing. The screen said
"anyone with the link will be able to open and download it" while the
only route that makes a link was staff-only, so the link existed for
nobody. Version 1 could do this.

Making one now asks `upload_public`, the same key that lets them mark the
file public, and `update` on the file, which for a client means one they
uploaded and nothing else. Staff are not asked for the key, as they never
have been: `update` is their boundary and asking now would be a new
refusal on every installation that upgrades.

Revoking deliberately does not ask for the publishing key. It takes
access away, and somebody whose permission to publish was withdrawn must
still be able to undo what they published.

The portal's file editor grows the section the staff screen has, minus
what a client has no business setting: the link, a copy button, and
revoke.
2026-09-18 13:37:25 -03:00
ignacionelson ff9ad10742 Let an account that signs in through a provider set a password
Reported by Ricardo Cazati, who had to turn compulsory two-factor off to
get his colleagues working.

An account provisioned by a provider carries a generated password nobody
has ever seen. The password screen asked for the current one before it
would set a new one, so those accounts could never have a password of
their own — and enrolling in two-factor is behind a password
confirmation, so they could not enrol either. With
`TwoFactorEnforcement` set, the enforcement middleware sent them to
enrol, enrolling sent them to confirm a password they do not have, and
every other screen — including the one that would have given them one —
redirected back. No way in and no way out.

- The password screen asks for the current one only where there is one,
  and says "Set a password" otherwise. Setting it moves the account to
  `local`, the line NewPasswordController already writes when such an
  account resets its password: the hash is now what signs it in, and the
  settings screens read that off this column.
- An LDAP account is refused outright rather than handed a password that
  signs nothing in — its password lives in the directory.
- The enforcement middleware lets the password screen through, the way it
  already lets the confirm-password screen through, so the loop has an
  exit.
- The confirm-password screen offers to set one instead of asking for a
  password that does not exist.
2026-09-18 13:16:20 -03:00
ignacionelson 305c79bc96 Keep an invitation inside the sender's own client scope
Reported by @hackchang (GHSA-c6h9-hcm7-j3x9). GHSA-r3hg-3fxw-rcmr scoped
the group controllers; invitations were written afterwards and were not,
so the same reach was open through a different door.

A client-scoped staff member with `create_clients` could read every
group's id and name off the invitation form, name any of them on an
invitation, and have the invited client added to it at redemption — a
group whose files they cannot see and whose members are not theirs. The
ordinary way to do that, adding a member to a group, refuses on
StaffLibraryScope::allowsGroupMembership(); the invitation path never
asked.

Three places, because the hole had three halves:

- the form lists `$this->scope->groups($viewer)`, as GroupsController
  already does;
- the request is validated against those groups rather than every group
  there is, since a request need not come from the form;
- redemption asks allowsGroupMembership() of the invitation's sender
  before writing the membership.

The last one is the one that matters. An invitation is a grant that lands
days later, when the sender is not present to be checked, and the ones
written before today are still outstanding. A refused membership is
dropped and logged rather than failing the redemption: the account is
what the person holding the link came for, and it is theirs either way.
An invitation whose sender has since been deleted keeps its group — there
is no longer a reach to exceed.
2026-09-18 03:35:14 -03:00
ignacionelson 521927a3bb Note the public unscanned notice in 2.5.0, and date it today 2026-09-18 02:09:11 -03:00
Ignacio Nelson 8fdc8b0102 Merge pull request #1787 from projectsend/public-unscanned-notice
Tell whoever opens a public link that nothing checked the file
2026-09-18 02:08:57 -03:00
ignacionelson 7ce1e3487f Tell whoever opens a public link that nothing checked the file
An installation that scans can still let files through: too large for the
scanner, an archive it could not open, or an upload that arrived while the
scanner was down. Both policies default to letting those through, and the
count of them is on the settings screen and the dashboard.

Everyone could see that except the one person it matters to. The uploader
sees the state on their own file and staff see it in the library; whoever
follows a public link saw the page a file that passed gets, having neither
chosen the policy nor any way to see the setting. On the hosted free plan,
where every upload is published behind a link, that is the whole audience.

The share page and the public file page in all four themes now carry one
line: "This file was not checked for viruses." Said plainly and without
alarm — nothing is known to be wrong with the file; what is known is that
nothing looked.

Only where this installation scans, and only for the three reasons that
mean a scanner let something past. A file from before scanning was
switched on says nothing: on an installation that has only just switched
it on that is every file, and saying it about all of them says it about
none of them.
2026-09-18 01:58:36 -03:00
ignacionelson 419ecea3b0 Add the scanner's encrypted-archive check and file expiry dates to 2.5.0 2026-09-17 13:17:31 -03:00
ignacionelson 7762755a2d Write the 2.5.0 changelog entry
Seven features since 2.4.1, so the middle number moves: virus scanning,
client invitations, client account expiry, start pages, per-client folders,
the library filters and the missing-from-storage report. Three fixes to
behaviour that shipped in 2.4.1, plus a dependency advisory.

One line per item, which is the 2.4.1 shape rather than the paragraphs
older entries carry. Virus scanning is six lines rather than one long one --
splitting is how a big feature fits the format.

No "Important -- do these yourself" section, and that was checked rather
than assumed: virus scanning and per-client folders both default to off,
the three new scheduled commands run on their own, and the six migrations
need nothing from anybody. Upgrading leaves nothing working differently
from how you expect.

The date is today's. Whoever cuts the release restamps it if it lands on
another day.
2026-09-17 13:12:58 -03:00
Ignacio Nelson 62c2ddfdd3 Merge pull request #1785 from projectsend/shared-file-lifetime
Show clients when a file expires, and let a package speak on the upload page
2026-09-17 13:07:36 -03:00
ignacionelson 99c8c469c5 Show clients when a file expires, and let a package speak on the upload page
Client file rows in My files now carry expires_at, and all four portal
themes show "Available until <date>" beside the size, date and download
count. Before, the date was only on the file's edit screen, so a file
about to disappear gave no sign of it where the client looks for it.

The portal upload page dispatches ResolvingUploadNotice, handing the
uploader to listeners, and shows any lines they add above the uploader.
The upload page is one page for every theme, so this is the only place a
rule about uploads can be read before one happens; the announcement band
is drawn by one theme's shell and would miss the other three. With
nothing listening it shows nothing. First caller: cloud-modules, telling
free-plan customers how long uploads are kept.
2026-09-17 12:18:03 -03:00
ignacionelson 963e36397d Keep test tooling out of a production install in the migration guide
composer require and composer remove both update the rest of the
dependencies, and without --update-no-dev they install ProjectSend's
development and test tools too. The Docker section already said so; the
release-zip install and the removal step did not. The removal step also
now says how to run it on the Docker image.
2026-09-17 11:43:01 -03:00
ignacionelson ba99674cc7 Count files going out unscanned, not log entries about them
The dashboard warned that 62 files had been let through unscanned in the
last day. They were 62 activity log entries for 16 files, and none of
those files could be downloaded: 50 entries were for files now missing
from storage, and 12 for files since deleted. The count read the log, so
it counted a file once per attempt and kept counting it after it was
deleted, went missing or was scanned clean.

It now counts files in their current state: not scanned, let through
while the scanner was down or because it could not open them, with that
verdict in the last day. The settings screen already counted this way
without the time limit; the rule is one File scope used by the
dashboard, the settings screen and projectsend:status. The status key
keeps its name and meaning, and is now accurate.
2026-09-17 11:09:40 -03:00
ignacionelson 783536be40 Show a passing scanner test in green
A failed test was red and a passing one was a plain box, so the answer
somebody pressed the button for was the one that did not stand out.
2026-09-17 11:04:23 -03:00
ignacionelson 3b5352d886 Name EICAR in the scanner test, and say it is harmless
"Detected the test file" left it open what the file was, which reads
like ProjectSend carrying something malicious. The Test button's
description and both of its results now name EICAR and say it is a
harmless file made only for testing, as do DOCKER.md and INSTALL.md.
2026-09-17 11:01:18 -03:00
Ignacio Nelson 6e5edfa7ad Merge pull request #1784 from projectsend/virus-scanning-fixes
Close the gaps an end-to-end and security pass found in virus scanning
2026-09-17 03:08:51 -03:00
ignacionelson c15c9c48f8 Close the gaps an end-to-end and security pass found in virus scanning
Run against the dev stack with real ClamAV and queue workers, and a code
review looking for ways around the scanner.

Quarantine now stays quarantined until somebody releases the file. A
rescan only touches files people can download, and changes nothing when
the scanner cannot answer or scanning is off. Before, an old infected file
rescanned while clamd restarted went through the "allow" policy and became
downloadable. The daily missing-files check leaves quarantined files alone,
so a storage outage no longer brings one back as a fresh upload.

A file longer than clamd's StreamMaxLength is "too large" again. clamd
answers and hangs up; the next write raised a warning that became an
exception before the answer was read, so the file was recorded as
"scanner down" and retried past the unscannable policy.

The production compose example gives clamd the settings it needs. On its
own defaults an encrypted zip comes back clean. The Test button now sends a
password-protected zip and fails when it is called clean, and says when an
address answers but is not ClamAV.

Saving the settings restarts the queue workers, which kept the old values
in memory. New scan runs --all, as its name says, and is refused while
scans are queued. A retry scheduled for later no longer counts as a scan
in progress.

Also: quarantine respects client scope for listing, release and
notifications; a zip built before a file was quarantined is refused;
public comments and version links skip unavailable files; a client no
longer sees their own quarantined or missing upload; a file whose bytes
return is scanned at once; clamd listens on IPv6 too, so its container
health check passes.
2026-09-17 02:48:03 -03:00
ignacionelson 5e6b792104 Translate the 155 strings that have accumulated since 2.4.1
Virus scanning and the missing-from-storage report account for most of
them; the rest are the library filters and the per-client folder setting.
Every locale was at 0 missing before this and is at 0 missing again.

Notes for whoever reads this next:

- The Slavic locales keep the `Label: :count` shape rather than `:count
  plików`, because Polish, Czech and Russian inflect the noun according to
  the number in front of it and no single form is right for every value.
  That is the existing convention in those files, not a new one.
- "Scanner" is now counted as untranslated in German and Dutch. It is the
  correct word in both, the same way API and OK are elsewhere.
- Russian stays on the formal вы/ваш the rest of that catalogue uses --
  152 entries to 0 before this, and unchanged after.

Checked in a browser with the locale switched, not only in the file: the
client settings screen reads correctly in Spanish and the long
folder-setting description fits its column. A catalogue that parses is not
evidence that a sentence reads well.
2026-09-17 01:07:47 -03:00
ignacionelson 616a355d54 Give each client a folder of their own, standing in for the root
A client who may create folders creates them at the top of the library,
beside the ones staff made, and their uploads land at the root too. An
administrator opening /files gets one flat pile with nothing saying which
parts belong to whom.

With the new "Give each client a folder of their own" setting, every new
client gets a folder named after them and it acts as their root: what they
upload and any folder they create goes inside it. /files becomes a list of
clients rather than a pile.

The sentence this feature has to keep true: **the home is a default
location, not a boundary.** Folder::scopeVisibleToClient is untouched, so a
folder staff shared with a client still reaches them and sits beside their
own. Making the home a jail would have silently revoked every share that
already exists -- a data-access change wearing the clothes of a tidying-up
feature. There is a test named after that rule.

What the client sees is the *inside* of their folder, not a folder wearing
their own name, which is not information to them. The breadcrumb is trimmed
of it for the same reason: "Invoices", not "Acme Ltd / Invoices".

Some decisions worth naming:

- **A column, not a convention.** `folders.home_for_user_id`, unique.
  Matching on the name breaks the moment two clients share one, and
  `created_by` plus a null parent catches every root folder a client ever
  made themselves. The question is asked on each upload and each portal
  listing and the answer has to be exact.
- **created_by is the client**, because that is how scopeVisibleToClient
  already grants somebody their own folder -- no assignment row to keep in
  step with it. That is also why this writes the row rather than calling
  FolderService::create(), which takes created_by from auth()->id().
- **On model events**, not in the services that make and rename clients.
  There are nine of those (ClientAccounts, ClientProvisioning, the profile
  screen, two update endpoints, AccountConversion, invitations, LDAP,
  social) and a rule repeated in nine places is missing from the tenth.
- **Turning the setting on creates nothing.** Existing clients get a folder
  when an administrator presses a button that says how many are waiting,
  and it reports created/total/already-had afterwards. Somebody should be
  able to switch this on, look, and switch it off without having
  reorganised a library. It moves no files either.
- **Nobody deletes a home from a folder screen**, staff included, and the
  client cannot rename theirs -- they own it, so ownership alone would have
  let them, and its name follows the account anyway.
- **The name always follows the client**, over a hand-typed one. A folder
  still called "Acme Ltd" under an account now called something else
  misleads the administrator the feature exists for.

Verified in a real browser as well as in tests: the screen mounts, the
panel reads "24 of your existing clients have no folder yet", and pressing
the button answers "24 of 24 clients got a folder. 0 already had one."
2026-09-17 00:22:29 -03:00
Ignacio Nelson 044afe5fcb Merge pull request #1783 from projectsend/virus-scanning
Scan uploaded files for viruses
2026-09-17 00:01:47 -03:00
ignacionelson a255a883a8 Merge remote-tracking branch 'origin/main' into virus-scanning
# Conflicts:
#	tests/Feature/Platform/SchedulerMonitoringTest.php
2026-09-16 23:57:19 -03:00
ignacionelson 5f7e3089eb Stop offering a file nobody can have
A quarantined file was listed in the library with every button a working
file has, and Download answered with an error page. Three changes, all
the same idea: do not offer what cannot be done.

The library no longer lists a file that is quarantined or missing from
storage. Those two live on the screens that exist to act on them —
Quarantine, and Files missing from storage — and both now link each row
to the file itself, which is where somebody deciding needs to look.

That page says why, at the top, in the colour the state deserves: red
for a threat, amber for bytes that are gone. And it stops offering the
download and the preview, because a button that answers 423 is not an
affordance.

A file still being checked stays in the library. It is about to be
usable, and its uploader should be able to see where it went.
2026-09-16 23:51:33 -03:00
ignacionelson 7119435c3c Make "New scan" mean a new scan, and colour a result by what it is
The button was disabled on a library that had already been scanned
once, which is most of the time and exactly when somebody would press
it — after updating definitions, say. It now re-checks everything
rather than only what was never looked at, which is what its name says.
A rescan keeps each file available until its new verdict arrives, so a
full pass takes nothing offline. The two states it skips are a file
already waiting for its first verdict and one whose bytes are gone.

It is disabled for two honest reasons now — scanning is off, or a scan
is already running — and says which.

Results are green, amber and red: checked and fine, checked and could
not be read, checked and something was found. The badge gained a
warning variant to say the middle one, matching the amber the warning
alert already uses; before this a missing file wore the same red as a
virus.

A file found missing is also stamped with the time it was checked, so
it appears in the Activity list. It is a verdict like any other, and
without the stamp it was decided somewhere nobody could see.
2026-09-16 23:20:34 -03:00
ignacionelson 25b92c086b Refuse a scanner address that only looks like one
tcp://clamav:3310djlkasjdlk connected happily. PHP reads a port the way
atoi does — the digits at the front, the rest ignored — so an address
with a typo on the end was saved, tested, and reported as working, while
tcp://clamav:33101 went somewhere else and failed. The feedback an
operator got had nothing to do with the mistake they made.

ScannerAddress says what an address is: tcp:// with a host and a port of
1 to 65535 and nothing after it, or unix:// with an absolute path. It is
asked in all three places an address arrives — saving, testing, and
connecting. The third matters because a managed address comes from the
environment and never passes the screen.

The answer names the problem rather than reporting "no answer", which
would be true of any unreachable scanner and would send somebody to look
at their network for a typo.

Checked in a browser with both addresses from the report: each is now
refused on Test and on Save, with the same sentence, and
tcp://clamav:3310 still comes back "Working. ClamAV 1.5.4 detected the
test file".
2026-09-16 23:09:24 -03:00
ignacionelson afb4c2c6d4 Test the address on screen, and count the quarantine in the sidebar
The Test button asked the scanner on file, which makes it useless at
the moment it is most needed: the first attempt, before anything has
been saved. It now tries what is typed, falling back to the stored
address when the field is empty so the button still answers on a screen
nobody has touched. Nothing is written either way — testing is not
saving.

The address travels as a request field and is applied to the request's
own ScanningConfig, which is scoped so the screen and the scanner it
resolves share one. A preview address beats even a managed one, and is
set in exactly one place.

Quarantine now carries a count in the sidebar, like Comments — in amber
rather than the usual colour, because the others count work waiting and
this one counts something that went wrong. Shown only to whoever holds
the permission to act on it.

Both checked in a browser: an address typed and not saved came back
"No answer from tcp://escrito-a-mano.invalid:3310", the stored one
untouched, and the badge renders amber with the real count.
2026-09-16 22:57:58 -03:00
ignacionelson 0b36cf2c38 Put the connection test under the address it tests
It sat above the form, which read as a box about the screen rather than
about the field. It belongs under "Scanner address" and above Save,
where it is plainly the answer to "is this address right?".

The box is now shown only where there is something to test — an
installation that connects its own scanner. On a hosted one the endpoint
answers 403, and a button that leads to a refusal is worse than no
button. That is a separate prop from `managed`, which covers two
different reasons the address is not editable: a scanner named in the
environment still has a connection worth testing.
2026-09-16 22:54:24 -03:00
ignacionelson d445b01dd4 Make starting a scan a button in the header, and drop the box it lived in
"New scan" sits beside Quarantine as the screen's one action, in the
primary colour, on every tab. The "Files already here" box it replaces
is gone: it held an action under a Save button, a counter that the
Activity tab now shows live, and a field that belongs with the other
settings, which is where it is now.

The button says why when it cannot be pressed — scanning is off, every
file has already been checked, or a scan is already running — rather
than sitting grey with no explanation. It refuses a second scan while
one is working through the queue, which is a thing somebody would
otherwise do by clicking twice.

Starting one lands on the Activity tab. A button whose screen looks
unchanged afterwards reads as a button that did nothing, and this one
has somewhere worth looking.

Checked against a real ClamAV: 42 files queued, 31 came back clean, the
rest were the ones whose bytes are missing. The button then disabled
itself, because there was nothing left to scan.
2026-09-16 22:52:08 -03:00
ignacionelson f937b4398d Tell a missing file apart from a missing scanner, and do something about it
A row whose bytes are gone was recorded as "the scanner could not be
reached". Wrong on screen, and wrong underneath: that is the one reason
the hourly sweep re-queues, so every orphaned row would have been
rescanned hourly forever.

It is its own state now, `missing`, and withheld rather than offered:
a client who sees a file listed and gets an error on the download is
worse off than one who never saw it. Staff still see it, marked, which
is the point — somebody has to decide what to do about it. The refusal
says what it is ("no longer on the server") instead of sending somebody
looking for a permission that would let them through.

A daily `projectsend:check-missing-files` finds them, whether or not
this installation scans for viruses: it is not a virus question, and an
installation with no scanner has exactly the same problem. It compares
one disk listing against the rows rather than asking "does this exist?"
per file, which on object storage would be a request per file per day.
Files that come back — a remount, a restored backup — are picked up on
the next run and re-checked rather than left for dead.

They are listed beside the orphans, which is the same fault seen from
the other end: bytes with no row, rows with no bytes. The tab carries
the count, each row says where the file should be, and removing one
takes the record with it through the deletion that already exists.

The dashboard says how many there are, and so does
`projectsend:status`, because a fleet-wide jump in this is a storage
fault nothing else in that document would show.
2026-09-16 22:46:21 -03:00
ignacionelson dc0937fda1 Add an Activity tab that shows a scan as it happens
A backfill runs for minutes or hours inside a queue worker, where none
of it is visible. The third tab polls every four seconds and says what
is happening: whether anything is running, how many uploads are held,
how deep the queue is, how many files were checked in the last hour,
and the last twenty verdicts with what each one was. When nothing is
running, that same list is the record of the last run, which is what
somebody opening the tab after the fact came for.

Two things the live screen found that the tests had not:

**A backfill read as "nothing is being scanned."** Re-scanning a file
that already went out unchecked deliberately leaves it available, so it
is never "pending" — and the screen counted only pending files. It
counts the scans queue too, and the two are shown separately, because
"an upload nobody can download yet" and "work the scanner has not
reached" are different facts.

**A file whose bytes are missing was recorded as "the scanner could not
be reached."** Wrong on screen, and worse than wrong in behaviour: that
is the one reason the hourly sweep re-queues, so every orphaned row
would have been rescanned every hour forever. It has its own reason
now, and goes through the same policy as a file the scanner could not
open.

Both tabs also gained the header shortcut to Quarantine, and Quarantine
one back to the settings, each shown only to somebody the destination
will actually let in.
2026-09-16 20:18:37 -03:00
ignacionelson c2039d9608 Find the files a real upgrade leaves behind
"Scan existing files" sat disabled on an installation with a library of
144 of them, and the hourly backfill would have found none either. Both
looked for `scan_note = 'before_scanning'`, and no file on any upgraded
installation carries it: the migration gives `scan_status` its default
and writes no note, and the v1 import inserts rows the same way. The
feature was inert on exactly the libraries it exists for.

A file with no reason beside its "not scanned" is now what it plainly
is — one nothing has ever looked at — through File::neverScanned(),
which the backfill, the counts and the badge all ask. Reported as
"Uploaded before virus scanning was switched on" rather than as a bare
"Not scanned", which is the one badge somebody would have had to come
and ask about.

Found on the real screen, not by a test. The tests now cover the shape
an upgrade actually produces.
2026-09-16 20:11:08 -03:00
ignacionelson da79969435 Put virus scanning on the System card as a line, not only as a warning
"Uploads checked by: ClamAV 1.5.4" now sits beside "Downloads sent by"
and "Files stored on", and is always there. Same reasoning those two
already carry: being able to confirm at a glance that uploads are
checked is worth as much as being told when they are not.

Four states in one row. A working scanner is named. One that is not
answering says so. One letting files through is amber. No scanner at
all reads "Nothing", amber, and links to the screen that sets it up —
which is where the "turn it on" link now lives, so the big alert above
is left to the cases where a configured scanner is misbehaving. Absent
entirely where the scanner is not this installation's to connect.

Also fixes a line the dashboard itself exposed: the activity log read
'The file "" was quarantined'. The scan job has no actor and attaches no
subject, so those two templates have to take the name from their
context, not from :subject. There is a test now, which there was not
before, because a real screen caught it and a green suite did not.
2026-09-16 15:41:47 -03:00
ignacionelson 85b1650ef0 Tell a self-hosted installation when nothing is checking its uploads
The dashboard's System card now says so when no scanner is configured
at all, not only when a configured one is failing: "Anything uploaded
here — by staff, by clients, or through an upload link — is passed on
unchecked", with a link to set it up.

Said only where somebody can act on it. Connecting a scanner is a new
capability, scanning.connect, community only — on a hosted installation
the scanner is infrastructure the platform runs, so its address is not a
tenant's to set and its absence is not a tenant's to fix. The two
policies stay on both editions, because what to do with a file nobody
could scan is a decision about somebody's own files. An edition
difference through the registry, never an edition check.

Also: PROJECTSEND_SCANNER_DEFAULT_ADDRESS, seeded into the settings on
first boot by the command that already does this for two-factor
enforcement. It is the opposite of PROJECTSEND_SCANNER_ADDRESS — a
starting value rather than a policy, so a Docker install that brings up
the optional scanner container arrives configured while the address and
the switch stay on the settings screen. Both are seeded together or
neither: an address with scanning off would look configured and check
nothing.

Nothing changes for an existing installation on upgrade: scanning stays
off, existing files are marked "never scanned", and the scanner
container is still opt-in.
2026-09-16 15:34:49 -03:00
ignacionelson f2a7bbb182 Split the virus scanning screen in two, and make the Test button answer
The Test button did nothing visible. The page read `scanner_test_result`
off the shared props, and HandleInertiaRequests shares `success` and
`error` and nothing else — so the answer was set on the session and
never arrived. It is read in the controller and handed over as a prop
now, the way the CAPTCHA screen does it.

The screen is two tabs, Scanner and Options, following the scheduler's
`?tab=` links. Scanner holds the connection and the Test button; Options
holds the policies and the backfill. Both end with their Save, and
nothing sits below it — before this, "Files already here" and its button
were stranded under the Save button of a form they had nothing to do
with.

Verified by clicking the real button in a browser against a real ClamAV:
"Working. ClamAV 1.5.4 detected the test file as Eicar-Test-Signature."
2026-09-16 15:08:41 -03:00
ignacionelson 73d5a5f8e4 Record which engine and definitions reached each verdict
`scan_engine` was always null: the column existed, the client never
filled it. Found by running a real ClamAV against a real upload rather
than by a test, since the fake scanner reports whatever it is told.

Asked once per scanner instance — so once per queue job, and the worker
is recycled hourly — rather than on every scan, which would double the
connections to answer a question that changes daily.
2026-09-16 15:02:54 -03:00
ignacionelson c0494c6b4b Explain virus scanning in both install guides
Docker gets the profile command, the memory it needs, and why the first
start is slow. A manual install gets the packages, the three clamd
settings without which an unopenable file comes back clean, and where
the socket usually lives. Both point at the Test button, because
"connected" and "detecting" are different answers.
2026-09-16 14:46:19 -03:00
ignacionelson 5493955bea Show staff where a file stands, and say the same through the API
Staff keep seeing every file they always saw — withholding is about
recipients, not about the library — so the library now carries the state
on the row: Checking, Quarantined, Released, or Not scanned with the
reason behind it. Nothing at all for a clean file, which is the common
case.

The API says the same in a `scan` object on every file, with an
`available` flag so a caller need not learn which of six states mean
"you can have it", and `scan_status` is a filter, so an integration can
wait for the file it just uploaded or collect what is in quarantine.
The download endpoint answers 423 for a file that is not available,
which it already did through the shared controller.

Re-exported the OpenAPI document.
2026-09-16 14:45:13 -03:00
ignacionelson 0ae3f3d0f0 Hold the "shared with you" email until the file can actually be had
Sharing a file that is still being checked writes the assignment and
says nothing. The announcement goes out when the file becomes
available — a clean scan, a file let through while the scanner was
down, or an administrator releasing it from quarantine — so nobody is
ever sent to a page that refuses them, and a file about to be
quarantined is not announced to everyone before anybody knows.

Recipients are derived from the assignments as they stand at that
moment, not remembered from the moment of sharing: a share taken back
in the meantime produces no email, and one added does. New-version
notices ride the same path, which they had to anyway — the audience
rule re-checks visibility, and a file being scanned is not visible.

Two bugs found while writing the tests, both in the hourly command:

Re-queuing a file marked it pending first. Pending means withheld, so
running --existing over a library that predates scanning would have
hidden every file in it from every client for as long as the backfill
ran, and then announced each one to its recipients a second time when
it came back. The job now knows which state it expects instead, and a
rescan leaves the file downloadable until a verdict actually arrives.
2026-09-16 14:41:59 -03:00
ignacionelson d11bda094b Say out loud when scanning has quietly stopped protecting anything
The defaults let files through when the scanner cannot answer, so an
installation whose scanner died looks, from every screen anybody uses,
exactly like one that is working. Three places now say otherwise.

`projectsend:status` gains a `scanning` block: whether it is on, whether
it is managed, whether the scanner answers right now, the engine and
how old its definitions are, what is waiting, what is quarantined, and
how many files went out unscanned in the last 24 hours. Absent, null and
zero stay distinct — `reachable: null` means there is nothing to reach,
`false` means it should be answering and is not. The scans queue is
reported beside the other two.

The dashboard's System card carries the same warning for whoever is
actually looking at a screen, and says nothing at all while scanning is
healthy or switched off.

Docker gets the scanner as an opt-in profile — `--profile scanner` — in
both the development compose file and the published example, with a
clamd.conf whose Alert* options are what make an encrypted archive come
back as "could not scan" instead of "OK". No published ports: clamd has
no authentication and the file crosses that socket in the clear. Both
images also run a worker for the scans queue.

The dashboard test caught a 500 before it shipped: a nullable return
written as `array`.
2026-09-16 14:39:06 -03:00
ignacionelson b6b777e42f Add the virus scanning settings screen, with a button that proves it works
Settings → Virus scanning: switch it on, point it at a ClamAV daemon,
choose the two policies, and see how many files are waiting, in
quarantine, or were let through unscanned. Switching it on with no
address is refused rather than saved and left inert.

The Test button is three answers, not one. Unreachable is obvious.
Reachable but detecting nothing is the failure that looks like success
— empty or broken virus definitions — so the test sends the EICAR
string and reports "it found the test file", never "it did not
complain". The string is assembled at runtime so no checkout contains
it: antivirus software on a developer's machine quarantines files that
do.

"Scan existing files" queues the library that predates scanning,
through the hourly command so no request is held open, paced by a
setting so it does not starve today's uploads.

Where the environment names a scanner, the connection and the on/off
switch leave the screen and scanning cannot be turned off — the same
managed shape the CAPTCHA screen has. The two policies stay editable,
because what to do with a file nobody could scan is a decision about
somebody's own files.
2026-09-16 14:34:09 -03:00
Ignacio Nelson 7944eccaf1 Merge pull request #1782 from projectsend/client-expiry-and-start-page
Client accounts that expire, a start page per role and person, and five more file filters
2026-09-16 14:30:14 -03:00
ignacionelson e9496dc357 Give quarantined files a screen, an owner, and somebody to tell
An infected file now goes somewhere rather than nowhere. Staff holding
the new release_quarantined_files permission get a Quarantine screen
listing what was refused, who uploaded it, and what the scanner called
it. They can delete it as they always could, or release it — which
needs a written reason, a password confirmation on top of the
permission, and lands in the activity log under their name.

Only the administrator role holds that permission by default. Deciding
a threat report is wrong is a different judgement from deciding a file
is no longer needed, which is why it is not delete_files.

Two notifications, two audiences: staff who can act on it, and the
person who uploaded it — for whom this is how they learn their own
machine has something on it. The people the file was shared with are
deliberately not told about a file they never received.

`projectsend:scan-files` runs hourly: it re-queues files still waiting,
and re-scans the ones that went out unscanned while the scanner was
unreachable, since it may be back. With --existing it also works
through a library uploaded before scanning was switched on, paced by a
setting so it does not starve today's uploads.

A file that was downloadable before it was caught says so on the
screen, with its download count, because that is the case where
somebody may already have a copy.
2026-09-16 14:29:41 -03:00
ignacionelson bab90c0ad8 Scan uploaded files for viruses, and withhold them until they are checked
Every upload now starts as "being checked" and is not served to anyone
until a scanner has looked at it. Infected files are quarantined: kept
on disk, unreachable, waiting for an administrator.

The scanner is ClamAV, reached over a socket, streaming the file
wherever it is stored — no temporary copy for an S3 or GCS disk. What
the scanner answers is a fact; what it means for the file is this
installation's setting, so ClamAvScanner knows nothing about settings
and ScanPolicy knows nothing about sockets. Three of clamd's own alert
options are what make a file it could not open come back as an answer
rather than as "OK"; the client maps those to "too large" and
"encrypted" instead of to a threat.

Both policies default to letting files through, marked "not scanned",
which is the product owner's decision: a scanner that cannot answer must
not stop people working. Every such file is logged, and the screens that
say so come with the rest of this work.

Withholding is two rules. A file that is not available drops out of the
scopes that answer "what may this person see" — recipients and the
public listings, never the uploader's own copy. And every route that
puts bytes on the wire asks FileAvailability first: download, thumbnail,
preview, share link, the four public routes and both ends of a zip
build. A share link minted before the scan finishes says the file is
still being checked rather than 404ing.

Not yet here, and coming next: the quarantine screen and its permission,
the notifications, the settings screen, the hourly retry, the backfill
for existing libraries, and the Docker service.
2026-09-16 14:23:56 -03:00
ignacionelson 5f414c7a4a Give the API the same file filters the library screen has
The staff library grew filters for uploader, role, public/private, download
count and version. Two of those already existed on /api/v1/files
(`uploaded_by`, `public`); the other four did not, so an integration could
not ask what the screen asks.

Adds `role_id`, `downloads=none|any`, `version=current|outdated` and
`visibility=public|private`.

`visibility` rather than changing `public`, and that is the decision worth
explaining. `public` has always tested the file's own column, and callers
depend on that answer; changing what an existing filter means is breaking
for everyone already sending it, however much better the new meaning is. So
`public` is untouched and `visibility` is added beside it with the
application's own definition -- File::isEffectivelyPublic(), the flag or a
public folder anywhere above the file -- which is what the badge on a staff
row means. The guide says in a sentence which to reach for. Point the
visibility filter at the column instead and the test that separates them
fails, which is the whole point of having both.

That predicate now lives once, as File::scopeEffectivelyPublic(), beside the
isEffectivelyPublic() it has to agree with. It was a private helper on
FoldersController until a second surface wanted it.

`role_id` deliberately carries no identity guard, unlike `uploaded_by` beside
it. A role names nobody: the files in the result are ones the caller may
already read, and learning one came from somebody holding the Client role
narrows to a set they could have guessed. `uploaded_by` is different in kind
-- a non-empty answer confirms exactly the identity the response is
redacting -- which is why only it is guarded. The reasoning is in the code,
because an absent guard sitting next to a present one is the kind of thing a
reader should not have to re-derive.

Tests cover each filter, the public/visibility split, and the client-scoped
negative: every new filter still returns nothing outside the token's own
library, because a filter narrows a library and never widens one.
2026-09-16 14:11:06 -03:00
ignacionelson 5966d22f50 Add five ways to narrow the file library
Search and the category dropdown were the whole filter bar, which is thin
for a library of any size. It now also narrows by:

- who uploaded the file, and separately by the role they hold
- public or private
- never downloaded, or downloaded at least once
- current version, or outdated

Each one forces the same flat, whole-library view search already used, and
they combine.

Two decisions worth naming.

"Public" means what the badge on the row means -- File::isEffectivelyPublic(),
the file's own flag or a public folder anywhere above it. Filtering on the
`public` column alone would have hidden files this very screen labels
Public, which is a filter arguing with the list it filters. The private half
needs its own null branch, because `folder_id NOT IN (...)` is never true for
a NULL folder_id: without it a file at the library root belonged to neither
half and vanished from both. Removing that branch turns the private filter
from one row to zero, which is the test.

The uploader filter carries the same guard /api/v1/files puts on
`uploaded_by`. fileRow() already withholds an uploader's name from a viewer
who may not identify them, so answering this filter plainly would have handed
the same identity straight back as a row count. An id the caller may not
identify now matches nothing, which is indistinguishable from someone who
uploaded nothing, and the dropdown is built through filterClientPairs so it
never offers the name either. Without the guard the scoped-staff test gets
its stranger's file back.

"Outdated" rather than "superseded" throughout, because that is the word the
version badge already uses and the two should not disagree. A file nothing
has replaced counts as current, including one never versioned at all.

The ids are cast out of the validated input: `integer` validates "5" without
converting it, and permitsClientId() takes a strict ?int.
2026-09-16 13:04:10 -03:00
ignacionelson 9b99972a1a Update js-yaml to 4.3.2 to close a Dependabot alert
js-yaml 4.3.1 had a high-severity advisory: its limit on YAML merge
keys did not bound CPU use when the merge sources were empty. It is
only here as a dependency of ESLint's config loader, so it was never
part of a build or a release. ESLint's range (^4.3.0) already allowed
the patched version, so only the lockfile changes.
2026-09-13 16:25:41 -03:00
ignacionelson 2768c87b27 Widen the start page picker so a translated default fits
"Predeterminado (Panel de control)" was cut off in the old fixed width.
The picker now takes the column's width, up to a readable maximum, and
still fits a phone screen.
2026-09-13 15:38:11 -03:00
ignacionelson b0a95f953d Translate the fourteen strings client expiry and start pages added
Sixteen locales, fourteen strings each: the expiry field and its two
hints, the expired badge text, the sign-in refusal, the scheduler label,
two activity-log entries, and the start page picker with its three hints
and the role permission error.

Wording follows each catalogue rather than being chosen fresh: every
locale already had its own words for expired, deactivated, sign in and
default, and these reuse them. Formality follows each file too. Every
:placeholder was checked to survive in every locale.
2026-09-13 15:37:21 -03:00
ignacionelson 3917cb2af3 Stop a file expiry date sent as a number from 500ing
Laravel's `date` rule accepts a JSON number when it reads as a real day
(20301231 passes) and hands it on unconverted. Every file expiry field
then passes it to a method that only takes a string, so the request
failed with a 500 instead of a validation error.

Affected: PATCH /api/v1/files/{file}, the staff file editor, the bulk
editor, new share links and the client file editor. Each now also
requires `string`, so a number is a 422 on `expires_at`. Dates sent as
text behave exactly as before. A form never sent a number, so this was
only reachable with a hand-written JSON body.
2026-09-13 15:34:05 -03:00
ignacionelson 495f3ae471 Let each role, and each person, choose where they land after signing in
A role now has a start page: the dashboard, files, upload, groups,
clients or the activity log (the last two for staff only). Anyone can
override their role's choice in their profile. The administrator role
takes a start page too, while everything else about it stays locked.

A choice is only used if the account can open that page now. Otherwise
the next one down is tried, ending at the dashboard, so a permission
removed later never lands somebody on a 403. A role cannot be saved
with a start page its own permissions block. A link followed before
signing in still wins, and a waiting getting-started or what's-new page
still goes first.

Applies to password, two-factor and provider sign-ins, and to the site
root for someone already signed in. StartPageTest opens every page for
real, with and without its permission, so the enum cannot drift from
the routes.

Requested by @Zodiac1978 in #1777.
2026-09-13 15:05:40 -03:00
ignacionelson c21658f6f7 Let a client account expire on a date
Staff can give a client an expiry date on the create and edit screens,
and through /api/v1/clients. When the date passes, the client is refused
at sign-in and on their next request, and their API access ends too.
Files and history stay, and a later date (or none) brings them back.

Access is checked through one predicate, User::maySignIn(), at every
door: sign-in, the web session, API tokens and the two-factor
challenge. An hourly sweep also switches `active` off, so the list,
its filter and seat counts agree. The sweep is not what enforces it,
so a scheduler that is not running cannot keep an account open.

An account cannot be active with a date that has passed. Reactivating
an expired client needs a new date in the same save.

The day-means-end-of-day-where-you-are rule moved out of FileExpiry
into a shared DateInput, so file and account expiry read dates the
same way.

Requested by @Drardollan in #1310.
2026-09-13 14:57:16 -03:00
ignacionelson 7c7ba7cd53 Stop a typed-in storage quota from 500ing when a client is created
Filling the "Storage quota (MB)" field on the new-client form raised a
TypeError and the request died with a 500. Leaving it blank worked, which
is why it reached a release: that path goes through `null ?? 0`, and the
0 is an int.

The `integer` validation rule checks that a value looks like an integer.
It does not convert it. `$request->validate()` returns the raw input, so
the form field arrives as the string "2048" -- and the create form types
that field as a string in React, so it is a string even over JSON. Both
controllers declare strict_types, so handing it to
`ClientAccounts::create()`'s `int $storageQuotaMb` is a TypeError.

Fixed on both surfaces that call create(): the staff screen and
/api/v1/clients. The API twin had the same defect, reachable by sending
the quota as a quoted JSON value or a form-encoded body -- its own create
test only ever sent a JSON number.

Two more call sites had the same shape and are cast too, though nothing
sends them a string today: the share-link download cap and a comment's
reply_to. Both are safe only because a frontend file happens to call
Number() first, which is a fact about that file rather than anything the
signature guarantees. The null in each is preserved rather than collapsed
to 0 -- "no cap" is not a cap of zero.

`storage_quota_mb` is also cast on User and Invitation. The column is an
unsignedInteger and both docblocks already promise int; it is read
straight into provision()'s typed parameter when an invitation is
redeemed, and which type a driver hands back is not something that call
site should depend on.

Found on the new files-test rehearsal instance, on its first real use,
against the same build the whole fleet is running.
2026-09-12 21:16:49 -03:00
ignacionelson f06a3c7ab3 Put a new client account in front of the staff who administer clients
Invitations produced no in-app notification at all, and neither did
self-registration: the whole Clients module raised none. The only
admin-facing signal when an account appeared was an email to whatever raw
addresses an operator typed into a setting -- addresses that need not
correspond to any account in this installation, and that plenty of
installations never fill in. An invitation could be accepted and nobody
signed in would ever be told.

So: one new type, client_registered, reaching the bell and /notifications.
One type for both doors on purpose. A client arriving through the public
form and one arriving through an invitation are the same event to the
person being told -- an account now exists that did not -- and a second
type would buy nothing, because preferences here govern email only, so it
could not have been switched off separately anyway. Which door it came
through is one click away in the activity log and on the invitations
screen.

In-app only, the reasoning client_uploaded already states: email for this
event is sent separately to that address list, and routing it through
Notifier's mail dispatch too would risk double-emailing any staff member
who is also on it.

Two things worth stating about who gets it. Recipients are resolved at the
call site, because Notifier authorizes nothing by design -- its security
contract is explicit that a broad query must never be handed to it. And a
client-scoped staff member is deliberately not told: their whole view is
the clients assigned to them, and a brand-new account is assigned to
nobody, so it would link them to a screen they are refused.

Which is also why the notification links to the clients list filtered to
the address, and not to clients.edit: that route is gated by edit_clients
while these recipients are chosen by manage_clients. A notification that
refuses the person it was sent to is worse than one that lands a click
short.

Translated in all sixteen locales, and the redemption was driven through a
real browser to see the row arrive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
2026-09-12 17:34:47 -03:00
ignacionelson 123ae68972 Give invitations their own place in the navigation
The "Invite client" button led to a history, which is not what it says. The
tabs were a way of housing two things that had nowhere else to live, and
now they do: Invitations is a sidebar entry between Custom fields and
Groups, and the button goes to the form.

That is also the shape every other list in this application already has --
Clients, Groups, Categories, Roles all sit in the sidebar with a "New X"
button leading to their own create screen -- so the tabs were the odd one
out rather than the pattern. Two URLs, each meaning one thing:
/clients/invitations is the history, /clients/invitations/create is the
form. Sending now returns to the history, where the invitation just sent
is the first row.

No badge on the sidebar entry, deliberately, unlike the two queues below
it. Account requests and Membership requests count things waiting on
somebody here; an outstanding invitation is waiting on the person who was
invited. A number there would say "you have three things to do" about
three things nobody in this installation can act on.

Translations move with it: "History (:count pending)" was the tab label and
is gone from all sixteen, and "Invite a client to share files with" comes
back -- it was the form's description before the tabs took the heading, and
had never been translated because it left the code in the same commit that
would have reported it missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
2026-09-12 17:22:31 -03:00
ignacionelson 1577862099 Translate the forty-one strings the invitation work added
Sixteen locales, forty-one strings each: the invite screen and its
history, the redemption and expired-link pages, the invitation email, the
expiry setting, and the four new activity-log entries.

Two English strings were fixed before translating rather than after.
"Waiting" was a second word for what this application has always called
Pending -- account requests and membership requests both use it -- and a
synonym is much harder to take back once it exists in sixteen files. And
the submit button said "Send invitation" while the tab beside it said
"Send an invitation", which is a distinction a translator has to stop and
puzzle over to discover there isn't one. Both now reuse what was already
there, which is also why this pass is forty-one strings rather than
forty-three.

Terminology was read off each catalogue rather than chosen. Every locale
already had its own words for client, group, expired, revoke and status,
and these strings reuse them exactly, so a badge in the new table reads in
the same vocabulary as the filter above it. Formality follows each file
too -- informal in ca, es, it, nl, pl and zh_CN, formal in cs, de, fr, id,
pt_BR, ru and tr -- which for Polish meant following the catalogue rather
than the note in the skill, since its existing strings say "Cześć" and
"Twoje konto".

The numeral trap caught one for real, and only because the screen was
looked at: "Historial (1 pendientes)". Spanish, Catalan and Portuguese
inflect that adjective with the number, so those three now use the
"label: :count" shape the Slavic locales already use, and Swahili follows
for the same reason. French, German, Dutch, Italian, Turkish, Indonesian
and Vietnamese keep the natural word order because their word does not
move.

Verified by rendering the screen in Spanish, German, Japanese and Polish
against real rows in all five states -- nothing overflows, and the German
column headers wrap rather than collide. Enabling every locale to do that
meant changing a live setting: it was ["en","es"] and is ["en","es"] again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
2026-09-12 16:47:29 -03:00
ignacionelson fb3fd1766c Turn the second tab into a history of every invitation, and open on it
The tab listed only what was still live, which cannot answer the question
somebody actually arrives with: did we ever invite this person, and what
happened? An invitation that was accepted, revoked or replaced by a newer
one simply vanished from the screen, and the activity log was the only
place left to look.

So the tab is a history now -- every invitation ever sent, newest first,
each carrying the state it ended in -- with a status filter for reading one
slice of it. And it opens first, because arriving here the question is
usually about what has already been sent, including to the person you were
about to invite again. ?tab=send still goes straight to the form.

Five states, and two of them needed deciding:

- "Expired" is not a stored status and deliberately is not one: nothing
  writes it, a row becomes expired by the clock passing rather than by
  anybody acting, and storing it would need a scheduled task to stay true.
  Invitation::state() derives it, once, and both the badge and the filter
  read that -- two copies of the rule is how they start disagreeing about a
  row whose expiry passed a second ago.
- "Replaced" is what superseded says to somebody who is not reading the
  source. It is a different fact from expired, and worth telling apart: one
  ran out, the other was retired by a newer invitation to the same address.

The count in the tab label stays a count of live invitations rather than of
the rows below. The history is mostly settled, and the number worth
carrying in a label is the one that says whether anybody is still waiting --
which is also why it is counted over the table rather than the filtered
page, so narrowing the list cannot change it.

Revoke appears only on a row that still has something to revoke, and the
expiry column is blank on a settled one: the date is still stored and still
true, and printing it invites somebody to wonder what expires about an
invitation that was accepted.

The screen is called Invitations now, in the heading and the breadcrumb.
With the history first it is no longer a form with a list under it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
2026-09-12 16:23:50 -03:00
ignacionelson 073bf8b853 Put the invite form and the pending list on their own tabs
They were stacked: the form, then the list under it. Two different jobs on
one scroll, and the list -- the half somebody opens this screen to act on
rather than to fill in -- sat below the fold on any installation with a few
invitations out.

Two tabs now, the same plain border-bottom nav the staff account screen and
the theming settings already use. The count rides in the tab label, because
the reason to open that half is that something is waiting in it.

Three details worth stating:

- The form is hidden rather than unmounted, exactly as the staff account
  form is, so switching to the list and back does not throw away a
  half-typed invitation.
- Revoking passes preserveState, so the page comes back on the tab the
  person was working in. Acting on a row and landing on the other half
  reads as having lost the list.
- ?tab=pending opens on the list, so something elsewhere can link at the
  half it means rather than at the screen plus a sentence telling the
  reader which tab to find.

Verified in a browser, since none of this is visible to the suite: both
tabs render, the form is present on one and absent on the other, the query
parameter opens the right one, and revoking leaves the page on the pending
tab with the count down by one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
2026-09-12 16:16:35 -03:00
ignacionelson 38187dcf1a Stop an expired invitation being renewed for ever
The expired page offers a "send me a new one" button that re-issues the
invitation with no staff member involved. On its own that is reasonable --
the new link goes to the address on the invitation, never to whoever
clicked, so holding a leaked URL gets nobody a working one, and it is the
same shape as a password reset.

What it spent was the operator's expiry window. A link could be renewed
from a dead link, indefinitely, so a window set to 72 hours was only ever
as short as the longest anybody bothered to wait. That matters in the case
expiry is actually for: a link sitting somewhere it should not be -- a
forwarded thread, a shared inbox, a mailbox that changed hands.

So the chain gets a limit: three renewals, then a staff member has to send
a new invitation. The count is carried forward on each renewal rather than
stored per row, which is what makes it apply to the chain; a staff-sent
invitation starts at zero, because sending one is somebody deciding to.

A renewal beyond the limit answers in exactly the same words as a spent,
unknown or revoked token, and sends nothing. Four situations, one sentence:
telling them apart is how this door would become a way to learn which
addresses an installation has invited.

Renewals are now logged, which they were not -- sending and redeeming
already were, and renewing was the one step that moved an invitation along
with nobody behind it and left no trace. The limit bounds how many rows an
anonymous door can write.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
2026-09-12 15:57:27 -03:00
ignacionelson a502a26075 Let staff cancel an invitation nobody has used
An invitation could be sent and never taken back. There was no list of
outstanding ones and no revoke, so the only way to withdraw a link sent to
the wrong address was to let it expire -- and the expired page's own "send
me a new one" button undoes exactly that, silently, for anybody still
holding the link. The one cancel the feature had could be reversed by the
person it was aimed at.

So: a new STATUS_REVOKED, outside the pending() scope that both the
redemption and the resend doors look through. A revoked link is dead to all
three things a live one can do -- opening the form, redeeming it, and
asking for a replacement -- and nothing but sending a fresh invitation
brings it back.

The list sits under the invite form rather than on a screen of its own,
because the person who wants to cancel an invitation is the person who just
sent one. It shows outstanding invitations only: pending, expired ones
included. An expired invitation is not inert until it is revoked, so hiding
it would hide the rows most worth a decision -- which is why they sort to
the top, soonest expiry first.

Revoking is gated by create_clients, the same authority as sending:
whoever may invite somebody may take it back. It is logged, like sending
and redeeming already were. The row is kept rather than deleted, for the
reason a superseded one is kept -- the activity log names who invited this
address and when, and that trail should still lead somewhere.

Verified in a browser, not only in tests: the screen mounts, both rows
render, and the expired one carries its badge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
2026-09-12 15:55:17 -03:00
ignacionelson 74d1de2d6e Say why an invitation was refused when the installation is full
Two halves of the same gap. Redemption always provisions with
autoApprove: true, so it always meets SeatAllowance::guardClient(), which
refuses on the `email` field -- and the redemption form's email input is
read-only, never submitted, and had nowhere to render an error. The account
was correctly not created and the person was told nothing at all: the form
simply came back. The field now renders errors.email, which is where every
other account form's refusal already lands.

The other half is the button. "New client" has been seat-limited since the
limit existed -- it goes dead with the reason beside it, rather than
offering a form that cannot be submitted. "Invite client" sat next to it,
live, on a full installation. Worse than the original complaint, because
the refusal is met by the invited person rather than by the staff member
who caused it.

So the invite button is seat-limited too, and sending guards as well as
redeeming. An outstanding invitation is still not a client and is still not
counted as one -- the rule a pending account request follows, for the
reason SeatAllowance spells out -- so this reserves nothing. It refuses to
send a link a full installation could not honour, and redemption keeps its
own guard, because the seat can be taken by somebody else in the days
between.

SeatLimitedAction's usage caption is now optional, and the invite button
omits it. Two buttons governed by one limit, each captioned with the same
sentence, reads as two limits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
2026-09-12 15:50:21 -03:00
ignacionelson cd2ce960d3 Refuse an invitation whose address was taken while the link was live
An invitation stays live for days -- 72 hours by default -- and the address
it names can be claimed in that window: staff got impatient and created the
account by hand, or the person used the public registration form instead.
Redemption never asked, so User::create() met the unique index on
users.email and raised a QueryException. A 500, on the screen of somebody
who had just chosen a password, having done nothing wrong.

ClientProvisioning::addressIsFree() exists for exactly this, and its
docblock says who must call it: the paths with no form to validate. LDAP
asks. Redemption is the third such path and did not.

It now refuses with a message that says what happened, rather than the
generic "this invitation is no longer valid" the expired case uses. There
is nothing to withhold here -- whoever holds the link already knows the
address, because it is the one the invitation was sent to -- and being told
to sign in instead is the only useful thing to say.

The invitation stays pending rather than being retired. It is the account
that resolved the situation, not the link, and a retired row would only
make the second attempt read as expired.

The address rule spans soft-deleted accounts, the same as it does
everywhere else, so a deleted account still holds its address until erasure
takes the row away. Both cases are tested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
2026-09-12 14:30:42 -03:00
mash2k3 856c13b09c Invite a client to register instead of handing them a password (#1780)
Staff can now invite a specific address to register instead of typing a
password for somebody and finding a way to get it to them. The invited
person sets their own, the link is locked to the address it was sent to,
and an invitation always activates the account regardless of the
auto-approve setting -- naming an address is already the decision the
approval queue exists to make for one nobody named.

Two fixes ride along: outgoing mail now reads the installation's own site
name in its title, header and signature rather than the one baked into
config('app.name') at install time, and the CSRF cookie name is read per
request rather than captured once at load.

Follow-up work, tracked separately: an invitation cannot be cancelled --
there is no pending-invitations screen and no revoke, so letting one expire
is the only way to take it back, which the self-service resend button then
undoes. Redemption also needs the address-availability check every other
non-form caller of ClientProvisioning makes.

Thanks @mash2k3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
2026-09-12 14:28:47 -03:00
ignacionelson b8050b36ca Release 2.4.1
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
2026-09-11 13:59:02 -03:00
ignacionelson da5aadd1f3 Cut the 2.4.1 entry down to what changed, and add what was missing
Three entries were absent. A sweep of every commit since v2.4.0 for code
changes with no changelog line returned thirteen; ten were correctly absent
— the announcement work is a seam with no content on a self-hosted install,
the client share link is always null unless a platform module mints one, the
quota floor is a platform environment variable, and the email_verified_at
change is inert while MustVerifyEmail is off. The other three were real:

  - the IAM-role feature, a visible control on the storage settings screen
    that every self-hosted administrator can reach, with no entry at all;
  - #1770, a Docker upgrade that fails outright when external storage is
    already configured, which is exactly what a changelog is for;
  - download counts on a client's own files, which OwnFileDownloads gates
    on nothing, so it is live everywhere.

Every entry is now one line. The explanatory paragraph, the "who this
affected" note and the upgrade advice are gone from the change list; what an
operator must actually do was already collected at the top and stays there,
because a title alone cannot be acted on.

Reordered so the account takeovers lead rather than sitting ninth and
eleventh behind a thumbnail-rendering fix, and the two public-folder entries
sit together — they are one boundary reported in two halves, and had six
entries between them.

All seven reporters keep their credit, moved inline.

Version is the user's call, recorded here rather than argued: 2.4.1. Note
that the file's own rule above says the last number moves when there are
only fixes, and this entry has an Added section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
2026-09-11 13:55:06 -03:00
ignacionelson 386cb32ebb Translate the four strings that accumulated since 2.4.0
Sixteen locales, four strings each, which is the whole of what had drifted:
three from the IAM-role work (the toggle, its explainer, and the warning
that saving clears a stored key and secret) and one from this week's upload
limits ("Too many uploads are already in progress").

Terminology was taken from each catalogue rather than chosen: every locale
already had "Access key" and "Secret key", and the new strings reuse those
words exactly, so the explainer reads in the same vocabulary as the two
fields directly beneath it. Product nouns stay as they are — AWS, IAM, ECS,
EC2, EKS/IRSA, MinIO, Backblaze, Wasabi, ProjectSend.

Formality was read off each file instead of assumed: informal in ca, es, it,
nl and zh_CN, formal in cs, de, fr, id, pl, pt_BR, ru and tr, matching what
the neighbouring sentences already do. Spanish has three voseo entries among
thirty-three tuteo ones — they arrived with the password-reset work on
2026-09-08 (df44c46a, 6339ae15) and are the outliers, so these follow the
tuteo the rest of the file uses. Worth settling one way or the other by
somebody who speaks it, which is not a job for this commit.

The edits are additive: no entry reordered or rewritten, so the diff is the
four new lines per file and the comma the previous last line grew.

Verified past the point where a JSON file merely parses. The scanner reports
0 missing across all sixteen, the Locale suite passes, and the Spanish
strings were read off a rendered page in a browser — signed in, interface
switched to Spanish through the app's own control, with the explainer
wrapping to four lines inside its column and reading in the same words as
the "Clave de acceso" and "Clave secreta" labels under it. That screen only
draws its S3 half when the provider is S3 and the dev instance is on GCS, so
the provider was flipped for the reading and put back; it was snapshotted
first and restored to what it was, not to a default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
2026-09-11 13:26:08 -03:00
ignacionelson 38400956bc Put the installation's own logo on the pages people sign in through
Requested by @Zodiac1978 in #1777.

The logo already replaced ours in the staff sidebar, on the public listing
and in the client portal. The sign-in screen still wore the ProjectSend
wordmark — and that is the first page of yours most people ever see, and
often the only one a client sees, because it is where the link in a
notification email lands them.

One layout serves every screen reached before signing in, so this covers
login, registration, both password-reset pages, the two-factor challenge,
first-run setup and the page a share link opens. That breadth is the reason
to change the layout rather than the login page: the same visitor moves
between several of them in one sitting, and a logo that appeared on one and
not the next would read as a different site.

Nothing needed gating. `branding.logo_url` is already shared on every
request and is already null wherever the Branding capability is absent, so
an installation that has withheld branding, or never uploaded anything,
renders exactly what it rendered before.

Three tests cover the server's half — the prop reaching a page nobody has
signed in to see, the null fallback, and the capability being taken away.
None of them can say whether the component mounted or the image resolved,
so that was checked in a real browser: headless Chrome against the dev
instance with a logo installed reports the <img> present, naturalWidth 360
(so it decoded rather than sitting broken) and a rendered height of 48px,
and with the logo removed reports no <img> and the fallback SVG in its
place. The branding row was snapshotted before and restored after.

One thing worth knowing, unchanged by this and not introduced by it: a logo
drawn for a white background is hard to read on the dark theme, here and on
every other surface that shows it, because none of them filter the artwork.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
2026-09-11 13:00:38 -03:00
ignacionelson 8aef6e5b5a Tell an Apache install what it needs, and where its 500 is written
Reported by @Zodiac1978 in #1778, on IONOS.

Step 6 was nginx and only nginx, while "What you need" says Apache is fine.
It is fine, but not without being told two things — document root at
public/, and AllowOverride All with mod_rewrite on, or the .htaccess we ship
does nothing and every address but the home page is a 404. There is now an
Apache vhost beside the nginx one.

The 500 in the report is its own troubleshooting entry, because the entry we
had sends people to storage/logs/ and for this class of failure that
directory is empty — Apache never reached PHP, so ProjectSend had nothing to
write, and an empty log reads as a dead end rather than as the clue it is.
The error is in Apache's log. Two causes cover nearly all of them: Options
refused by AllowOverride, and the internal-redirect loop this reporter hit,
where Apache cannot derive the per-directory base and the front-controller
rule rewrites to a path that is not there, repeatedly.

public/.htaccess now carries a commented-out RewriteBase with the
explanation next to it, which is where somebody debugging a 500 is already
looking.

Only RewriteBase is documented, not the report's second change — making the
substitution absolute (`/index.php`). With the base set correctly the
relative form resolves to the same place, and the absolute one would send a
subdirectory install to the domain root's index.php instead.

The trap underneath all of this is worth its own paragraph, and nothing said
it before: update.sh merge-copies the release over the install, so an edit
to public/.htaccess is reverted on the next update and the site 500s again.
Put the directives in the vhost if it is yours to edit, since an update
cannot reach there — and on shared hosting, where it is not, keep a note.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
2026-09-11 12:45:26 -03:00
ignacionelson 8372f42525 Ask the picker's own question of what comes back from it
Reported by @skeletonsec as GHSA-w29w-pj29-x7ww.

Deleting an account that owns files makes the admin choose who inherits
them. The picker narrows that list for a client-scoped staff member to their
own roster, and says why two methods up: "a client-scoped staff member is
not shown the name of somebody they can reach nothing of, and a picker is no
more a reason to hand one over than a listing is."

The write asked something else entirely — exists, active, and not the
account being deleted. All three are true of every account on the
installation. So a scoped staffer could name an id the picker had
deliberately kept off the list, and a roster client's files and folders
landed with a client on somebody else's roster: readable, editable and
deletable there, because a client owns what they uploaded and
visibleToClient() includes uploaded_by.

The entry doors scope the source account and always did — guardTarget goes
through canAssignClient. It is the destination nobody scoped.

candidates() and validate() now run one predicate, reachableTargets(),
rather than two that happened to agree. Two that agree by inspection is what
this was: the narrowing existed, was correct, and was only ever applied to
the list.

The refusal deliberately reads as "no such account". An out-of-roster id and
an id belonging to nobody now produce the same message, because a refusal
that distinguishes them lets a scoped staffer walk the id space and learn
which accounts exist outside their roster. That is why Rule::exists is gone
rather than kept alongside: one code path, one answer. A test pins the two
messages as identical instead of naming either.

Both the web screen and the API twin come through this one validate(), so
both are fixed by it — and the test file proves each separately rather than
assuming the sharing holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
2026-09-11 12:19:28 -03:00
ignacionelson 50f8b578df Ask the publication question wherever content lands, not just on upload
Reported by @skeletonsec as GHSA-rxf8-wh8v-jm9j.

A file in a public folder is public: isEffectivelyPublic() is "my own flag,
or my folder's", read up the whole ancestry. GHSA-237r-jx85-j3hr settled
that three days ago, put the rule in Folder::uploadableBy(), and wired it
into the upload paths.

Content arrives in a folder four other ways. move() drags one file in,
bulkUpdate() moves a selection, update() reparents through the edit form,
and FoldersController::move() drags a whole folder — every file in its
subtree — under a public parent. Each of them asked whether the destination
was *visible* to the mover and then wrote folder_id. Visible is not the same
question as publishable, and the difference is the entire permission: a
staff member given editing rights and deliberately not given upload_public
could publish confidential files to the anonymous site by choosing where
they landed. The API twin of update() had the same gap.

Both earlier advisories named these paths in their own "suggested fix"
sections. Neither demonstrated them, so neither was followed. The fix to a
report wants the scrutiny the report got, and this one did not get it.

The predicate did not need changing — it needed calling. Four sinks now ask
it, plus the API twin. The check stays split in two deliberately: the
destination is resolved through StaffLibraryScope as before, so a folder
somebody cannot see is still a 404 and not an existence oracle, and the
publication clause is a separate 403 on top. They agree by construction —
allowsFolder() is folders()->whereKey()->exists() — so nothing that used to
resolve can now fail the first half.

On the file paths the check fires only when folder_id actually changes,
which is the convention already there: re-saving a file that sits in a
folder out of the saver's scope must keep working. bulkUpdate() checks its
destination once instead, before the loop, because there is one destination
for the batch and if it publishes then no file in the batch may go.

Folder::uploadableBy()'s docblock now says to read the name as "may place
into", with why: the name is what made this easy to miss, and the next
folder_id or parent_id write will be written by somebody reading it.

Ten tests, one per sink with a private-destination control beside it, plus
an editor who *can* publish to show the boundary is about publishing and not
about moving. The last one follows the advisory's own chain to the end and
asserts the thing actually claimed — a stranger with no session, no token
and no assignment fetching the anonymous download URL. It returns 200 on the
code before this commit and 404 after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
2026-09-11 12:16:23 -03:00
ignacionelson 6ad26bb61e Hold an upload to the size it said it was sending
Reported by @ry2811 as GHSA-6jh6-gvj5-pv8v.

A resumable upload declares its size, and that declaration is what store()
weighs against the maximum file size and the client's storage quota. Only
the assembled file was ever held to it. The parts in between were bounded
one request at a time and never added up, so a client could declare one
byte and then stream parts: ten thousand part numbers at twice a 20 MB
part is about 400 GB, per session, and the number of sessions was not
bounded either. None of it counted against anything, because nothing
becomes a File row until the upload completes and ClientStorageUsage sums
File rows. A client with a 1 MB quota could fill the volume and repeat.

putPart()'s own comment described this defect and treated the per-part cap
as the answer to it: "without a cap here the exposure is a day's worth of
disk". A cap on one request bounds one request. The exposure was a day's
worth of disk multiplied by however many requests somebody cared to make.

Three limits, and each one exists because the other two do not cover it.

A session may not stage more than it declared. The room for a part is
claimed before the body is read — a body's length is not known until it
has arrived, and by then it is on the disk being protected — and the write
is then capped at exactly what was claimed, so an over-long body is cut
off mid-stream as it always was, against a smaller number. The claim is a
read and a conditional update under a per-session lock, the same shape
complete() already uses: the protocol sends parts in parallel and how many
is the client's choice, so an unlocked read lets every part in flight
claim the same room, while an atomic claim alone refuses the honest
parallel upload instead. Whatever the part really weighs is settled back
afterwards, in a finally, or a client's own retries would exhaust a
session with room to spare.

Open sessions count against the quota at the size they declared. A quota
measured against finished files alone is spent twice by opening sessions
one after another — each is told there is room, because the ones before it
have not finished. The cost is that an abandoned transfer holds its share
until it is cancelled or swept, so the sweeper now runs hourly rather than
daily: that gap is now somebody unable to upload, which it was not before.

And a cap on open sessions, because for anyone with no quota to spend —
staff, and clients on an installation that sets none — the session count
is the only thing between a declared size and any multiple of it.

Four tests fail on the unfixed code, and three existing ones had to change:
they declared a tiny size and sent a large part deliberately, to reach the
re-checks at complete(). That route is now closed at putPart(), so they
reach those re-checks the way a real install would instead — the file-size
limit or the quota moving while a long transfer is running, which is the
reason complete() re-asks rather than trusting what store() decided.

The staged-byte total is BIGINT UNSIGNED, and the suite runs SQLite, which
has no unsigned integers. The first version of the bounds read
`staged_bytes + :delta BETWEEN 0 AND size` and raised SQLSTATE 22003 on
MySQL for any refund — in the comparison, so the bound written to prevent
the underflow was the statement that underflowed. Every SQLite test passed
on it. Both bounds are now arranged so the column is never inside a
subtraction, and UploadSessionStagedBytesMysqlTest skips loudly unless the
connection is MySQL. Verified against 8.4, as was the report itself: three
sessions declaring one byte each put 6 MB on the volume of a client with a
1 MB quota before, and nothing at all after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
2026-09-11 12:05:41 -03:00
ignacionelson 83a8fe2288 Claim the installation instead of checking whether it is free
Reported by @ry2811 as GHSA-w3w9-prpw-qx77, with a working two-worker
reproducer.

Setup asked the database whether any staff user existed, and created one
some time later, in a separate statement with nothing joining the two. So
two POSTs arriving together both read "no staff" and both inserted a System
Administrator. Different addresses do not collide; `users.email` is the only
unique key and it has nothing to say about there being one first
administrator.

The gap is not narrow. Between the check and the insert sits password
hashing at BCRYPT_ROUNDS=12, which is slow on purpose, so the window is
hundreds of milliseconds wide and observable without trying.

What makes this worth fixing is not that a stranger can set up an
unconfigured installation — first-run setup is open to whoever reaches it
first, and always was. It is that racing the operator is *quiet*. The
operator's own request also succeeds, also redirects to /setup/success, and
the installation they get looks exactly like the one they expected. The
second administrator is discovered later or not at all, and closing setup
afterwards does not revoke it.

FirstAdministrator::claim() makes it one operation. The row it locks is the
System Administrator role, because the obvious candidate cannot work: there
are no staff rows on a fresh install and a lock over an empty result
serialises nothing. That role row is written by the roles migration and
rewritten on every boot, so it is always there to be locked. The second
caller waits on it, and by the time it has the lock the first caller's user
is committed and visible to the re-check it then makes.

Everything the request writes moved inside the claim, including the site
name. A request that loses now writes nothing at all, rather than renaming
the installation on its way to the login screen.

`projectsend:admin --if-none` had the same shape and is fixed the same way
— two containers coming up against one database is the version of this that
needs no attacker. The early check stays where it is so an unattended boot
does not prompt for a password it is about to discard; it is simply asked
again under the lock.

Both tests fail on the unfixed code. They stage the interleaving rather than
attempting real concurrency, creating the winning administrator from a query
listener after the request has made its first check — which is exactly the
window, and the re-check is the only thing that closes it. The lock itself
is invisible to them: the suite runs SQLite, where lockForUpdate() compiles
to nothing. That half was verified against MySQL 8.4 by running the
reporter's race for real, two processes through the full HTTP kernel: two
administrators before, one after, repeatably.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
2026-09-11 11:34:26 -03:00
ignacionelson 62c763d04e Put a floor under a client quota nobody set
Setting::DefaultClientStorageQuotaMb defaults to 0, and 0 means
unlimited. That is the right default for somebody setting up their own
installation and the wrong one for an installation a platform operates
on other people's behalf: an account that arrived without an explicit
quota has no ceiling at all, and it does not have to be an account the
platform created.

So a platform may set a floor in the environment
(PROJECTSEND_PLATFORM_DEFAULT_CLIENT_QUOTA_MB), exactly as it sets the
seat caps, and for the same reason those are not settings: it is the
shape of what was sold rather than a preference the installation's
administrator is expressing. It applies only where the setting says
nothing, so an administrator who chose a number keeps it, and an install
with no platform behind it is unaffected.

ClientStorageUsage::defaultQuotaMb() is where the three sources resolve,
and every screen that presents the answer now reads it there:

  - The client create and edit screens. The edit screen mirrors that
    resolution client-side to draw the usage bar, so handed the raw
    setting on a floored installation it computed an effective quota of
    zero, printed "unlimited" and hid the bar entirely -- for a client
    whose next upload was about to be rejected for exceeding a limit the
    screen said did not exist.

  - projectsend:status, which gains clients_can_register and
    default_client_storage_quota_mb. Both defaults are the permissive
    ones, both are invisible from outside, and a document reporting the
    setting while uploads obeyed the floor would say the ceiling was
    missing on an installation that has one.

The Client settings form deliberately still reads the raw setting: that
field is read and written back on save, so prefilling it with the floor
would write the platform's number into the setting as the
administrator's own choice, where it would outlive the floor.
2026-09-11 00:41:31 -03:00
ignacionelson 0671848bfa Read settings written before the columns they name existed
Reported by @apps3000 in #1770. Upgrading a container from 2.0 or 2.1
with external storage configured restart-loops, and says the database is
unreachable while the database is fine.

A row hydrated from the database does not get the model's column
defaults — only a new model does. So a row written before
external_storage_settings.provider existed reads that column as null,
and the enum match in isConfigured() throws UnhandledMatchError.

That would be a small bug anywhere else. It is not here, because
PlatformServiceProvider::boot() reads these settings on every process
boot, and boot happens before `artisan migrate` runs. During an upgrade
the code is new and the schema is still old, so every artisan command in
that window dies — including `projectsend:update`, the one that would
have added the column. Reordering the entrypoint or using a lighter
readiness probe does not help for that reason; the crash is in the
bootstrap, not in the probe.

current() now applies the model's declared defaults to any column the
hydrated row does not have. That closes the window for every column with
a default rather than for the one where it was found, and goes inert the
moment the schema is current. The match in isConfigured() is left total
on purpose: a default arm would swallow a real unhandled case, and the
invariant it needs now holds at the one place the row is read.

The probe's message is the other half. It boots the whole application,
so it fails both when the database is absent and when the application
cannot start, and it reported the second as the first — sending an
operator off checking credentials that were never wrong. It now prints
the error it actually hit and says which of the two it looks like.

Verified end to end against a 2.1-shaped database: `artisan migrate`
dies with UnhandledMatchError before the change and completes after it,
leaving the row reading as S3 with its bucket intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QmyH342d8MuW3pDuE9mbtS
2026-09-10 17:23:26 -03:00
Ignacio Nelson 469297893b Merge pull request #1775 from projectsend/s3-instance-role
Support AWS IAM roles for S3 storage
2026-09-10 16:38:50 -03:00
ignacionelson eaba7ff633 Let an AWS-hosted install authenticate as its own IAM role
Requested by @ToMMy86 in #1773: an install running on ECS, EC2 or EKS
already has a role attached, and making it also create an IAM user with
a long-lived access key is both extra work and a worse security posture
than the one AWS offers.

The AWS SDK resolves credentials from its default provider chain
whenever none is supplied, and Laravel's FilesystemManager already omits
the `credentials` entry when the key and secret are empty — so the
upload path needed almost nothing. What blocked it was ours:

- `isConfigured()` demanded a key and a secret for S3, so a
  credential-less row was never "configured" and every upload silently
  stayed on the local disk.
- `access_key` was `required_if:provider,s3` on both the save and the
  connection test.
- `probeS3()` built an explicit `credentials` array, so Test connection
  would have failed even once uploads worked.

An explicit `use_instance_role` column rather than "the key was left
blank", because blank already means "keep the credential you have" on
this form — neither the secret nor the GCS key file is ever sent back to
the browser. Ticking it deletes the stored key and secret rather than
leaving them in the row for the next database dump.

Unchanged for everyone else: MinIO, Backblaze, Wasabi and any other
S3-compatible service still authenticate with a key and secret, and the
region is still required — the chain resolves credentials, not regions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QmyH342d8MuW3pDuE9mbtS
2026-09-10 16:16:31 -03:00
ignacionelson 6339ae1514 Answer a failed reset the same way whatever failed
The screen leaked account existence a second way, through the write, and
this one is older than last night's: it is the scaffolding. Laravel
answers a failed reset with passwords.user for an address it cannot find
and passwords.token for a real one whose token is dead, and the controller
surfaced __($status) straight through. Two sentences, one difference, and
the difference is whether the account is here.

passwords.throttled is the third and the sharpest. The broker throttles
per user, so an address nobody holds can never be throttled — being told
to wait is being told the account exists.

All of them collapse to one sentence now. Nothing is lost: the action is
the same in every case, and /forgot-password one step earlier already
refuses to say whether an address has an account. Keeping three messages
was only ever more precise about a thing we had decided not to say.

Found because the portal session went looking for the GET oracle I had
just fixed, found theirs, and also found a POST variant I had not thought
to check. I had it too.

The test asserts the two refusals are identical rather than naming the
sentence, so it survives the wording changing.
2026-09-09 08:04:54 -03:00
ignacionelson 7c4b582d25 Stop the expired-link notice saying whether an account exists
I built the oracle in the commit whose docblock describes preventing it.
The comment said a page answering "expired" for a real address and
something else for an unknown one would tell anybody who typed a guess
whether an account is here — and then the method returned false for an
unknown address and true for a known one. Two branches, two answers, and
the difference was the account.

/forgot-password deliberately says "a link will be sent if the account
exists". This undid that on the next screen along.

Both branches answer the same now: anything that will not validate reads
as expired, whether the address is known, unknown or absent. The message
stays right in every case somebody real will meet — a mistyped address
gets "ask for a new link", which is what they should do anyway — and the
page reveals nothing.

The test is written as "these two are the same answer" rather than "both
are false", so it keeps holding if somebody later changes which answer it
is. The two tests that encoded the oracle asserted `expired` was false for
an unknown address; they were pinning the bug.

Found by asking my own question of my own code. The portal session had
checked whether their collation folded addresses, which sent me back to
the reset screen to see what it does with an address it cannot place.
2026-09-09 07:53:30 -03:00
ignacionelson b96d060ad8 Compare an address ourselves, instead of asking the collation
Reported by @choewonwoo1817 as GHSA-wgxf-v8cr-37mj, with a working
end-to-end reproducer against Keycloak.

`where('email', $address)` is not an exact match. It is whatever the
database says equality means, and the collation INSTALL.md tells people to
create — utf8mb4_unicode_ci — folds accents:

    administrator@example.com = administrator@éxample.com   -> 1

Those are two different domains. The second is xn--xample-9ua.com, which
somebody else can register and honestly verify at an OIDC provider. So an
attacker with no account here could sign in as themselves and be handed
the first account: SocialAuthenticator found it, linked their subject to
it permanently, and started a session. No password, no interaction from
the owner, an administrator session where that account was one.

Comparison now happens in PHP, in one place, on every driver. Case is
still folded because that is a real requirement — addresses are stored
lowercased and a provider may send any case — and mb_strtolower folds case
without folding accents, which is exactly the line to draw.

Three call sites move to it and two deliberately do not. Loose matching is
right when *refusing* and wrong when *selecting*: AvailableEmailRule and
ClientProvisioning ask "is this address free", where a collation that says
no to a near-miss refuses more registrations, which is the safe direction.
The three that ask "which account is this" are the social path, the login
form (where a password still gated it, so it was confusion rather than
takeover) and the erasure command (irreversible, and the wrong row is the
wrong person).

The test story is the part worth reading. The suite runs on SQLite, whose
`=` is byte-exact, so this defect does not exist there and never did —
which is how it survived six releases with everything green. A test
written the obvious way passes on unfixed code. So the comparison is
pinned by driver-independent tests that always run, and the chain is
proved by AccountLookupCollationTest, which skips unless the connection is
MySQL and carries the command to run it. Run against real MySQL with the
real collation: it fails on the old code and passes on the new.
2026-09-09 07:32:26 -03:00
ignacionelson 6d7d80f62f Give the announcement band its own row, and put it on the dashboard too
All four themes had it as a flex child of the row holding the heading and
the buttons, so it was never full width: it took part of the line and
squeezed "My files" and "Upload a file" into a narrow column beside it. It
now sits above that row, which is where the staff dashboard has always put
it and where the comment claimed it was.

Worth noting why four themes shipped it wrong. The band renders nothing
for almost every viewer — it needs a hosted instance, a free plan, and a
client looking — so the broken layout was invisible to the suite, to the
build, and to anybody working on those pages. Seen only once somebody on
the real free tier looked at it.

Also adds it to the client's dashboard. That screen is about the account
rather than about the files, which is the more natural place for an offer
about the plan, and both read the same shared prop so they cannot disagree
about what is said or to whom.

Checked with real screenshots in all four themes and on the dashboard,
against a temporary listener standing in for cloud-modules, since this
install is community and would otherwise render nothing. The listener and
the theme setting were both put back.
2026-09-09 01:04:15 -03:00
ignacionelson bc559ade3f Prove a client's upload announces itself, not just a staff one
The test above this said "whichever path stored it" and only exercised
the plain staff POST. The path the hosted free tier hangs on is the other
one: a client, through the resumable flow, whose upload is what
cloud-modules listens for to mint the public link.

Worth its own test rather than trusting the shared StoreUploadedFile,
because the package's suite structurally cannot tell us. It fakes both the
event and the link-minting, so a chunked path that stopped dispatching
would leave all 140 of its tests green and the free tier silently inert on
a real instance. That gap is why the listener was checked against
sim-cloud by hand rather than believed; this is the half of it that
belongs in core and runs on every commit.

The counter-check is worth a note. The first attempt at it changed
nothing — `\$file` inside a sed pattern is a literal, so the substitution
never matched and all six tests passed, which reads exactly like a fix
that is not load-bearing. Confirmed the mutation landed by counting the
line before re-running: three tests fail without the dispatch, the new one
among them.
2026-09-08 21:12:50 -03:00
ignacionelson df44c46a12 Say a reset link has expired before asking for the work
The page rendered the form without looking at the token, so somebody
opening a link an hour late typed a password, typed it again to confirm,
and was then told "this password reset token is invalid" — a word nobody
outside the code knows, at the end rather than the start. Links last an
hour and people open them late. That is ordinary, not an error to be
scolded for.

store() still validates and is still the rule; there is a test that a
spent token is refused there whatever the page drew. This is only the
screen being honest a minute earlier.

An address that is missing, or belongs to nobody, is drawn as the form was
before. Partly because an unanswerable question is not an expired link,
but mostly because a page that said "expired" for a real address and
something else for an unknown one would answer whether an account exists
here to anybody typing guesses — the exact property /forgot-password
protects by saying "a link will be sent if the account exists". Two tests
pin that.

Worth having now rather than later: the advisories publishing with this
release will send more people than usual through this screen, in a hurry
and some of them frightened.

Found by the portal session's user, who opened a real link an hour and
forty minutes after it was sent.
2026-09-08 20:08:28 -03:00
ignacionelson a1f59e133b Translate the strings the security fixes added
Three, sixteen locales: the Entra optional-claim instruction on the
social-login screen, and the two the profile screen gained when changing
an address started asking for a password.

The catalogues gate the release build, so these had to land before the
version could be stamped.
2026-09-08 19:59:31 -03:00
ignacionelson 0a3410140d Keep an erased staff member's library away from a client
The reassignment target is one installation-wide id used for every
erasure, and the picker offers clients deliberately: erasing a client and
handing their files to another client is what the setting is for.

Applied to a staff account the same id means something else. A staff
library is usually the whole installation's, so a client named there
inherits all of it — through an unattended scheduled job, with no
per-account confirmation, because this is the default rather than a choice
somebody makes at the moment of deleting.

So a staff account's content may only go to staff. With nobody valid to
hand it to, handleContent() already cascades, which keeps the existing
promise that content is never orphaned — it now also never becomes a
disclosure.

Not in the settings validation, which is where it looks like it belongs.
That runs when the target is chosen, and whose account will be erased
later is not knowable then. Both halves are only in hand here.

Found while checking a list from the portal session, who had it as one
where() on `type`. That would have been too broad: it would also have
stopped a client's files reaching another client, which is the case the
setting exists to serve. The condition is on the account being erased,
not on the target alone.
2026-09-08 19:36:48 -03:00
ignacionelson 80cf99d80e Put what the operator must do at the top, and mark it
The upgrade notes sat at the bottom of a release entry, after every list
of what changed. That is the wrong end of the page: somebody deciding
whether to upgrade reads the first screen and stops, and that is exactly
the reader who needs to know a permission stopped working or a value
changed meaning.

So the section moves to the top of the entry, gets a heading nobody
skims past, and opens by saying what these items have in common —
everything else in a release happens on its own, and these do not. It also
says plainly that none of them stops the upgrade, because an operator who
cannot tell "the installation will not start" from "one role gets a 403"
plans the wrong maintenance window.

Three items for this release: the CAPTCHA flag that now means the
opposite for anybody who typed something other than true or 1, the public
folder permission that is now asked of staff, and the Entra claim.

Releases before this keep the old "Upgrade notes" heading — rewriting
published entries would change what people were told at the time. The
file's own header names both, and the release skill now carries the new
shape so the next one does not drift back.
2026-09-08 19:15:10 -03:00
ignacionelson cbc6760a93 Warn that a mistyped CAPTCHA flag now means the opposite
The fix reads only true and 1 as "disabled". An operator who wrote
"off" and has been running without a CAPTCHA gets it back on this
upgrade, which is correct and is still a surprise if nobody says so.
2026-09-08 19:10:13 -03:00
ignacionelson d8ca41ae0c Credit the reporters by their GitHub handles
One of the four said 'the same researcher as the group finding above',
which was wrong: @skeletonsec reported the public-folder and upload-target
findings, @Drescargot the group one. The other two were credited by name
rather than by handle, which is not how anybody finds them.
2026-09-08 19:08:27 -03:00
ignacionelson 1149df277b Ask for the public-folder key before publishing through a folder
Reported as GHSA-237r-jx85-j3hr.

A file is public if its own flag is on or its folder's is, so the upload
destination reaches the property `upload_public` guards without touching
the switch. A staff member allowed to upload but deliberately not allowed
to publish could publish to the anonymous public site by choosing where
the file landed.

No new key. `upload_to_public_folders` already exists, already appears on
every role's checkboxes, and already means exactly this on the client
branch of the same method — MyFilesController's picker calls it the
established meaning of the two keys. It was never asked of staff, so on a
staff role that checkbox did nothing at all: an unenforced permission, the
class this project audited and closed once already.

Effectively public rather than the folder's own flag, because the flag is
inherited down a subtree: a private folder inside a public one publishes
just the same, and a check on the folder's own column walks past it. There
is a test for that case specifically.

One place, because every upload path — the plain POST, the chunked flow,
the API and the client portal — already asks Folder::uploadableBy(). The
sibling report about the target folder not being scope-checked at all
(GHSA-56qr-cq56-qg66) was fixed in 2c2b86ff and is what put the scope
check on the line above this one.
2026-09-08 19:05:53 -03:00
ignacionelson ab5fa2da8b Make Entra prove the address, not just the directory
Reported by Dickson Massawe as GHSA-2rfh-v3j2-2jg7.

Pinning the tenant was half an answer. It defeats the classic
cross-tenant nOAuth, where a stranger's own directory asserts your
address, because a foreign tenant carries a different tid. It does
nothing about the same attack from inside the pinned tenant: Entra's
email claim is user-mutable — a B2B guest's otherMails among its sources
— so a colleague or an invited guest could present an administrator's
address and have their subject bound to that account.

Tenant-pinning answers "which directory said this". It never answered
"does this person own that address". xms_edov is Microsoft's own answer
to the second, and their guidance says to require it wherever email
identifies an account. Absent counts as unverified, which is the only
safe reading given it is absent by default.

Nobody is locked out by this, which is worth saying because it looked
like a breaking change until I read SocialAuthenticator::resolve in
order. An account already linked resolves by subject at step 3, before
trust is consulted at all — those keep working untouched. A first-time
link to an existing account is refused with the message that already
exists for exactly this case, which names the way through: sign in with
your password and connect the provider from your settings. A brand-new
account is still created; it goes to the approval queue rather than
auto-approving.

The settings screen and docs/testing-social-login.md now tell an
operator to add the claim, and there is an upgrade note.

The tests exercise fromSocialite() on raw claims, which nothing did
before: tests/Feature/Auth/SocialLoginTest.php builds a SocialIdentity by
hand and so never reaches this mapping. That is how the branch could
trust a tenant match alone with a full suite passing.
2026-09-08 19:00:46 -03:00
ignacionelson 3244be6bac Ask for the password before changing the address a reset goes to
Reported by Nooraldden Khalel as GHSA-f32x-fgmp-q353.

The profile screen let a signed-in session change its own email address
with nothing else, and that address is where a password reset is sent. So
a stolen session was enough: point the account at your own inbox, ask for
a reset, set a password, and temporary access is permanent ownership.
Clearing email_verified_at did not stand in the way, because the model
does not implement MustVerifyEmail and the reset broker never asks.

destroy(), thirty lines further down the same controller, has always
required the current password, and its comment says why: "the rule every
other door into this already asks". This door leads to the same place and
was not asking.

Only a *different* address asks. A name, a timezone or a custom field is
not a credential, so the rest of the screen saves with nothing extra —
which is why the rule is excluded rather than flat, and why the comparison
is trimmed and lowercased: re-saving a profile with the address typed in a
different case must not demand a password for nothing.

An account whose credentials live in a directory or at an identity
provider is refused outright and told why, rather than being asked for a
password it does not have. LdapProvisioner stores Str::password(64)
exactly so that local password can never be used, so asking would be a
dead end dressed as a form error — and the address is not theirs to change
here anyway: it is what the directory says it is.

The test walks the whole chain rather than checking the field is
validated, because the chain is what made this high: change the address,
ask for a reset there, and confirm nothing is sent and no such account
exists.
2026-09-08 18:57:01 -03:00
ignacionelson 5fb17388cd Stop scoped staff reaching groups that are not theirs
Reported by @Drescargot as GHSA-r3hg-3fxw-rcmr, in two halves.

The groups listing never narrowed at all. Every other action in that
controller is guarded with allowsGroupChange(), and index() — web and API
alike — built a bare Group::query(), so a client-scoped staff member was
shown every group on the installation with its name, description and
member count. StaffLibraryScope::groups() is that narrowing, and
assignableGroupIds() now reads from it rather than restating the same
rule a second time, which is how the two drifted apart to begin with.

The second half is the one that mattered. allowsGroupChange() asked only
groupReachesNoFurther() — "is anything shared with this group outside my
library" — which a group with nothing shared with it yet passes
vacuously. So a scoped staff member could rename, delete or publish a
group whose every member was somebody else's client. Publishing is the
sharp end: whatever is shared with the group afterwards is reachable
without signing in.

The reporter suggested putting the membership check inside
groupReachesNoFurther(). Tried, and it breaks two things. That predicate
is shared with allowsGroupMembership(), where a group nobody has joined
must stay usable so its creator can add the first member. And "every
member must be mine" is the obvious reading of the rule and is wrong: it
turns GHSA-whmp-p9hv-r7j7's narrowing — a mixed group's edit screen
loads and simply does not name the stranger — back into a 404, undoing
that fix. Four tests from it fail that way.

So the check sits in allowsGroupChange() alone, and asks whether the
group is wholly somebody else's rather than whether it is wholly theirs.
A mixed group stays workable and is still covered by the reach check; an
empty one stays nameable by whoever just made it; a group with members
and none of them theirs is refused.
2026-09-08 18:40:33 -03:00
ignacionelson 2be423d685 Translate the download counts and the copy-link control
Four strings, sixteen locales. Everything else was already complete, so
this is the whole of the debt from the client-portal work.

Czech, Polish and Russian do not get "downloaded :count times": those
languages inflect the noun according to the number in front of it, so no
single string can be right for every value. They read as a labelled count
instead — "Pobrania: :count — ostatnie :date" — which is the shape the
other numeric strings in those catalogues already use.

German and Turkish stay formal, Spanish, Dutch, Polish and Chinese stay
informal, as the rest of each catalogue does.
2026-09-08 18:22:25 -03:00
ignacionelson 6560346280 Mark the first administrator's address verified, as intended
The last two paths that passed email_verified_at into User::create() and
lost it: the setup screen, and projectsend:admin for a container that
comes up from environment variables. It is deliberately absent from
$fillable, so mass assignment drops it without a word, and both meant to
set it.

The intent is plain in both cases — the first administrator typed their
own address into the form in front of them, and whoever provisioned the
container supplied it themselves. There is nobody to confirm it to.

Inert today, since MustVerifyEmail is not enabled on the model, but the
column is what a later switch would read: turning verification on would
have locked out the one account that cannot be helped by another
administrator.

Both are now pinned by a test that fails when the forceFill is removed.
StaffAccounts had already fixed this for staff and named the rest; with
client accounts done earlier today, that list is empty.

Also says on User::$fillable what absence from it buys and what it does
not. It stops a request smuggling a value in; it does not tell code that
meant to set the value that it failed. Four separate paths made the same
mistake against the same comment.
2026-09-08 17:07:35 -03:00
ignacionelson e187513cdd Stop a mistyped CAPTCHA flag from switching the CAPTCHA off
`env()` recognises the words "true" and "false" and returns everything
else as the string it was — and every non-empty string is truthy in PHP.
So `(bool) env('PROJECTSEND_CAPTCHA_DISABLED')` read all of these as "yes,
disabled":

    PROJECTSEND_CAPTCHA_DISABLED=no
    PROJECTSEND_CAPTCHA_DISABLED=off
    PROJECTSEND_CAPTCHA_DISABLED=fasle

An operator who meant to say no took the bot protection off their login
and registration forms and had nothing to tell them so — the setting
screen still shows the CAPTCHA configured, because this is the escape
hatch that runs ahead of it.

For most settings the cast is a shrug: somebody notices the feature is on
and fixes the line. It stops being a shrug when the wrong answer is the
unsafe one, and this is one of those. EnvFlag lists what counts as yes —
`true` and `1`, either case, either type — and reads everything else,
recognised or not, as no. A value typed as `disabled` turns nothing off:
a configuration mistake to be found rather than guessed at.

Found while fixing the same bug in a new cloud-modules flag, where the
unsafe direction was publishing a customer's files rather than dropping a
CAPTCHA. Two of the four remaining `(bool) env()` casts are left alone on
purpose: a wrong S3 path-style value breaks storage loudly, and the
migration tool's direct mode defaults to true anyway, so neither fails
into an unsafe state.
2026-09-08 17:05:30 -03:00
ignacionelson 896675d631 Tell a client whether their own file arrived
"Did it arrive?" is the question somebody asks about a file they sent, and
on a hosted free account — where a link is the whole of the sharing — the
count is the only evidence either way. Every file a client uploaded now
shows how often it has gone out and when it last did, in every render mode
of every theme.

Only their own. A download entry says somebody fetched the file, so a
count on a file shared with several clients tells each of them about the
others' activity, and nobody is entitled to that but the person who put
the file there. A file shared *with* this client carries null, not zero:
a zero would itself be a claim, and the two have to be distinguishable
because zero is an answer the owner came looking for and is shown as
words.

Counted from the activity log through the same three actions
DownloadAllowance uses, so a file leaving by the public site counts as
much as one leaving by its link. One query for a listing, none at all for
a client with no files of their own. Both filters have a test that fails
when only that filter is removed.

Two things a render check caught that types and a green build did not.
`t()` does no plural selection — the catalogues are flat key/value — so a
"one|many" string reached the screen with its pipe intact; the strings are
whole sentences now, with the singular spelled out. And the gallery card
was already laying its text out beside the action icons in a 200px column,
truncating the filename to "Q…" and the size to "75 …" on main today;
stacking them gives every line its full width.
2026-09-08 16:56:54 -03:00
ignacionelson 92bb807849 Show a client the public link to their own file
A client's portal lists two kinds of file side by side: what they
uploaded, and what somebody shared with them. Where a link exists on one
of their own, they can now copy it from the row — which is what makes the
hosted free plan a product rather than a place to put files, since a
customer there has no staff screen on which to make one.

The rule is narrow, and both halves are load-bearing: a link this client
created, on a file this client uploaded.

Not "a link on a file shared with them" — that link is the sharer's
decision about who may reach the file, and handing the recipient the URL
would quietly turn "you may download this" into "you may pass this on to
anyone".

And not "any link on their own file" either — a link staff minted on a
file a client uploaded exists for a reason the client may be no part of,
and on the shared instance it would sit beside the one link they were
promised. Ordering is by id, so an unfiltered lookup would hand them
whichever was minted first.

Both halves have a test that fails when only that half is removed. The
first draft did not: every case was carried by the ownership filter
alone, so the creator check was green for the wrong reason.

Links that no longer work are left out rather than shown greyed. The only
thing a client can do here is copy it, and a URL that answers "this link
has expired" is worse than no URL at all.

One query per listing, not one per row, and none at all for a client with
no files of their own.
2026-09-08 16:47:57 -03:00
ignacionelson 0a28e239d6 One home for what a client account is
Three surfaces create client accounts now: the staff screens,
/api/v1/clients, and the platform control plane in the private package.
Two of them held their own copy of the type, the role, the active flag,
the "0 means inherit the site default" quota, the verified stamp, the
activity entry and the seat guard — and the third could not have a copy
at all, because a package cannot import a host class.

ClientAccounts is that one definition, reached by name from outside.
What stays with each caller is what genuinely differs: its validation,
its response, its custom fields, and who is asking.

Two things changed rather than moved:

The seat cap is now checked inside create(), before anything is written,
instead of at the top of each controller. That is what makes a leaked
platform token an incident rather than an unbounded one — a guard that
ran only where somebody remembered it is not a guard.

email_verified_at is written with forceFill. It is deliberately absent
from User::$fillable, so every client-creation path passed it into a mass
assignment and lost it in silence. StaffAccounts already noted this and
named the other paths; this closes the client half.
2026-09-08 16:23:34 -03:00
ignacionelson 3d923188d9 Show an announcement on the client's own file portal
The band existed only on the staff dashboard, which is a screen a client
never opens. On a shared instance the customer *is* a client account —
they sign in, upload, and share by link, and the administrator is the
operator — so a message for them has to reach the page they actually use.

ViewerAnnouncement reads the shared prop itself rather than taking one,
so each theme adds it in a single line without threading a prop through a
page that has no other reason to know about it. All four portal themes
render it, because a message that only appears in the theme somebody
remembered to wire is a message that quietly does not exist.

Above the heading, not inside the list: it is not one of the things the
client came to do, and burying it under the files would defeat the point
of having it at all.

No theme decides who sees it. Core drops anything not aimed at the
viewer, so a theme renders whatever it is handed and cannot leak a staff
message to a client by being careless.
2026-09-08 15:55:47 -03:00
ignacionelson b128b114b5 Make an announcement say who it is for
The first version refused clients outright. That was right for the only
message that existed — a hosted instance telling its administrator about
their plan — and it stopped being right the moment a message needed to
reach the *clients* of a shared instance, where the administrator is the
operator and the customers are client accounts.

The unsafe fix would have been to drop the guard and let each listener
check `isStaff`. The safe one is to make every caller say who it is
talking to and have core enforce it: `show()` now takes a required
`audience` with no default, and a message aimed elsewhere is dropped
before it reaches the props. A listener that forgets therefore reaches
nobody rather than everybody, which is the direction a mistake should
fall.

An unrecognised audience reaches nobody either, and is ignored rather
than thrown — a listener aimed at the wrong people should show nothing,
not break the page it was decorating.

The old "a client is never shown one" test became "a message for staff
reaches no client, even from a listener that never checks", which is the
property that actually matters and the one the enforcement provides. Two
more pin the other directions: a client message reaches clients and no
staff, and an unknown audience reaches neither.

cloud-modules declares `staff` for the free-plan band, and its test fake
enforces the same rule, so a listener aimed at the wrong audience fails
in the package's own suite rather than passing there and misbehaving in
the host.
2026-09-08 15:48:29 -03:00
ignacionelson 757fba19ca Give uploads a seam, and link-minting one home
Two pieces of groundwork, no behaviour change.

FileWasStored is dispatched from StoreUploadedFile, which every upload
path converges on — the chunked flow staff and clients share, and the
synchronous POST beside it. A listener therefore sees each upload once
without knowing which route produced it, which is the property that makes
it usable from outside this repository. A notification, not a filter:
nothing on it is mutable, and anything that needs to influence an upload
has to do so before the bytes land, which is what ResolvingUploadDisk is
already for.

CreateShareLink is the other half. Minting a link was a ShareLinksController
private concern, and the controller is an HTTP handler behind `staff`
middleware — so a link now needs making from outside a request as well.
Two copies of "make a token, write the row, log it" would drift, and the
half most likely to drift is the token, which is the entire authorization
for /s/{token}: there is no session behind it and no second factor, so
being unguessable is its only defence. Anything minted through the action
gets Str::random(32) — about 190 bits, more than a UUID's 122 — and never
a chosen value. The chosen-token path stays in the controller, where a
person is typing one into a form and its minimum length can be argued
about in a validation rule.

The permission questions stay in the controller too. Whether somebody may
set an expiry or a download cap is a fact about them, and the action has
no viewer to ask; it takes both already resolved, including the expiry,
because "the end of the 12th" depends on whose timezone you are in.

Five tests, including that the file a listener receives is complete and
readable rather than half-built, and that the staff form still refuses an
expiry to somebody without the permission after the extraction.
2026-09-08 15:39:55 -03:00
ignacionelson 763e7b0e2e Render one image once, however many requests ask at the same time
Renditions are generated on demand and cached by existence, and nothing
between the callers stopped two requests decoding the same image at once.
The atomic rename settled which file survived; it never stopped both from
doing the work. So N concurrent requests for one cold rendition were N
full-size decodes, each holding four bytes per source pixel — up to 160 MB
at the 40-megapixel ceiling.

That is not an attack. A public listing emits a thumbnail URL per file, a
browser opens six or more connections at once, and the first visit to a
gallery of ordinary camera images was six simultaneous decodes on a
container sized for one. PublicGroupsController reaches the generator with
no account at all, so nothing about it required a customer to be signed
in, and the 240/min throttle bounds rate rather than concurrency.

Worse than a crash, it did not resolve itself: a render killed mid-flight
renames nothing, so the cache warmed only by whatever finished before the
kill and the page died again on the next visit.

A lock keyed on the destination path — which already encodes the file, the
audience and the rendition, so two requests collide exactly when they
would have written the same path. The waiter re-reads after acquiring,
which is what turns a wait into a cache hit rather than a second decode of
the same image.

Waiting rather than refusing, because the arithmetic says so: a waiting
request holds an idle worker at about 35 MB, a rendering one holds that
plus the whole source bitmap. Six waiters cost what one renderer costs.

On timeout it refuses instead of rendering anyway. Falling through would
reinstate the pile-on at the moment the system is already struggling, and
one failed thumbnail is a better outcome than a container that dies and
takes the warm cache with it.

The wait is configurable because the right number is a property of the
machine — a small VPS reading a large source off a slow disk wants longer
— and clamped to at least a second, since a stray empty variable would
otherwise make every concurrent request fail instantly, which is the
opposite of the point.

Eight tests. Two go red without the lock, and the clamp is asserted on the
resolved value rather than the clock, because block() measures in whole
seconds and a timing assertion there would be flaky rather than wrong.

Found by the session sizing free-tier containers, from the outside.
2026-09-08 15:29:20 -03:00
ignacionelson a5496d24cd Stop describe() vouching for a detection it could not make
`FileDelivery::describe()` from a console returned
`{"method":"php","detected":true}` on every installation, whatever its web
server. detect() reads SERVER_SOFTWARE, which only exists inside a
request, so a console process has nothing to look at and falls to the
`php` default — and `detected: true` then vouched for it.

The value is right for that process and wrong as a statement about the
installation, which is how anybody running it from `artisan tinker` will
read it. Somebody verifying a healthy nginx tenant hit exactly that, spent
an afternoon on it, and only recognised it as an artefact of *where* the
question was asked after reading `nginx -T` in the container.

There is now a third field. `observed` is false only outside a request,
where `method` is a default rather than a finding. Both screens that read
this run in a request and always see true; it exists for whoever asks from
a shell, which is the one place the answer could mislead.

The two web paths are unchanged and were never wrong — `projectsend:status`
does not report delivery at all, so no fleet ever reported this
incorrectly. What was wrong was a confident answer to a question that
could not be answered from where it was asked.

Three tests: a console reading says not observed and still says php,
because php is what that process would actually do; a reading during a
request observes nginx; and a stated method is observed wherever it is
read, since a decision needs nothing detected to be true.

Found by the session verifying the 2.4.0 canary, not by me.
2026-09-08 10:21:02 -03:00
ignacionelson fba5f30436 Release 2.4.0 2026-09-08 09:33:14 -03:00
ignacionelson 0f66f9030c Cut the unreleased notes down to what an operator needs
Each entry was three paragraphs explaining itself. Somebody deciding
whether to upgrade reads a list, and a list that takes ten minutes is one
they skim — so the reasoning is gone and the fact is what is left.

What stayed long is Upgrade notes, deliberately: those are the two things
somebody has to *do*, and a one-liner that says "allow headroom" without
saying how much or when is a note they have to come back and ask about.

This makes the section shorter than 2.3.0 and 2.2.1 above it. Those are
published and stay as they are; the style changes from here.
2026-09-08 09:06:00 -03:00
ignacionelson 7c16733c16 Stop a managed instance being able to hide the project news
I shipped both daily calls as the same kind of thing — an operator's
preference — and only one of them is. That was wrong in the direction that
matters, because it handed a decision over rather than keeping it.

An update notice on a hosted tenant is useless: they cannot act on it, the
image is ours, and the screen that would show it is closed by capability.
So that check does not run there at all, which is right and unchanged.

News is the reverse. Announcements about the product are exactly what a
hosted customer should be told, and a Cloud client with view_news sees
that card today. One administrator switching it off for everybody on that
instance is not a decision the platform meant to hand over — so on a
managed instance the news now runs whatever any setting says, including a
row left behind by an instance that used to be self-hosted.

Capability::NewsConfigure, Community-only, and the thing it gates is the
*choice* rather than the news. A self-hosted operator keeps the switch,
because there nobody else decides what their installation reaches out for.
An edition difference through the capability registry rather than an
edition check, as everything here is.

Gated in all three places rather than only the screen: the command ignores
the setting without the capability, the controller neither sends nor reads
the field, and the checkbox is absent. There is a test that a hand-crafted
PATCH cannot do what the missing checkbox could not, and the guard is
proved load-bearing — remove it and the managed-instance test goes red.

The changelog and product highlights said "two switches" and now say what
is actually true, including that neither appears on Cloud and why they are
absent for opposite reasons.
2026-09-08 02:34:00 -03:00
ignacionelson d7d7acce85 Put the announcement behind the header icon too, from one source
A message worth showing was only on the dashboard, which means somebody
who works in Files and Clients all day never meets it. It now also sits
behind an icon next to the notification bell, and that is on every page.

**One shared prop, not two.** "The same message in both places" is the
requirement, and two props would have drifted the first time anybody
edited one — so the hook moved out of DashboardController into
HandleInertiaRequests, and the dashboard reads the same shared value the
header does. The band and the dropdown also share the component that
renders the words, for the same reason: the reliable way to keep two
renderings identical is not to have two.

Renamed with it. ResolvingDashboardCallout was accurate for about an hour
and became a lie the moment it appeared somewhere else; it is
ResolvingAnnouncement now, and the prop is `announcement`. Free to rename
because nothing has shipped yet — the only other reference was
cloud-modules', by string, updated alongside.

The icon follows UpdateAvailableIcon beside it: absent entirely when there
is nothing to say rather than a dead control, and a plain dot instead of a
count, because there is only ever one of these and a "1" would invite
somebody to look for the second.

Two tests worth naming. One asserts the message reaches a page that is not
the dashboard, which is the whole point of the addition. The other asserts
a client is shown nothing even from a listener that sets it
unconditionally — a client's header carries the bell too, and staff
messages must not reach it however careless the listener.
2026-09-08 02:17:56 -03:00
ignacionelson 334b11d562 Give packages a way into the sidebar and the top of the dashboard
Two seams, in the shape docs/extension-points-architecture.md settles on:
a Laravel event with a mutable payload, dispatched unconditionally, and
with nothing listening the documented default holds. A community
installation gets an empty list and a null callout, which is exactly what
it had before.

ResolvingNavigationLinks exists because the sidebar is a hardcoded array
in app-sidebar.tsx, so a package could not contribute to it at all — the
nav entry was a separate manual edit every time a package grew a screen,
and being manual it was forgotten more than once. Staff-only, decided in
HandleInertiaRequests rather than trusted to each listener: these render
in the administration area, and a client's portal shows their own files
and nothing about the installation. There is a test that a listener adding
unconditionally still reaches no client.

ResolvingDashboardCallout is one band above the widget grid rather than a
widget in it. The grid is a closed list of keys that dashboard.tsx renders
one by one and each viewer arranges, so a message that mattered would sit
wherever somebody dragged it, or under a fold, or switched off. One at a
time, first listener wins: a dashboard that can accumulate banners
accumulates them, and the second is what teaches people to skip the first.

Core learns nothing about what either seam carries. Titles, URLs and copy
all arrive from the listener, and that is not fastidiousness — the first
caller is the hosted edition's link to its own customer portal and its
pitch to free instances, which is commercial copy belonging to one
offering and has no business sitting in the public repository because the
sidebar happens to live here.

An external link renders as a plain anchor opening in a new tab, never an
Inertia <Link>: Link expects a page component back and another origin will
not give it one, so it fails without saying so. It is also never marked
active — nothing outside this app is the page you are on.
2026-09-08 02:07:08 -03:00
ignacionelson da1f432d87 Let an installation stop calling home, two different ways
Every instance reached projectsend.org twice a day and an operator could
stop neither. The news feed had no switch of any kind — FetchNewsCommand
went straight to the request, touching Settings only to write results back.
The update check had one, but its default is on, and a managed fleet had
been setting PROJECTSEND_CHECK_FOR_UPDATES=false for months against code
that reads no such variable: check_for_updates is a database setting, so
the environment never touched it and updates were enabled fleet-wide the
whole time.

They look like one problem and are two, which is why they are fixed
differently.

**The news feed gets a Setting**, its own key, default on. A Cloud client
with view_news sees that card today — DashboardController gates it on the
permission alone, with a comment saying in as many words that it is both
editions and carries no capability. So switching it off is an operator's
choice rather than an edition's, and it must stay reachable everywhere.
Its own key rather than riding on check_for_updates because they are two
different wants: "do not tell me about releases" and "do not show me the
project's news" are asked separately, and an installation with no outbound
access at all wants both.

**The update check gets a capability guard**, ahead of the setting it
already had, and deliberately not a Setting of its own. On a managed
installation the result is unreachable rather than unwanted: the
dashboard's System card and the update UI are both gated on
Capability::SystemUpdates, which is Community-only, and the image is
chosen by whoever provisioned the instance. A Setting would encode a fact
about the edition as a preference — leaving it switchable back on per
tenant, buying a nightly call for a number no screen can draw, and putting
the reason in a provisioning script rather than beside the code. A
self-hosted install holds the capability and loses nothing: its own
setting still decides.

Both guards return success rather than failure. A scheduled task that was
asked not to run has not failed, and reporting it as one would put a red
line in the scheduler history every night for an installation behaving
exactly as configured.

The news switch is on the General settings screen, outside the
can_manage_updates block that hides the update toggle where the capability
is absent — a setting only reachable by editing a database row is a row,
not a switch. Seven tests, and the two that matter go red when either
guard is removed. Sixteen locales translated in the same commit rather
than left for the pass, since a release is close.
2026-09-08 01:27:08 -03:00
ignacionelson 82dd475f8f Write up what #1724 and #1733 mean for an operator
Two entries under Unreleased, both for the same reason: an operator would
otherwise be surprised.

The shorter download link is a behaviour change with a cost attached — a
resumed download more than a minute old is refused where an hour tolerated
it — so it says that plainly rather than only advertising the benefit. It
also says who is not affected, since installations on local disk never used
one of these links at all, and neither do zip bundles.

The upload fix is an ordinary bug fix and would normally need no entry, but
it moves peak temporary disk from "the file plus one part" to "the file
twice over" while assembling. That is a sizing question somebody with a
small temp volume has to answer, so it gets an upgrade note. Nothing to
configure — just headroom.
2026-09-07 19:25:30 -03:00
ignacionelson b758fca19c Merge pull request #1724 from fix/assemble-keeps-parts-for-retry
Keep an upload's parts until its bytes are stored
2026-09-07 19:24:06 -03:00
ignacionelson 02946abf85 Stop the delivery docblock naming nginx as the only local path
#1733 explains its two lifetimes by contrasting a presigned URL with
X-Accel-Redirect, "nginx serves these bytes, now, to this request". That
was true when the branch was written and stopped being true on 1 September,
when FileDelivery gave the local path four methods — auto, nginx, xsendfile
and PHP streaming.

The argument survives intact: every one of those authorises exactly one
response and nothing that outlives it, which is the property the contrast
rests on. Only the naming was stale, and a docblock that says "nginx" to
an operator running Apache reads as "this does not apply to me".

Found resolving the merge, not by the author — the branch predates the
change it collided with.
2026-09-07 19:24:00 -03:00
ignacionelson b7ac44e77b Merge pull request #1733 from fix/presigned-download-window
Give a download's presigned URL a minute rather than an hour

Conflicted against FileDelivery, which landed on main after this branch
was written: main added a constructor where the branch added two
constants. Both belong; the resolution keeps each.
2026-09-07 19:23:52 -03:00
ignacionelson 50a6a19455 Translate the client file editor into all sixteen locales
The eight strings the portal file editor added, which had been sitting in
English since the feature landed — the deliberate trade, but the pass is
due now that the English has settled.

Nothing else came up. The scan reports eight missing per locale and they
are all from this feature, so no unrelated drift crept in alongside it.

Written against each catalogue's own established voice rather than
translated fresh: Spanish stays informal, and "Expires on" takes the verb
its neighbouring "Leave empty for a file that never expires" already uses
in each language. Quoting follows each locale too — Russian keeps its
guillemets, Japanese its corner brackets, Chinese and Vietnamese their
curly quotes — matching how the sibling folder-deletion warning already
reads there.

Every :name placeholder survives verbatim, checked rather than assumed,
and the diff is additive: the one deleted line per file is the previous
last entry re-emitted with a comma.

Checked on the screen, not only in the file, which is the part a parsing
JSON cannot show: the editor rendered in Spanish with every label and hint
in place, "Vence el" agreeing with the hint below it, and no console
errors. The dev instance's Client role was snapshotted, granted the keys
for the run, and restored; the throwaway file it needed is gone.

Locale suite green, 13 tests. The 249 orphans each catalogue reports are
older than this work and left alone deliberately — scan.php cannot see a
key held as data or supplied by a package, and a wrongly deleted entry
reverts a screen to English in silence.
2026-09-07 12:48:10 -03:00
ignacionelson 8de28059db Say when a folder choice publishes the file
Found reviewing the client file editor rather than building it.

File::isEffectivelyPublic() is "my own flag OR my folder's", and
Folder::uploadableBy() admits a client to a public folder on
upload_to_public_folders — a different key from upload_public. So a client
can make a file world-readable without touching the public switch, and
without holding the key that switch is behind.

That is what those two keys have always meant and what uploading into such
a folder has always done, so this does not refuse it. What was new is
where the choice is made. The upload page is entered from a folder the
client has already navigated to, where the list shows a Globe badge on a
public folder. The editor's picker is a flat list of names, and it is the
first place a destination is chosen with none of that context — so the
consequence was invisible exactly where it mattered most.

Public folders now carry the badge in the picker, and choosing one says in
words that anyone will be able to open the file without signing in. Two
tests: that the side door genuinely publishes and is labelled, and that a
private folder is not labelled — a warning on everything is a warning on
nothing.

The rest of the review found no defect. Ownership, the per-field keys, the
staff-scope trap and mass assignment were already covered; a client
deleting a file that staff later revised was checked directly and moves
the chain's recipients onto the successor without widening them, which is
what it is supposed to do. The write path was driven in a real browser —
rename, publish and delete through the actual form and dialog — because a
green suite over a write that 419s in every browser is a mistake this
repository has made before. Bytes gone, audit trail complete, and
file.made_public records the slug.
2026-09-07 12:09:27 -03:00
ignacionelson ea214fc27e Give the client portal a file editor
The authorization landed last commit; this is the way in. A client with
edit_files now gets an Edit action on the files they uploaded, opening a
form with every field their role actually grants, and a Delete beside it.

One page for every theme, not one per theme. portal/edit-file.tsx picks
its shell from the `theme` prop exactly as portal/upload.tsx does, because
a form with eight fields behind five separate permissions, rebuilt four
times, is four places for a field to go quietly missing. What *is*
per-theme is only the entry point: one <FileRowActions /> in each theme's
row actions group, the file twin of the FolderRowActions that was already
there.

Row actions gate on can_update/can_delete, sent per file by
MyFilesController and answered by FilePolicy — never on is_mine, which is
half the question. Holding the file is one half and the role's keys are
the other, and a theme that reads is_mine offers an Edit button that
403s. Written into docs/theming-files-checklist.md so the next theme does
not have to rediscover it.

The folder picker offers only folders the client could have uploaded to,
so it cannot present a destination the save would refuse. Publishing says
in plain words that anyone with the link will be able to open the file
without signing in, and says so differently when the installation has no
public page configured, because there the switch would do nothing visible.

Hiding a control is a courtesy, never the enforcement. Every can_* prop
here is the same question ApplyFileEdits asks when the form posts, and the
tests assert both ends.

Verified in a real browser over CDP rather than only by types and tests,
which say nothing about whether a page mounts: 23 edit actions on the
client's 23 own files and none on the file shared with them, the editor
mounting with its real values, every gated field present, no console
errors. The dev instance's Client role was snapshotted before the run and
restored to exactly what it was.

Refs #1771
2026-09-07 11:15:22 -03:00
ignacionelson 922be7226c Let a client edit and delete the files they uploaded
A client could upload a file and then never touch it again. No rename, no
description, no expiry, no categories, no delete — the portal has three
file routes and all three are GET. Meanwhile the Roles screen happily
grants the Client role edit_files, delete_files, set_file_categories,
set_file_expiration_date and upload_public, and every one of them was
inert, because the routes that honour them are `staff`-gated rather than
permission-gated. That is what #1771 hit: a permission granted, saved, and
silently doing nothing.

A client owns what they uploaded. Ownership is now what lets them edit and
delete it, subject to the same per-field keys staff are subject to.

The obvious implementation is a trap, and it is worth writing down. Both
policy methods began `if (! $user->isStaff()) return false;` and both end
in StaffLibraryScope, whose allowsFile() reads `if (! isClientScoped())
return true` — and isClientScoped() is `isStaff() && role->client_scoped`,
so it is false for every client. Delete the early return and a client
falls into the branch meaning "this staff member is unrestricted" and is
handed the whole library. Same for folders(), which returns an unfiltered
query: a client could move their file into any folder on the installation.
So clients get their own branch, reaching neither. The portal asks
Folder::uploadableBy() instead — a file cannot be moved somewhere it could
not have been uploaded.

edit_others_files and delete_others_files stay inert for clients by
construction. A client has no others' files, only files somebody showed
them, and being shown a file is not being given it.

Which fields an editor may write moved into ApplyFileEdits, shared by the
staff editor, /api/v1 and the portal. There were two copies of the same
eight permission checks and this would have been the third; the checks are
easy, which is exactly why the drift would have been invisible. Callers
normalise their own request shape, this gates and writes and logs. Expiry
reading and writing came along too, as FileExpiry — three copies, of which
only the API's could read a timestamp.

Clients do not choose the public slug. It is derived from the name they
already picked, because an installation-wide unique slug a client sets is
a name to squat and an existence oracle to probe with.

One consequence for later, written up in docs/api-todo.md: the policy now
says yes to a client for file writes, so `staff-token` is the only thing
holding the API boundary where there used to be two independent refusals.
ActorBoundaryTest pins it, and asserts the policy passes first so the test
cannot quietly stop testing the middleware.

Also corrects a stale comment that claimed a deleted file's bytes stay on
disk. They have not since File::booted() grew a `deleted` hook; nothing
ever forceDelete()s a File row, so "until a purge lands" would have meant
never — which is why a client's delete frees their quota by exactly what
it frees on disk.

The UI comes next; this is the authorization, the routes and the tests.

Fixes #1771
2026-09-07 02:37:26 -03:00
ignacionelson 1e30e83f11 Stop projectsend:captcha-off claiming a success it did not have
The command writes Setting::CaptchaProvider = 'none'. On an installation
using the platform's managed keys, Captcha::resolve() returns
managedConfig() — read from config — before it ever looks at that setting,
so the write lands somewhere nothing reads and every form stays protected.

The command then printed "CAPTCHA is off". That is false in the worst
direction: the person running this is locked out and debugging, and the
message sends them away from the one thing that would have explained why
they are still being challenged.

It now says it changed nothing, and names PROJECTSEND_CAPTCHA_DISABLED,
which is checked ahead of the key source and is therefore the only one of
the two escape hatches that works on a managed installation. The docblock
said those two were equivalent; they never were.

Deliberately not gated behind captcha.configure. Gating it would take a
self-hosted operator's way back in — the alternative being a hand-edited
database row — to close something that on a managed installation does
nothing anyway. Reaching it needs a shell in the container, which needs an
RCE, at which point the CAPTCHA is not the problem.

The command had no test at all. It has three now, including one that pins
the ordering inside resolve(): if the environment check ever moves below
the key source, a locked-out operator loses their last way in.
2026-09-07 01:24:13 -03:00
ignacionelson d32788e4a1 Put the CAPTCHA settings screen behind a capability
The screen is open in both editions and stays that way by default, so a
self-hosted installation loses nothing: nobody else supplies its keys, and
nobody else is affected by what it decides.

What the key buys is the ability to take it away. A hosted fleet puts every
tenant on one parent domain and one sending reputation, so an administrator
who turns their own CAPTCHA off is spending everybody else's deliverability
rather than only their own. That is not the shape LDAP and social login
have, which is why those two stay ungated and this one does not.

Gated all-or-nothing on the route, read included, exactly as Storage and
Branding are. Per-field gating in the controller would not have closed it:
switching the CAPTCHA off needs none of the gated fields — `provider: none`
does it, and so does unticking the four per-form switches while leaving good
keys in place — so the PATCH had to be closed too, and the middleware closes
both verbs at once. Which keys the screen may offer is still the separate,
narrower question Capability::CaptchaManagedKeys answers per field.

An operator withdraws it by naming captcha.configure in
PROJECTSEND_CAPABILITIES_DISABLED. Note that the key also joins the list
`projectsend:status` and GET /api/v1/me report, which is additive — the
OpenAPI document types capabilities as an untyped array, so nothing there
needed regenerating.
2026-09-07 01:20:22 -03:00
Eliana Bracciaforte c3503a0651 Merge pull request #1769 from projectsend/docs/readme-projectsend-cloud
Name the official hosted version near the top of the README
2026-09-04 09:22:10 -03:00
Eliana Bracciaforte 51477cbd02 Name the official hosted version near the top of the README
ProjectSend Cloud already appears in LICENSING.md and CONTRIBUTING.md,
but not in the README — the first thing people and search engines read.
One paragraph after the intro names it, says who runs it, and points to
LICENSING.md for where the line between the free core and Cloud sits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-04 09:19:53 -03:00
ignacionelson 7c9847981a Patch 8 pending security advisories in dependencies
league/commonmark 2.9.0 -> 2.10.0 fixes an XSS bypass and three DoS
issues; nanoid, qs, brace-expansion, and @humanfs/node bumped via
npm audit fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019iQNYLu5a65foArRdE9zzx
2026-09-03 11:57:15 -03:00
ignacionelson 7da4635f13 Say which clients a scoped staff member may be told about
A staff member limited to their own assigned clients could read the names
and ids of clients on nobody's roster but their own, out of ordinary file
metadata.

The file boundary was never wrong. Sharing means a file can legitimately
reach a scoped viewer through client A while client B uploaded it, or
while B also receives it -- StaffLibraryScope::buildFiles is right to
permit that, and a B-only file is still a 403. What was wrong is that
every response then went on to name B. FileResource serialised the loaded
uploader and each assignment unfiltered; ShareTargets::assigned took no
viewer at all, so the details panel published the recipient list as it
stands and forSubject narrowed available_clients while handing
assigned_clients straight through. FoldersController::fileRow,
FilesController::edit, FileDetailsController and ClientFilesController
each named the uploader the same way. The API's uploaded_by filter asked
the question without any name attached: it answered "does this client of
yours put files in front of a client of mine" for any id a caller cared
to try.

12a8ebe3 said the rule out loud while fixing topClientsByStorage -- "the
file was theirs to read and the uploader's name was not theirs to see" --
and then the rule stayed in that widget. So it is a class now.
ClientIdentityScope is the one decision, asked by every surface that
names a client, and it deliberately answers about clients only: 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. Groups go
through it too, on the same argument -- a group is a list of clients
wearing one name -- which the report did not cover but is the same leak.

Two judgement calls worth naming. assigned() keeps returning the whole
truth and gains a warning, because VisibleCommentScope resolves
notification recipients from it and a recipient filtered out of that list
is one who never hears about a message addressed to them; assignedFor()
is the display half. And FileResource asks at serialisation rather than
in its callers' eager loads, which is the opposite of how the version
counterparts next door are narrowed: that one is set-shaped and folds
into a query, this one is a per-row roster check across eight call sites
in four controllers, two of them re-loading assignments after a write.

The tests assert on whole response bodies rather than on named keys. The
leak was never in one field -- the same name arrived through the
uploader, through the recipient list and through four screens -- so a
body that does not contain the name anywhere is the only assertion that
would have caught all of it. Ten of the eighteen fail without this
change; the rest are the negative controls, including that an unscoped
administrator still sees every name and that the uploaded_by filter still
works for a client on the roster and for staff.

Reported by @Noorkhalel, GHSA-whmp-p9hv-r7j7. Their write-up named every
affected surface and the root cause in each, which is most of why this
took one pass.
2026-09-03 00:56:41 -03:00
ignacionelson ddf09677f0 Document the branding endpoints where their callers are
Branding moved into the application on 2026-08-28 and its two read-only
endpoints came with it unchanged -- same paths under
/api/v1/modules/branding, same capability, same ability. Their
documentation did not: the guide still sent readers to
packages/cloud-modules/docs/api.md, so endpoints that every installation
now carries were described in a private repository almost none of their
callers can open.

The OpenAPI document is still not the place for them. OpenApiContractTest
skips api/v1/modules/* on purpose: that document is served
unauthenticated and has to be identical on every installation, while a
module's paths exist only where the module does. So this is a plain
markdown file beside the guide, and published like it -- ignored docs are
maintainer notes, and this one is for integrators.

It is the cloud-modules file moved across, minus the attribution switch:
that half stayed Cloud-only and has no API surface at all. The gate is
described as every edition holding branding.customize, with a hosted plan
able to subtract it, because that is what the enum now says.
2026-09-02 18:56:55 -03:00
ignacionelson 9b2aea4812 Say what the actions cast actually costs if it goes
The comment said a reader unmarshalling a map breaks on an empty array.
Checked against the reader since, and it is worse than that: the hosted
platform decodes the block into a typed struct and discards a block it
cannot read, and Go refuses a JSON list into a map outright. A [] here
loses the whole usage block -- downloads and uploads with it -- on the
day a tenant happens to have no counted activity, with nothing logging a
fault. The quietest installations would be the ones that went quiet.

Comment only. The cast was already right; what was missing was the
reason it is load-bearing, which is exactly the kind of condition this
week kept proving nobody had written down.
2026-09-01 02:05:29 -03:00
ignacionelson 96107fdcd5 Release 2.3.0 2026-09-01 01:18:18 -03:00
ignacionelson eecd5b804d Write the 2.3.0 changelog entry
Turns the Unreleased section into a numbered entry and adds this
cycle's work to it: downloads on Apache and LiteSpeed, branding in
core, the build fact, the scheduler check, and the run of boundary
fixes.

The three security entries already in Unreleased are carried across
word for word rather than summarised. Their "Who this affected"
paragraphs are the part a reader decides on, and a one-line retelling
would have thrown that away. Their two upgrade notes move across whole
for the same reason, joined by the two this release adds.

Leaves an empty Unreleased scaffold for the next cycle. The version is
not stamped anywhere else yet -- config/projectsend.php and the tag are
still on 2.2.1, so this is the entry waiting for a release rather than
a released one.
2026-09-01 01:18:08 -03:00
ignacionelson 616aa49867 Translate the download delivery screens into all sixteen locales
The 29 English strings added by the file-delivery work: the System
widget row and its explanation dialog, and the settings panel that
repeats it.

Product nouns left alone throughout -- PHP, nginx, Apache, LiteSpeed,
X-Sendfile, mod_xsendfile, XSendFilePath, PROJECTSEND_FILE_DELIVERY,
INSTALL.md, S3, Google Cloud. "PHP" as a whole string stays "PHP",
which is why every locale gains one entry counted as untranslated, the
same way API and OK are.

Purely additive: appended rather than merged in sorted position, so each
diff is 29 new lines plus a comma on the line that used to be last.

Verified: scan reports 0 missing in all sixteen, 13 locale tests pass,
and the Spanish settings screen was read out of the running app rather
than out of the file.
2026-09-01 01:18:08 -03:00
Ignacio Nelson 525c464327 Merge pull request #1740 from denkfabrik-li/fix/update-keeps-cache-signal
Leave the caches update.sh's own update command needs to see
2026-09-01 01:17:46 -03:00
ignacionelson 97596da7d0 Refuse a stored path carrying a control character
Found reviewing the delivery work. The path is written into
X-Accel-Redirect or X-Sendfile, and a CR or LF in a header value is
header injection. PHP's header() refuses to emit one, so the real effect
is a 500 on every download, preview and thumbnail of that file rather
than a split response -- a file permanently broken by its own name.

Paths are generated here as Y/m/{uuid}.{ext}, so this should be
unreachable. The extension is not generated: it comes from the
uploader's filename, and on a migrated installation from a v1 database.
The upload routes all check the extension against an allowlist, which no
control character can match -- but upload_type_restriction can be set to
none, and the importer does not consult that policy at all.

assertRelative() was documented as the backstop for what a path may be
and only covered traversal, which is the half that cannot happen here.
Low severity, and the guard should have covered it either way.
2026-08-31 22:57:02 -03:00
ignacionelson 2ebadf0793 Mark the downloads settings link the way the dashboard marks it
Same amber, same underline, same warning triangle as the System widget
row. The two say the same thing about the same installation, so looking
different made the settings one read as an ordinary footnote rather than
the thing to go and read.
2026-08-31 22:49:06 -03:00
ignacionelson 25e4f77b63 Make the whole downloads row open the explanation
The warning triangle alone did not read as clickable. The label is now
an underlined button and the value and icon are a second one, so either
half opens the dialog and the row looks like the one thing on this card
you are meant to act on.

Two buttons rather than one wrapping the row: a dt/dd pair cannot be
nested inside a single button without losing the description-list
semantics that tie the value to its name. The value button carries an
aria-label because "PHP, button" describes nothing on its own.

Only when PHP is sending the files. On the fast path the row stays plain
text, since there is nothing to go and read.
2026-08-31 22:44:31 -03:00
ignacionelson 90ed2d60b9 Shorten the PHP downloads explanation
It was 2975 characters and scrolled. Same four questions answered and
nothing dropped -- what is happening, what it costs, why it is set that
way, the three ways out -- in 1696, which fits the dialog without
scrolling. The three fixes are a list rather than three headed
paragraphs, since each is one instruction.

A wall of text explaining a performance trade-off is self-defeating: the
person who most needs to read it is the one who opened the dashboard for
something else.
2026-08-31 22:37:06 -03:00
ignacionelson d6fd5a917d Send downloads the way the web server in front of us understands
Uploads live outside the web root, so PHP authorizes every download and
then hands the file to the web server with a header naming it. Four
routes decided that for themselves and all four hard-coded nginx's
spelling. On Apache or LiteSpeed nothing acts on the header, so the
empty body PHP sent goes to the visitor: files upload fine, thumbnails
are broken images, and downloads arrive as 0 bytes, with every other
page working. Reported as #1765 from an Apache 2.4 install, and before
that as #1266, #1215, #870 and #1271.

It is also a regression from v1, which had a download_method setting --
php, apache_xsendfile, litespeed, nginx_xaccel -- defaulting to php. v1
therefore worked on any server out of the box and v2 did not, and a v1
Apache user migrating lost every download with nothing to tell them why.

So the four sites now go through one FileDelivery, and it picks:

  auto (default)  nginx when SERVER_SOFTWARE says nginx, else php
  nginx           X-Accel-Redirect, a URL path via the internal location
  xsendfile       X-Sendfile, an absolute path (Apache mod_xsendfile,
                  LiteSpeed)
  php             BinaryFileResponse

Defaulting to auto rather than nginx is the point of the change: a
default that assumes nginx leaves an Apache install exactly as broken as
it is today until somebody reads INSTALL.md. Slow beats empty.

Auto never picks xsendfile, even where the module is loaded.
mod_xsendfile also needs XSendFilePath to allow the storage directory,
which cannot be seen from here, and choosing it on the strength of the
module being present would trade a silent failure an administrator can
diagnose from the dashboard for one nobody can.

BinaryFileResponse rather than a readfile loop because it answers Range
requests. nginx does that itself on the fast path, so hand-rolling it
would have broken seeking through a video on exactly the installations
this fallback exists for. Verified end to end: 206 with the right
Content-Range through the live stack.

Two guards. Every method checks the path cannot climb out of the storage
area -- nginx resolves `..` in the URL it is handed as happily as PHP
would -- and the two methods that hand over a filesystem path resolve it
and prove it lands inside the root. Callers pass paths from rows they
just authorized, so this is a backstop; it is here because the cost of
being wrong once is handing over any file the web server can read.

The dashboard's System panel names the method, with a warning icon and a
dialog when PHP is doing the sending: what is happening, what it costs
(one worker held for the whole of each download, so a few large
simultaneous ones can occupy every worker while the processor sits
idle), why it is set that way, and the three ways out. Written to be
accurate rather than reassuring -- nothing is broken, it does not scale
-- and the notice stays even when php was chosen deliberately, because
the trade-off is the same either way. /system/settings/downloads repeats
it, which is where somebody coming from v1 goes looking for the
dropdown.

An environment variable rather than a stored setting: it describes the
server this installation runs on, not a preference, and a value in the
database travels to a different server in a restore and is wrong there.
Read only in config/projectsend.php, so config:cache cannot blank it.

The suite pins itself to nginx. Left at auto it would detect no server
at all, fall back to php, and quietly retire the coverage of the
mechanism most installations actually use.
2026-08-31 22:31:27 -03:00
ignacionelson 6340b71dca Report recent usage and whether the scheduler is alive
projectsend:status could say what an installation holds and how many
accounts it has, but nothing about whether anybody was using it. Adds a
`usage` block -- downloads split staff/clients/anonymous, uploads, and
five allowlisted action counts -- plus `activity.last_client_login_at`,
`health.scheduler` and `health.failed_jobs_latest_at`.

The scheduler is the one worth having on its own. `health.queues`
catches a dead worker; nothing caught a dead scheduler, and its first
symptom is not a stalled feature but an expired file that is still
downloadable, because the job that was going to remove it stopped
running weeks ago. Nothing about the installation looks wrong while that
is true.

`failed_jobs_latest_at` exists because the count beside it cannot say
whether anything is wrong *now*, and reading it as though it could is a
category error rather than a threshold wanting tuning. The table is
swept daily, so the count spans a retention window -- one the
installation chooses, and one that can be set to keep-forever by
somebody who treats a failed job as evidence rather than debris. Two
identical installations therefore report different numbers, and on a
keep-forever one the count grows until any fixed threshold trips. A
timestamp is independent of how long rows are kept: 27 failures whose
newest is three weeks old is an installation that has been healthy for
three weeks and has not been swept yet.

`usage` is a rolling window with no lifetime totals, and that is a
correctness decision rather than a presentational one: activity_log is
never pruned, so a lifetime count over it gets slower every day of the
installation's life while a windowed one stays flat. The window is
emitted as `window_days` rather than left for the reader to assume.

The actions are an allowlist, not a `group by action`. This document
leaves the installation and Action gains cases most weeks, so an open
group-by would ship new action names outward with nobody having decided
they should go -- and some of them (account.erased, two_factor.reset)
are somebody's compliance event, not a business metric. It is also ~30x
cheaper: five keyed counts ride (action, created_at) while a group-by
starts from created_at and reads rows. The scheduler's failure message
and the queue exception text are omitted for the same reason; they are
the fields here that can carry a path or a stack trace, and a count with
a timestamp says "go and look", which is all a watcher is owed.

The two indexes ship as a pair and the migration explains at length why.
Measured at 2.1M rows: adding (action, created_at) alone fixes the
windowed counts and takes last_staff_login_at -- already running hourly
on every tenant -- from 0.63s to 7.7s, because the planner switches to
it, still needs actor_type, and does a scattered primary-key lookup per
row. With both, that query is answered from the index without reading a
row at all (0.0004s) and the whole new usage block costs ~70ms.

Also documents the keys as a contract, the way `capabilities` already
is. This one fails worse: a renamed capability key breaks a comparison
somebody is watching, a renamed usage key produces a chart that is
silently empty, and nobody gets paged for a flat line.
2026-08-31 18:51:47 -03:00
ignacionelson fb931819e2 Translate the auth and settings screens into all sixteen locales
#1762 put six screens through the translator and deliberately left the
new keys for a focused pass; this is that pass, plus the one older gap
the scan turned up -- the directory-account notice on the password form.

Twenty-seven keys each, written to match what the catalogue beside them
already says: de and tr formal, es, nl, pl and zh_CN informal, and each
locale's own established vocabulary rather than a fresh choice per file
(nl keeps wachtwoord, tr keeps parola, ru keeps "адрес эл. почты").

"Or, return to" and "log in" are rendered as one sentence with a space
between them, so each pair was chosen to read as a phrase in that
language rather than translated word by word -- Turkish reorders it to
"Ya da giriş sayfasına dön", Japanese to "または ログインに戻る".

Additive only: 432 insertions, nothing reordered or reformatted. The
scan now reports zero missing across all sixteen.
2026-08-29 16:27:50 -03:00
ignacionelson 41b22d003e Merge pull request #1764 from denkfabrik-li/fix/placeholder-case-convention
Honour Laravel's placeholder case convention in t()
2026-08-29 16:00:17 -03:00
ignacionelson 78d5067c6b Merge pull request #1763 from denkfabrik-li/fix/zip-poll-stops-with-its-page
Stop the zip poll when its page goes away
2026-08-29 15:58:11 -03:00
ignacionelson b7cc5e8615 Merge pull request #1762 from denkfabrik-li/fix/auth-settings-pages-translated
Run the auth and settings screens through the translator
2026-08-29 14:42:04 -03:00
denkfabrik-li ed82d748ea Run the auth and settings screens through the translator
use-translation.ts states the rule: every user-facing string in a
component must go through t(). Five screens never called it at all --
forgot-password, reset-password, confirm-password, verify-email and
settings/password had zero occurrences of useTranslation -- so a client
who had chosen Spanish reset their password in English, from the browser
tab down to the submit button. settings/profile had the hook but used it
for two strings, leaving its heading, labels and the whole
email-verification notice hardcoded around them.

The password page also carried a second, smaller mistake the miss was
hiding: its <Head> title said "Profile settings", copied from the
profile page, so the tab named the wrong screen in every language.
It says "Password settings" now, the wording its own breadcrumb and
the sibling "Notification settings" title already use.

Every string on the six screens goes through t() now. The two
module-level breadcrumb arrays moved inside their components to reach
the hook -- the shape two-factor, notifications and the other settings
pages already have. Where a key already exists in the catalogs (Email
address, Password, Confirm password, New password, Log out and friends,
shared with the login screen) the existing translations light up
immediately; the keys new to the catalogs fall back to their English
text, exactly what those lines rendered before, until the locales pick
them up.

TranslationUsageTest is the guard, a source scan like
DateFormattingUsageTest and for the same reason: no JavaScript test
runner gates this class of miss. It fails on any page under pages/auth
or pages/settings that never uses the hook -- those screens always carry
copy of their own, so a page there without it is a page somebody forgot
-- and on any literal <Head title="..."> anywhere, which is both a
user-facing string and where the copy-paste title above lived. Both
scans go red on the tree without this change: five pages and six
literal titles.
2026-08-29 08:58:00 +02:00
denkfabrik-li fe3b7b7018 Honour Laravel's placeholder case convention in t()
The frontend translator replaced placeholders by exact match only:
`:${name}`, nothing else. But the catalogs it consumes are Laravel JSON
catalogs, and Laravel's convention has always been three forms — :name
receives the value as-is, :Name capitalized, :NAME upper-cased. The
backend translator honours all three; fifteen values in lang/nl.json
and one in lang/tr.json already rely on it. Dutch writes "Add :name" as
":Name toevoegen" because the noun opens the phrase there and gets the
capital; Turkish does the same with "Go to page :page" as ":Page
sayfasına git". Through this hook, those sixteen values rendered the
literal ":Name" and ":Page" instead of the replacement — the value was
right for the language and wrong only for the half of the app that
reads it with an exact-match replace.

t() builds the three variants per replacement now, longest placeholder
first — strtr's implicit rule made explicit, so with :name and :names
both in play, :name cannot eat the front half of :names. Ties keep
insertion order, which resolves a fully-colliding key to the as-is
value, the same answer the backend's assignment order produces.

No test accompanies this: there is no JavaScript test runner in the
project, and the PHP suite exercises the backend translator, which was
never wrong. Counter-checked by running both implementations over the
affected catalog values in node — the old replace leaves ":Name
toevoegen" and ":Page sayfasına git" literal, the new one renders
"Bestand toevoegen" and "2 sayfasına git" — plus the existing in-repo
call shapes (":used of :limit", ":name — files"), which come out
byte-identical to before.
2026-08-29 08:57:49 +02:00
denkfabrik-li 6783fa0b81 Stop the zip poll when its page goes away
useZipDownload sets an interval that polls zip-downloads/{id} every two
seconds until the build reports ready or failed. The only paths that
ever cleared it were those two answers and close() — there was no
unmount cleanup at all, no useEffect in the file. But the pages that
hold the hook are Inertia pages: navigating away unmounts them without
close(), and the interval keeps hitting the endpoint every two seconds
for as long as the tab lives, polling for a download nobody can receive
any more. A zip stuck in pending — the exact case the polling exists
for — polls forever.

Three holes, one leak:

- No cleanup on unmount. A useEffect returning stopPolling closes the
  main path.
- An unmount while the store POST is still in flight: its then() runs
  after the cleanup already did, and would set a fresh interval on the
  dead component. The unmounted flag makes that then() a no-op.
- A second start() while a poll is running overwrote pollRef and
  orphaned the first interval the same way. start() stops the previous
  poll first now.

No test accompanies this: there is no JavaScript test runner in the
project, and the PHP suite never mounts a component. Verified with
tsc, eslint and prettier, and by reading the two consumers —
files/index.tsx and use-portal-files.ts — both of which only ever
clear the interval through the dialog's onClose today.
2026-08-29 08:57:49 +02:00
ignacionelson 4556ccf691 Merge pull request #1761 from denkfabrik-li/fix/self-hosted-font
Serve the interface font from the installation, not from a font CDN
2026-08-29 02:34:53 -03:00
ignacionelson 85572eb45e Write up the image's production defaults for operators
#1760 changes how an existing installation behaves on the next pull, and
the part worth saying out loud is the one nobody could see: "reject
known-breached passwords" was reporting itself as on while doing nothing.

Filed under Security with who was actually affected -- not anyone
following the documentation -- and under Upgrade notes with the one thing
that stops working: APP_ENV and APP_DEBUG edited inside storage/.env.
2026-08-29 02:32:40 -03:00
ignacionelson 227a08dfce Merge pull request #1760 from denkfabrik-li/fix/image-defaults-to-production
Have the production image default to production
2026-08-29 02:32:21 -03:00
ignacionelson 43e9985b2b Write up the quickstart's loopback binding for operators
#1759 changes a file people copy verbatim, so the change has to reach
them somewhere other than a diff: anyone who copied the old example and
reaches the app on <server-ip>:8080 will find it stops answering.

Filed under Security with the reason it was a finding at all -- a
published Docker port is not covered by a host firewall, so the port was
often open without anyone intending it -- and under Upgrade notes with
what to do when the proxy lives on another machine.
2026-08-29 02:30:15 -03:00
ignacionelson f931c6a492 Merge pull request #1759 from denkfabrik-li/fix/quickstart-binds-to-loopback
Publish the quickstart on loopback, since it trusts any proxy
2026-08-29 02:29:52 -03:00
ignacionelson 1a3260a397 Merge pull request #1758 from denkfabrik-li/fix/confirm-password-asks-the-directory
Let the confirm-password screen ask where the password lives
2026-08-29 02:05:49 -03:00
ignacionelson 07e7132747 Merge pull request #1757 from denkfabrik-li/fix/dont-flash-stored-secrets
Stop a rejected settings form flashing the credential it carried
2026-08-29 01:21:13 -03:00
ignacionelson f4fd194991 Merge pull request #1756 from denkfabrik-li/fix/provider-link-password-confirm
Make linking a provider re-prove the password
2026-08-29 01:16:16 -03:00
ignacionelson ea45943f40 Merge pull request #1755 from denkfabrik-li/fix/credential-checks-rate-limited
Give every password check in front of an account its own bucket
2026-08-29 01:10:53 -03:00
ignacionelson ce96313710 Merge pull request #1754 from denkfabrik-li/fix/api-group-members-response-scope
Narrow the membership an API member write hands back
2026-08-29 01:09:46 -03:00
ignacionelson 77dd5ff90b Merge pull request #1753 from denkfabrik-li/fix/account-conversion-list-scope
Narrow the conversion list to the clients its own refusal allows
2026-08-29 01:07:22 -03:00
ignacionelson 8984aba7d8 Merge pull request #1752 from denkfabrik-li/fix/preference-writes-bounded
Bound the two preference endpoints by their own registries
2026-08-28 23:41:46 -03:00
ignacionelson 188848b549 Merge pull request #1751 from denkfabrik-li/fix/credentials-in-boot-cache
Keep the mail and storage credentials out of the boot-config cache
2026-08-28 22:56:37 -03:00
denkfabrik-li 7264c44fd7 Serve the interface font from the installation, not from a font CDN
app.blade.php is the root template for all three interfaces, and it opened
with two lines pointing at a third party:

    <link rel="preconnect" href="https://fonts.bunny.net">
    <link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />

Every visitor to /login, /register, /forgot-password, /s/{token} and every
public listing page therefore made a request to a host the operator did
not choose and could not switch off, before they had done anything at all
-- handing it their IP address, their user agent, and through Origin the
hostname of the installation they were visiting. On the signed-out pages
that is a visitor who has agreed to nothing, and an operator who often has
told their own users that this server is where their files live.

There was no self-hosted copy in the repository, no setting, no mention in
INSTALL.md, DOCKER.md or SECURITY.md, and no SRI on the tag.

The font now ships with the application, through @fontsource/instrument-sans
-- the same font, the same three weights the URL asked for, from a
versioned dependency rather than binaries pasted into the repository.
Vite fingerprints and emits them like any other asset.

Cost, measured on this build: twelve files, 192 KB on disk. A browser
fetches only woff2 and only the subsets it needs, which is 73 KB for all
six woff2 files together and typically 41 KB (latin, three weights) for a
page in English. Against that, every page load loses a DNS lookup, a TLS
handshake and a round trip to another origin, so signed-out pages get
faster rather than slower.

This is a privacy change rather than a vulnerability fix, and worth saying
plainly: the share token does not leak this way. Referrer-Policy:
strict-origin-when-cross-origin is set in both nginx configs and in the
INSTALL.md snippet, so the path never travelled in the Referer. What
travelled was the visit itself.

Not changed: public/.htaccess still sets no security headers at all, so an
Apache installation has no Referrer-Policy. That is a real gap and a
separate change.

Verified: `npm run build` succeeds and emits the faces; no reference to
the CDN survives anywhere in public/build; `tsc --noEmit` and prettier are
clean. No test asserts on the font, before or after.
2026-08-29 00:50:02 +02:00
denkfabrik-li 3e24ccd42f Let the confirm-password screen ask where the password lives
ConfirmablePasswordController checked the local hash and nothing else:

    Auth::guard('web')->validate(['email' => ..., 'password' => ...])

An account provisioned from a directory has no local password. It holds a
Str::password(64) generated at provisioning time that nobody has ever
seen, and the application knows this -- LdapAuthenticator::isDirectoryAccount()
is the question, and the sign-in form asks it before deciding what to
check. This screen did not, so it refused those accounts the only password
they have.

That is not a cosmetic refusal. `password.confirm` stands in front of
enrolling in two-factor, so a directory-provisioned client could not enrol
at all. Set TwoFactorEnforcement to `clients` or `all` and EnforceTwoFactor
redirects every request they make to two-factor.show -- a screen whose
"enable" button leads to a door they cannot open. PR #1708 fixed the
routing half of that ("Let an enforced user reach the far side of the
confirm-password screen"); this is the credential half.

The rule now lives in one place. PasswordVerification is the sibling of
SignIn on the other side of the line SignIn draws -- SignIn is everything
after a credential checks out, this is the one question asked before it --
and it exists for the reason SignIn gives for existing: "the way they get
broken is by being written twice". LoginRequest keeps its ordering, its
provisioning and its rate limiting, and delegates the check itself.

Behaviour preserved exactly on the sign-in path: local hash first so an
account that answers locally generates no directory traffic, directory
only for accounts whose credentials live there, the stale-hash re-hash on
the local branch only, and the ldap_dn stamp on the directory branch. All
23 existing LDAP sign-in tests pass unchanged.

One thing this closes on the way past. Because the old check went straight
to the local hash, a directory account's placeholder *would* have confirmed
if anybody ever learned it -- a door the sign-in form does not have, since
it skips the local branch for those accounts. It now behaves the same on
both screens; there is a test.

**What this does not fix, and should be read as a limitation.** Accounts
provisioned by a social provider are in the same position -- a random local
password nobody holds -- and they are not directory accounts, so this
changes nothing for them. Their route to a local password is the password
reset, which #1748 made work end to end by moving auth_source to Local when
the reset completes. A social account that has never done that still cannot
confirm a password, and so still cannot enrol in two-factor.

Tests: three fail against the unfixed pair, including the placeholder case
above. Two more pin what must not change -- a wrong directory password is
still refused, and a local account with LDAP switched on still confirms
against its own hash.
2026-08-29 00:10:59 +02:00
denkfabrik-li 74077993de Have the production image default to production
The entrypoint seeds the .env on the storage volume when there is none:

    if [ ! -f storage/.env ]; then
        cp .env.example storage/.env

.env.example is the development template. It carries APP_ENV=local and
APP_DEBUG=true, and the Dockerfile set no defaults of its own -- its only
ENV was PROJECTSEND_IMAGE=1.

Real environment variables win over that file, so compose.example.yaml
(APP_ENV: production, APP_DEBUG: "false") was never affected, and neither
was anybody following the documentation. Everybody else was: `docker run`
with nothing but a database address, the Portainer / unRAID / TrueNAS
templates people actually use, a Kubernetes manifest naming only
DB/Redis/APP_URL. All of them booted a debug build and nothing said so.

Two things follow, and neither is visible from inside the application:

1. **Every 500 hands its stack trace to whoever caused it**, signed in or
   not -- Laravel's exception page, with the file, the line and the
   surrounding source. docker/production/php.ini sets display_errors=Off
   and that does not help, because Laravel renders the page itself rather
   than letting PHP print it.

2. **"Reject known-breached passwords" never ran.** PasswordPolicy::rule()
   appends ->uncompromised() only when app()->isProduction(). On
   APP_ENV=local an administrator could switch the setting on, watch
   descriptor() advertise it on every password form and the security
   settings screen report it as active, and have it do nothing.

The image now states its own environment.

Set in the Dockerfile rather than in the seeded .env on purpose: the copy
only happens when no .env exists, so seeding would fix a fresh install and
leave every installation already running on a stale one exactly as it is.
As an ENV it takes effect on the next pull.

What it does not outrank: Laravel builds its env repository immutable
(Illuminate\Support\Env), so a real environment variable beats the .env
file. `docker run -e`, compose `environment:` and Kubernetes `env:` all
set real environment variables, so an operator who asks for something
explicitly still gets it -- verified against this image, where a .env
saying local/true is overridden to production/false by the variables.

The trade-off, stated because it is a behaviour change: editing APP_ENV or
APP_DEBUG inside storage/.env no longer has any effect, since these are
real environment variables and that file is not. Turning debug on
deliberately is `-e APP_DEBUG=true`, which still works. That is written
into the Dockerfile comment so the next person finds it there.

Not changed: PasswordPolicy's isProduction() test itself. Tying an
administrator's setting to the environment rather than to the setting is
arguably wrong on its own, but it is a separate question with its own
blast radius, and this change makes the shipped image behave the way that
code already assumes.

No test: the environment an image ships is not observable from the suite.
`docker build --check` reports no warnings on the edited file.
2026-08-29 00:07:12 +02:00
denkfabrik-li da7eb6f67d Publish the quickstart on loopback, since it trusts any proxy
compose.example.yaml does two things that are each fine alone and unsafe
together:

    ports:
      - "8080:80"            # Docker binds 0.0.0.0 unless told otherwise
    environment:
      TRUSTED_PROXIES: "*"   # believe the X-Forwarded-For of whoever connects

Behind a proxy that appends the header, "*" is correct and harmless --
Symfony strips the peer and takes the real client the proxy appended. The
example never gets there. It publishes the container on every interface,
so a visitor can reach port 8080 themselves, and then *they* are the peer
the application has been told to trust. `X-Forwarded-For: 203.0.113.9`
makes request()->ip() return exactly that.

What that costs, all of it on the signed-out surface:

  - the login lockout, keyed on `email|ip` in LoginRequest::throttleKey()
  - throttle:6,1 on register, password-email, password-reset, two-factor
  - throttle:30,1 on share-link, public-browse, public-comment
  - the download log, the activity log, and the `ip_address` recorded on
    guest comments -- which FileComments::post calls "the one handle that
    makes spam actionable"

Rotate the header and every one of them counts a different attacker.

The project's own test states the primitive: TrustedProxiesTest sets
trustedproxy.proxies = '*', sends X-Forwarded-For from a *direct* client,
and asserts the address is taken.

The documentation has always qualified "*" correctly -- .env.example says
it is "only safe when nothing but the proxy can reach the app", and
dockerhub-overview.md repeats it. The example file is what did not meet
its own precondition, and it is the file the Docker Hub description tells
a first-time reader to copy.

Publishing on 127.0.0.1 restores the precondition: a proxy on the host, or
in this compose file, still reaches it; nothing off the machine does. This
repository's own compose.yaml already publishes Adminer that way, for the
same reason.

The two settings are now documented as a pair in all three places that
carry them, including what to do when the proxy is on another host: bind
to the interface it arrives from and name that address in TRUSTED_PROXIES
instead of "*".

DOCKER.md's health-check command changes with it -- it told the reader to
curl <host-ip>:8080 from the same machine, which the new binding does not
answer. It now says 127.0.0.1:8080.

No test: this is packaging and prose. `docker compose config` parses the
edited file.
2026-08-29 00:05:03 +02:00
denkfabrik-li 35d68a792b Stop a rejected settings form flashing the credential it carried
When validation fails, Laravel flashes the request's input into the
session so the form can be repopulated. Its exclusion list is
current_password, password and password_confirmation -- written for the
login and password screens, and covering none of the credentials the
system settings screens take. `dontFlash` did not appear anywhere in this
repository.

So every one of these went into the session in clear the moment its form
was rejected:

    secret          ExternalStorageSettingsController   (S3 secret access key)
    key_file        ExternalStorageSettingsController   (GCS service account JSON)
    bind_password   LdapSettingsController
    client_secret   SocialLoginSettingsController, EmailSettingsController
    secret_key      CaptchaSettingsController

Each is stored with an `encrypted` cast, and config/session.php puts
sessions in the database with `encrypt => false` -- so the rejected save
wrote in clear into the same database the cast exists to protect.

The sharpest one is key_file. serviceAccountKeyRule() exists to catch a
paste that lost its last line, which makes "the request carrying a
service account private key" and "the request that fails validation" the
same request more often than not.

dontFlash() merges rather than replaces, so the framework's three stay.

The cost is that these five come back blank after a failed save. That is
already what they do after a successful one -- every screen here treats
them as write-only, and a blank means "keep what is stored" -- so the
behaviour is now the same either way instead of only on success.

Tests: one per field, each submitting a form that fails validation while
carrying a secret, then reading the old input back the way the form
would. All five fail against the unmodified bootstrap/app.php. A sixth
pins that the framework's own three are still excluded, and each
assertion checks a neighbouring non-secret field still comes back, so
this cannot pass by flashing nothing at all.

Note for the record: this is testable in the existing harness after all.
phpunit.xml sets SESSION_DRIVER=array, but old input is written to the
session whatever the driver backs it, so getOldInput() sees exactly what
a database session would have stored.
2026-08-29 00:02:21 +02:00
denkfabrik-li bde86c10e4 Make linking a provider re-prove the password
Connecting a provider needed nothing but the session. Anyone holding one
could POST /settings/connected-accounts/google, follow the returned
Inertia::location(), sign in at the provider as *themselves*, and
completeLink() would bind their identity to the victim's account.

SocialAccount says what that row is:

    This row *is* the authorization to sign in as that account.

So it is not a preference -- it is a credential, and one that outlives
every way the victim has of ending the session that created it. It
survives a password change, it survives Auth::logoutOtherDevices(), it
survives invalidating every session. Where a stolen session gives an
attacker access until it is noticed, this gives them an account.

routes/settings.php already makes exactly this argument, twenty lines
down, for the two-factor block and the API token routes:

    a token outlives the session that minted it, so a stolen session must
    not be enough to mint one

The link has that property too, and was the one thing on this screen
without the gate. Now it has it.

The gate goes on `connect`, not on the callback: starting the flow is what
writes the intent the callback completes, and the callback deliberately
sits outside every group so a provider sign-in works without a session.

Not changed, deliberately: `connected-accounts.destroy`. Disconnecting
removes a way in rather than adding one, and destroy() already refuses to
remove the last one ("This is the only way you can sign in. Set a password
first"). Putting it behind password.confirm would fall hardest on the
accounts a provider provisioned -- they hold a Str::password(64) nobody
has ever seen -- and leave them unable to disconnect anything at all.
There is a test pinning that it stays reachable.

Also not changed: the account owner still is not told. SocialLoginController
writes an activity log entry, and that sits behind `staff` +
can:view_actions_log, so a client never sees it. Notifying them is a real
gap and a separate change; this one closes the door rather than adding a
bell to it.

Tests: two that fail against the ungated route -- the redirect, and the
whole attack end to end with a stranger identity never binding. The
existing connect() helper now confirms the password, the way
enableTwoFactor() already did, so the rest of the file keeps exercising
the real gate rather than asserting around it.
2026-08-28 23:59:11 +02:00
denkfabrik-li c72adadc44 Give every password check in front of an account its own bucket
POST /confirm-password verified the account's password and counted
nothing. Forty wrong guesses, forty identical refusals, no lockout, no
Retry-After, no log line.

routes/auth.php opens by requiring the opposite:

  **Every `throttle:` below names its own bucket, and must.**

and every other route in the file has one. POST login is the deliberate
exception, and the file says why -- LoginRequest limits it per email *and*
IP, which is a stronger boundary than a per-IP count. confirm-password had
neither of those things.

It is the wrong door to leave unlatched. Re-proving the password is what
stands between a stolen session and disabling two-factor, regenerating
recovery codes, or minting an API token -- credentials that outlive the
session, which is the reason routes/settings.php gives for putting those
routes behind it. An attacker who already holds the session can sit on
this endpoint until the password falls out of it, and then has the
password for everything else too.

Two more with the same shape, in routes/settings.php:

  - PUT /settings/password -- update() validates `current_password`.
  - DELETE /settings/profile -- destroy() validates `current_password`.

Both were equally uncounted, and both answer the same question in the same
way, so an attacker refused at one door simply used the next. Fixing one
of three would have been cosmetic.

All three get named buckets at 6/1, matching the credential-facing routes
already in auth.php. Named rather than bare: a bare `throttle:` keys on
sha1(domain|ip) or sha1(user_id) with no route in it, which is how six
share links once locked a visitor out of the two-factor challenge.

Not changed: POST /logout has no bucket either and does not need one -- it
checks no credential and reveals nothing by being repeated. PATCH
/settings/profile likewise.

Tests: three that fail against the unthrottled routes, and two that pin
what the buckets must not do -- exhausting one must not spend another's,
and one account's guesses must not lock a different account out.
2026-08-28 23:55:58 +02:00
denkfabrik-li 1ed29ec072 Bound the two preference endpoints by their own registries
Both preference writers validated their array as ['required', 'array']
and looped updateOrCreate over it:

    'widgets' => ['required', 'array'],
    'widgets.*.widget_key' => ['required', 'string', Rule::in(WIDGET_KEYS)],

Rule::in answers "is this a key I know", once per element. It says
nothing about how many elements there are, and nothing about whether they
repeat -- so a request could name the same valid key any number of times
and buy a SELECT and an UPDATE for each one.

Measured on this base, sent as JSON (a form-encoded array that size is
truncated by max_input_vars long before it reaches the controller):

    widgets        10 entries    37 queries   1 row
                  500 entries  1044 queries   1 row
                 3000 entries  7051 queries   1 row

    notifications   10 entries    23 queries   1 row
                 2000 entries  2025 queries   1 row

One row, every time. The work is not even data growth -- 3000 entries
write the same single row 3000 times, because updateOrCreate matches on
(user_id, widget_key) and every element after the first is an update of
what the one before it just wrote.

Neither route is behind a throttle: bootstrap/app.php applies
throttleApi() to the API group only, /dashboard/widgets is behind `auth`
alone and /settings/notifications is deliberately outside the `staff`
group, since every account manages its own. So the weakest account on the
installation -- a client with no permission at all -- can reach both, and
the only ceiling is post_max_size.

Both are bounded by the list they already validate against, not by a
number:

  - widgets by count(self::WIDGET_KEYS), the same constant Rule::in reads.
  - preferences by count($this->emailableKeys()), because
    NotificationTypeRegistry is deliberately open -- "never a closed enum,
    since core must not need to know a package's notification type keys at
    compile time" -- so a literal would be wrong the day a module
    registers one.

`distinct` on the key does the other half: a layout has at most one entry
per widget, which is what the screen sends and what the loop assumes.

After: 3000 entries cost 30 queries and write nothing, refused with a 422
instead of half-applied.

Two findings, one cause, one change -- they are the same three words in
two modules, and splitting them would leave the rule stated once and
broken once. Tests live with each controller: two refusals each, both
failing against the unfixed controllers, plus one for the largest
legitimate submission -- a full nine-widget layout, and every emailable
type at once -- so the bound can never be tighter than the screen.
2026-08-28 23:53:12 +02:00
denkfabrik-li 9af0d643b1 Keep the mail and storage credentials out of the boot-config cache
MailConfigApplier and ExternalStorageConfigApplier read their settings
through the `encrypted` casts -- decrypted -- and wrote the result into
the cache store with rememberForever(). The SMTP password, the S3 secret
access key and the whole GCS service account key file, private key
included, went in as plain text under a key that never expires.

The cache store encrypts nothing. On the store INSTALL.md documents for a
manual install (CACHE_STORE=database) and config/cache.php defaults to,
that is the `cache` table of the same database whose dump the `encrypted`
cast exists to survive. On redis it is the redis dump.

The rule already exists, two files away. MailOAuthConnection states it:

  Transports read this row fresh at send time -- tokens must never travel
  through the boot-config cache (see MailConfigApplier, which caches only
  readiness and the account address).

MailConfigApplier's own cache-key comment says the same thing about the
same array: what is deliberately NOT in the cached shape is tokens,
because neither readiness nor an address is a credential. The SMTP
password was in it anyway. SocialSettings::available() names both classes
outright as making the mistake.

So the credentials are read the way the tokens already are: from the row,
at the point that uses them. The cached array keeps everything that is
not a credential, and each applier reads its secret inside the branch
that configures a transport -- an installation on OAuth, on cloud, or one
that has never opened the Email or Storage screen reads nothing extra.

BootSettingsCache grows a second entry point rather than the callers
restating its rule. The cached read already survives a database with no
tables, because booting must not require this application's own database;
an uncached credential read on the same path needs exactly that guarantee
and nothing else, since resolve() can hand back a warm "configured" from
a database that has since stopped answering.

Both cache keys are bumped, as their comments require on a shape change.

Tests: five for the absence, two of them against the database cache store
read as the raw rows an operator would find in a dump, since phpunit.xml
runs the suite on the array store and the cache path was structurally
invisible -- which is why GoogleCloudStorageTest could assert that the private
key is not in the column while it sat in the cache. All five were run
against the unfixed appliers and fail there. The three "still configures
what it no longer caches" tests deliberately pass either way: they pin the
behaviour the fix must not break.
2026-08-28 23:46:19 +02:00
denkfabrik-li 19ee9d9833 Narrow the membership an API member write hands back
Adding or removing a group member answered with the group, and loaded the
relation whole:

    return new GroupResource($group->loadCount('members')->load('members'));

GroupResource gives each member an id, a name and an email. So a
client-scoped staff member who added one of their own clients to a group
was handed, in the same response, the name and address of every other
client in it -- people they may not read anywhere else in the application,
and whom the group edit screen refuses to name for exactly that reason.
syncWithoutDetaching() makes the call idempotent, so the same request
returns the same list as often as it is sent.

The boundary is already written down. GroupResource's docblock:

  both narrow the list to the clients the viewer may act on, and the
  controller loading this relation is where that narrowing is applied

and Api\GroupsController::show() does it for the read of the same group,
noting that "it hands back the membership with addresses". Changing the
membership is not a reason to be told more than reading it is, so both
halves now narrow by the same query, through one private helper rather
than a third copy of it.

members_count is deliberately left whole, matching show(): a size is not
an identity, and it is the number the group listing already reports.

Nothing about who may perform the write changes -- StaffLibraryScope
::allowsGroupMembership() already decided that, and still does. This is
only what the answer is allowed to say.

Tests: added beside the existing "the API twin narrows the membership it
hands back", which covered the read half only. Both write tests fail
against the unfixed controller; the third pins that an unscoped token
still gets every member.
2026-08-28 23:46:18 +02:00
denkfabrik-li cad112522d Narrow the conversion list to the clients its own refusal allows
/users/convert lists the accounts a conversion can be started from. For
the promotion direction those are clients, and the query asked only for
the type:

    User::query()->where('type', UserType::Client)

The write beside it does not. AccountConversion::guardToStaff() ends with

    abort_unless($this->library->canAssignClient($actor, $target), 404);

and says why: a promotion is the most far-reaching thing that can be done
to a client, so reaching one outside the actor's roster "through this door
and no other is not a rule, it is a gap".

The gap was on the way in. A client-scoped staff member holding
manage_users and edit_users was refused the promotion with a 404 -- the
refusal that is careful not to distinguish a stranger from an account that
is not there -- and then shown that same person's name, email, role,
status and consequence counts in the list the refusal came from,
searchable by name or address and paginated to the end.

StaffLibraryScope::clients() is canAssignClient()'s listing half, written
for this: "so a screen narrows by the same rule its buttons are guarded
with rather than restating it -- which is how ClientsController came to
list every client on the installation, name and email, to a viewer who
could reach nothing of theirs." The picker twenty lines below already went
through the same boundary via assignableClientIds().

Only the client direction is narrowed. The staff direction is left exactly
as it was: whoever may demote a staff member may see the staff roster, and
what limits a demotion is guardTarget() on the write, not the listing.

Tests: the listing half added to AccountConversionScopeTest, which until
now covered only the refusals. Two of the five fail against the unfixed
controller -- the stranger's address in the list, and reaching it by exact
search. The other three pin what must not change: the actor still sees
their own client, unscoped staff still see everybody, and the demotion
list still lists staff.
2026-08-28 23:38:48 +02:00
ignacionelson 81bb136e9e Merge pull request #1750 from denkfabrik-li/fix/mail-oauth-alarm-fires-once
RefreshMailOAuthTokensCommand is the daily refresh and, by its own docblock, the health check that goes with it: a delegated grant can die silently, and for a portal whose password-reset mails ride on this connection that must surface as a warning rather than as a support ticket weeks later. It decided whether to warn from last_error -- but last_error has a second writer. OAuthCodeFlowBroker::refresh() records a dead grant and notifies nobody, and freshAccessToken() reaches it from every send. So on an installation that is actually sending mail the send got there first, the command read the column as "already told them", and the warning never went out. last_error is cleared only by a successful refresh, which a dead grant never has, so it never went out later either. The alarm worked on installations that were not using the mailbox and failed on the ones that were.

The anti-nag rule is not the problem and does not change: one notification per broken state is still all anybody gets. The problem is that one column was answering two questions, which the table's own comment describes -- "what the settings page's warning and the admin notification read". The warning wants "is this connection broken", and any writer may answer it, which is why the settings page turning red on a failed send is correct and stays. The notification wants "have the admins been told", and only the notifier can answer that.

broken_notified_at is stamped when the command notifies, and the command asks that instead. It is cleared wherever last_error is cleared -- a successful refresh, a disconnect, a changed client id -- and those three sites now call clearFailure() rather than nulling two columns each, because a connection left healthy but still marked "already told them" would go quiet the next time it died, and a fourth caller is exactly how the first one happened. The send path still records the failure and still notifies nobody: a transport is not a place to decide who gets alarmed.

Verified before merging: 27 passed on the merged tree, 2 failed / 25 passed with app/ reset and the migration and tests kept. The recovery test is green either way by design. This touches the same command and broker as #1739 and the follow-up to it, so the merged result was read rather than trusted: the refresh reporting sits in the try and the notify guard in the catch, they do not interact, and refreshSerially() re-reads the row before refreshing so the broken_notified_at the catch reads is the stored one -- while a stand-aside throws nothing and never reaches the catch at all.

Note for the next release's upgrade notes: this adds a migration, so "nothing to do beyond dropping in the files" no longer holds.

Reported and fixed by @denkfabrik-li.
2026-08-28 18:04:37 -03:00
ignacionelson a2bc3fa163 Merge pull request #1749 from denkfabrik-li/fix/deleted-comment-author-type
file_comments.author_id is cascadeOnDelete and the cascade never fires, because a user is soft-deleted. The row behind a deleted commenter is still there and the column still points at it -- the relation just would not hand it over, and every caller then had to invent a meaning for the absence. They invented different ones: the author type became "guest" on the moderation screen and on the file's own thread, and "client" in the API, each printed beside a name that stayed correct, so one row said "Dana Staff" and "guest" at the same time. The author filter and the name search stopped matching the comment altogether, which is the worse half: a moderator filtering for staff comments did not see a staff comment sitting in front of them, and nothing about that looks like a missing row.

This is the author half of #1717, and DeletedClientThreadTest's docblock already described both columns. FileComment::authorName() was the one place that reached past the relation by hand, which is why the names were right while everything beside them was wrong.

The relation is fixed rather than the five call sites: author() reads a deleted account, and the API resource, the author filter and the name search then need no change at all, because they were already asking the right question of a relation that would not answer it. The two authorType() copies now ask author_id, which after the relation fix answers the same either way -- written that way because "no author row means guest" is exactly the reading that produced the bug.

Verified before merging: tests/Feature/Comments at 186 passed on the trial-merge, 5 failed / 1 passed with app/ reset. The survivor is the guest guard, green either way, which is what says this did not simply relabel everything as staff. scramble:export reproduces the spec byte for byte.

No visibility widens, and that was checked rather than taken on trust: every decision point in VisibleCommentScope and FileCommentPolicy compares author_id directly, five sites, none through the relation. No new field is exposed either -- the API resource reads only id, name and type from the author, and name already went through authorName()'s withTrashed lookup. Deleting an account still takes its comments with it when the grace period ends, since author_id is cascadeOnDelete.

Reported and fixed by @denkfabrik-li.
2026-08-28 18:03:03 -03:00
ignacionelson ef6f8fea56 Merge pull request #1748 from denkfabrik-li/fix/password-reset-credential-source
Two accounts reach the same reset with opposite needs, and it answered both by writing a hash and hoping.

A provider account is asked for something it cannot do. The Connected accounts screen refuses to release an account's last provider -- "Set a password first, then disconnect Google" -- and nothing set auth_source back to Local, so the screen went on asking for what had just been done, with no way out from inside the application. AuthSource already states the rule that closes it, for this case by name: a social account may later set a real password, and social only means the account came into existence without anybody choosing one. A reset by emailed token is where somebody chooses one, and the prop the screen reads is literally auth_source === Local under the name has_local_password.

A directory account is told something untrue. isDirectoryAccount() means the local hash is not consulted at all, so the same reset wrote a password that could never sign anybody in and reported success -- including when the directory it points at is gone, which is exactly the situation that sends somebody to a reset.

The reset now asks where the account's credentials live. social becomes Local, because the new password is the credential now. A directory account is refused, with the reason, and nothing about it moves -- writing Local there would not record something that had happened, it would take the account off its directory as a side effect of a password reset, which is an administrator's decision and already lives in AccountConversion with the password requirement and activity entry that belong to it. Everything else is byte for byte as before.

Verified before merging: 20 passed on the trial-merge, 2 failed / 18 passed with app/ reset, and the wider suites green -- tests/Feature/Auth 96 passed, tests/Feature/Identity 302 passed. Four properties were checked in the framework rather than argued. PasswordBroker::reset() calls validateReset() before the callback, so the refusal only reaches somebody holding a token emailed to that address and nothing is enumerable. It deletes the token after the callback, so a throw leaves the link usable. Every use of AuthSource::Local is in ConnectedAccountsController -- the has_local_password prop and the last-provider guard -- so the social-to-Local flip grants exactly the ability the screen instructs the user to obtain and nothing else, and no new login capability at all, since password login already worked for social accounts. And the check is isDirectoryAccount() rather than an auth_source comparison because LDAP is client-only, so staff are not refused; the test for that is green either way.

One new string is English only for now: "This account signs in through your directory, so its password is not set here."

Reported and fixed by @denkfabrik-li.
2026-08-28 18:01:47 -03:00
ignacionelson 927c8fc991 Translate the bulk edit's second skip reason
#1747 split the bulk edit's skip message in two, because "you don't have permission to edit them" was being said to somebody about files they own. The new sentence arrived English only, so every non-English installation would have read the correct reason in the wrong language.

Translated from its near-twin rather than from scratch: the two messages differ in one clause, so each locale keeps the first sentence it already had, its own register -- de and tr formal, es, nl, pl and zh_CN informal -- and only the reason changes. That way the pair reads as one voice on the same screen, which is where a staff member meets both.

Added immediately after the sibling key in each file, which is also its sorted position, so the diff is one line per locale and nothing else moved.
2026-08-28 17:59:29 -03:00
ignacionelson 144f5fc578 Merge pull request #1747 from denkfabrik-li/fix/bulk-edit-skip-reason
Two different things stop a selected file being changed in a bulk edit, and bulkUpdate() reported both as the first one. Files dropped by the Gate::allows('update') filter are ones this staff member may not edit at all. A file that survives the filter and still changes nothing is a different case: it was editable, and every field they asked to change is one their role does not let them set -- expiry, download limit and categories each sit behind their own permission here, exactly as they do in the single-file editor. So a staff member with edit_files but without set_file_expiration_date, editing three files they own, was told "0 of 3 selected files were updated. The rest were skipped because you don't have permission to edit them." They own all three, and editing is precisely what they may do: the sentence was both wrong and unactionable, since nothing in it points at the permission that actually stopped the edit.

The two cases get their own sentences now. Every skip being a file they may not edit keeps the existing string, unchanged, so its sixteen translations stay in use. Anything else gets a new one, "because you don't have permission to make those changes", which is also true when both reasons are in play, so a mixed selection is described correctly rather than approximately. Which files get changed is untouched, as is the silent-skip convention and the 422 when nothing at all is authorised.

Verified before merging: 14 passed on the trial-merge, 2 failed / 12 passed with app/ reset -- the field-permission case and the mixture. The pure edit-permission case is green either way, which is what says the existing message was not disturbed. FilesController overlaps #1728, already merged, and its expiryDateFor work is intact in the merged tree.

The new string arrived English-only; the sixteen catalogs are filled in the commit that follows.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:58:14 -03:00
ignacionelson 383c3b2ff5 Merge pull request #1746 from denkfabrik-li/fix/expired-file-staff-access-comment
File::isExpired() documented the rule the whole application is supposed to follow: once past, the file is hidden from clients and the public site but staff keep full access. The second half is not true of a client-scoped staff member. StaffLibraryScope::buildFiles() builds their library as own uploads plus what each assigned client may see, and that second half runs through File::scopeVisibleToClient, which ends in notExpired() -- a client-side rule. So an expired file they held only through a client leaves their library and answers 403 on download, while their own expired upload stays and an unscoped administrator is unaffected. Api\FilesController stated it the same way, "Only the client branch of the visibility rules drops them", which reads as though a staff caller is unaffected when a client-scoped one is reached through that very branch.

This does not change that behaviour. c8078f65 weighed widening it and decided against, because scopeVisibleToClient is the single source of truth for client file access and the highest-stakes function to go changing for a dashboard widget, and relabelled the widget instead. That decision lived in a commit message and one widget's label; nothing in the code said it, and the docblock nearest the rule went on promising the opposite -- which is how the next person re-derives "staff keep full access" and widens the scope to match.

Documentation and characterisation only. isExpired() now states the boundary and why it is where it is, the API comment is corrected, and ExpiredFileStaffAccessTest pins all three cases.

Verified before merging: 3 passed on the trial-merge. The counter-check has to be inverted for a characterisation test -- these pass on unmodified main by construction, so the question is whether they fail when the boundary moves. Deleting the closing notExpired() from scopeVisibleToClient gives 1 failed / 2 passed, and it is the third case, the one carrying the decision, that falls. File.php overlaps #1726 and Api/FilesController.php overlaps #1727, both already merged, and both are intact in the merged tree. scramble:export reproduces the spec unchanged.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:56:11 -03:00
ignacionelson b9807bf610 Record the comment moderation read boundary as a security fix
#1745 closes an unauthorised read, so it belongs in the changelog rather than only in the merge log: CHANGELOG.md ships in the zip and renders in the application, and it is where somebody running an installation finds out whether a release is about them.

Written for that reader rather than for the codebase. Permission to moderate comments was letting somebody read them, which is not the same thing, and the entry says what was exposed -- the text, staff-only notes, the client each conversation belongs to, and a visitor's IP -- because "a scoping issue" tells an operator nothing about whether to worry.

It also says who was affected and what to do, which matters more than usual here: no role ProjectSend ships is affected, and the one configuration that is -- a custom role that moderates comments but may open no file -- stops being able to moderate at all. Somebody meeting that on a Monday morning should find the answer in the release notes rather than in a bug report.
2026-08-28 17:44:10 -03:00
ignacionelson d91cf97bcb Merge pull request #1745 from denkfabrik-li/fix/moderation-view-read-permission
FilePolicy::view() has two halves for a staff member: one of the three file keys (upload / edit_files / edit_others_files), AND StaffLibraryScope. Every comment surface that spans files narrowed by the library half alone -- VisibleCommentScope::across(), pendingTotal(), and the API's GET /comments/pending. A role holding moderate_comments and no file key at all therefore read, on /comments, every comment in the installation: the text, staff-only notes, the client's name in conversation, and a visitor's IP, while getting a 403 on every file those comments were about. POST /api/v1/comments/{id}/approve was the same door on the write side, and its response carries the comment body, so an id was enough to read one.

The project already states the rule this breaks in four places, including across()'s own docblock -- "a moderation screen is not a way around the visibility model: moderating means deciding about comments you can already see" -- and only the cross-file queries did not ask it.

The cross-file queries now take their files from ViewableFileScope, which is FilePolicy::view() expressed as a query and already in the codebase for exactly this, instead of from StaffLibraryScope, which is only its second half. The permission half becomes a named method there, permitsAnyFile(), because three modules now ask it, and FileCommentPolicy::moderate() asks it in both of its forms. This is the other half of #1698, which library-scoped the same screen: library is not readability.

Verified before merging: tests/Feature/Comments at 180 passed on the trial-merge; with app/ reset and the new test file kept, 5 failed / 2 passed. The two green either way are the right two -- the premise, that the file itself 403s for this viewer, and the guard that a moderator who does hold a file key still moderates the whole installation.

Compatibility was the question worth asking, and it is clean: the only shipped roles holding moderate_comments are Account Manager, which also holds Upload, EditFiles and EditOthersFiles, and System Administrator, which holds everything. No shipped role loses moderation. The only configuration whose behaviour changes is a custom role granting moderate_comments with no file key, which is precisely the leaking one.

This PR also edits docs/api/openapi.json, which #1727 edited too, so the merged result was checked rather than trusted: scramble:export on the merged tree reproduces the committed file byte for byte, with both endpoints' descriptions present.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:43:30 -03:00
ignacionelson 89b3d34c8f Merge pull request #1744 from denkfabrik-li/fix/version-link-duplicate-share-notice
FileVersions::link() resolves its audience before the merge, and its own comment says the ordering is the whole dedupe: these are the people who could already see both files, so anyone the merge is about to reach for the first time is excluded and gets file_shared from FileSharing::assign() instead. The merge then undid it. moveAssignmentsToRoot() handed every one of the revision's targets to assign() under the comment "firstOrCreate inside, so a target the root already has is a no-op rather than a duplicate notification" -- but firstOrCreate makes the assignment row idempotent, not the three side effects below it. The activity entry, the in-app notification and the digest all ran unconditionally, so a client who already held both files was told a file had been shared with them about a file they had had all along, on top of the file_new_version they were owed. Two notifications for one action, for exactly the people the early resolve exists to protect.

A target the root already holds is now skipped rather than handed to assign(). Nobody is gaining access in that case, so the activity entry would have been as untrue as the notification -- which is the rule copyAssignmentsFrom() states outright for its own case, and why it inserts directly instead of going through FileSharing. The two stale comments are corrected with it.

Deliberately not changed: assign() itself, and so the behaviour ShareNotificationsTest pins, where re-posting an existing assignment through the share endpoint still notifies again. That test says the condition for changing it -- it should stop for files and folders at once, which is the point of them sharing one implementation -- and a version merge is not somebody choosing to share again.

Verified before merging: 10 passed on the trial-merge, 2 failed / 8 passed with app/ reset, and the whole tests/Feature/Files directory at 548 passed. The case where somebody genuinely gains the root still gets file_shared is green either way, which guards against skipping too much. The method was read whole rather than just the hunk: $file->assignments()->delete() still runs for a skipped target, so no row is left dangling and nobody loses reach.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:41:39 -03:00
ignacionelson d09cb602c1 Merge pull request #1743 from denkfabrik-li/fix/read-redirect-covers-every-door
Three middleware answer before HandleInertiaRequests and so repeat its 302-to-303 upgrade themselves: EnsureSetupIsComplete, EnsureUserIsActive and EnforceTwoFactor. This file has a write case for each. The rule has a second half -- a read still gets a plain 302, because a 303 there is an upgrade nobody asked for -- and that half was checked once, on the deactivation door, under the name "leaves a read alone in every one of those cases". So a change that upgraded reads at the setup door or the two-factor door would have gone through with the suite green and this test still claiming it would not.

One case per door now, as a dataset. The setup case reads a guest-reachable GET for the same reason the write case posts to /timezone: anything behind auth is answered by the guest redirect before EnsureSetupIsComplete ever sees it. No production code changes -- all three doors answer a read with 302 today, which is what the new cases assert.

Verified before merging: 9 passed on the trial-merge, and the mutation counter-check was run here rather than taken from the PR. With EnsureSetupIsComplete answering 303 to everything, this branch's file goes 1 failed / 8 passed and main's version goes 7 passed. The write case for that door stays green under the mutation, which is right: 303 is what a write should get. The mutation itself was confirmed live first, by making the middleware throw and watching the response become a 500 -- a first attempt at it bound no argument and was a silent no-op, which would have looked exactly like the new test failing to notice.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:38:47 -03:00
ignacionelson b7a94d4479 Merge pull request #1742 from denkfabrik-li/fix/file-permissions-test-reads-config
FILES_WEB_SERVER_READABLE exists so a web server running as a different user can traverse the directories a download lives in. It asked for 0755 from a key that is never consulted: FilesystemManager::createLocalDriver() passes directory_visibility ?? visibility ?? private as the default visibility for directories, and this disk sets visibility to public two lines above with no directory_visibility, so Flysystem reads dir.public and never looks at dir.private. The mode came out 0755 anyway, because 0755 is Flysystem's default for a public directory -- the right answer from the wrong place, which is the kind that stops being right quietly. Adding a directory_visibility to this disk, an ordinary hardening move, or a change to that Flysystem default would have been enough to break the flag silently on exactly the hosts that need it.

Both directory keys are now named, so the intent survives whichever branch Flysystem takes. Nothing widens: the flag-off path is still literally the old configuration, spread rather than ternary, and under the flag 0755 was already the effective mode.

And the test could not have caught it, because it was not testing this configuration: filesDiskWith() restated the shipped branch inline, verbatim down to the 0755, so it kept passing against its own copy however the real one changed. It now requires config/filesystems.php and replaces only the root. Two housekeeping fixes ride along: the scratch root is per parallel worker, the way Tests\TestCase already does it for upload parts, because eight workers sharing one real directory means one worker's afterEach deletes another's tree mid-test; and the tree is cleared before each test as well as after, so a killed run does not poison the next one.

Verified before merging: 3 passed on the trial-merge, and the mutation counter-check was run here rather than taken from the PR. With the shipped dir.public changed to 0750, this branch's test goes 1 failed / 2 passed and main's version of the same file goes 3 passed -- the old one genuinely could not see a change to the shipped configuration.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:34:33 -03:00
ignacionelson fdcdad7fb2 Merge pull request #1741 from denkfabrik-li/fix/zips-queue-check-reachable
ensure_worker_watches_zips() exists because a worker unit written before zip downloads had their own queue watches default only, and a zip enqueued to zips then waits forever with nothing to say why. It was called from exactly one place: inside the branch that reloads PHP-FPM, nested inside the branch that found a worker unit -- so it ran only when a PHP-FPM unit had been detected. A worker is a different unit from PHP-FPM, and not finding one says nothing about the other: a host running mod_php, or one whose FPM unit is named in a way this script does not recognise, still has a systemd worker that may predate the zips queue, and it got no check and no mention.

The check now also runs in the else branch, where it costs nothing -- its own first line returns immediately unless systemd and a worker unit are both present -- and the worker is restarted after it, paired exactly as the FPM branch pairs them. That pairing is the point: the new --queue argument reaches the worker only when systemd next starts it from ExecStart, and the queue:restart projectsend:update signals cannot deliver it, because that makes a worker pick up new code and it has already run by the time this block is reached. Editing the unit without a restart would leave the operator told that zip downloads were fixed while they still could not finish, which is worse than the silence it replaces: silence sends somebody looking and a success message does not.

Under --no-restart it is said rather than done. Editing a unit file is exactly what that flag asks us not to do, but a worker that cannot finish a zip is broken whether or not we may touch it, and this is the only place that knows to mention it.

Verified before merging: bash -n parses, and the restart block was driven through four host shapes with say, warn, systemctl and the check itself stubbed, on both this branch and main, rather than relying on the transcript in the PR. Before: fpm+worker reached; worker without fpm silent; --no-restart silent; no systemd silent. After: the first two both reached and restarted, --no-restart not reached but said so, no systemd reached and a no-op. That no-op rests on update.sh:330, which returns unless systemd and a worker unit are both present, so it was read rather than assumed.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:32:24 -03:00
ignacionelson ff26fac9c5 Say when the scheduled mail refresh stood aside
#1739 put the nightly OAuth refresh under the same lock a send holds, which is right -- but standing aside for the lock holder still printed "Refreshed <provider> (<account>)". No token request was made, so the line describes something that did not happen, and scheduler output is read precisely by somebody trying to work out what did.

refreshSerially() now answers whether it refreshed, and the command says which of the two happened. Standing aside is a healthy outcome: somebody else is refreshing this very connection, which slides the token window just as well as doing it again would. It is just not a refresh, and it should not claim to be one.

The existing test for the stand-aside now asserts the output too, and it fails against the old message.

Same reasoning as d8ef21b, which said when the worker check was skipped rather than skipping it quietly.
2026-08-28 17:27:30 -03:00
ignacionelson a7e883ef70 Merge pull request #1739 from denkfabrik-li/fix/scheduled-mail-refresh-lock
OAuthCodeFlowBroker::freshAccessToken() serialises refreshes per connection, and its comment says why: both providers rotate the refresh token as they hand out a new access token, so a refresh token is good for exactly one use, and "a worker racing the nightly refresh command" means the slower one spends a token the faster one has already replaced. The provider answers that with invalid_grant, which is the same thing it says about a genuinely revoked grant -- last_error gets written, the settings page turns red, and every admin is told to re-consent a connection that was never broken. RefreshMailOAuthTokensCommand called refresh() directly, outside that lock: it was the racer the comment names rather than a party to the arrangement it describes, and the false alarm landed on the connection the daily run exists to protect.

The command now goes through refreshSerially(), which takes the same lock -- named once, in one place, for both callers -- re-reads the row inside it, and refreshes. Unlike freshAccessToken() it refreshes a token that is still usable, which is the point of the daily run: a delegated refresh token dies of disuse and this keeps the window sliding. The lock is taken rather than waited for, unlike the send path: nobody is standing at a screen for a scheduled job, and a held lock means somebody is refreshing this very connection right now, which slides the window and establishes its health just as well. refresh() stays lock-free, because making it self-locking would deadlock the send path that already holds the lock.

Verified before merging: 24 passed on the trial-merge, 1 failed / 23 passed with app/ reset. PHPStan level 8 clean across app/Modules/Platform/Mail. Adding a method to the MailOAuthBroker interface breaks nothing: OAuthCodeFlowBroker is its only implementer, and MailOAuthBrokers is a registry rather than an implementation.

Known nit, fixed in a follow-up rather than here: when refreshSerially() stands aside because the lock is held, the command still prints "Refreshed <provider> (<account>)".

Reported and fixed by @denkfabrik-li.
2026-08-28 17:26:24 -03:00
ignacionelson 90009b7029 Merge pull request #1738 from denkfabrik-li/fix/totp-replay-claim-atomically
TwoFactorService::verify() asked Cache::has(), verified the code, then Cache::put(). Between the read and the write the key is free, so two requests carrying the same code could both be told yes -- which is precisely what the replay guard exists to prevent, and the window an intercepted code has is the whole of its validity either side.

Cache::add() writes only if the key is absent, so of two requests carrying the same valid code exactly one gets true back, and has() is gone: a failed claim is "already used". Verification still runs first, so a wrong code never touches the cache and cannot burn the window for the code the person is about to type correctly. The 90-second claim, the key's shape, and the recovery codes are all unchanged.

Verified before merging: 11 passed on the trial-merge, 1 failed / 10 passed with app/ reset. The existing "a totp code cannot be replayed" test is green either way, because it covers the sequential case, which was never the problem. Being on the authentication path, the wider suites were run too: tests/Feature/Identity and tests/Feature/Auth together, 393 passed.

Cache::add() is only as atomic as the store under it, so every store an installation could realistically run was checked in the vendored framework rather than assumed: database (the default when CACHE_STORE is unset) decides on insertOrIgnore(...) > 0 against the cache table's primary key; redis and memcached have native atomic adds; and the file store takes an exclusive flock before it reads and writes.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:24:28 -03:00
ignacionelson 9508750c60 Merge pull request #1737 from denkfabrik-li/fix/transfer-range-utc-bounds
resolveTransferRange() builds every boundary in the viewer's zone, deliberately: "last week" should end when their evening does, not at whatever hour UTC midnight falls on for them. Its docblock then claimed the instants "compare against the UTC column directly". They did not -- the query builder formats a Carbon in whatever zone the object carries and discards the offset, so the viewer's midnight reached the database as a UTC string. For Asia/Tokyo the window really began at 2026-08-21T15:00:00Z while the query asked for 2026-08-22 00:00:00: nine hours at each end, both in the same direction, so the first nine hours of the viewer's window were missing from the chart and the last nine hours of somebody else's day were counted into it.

The comparison now converts to UTC, one ->copy()->utc() per boundary. The copy matters: the originals keep the viewer's zone, so the day cursor and the grouping below still put an evening upload on the right bar, which is the half that really is about the viewer's calendar. Every other date filter already goes through LocalDay::start()/end(), which return UTC, which is why the activity log and the download history never had this.

Verified before merging: 20 passed on the trial-merge, 1 failed / 19 passed with app/ reset. Shares DashboardController and its test file with #1722, already merged, so the merged tree was checked -- that PR's visibleToClient change is intact.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:22:37 -03:00
ignacionelson 9c6f4df5bc Merge pull request #1736 from denkfabrik-li/fix/scoped-creator-keeps-client
A client-scoped staff member with create_clients created a client and lost it in the same request. guardTarget() answers 404 for anything off their roster, and StaffLibraryScope::clients() leaves it out of their list -- so the record existed, was logged, was welcomed by email, and was invisible to the person who made it. store() redirects to the edit page, which is exactly where they landed on a 404. The API twin had the same shape: a scoped token got a 404 from every route that binds the client it had just created.

The new client is now attached to the creator's roster when the creator is client-scoped, on both sides. That is where a client they created belongs -- the roster is the same list assignedClients already uses for everything else they may reach. Unscoped creators gain nothing: they see every client already, and a roster entry would change what assignedClients means for them. Nothing is attached retroactively.

The widening this involves is self-limited: the only thing added is an account the creator just made, which starts with no files, no folders and no group memberships, so assignableClientIds gains nothing to reach. Seats do not move either, since they are counted from active and account_requested.

Verified before merging: 34 passed across both suites on the trial-merge, 2 failed / 32 passed with app/ reset. This is the busiest file set of the series -- it shares ClientsController with #1718 and Api/ClientsController plus the API test file with #1723 -- so the merged result was read rather than trusted: #1718's reassign_candidates gating and #1723's patchCustomFieldValues are both intact alongside it. scramble:export reproduces the committed docs/api/openapi.json byte for byte.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:20:44 -03:00
ignacionelson 2903a1da6d Merge pull request #1735 from denkfabrik-li/fix/editable-once-checkbox
ClientPortalCustomFields::save() writes '0' for an unticked checkbox, and filled('0') is true in Laravel. isLocked() asked whether anything is stored, so an editable_once checkbox locked itself the first time the client saved the page it sits on, whatever they had chosen. A box they never ticked could then never be ticked, and the one edit the setting promises was spent on a decision they had not made. A text field left empty stores null and stays open; that asymmetry was the bug, and '0' is the absence of a decision in exactly the way null is for every other type.

A checkbox now locks on a stored '1' and nothing else. Every other type keeps filled(). What save() stores is unchanged -- '0' remains a recorded "no", as the API's client create also writes it -- and the behaviour after a real tick is unchanged too: the client still cannot untick it, and the test pinning that is untouched.

Verified before merging: 6 passed on the trial-merge, 1 failed / 5 passed with app/ reset. The editable-once text field test is green either way, which confines the change to checkboxes. The relaxation is safe because the lock is enforced on the write path and not only rendered: isLocked() gates rules(), which drops the field from validation, and save(), which skips it, so the ticked-to-unticked direction stays closed server-side.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:18:38 -03:00
ignacionelson c11cb3cc63 Merge pull request #1734 from denkfabrik-li/fix/quota-message-inherited-default
ClientStorageUsage::quotaMb() exists because a client's own storage_quota_mb of 0 does not mean "unlimited" -- it means "no quota of their own", and the site default is what is then enforced. Both chunked-upload quota checks enforced the resolved limit through quotaBytes() and then printed the raw column in the rejection, so a client with no quota of their own and a site default of 1 MB was told "This upload would exceed your storage quota of 0 MB." That is every client who was never given a quota, including every self-registered one, and the sentence appears at the one moment somebody is trying to find out what their limit is.

Both now print quotaMb(), which is what the check enforced. The API's single-request upload already did exactly this for the same sentence, so the three copies agree. The enforcement itself is untouched -- only the number in the message changes -- and the unlimited case never reaches these branches, because quotaBytes() > 0 guards them.

Verified before merging: 16 passed on the trial-merge, 2 failed / 14 passed with app/ reset. The "a client with a quota of their own still sees their own number" test is green either way. The string itself is unchanged, so no locale file needs anything.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:17:17 -03:00
ignacionelson 5117511946 Merge pull request #1732 from denkfabrik-li/fix/public-preview-log-debounce
FileThumbnailController::preview() writes at most one FilePreviewed row per viewer per file per five minutes, because watching a video is a single deliberate act that the browser turns into dozens of Range requests. Its docblock ended by naming the route where the same act happens without an account -- PublicGroupsController::preview -- and that route logged unconditionally. Five requests for the same public file wrote five rows where the signed-in twin wrote one, so one visitor watching one clip buried the public half of the activity log, which is the half an operator reads to see what the outside world is doing.

The window moves into a shared PreviewLog, next to PreviewKind, which those two routes already share for the same reason. Keying is unchanged for a signed-in viewer. An anonymous visitor has no account to key on, so the request IP stands in -- the same substitute ApiServiceProvider's rate limiter makes for an unauthenticated caller. It is a cache key with a five-minute life and never reaches the log, which keeps its own decision about recording an IP.

Downloads are deliberately untouched and stay one row per download: each is a transfer, and DownloadAllowance::used() counts those rows to enforce a per-file cap, so swallowing one would hand out free downloads.

The limit this leaves open, since the IP is a stand-in and not an identity: two anonymous visitors behind one address share a key, so within five minutes the second one's view of the same file is not recorded. That is the same trade the signed-in side has always made per account, and the alternative is the row-per-Range-request this fixes.

Verified before merging: 24 passed across the public-preview and thumbnail suites on the trial-merge, which also confirms this co-exists with #1725 -- the two share both controllers and change different methods in each. With app/ reset and PreviewLog deleted, 1 failed / 9 passed. The signed-in route's existing debounce tests pass unchanged, which is what says the shared class did not move that side. request()->ip() honours the trusted-proxy configuration, so a forged X-Forwarded-For cannot defeat the window from outside.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:13:05 -03:00
ignacionelson b6f4770795 Merge pull request #1731 from denkfabrik-li/fix/zip-build-failure-hygiene
BuildZipDownloadJob already draws this line in its write-failure branch: "What the requester sees stays generic: a libzip string means nothing to them and can name a server path. An operator needs the opposite, so the reason goes to the log instead." Thirty-seven lines below it, the catch-all around the whole build stored $e->getMessage() in the row the requester polls -- and ZipDownloadsController hands that column straight back to whoever asked, clients included. A client asking for an archive of a file whose disk is no longer configured read "Disk [a-disk-that-is-not-configured] does not have a configured driver." verbatim. The reason now goes to the log with the exception class, and the row carries the same kind of sentence fail() already uses.

Two more in the same method. tempnam() creates the file, and $tempFiles[] was appended only after the copy finished, so every throw in between left a zip-src- file in the system temp directory that nothing ever removed; it is now registered the moment it exists. And the copy itself was unchecked -- a copy that stops early is a truncated member added to the archive as though it were the file, so the build reports ready and the recipient gets something that opens and is wrong. stream_copy_to_stream and the flushing fclose are both checked now, and both handles close on every path.

Deliberately not changed: comparing the copied byte count against files.size, which would fail perfectly good archives whenever that column is stale; the write-failure branch and its wording; and the skipped-files reporting, which still says which files and why, so only the catch-all went generic.

Verified before merging: 37 passed on the trial-merge, 2 failed / 35 passed with app/ reset. The leak was confirmed at the consuming end rather than inferred -- ZipDownloadsController:169 returns the error column to the requester.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:05:30 -03:00
ignacionelson 037439e1f2 Merge pull request #1730 from denkfabrik-li/fix/provisioning-over-deleted-address
The unique index on users.email spans soft-deleted rows -- AvailableEmailRule is built on exactly that -- so a deleted account keeps its address until erasure removes the row. The registration form learns this from validation. The machine paths have no form to validate: a directory or an identity provider hands over an address and ClientProvisioning::provision() inserts it, so a client deleted earlier signing in through a provider that may auto-provision got a QueryException, and what the person met was a 500 in the middle of their sign-in. Same shape through LDAP at POST /login.

Both provisioners now ask ClientProvisioning::addressIsFree() first and refuse. The social flow reuses the refusal it already gives every other identity it cannot provision -- "There is no account here for that address." -- which is also all a stranger should learn: whether an address was once an account here is not the provider's to publish. The LDAP flow falls through to the ordinary failed sign-in.

The deleted account is deliberately not resurrected and not linked. Restoring one because a directory still lists the address is a decision for a person, not a side effect of somebody signing in -- and a linking shortcut here would be an account takeover. Everything about an address belonging to a live account is untouched.

Verified before merging: 47 passed on the trial-merge, 2 failed / 45 passed with app/ reset. addressIsFree() queries withTrashed(), the same span as the unique index it protects, so the check and the constraint agree. Worth noting that both new warning lines record the email address, which is consistent with what these paths already log but is PII in the application log.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:03:34 -03:00
ignacionelson 6b99e37d01 Merge pull request #1729 from denkfabrik-li/fix/api-surface-by-route
Two places asked "is this the API?", and each got it wrong in the opposite direction.

EnsureCapability asked $request->expectsJson(). Whether a feature exists in this installation's edition is a property of the installation, not of what the caller is willing to parse: the same capability-gated API route answered 403 capability_unavailable to Accept: application/json and a bare 404 to Accept: */*, which is curl's default, while routes/api.php promises the 403 in as many words. The mirror image was worse -- an Inertia visit to a capability-gated web screen accepts JSON, so it took the API branch and announced the feature by name, where the whole point of the 404 is that an unavailable feature is absent rather than teased.

ProblemDetails asked $request->is('api/*'). Two staff pages live under that prefix -- the API dashboard at /api and the OpenAPI reference at /api/docs, both from routes/web.php -- so a signed-out visitor to /api/docs got a 401 problem+json telling them to send a Bearer token instead of the login redirect every other page gives.

One question now, asked once, in App\Support\ApiSurface: under the API prefix, and not part of the web middleware group. The group is what actually separates the two surfaces -- sessions, cookies and CSRF on one side, tokens on the other -- and it keeps answering correctly for a future /api/v2 without being edited. An unmatched path has no route to ask and stays the API's answer, which is what the existing "a missing API route is a problem+json 404" test pins. EnsureStaff keeps its expectsJson() check: there the question really is about the caller.

Verified before merging: the discriminator was checked in the running application rather than assumed -- /api/docs and /api resolve to [web, auth, staff], /api/v1/files to [api, auth:sanctum, api-active, staff-token, token-can:...]. 12 passed on the trial-merge; with app/ reset and ApiSurface deleted, 3 failed / 9 passed, every new test and no old one. Wider suites green: tests/Feature/Api 239 passed, tests/Feature/Platform 485 passed. scramble:export reproduces main's docs/api/openapi.json byte for byte. Worth recording that the blast radius here is the shape of a refusal and never whether one happens: both call sites run after authentication and authorization.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:02:05 -03:00
ignacionelson f676e09bb2 Merge pull request #1728 from denkfabrik-li/fix/expiry-timezone-drift
The edit screen is given a file's expiry as a calendar date read back in the viewer's own zone -- deliberately, or "a file set to expire on the 12th reopens showing the 11th". Every save posts that date back, touched or not, and update() derived a fresh instant from it every time. So the expiry drifted by the difference between two people's zones on any other edit: a file set from Pacific/Auckland moved 19 hours later the moment somebody in Buenos Aires renamed it, and moved again on the next save from a third zone. A file could quietly outlive the expiry somebody set for it, through an edit that had nothing to do with expiry.

The instant is now re-derived only when the posted date differs from the one the form was given, compared against the same string through a named pair: expiryDateFor() renders it, expiryInstant() reads it back, and the edit screen calls the render half so the two cannot drift apart. What a changed date means is unchanged -- still the end of that day in the zone of whoever changed it. bulkUpdate() needs nothing: its expiry is an explicit set / clear / no_change action, so an untouched expiry is never posted at all.

Verified before merging: 22 passed on the trial-merge, 1 failed / 21 passed with app/ reset. The "a real change still lands in the editor's zone" and "clearing still clears" tests are green either way. Edge cases walked: a posted date against no stored expiry still sets it, and a posted null against a stored null leaves the column alone rather than writing.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:00:16 -03:00
ignacionelson eb3d6e321d Merge pull request #1727 from denkfabrik-li/fix/api-expiry-end-of-day
FilesController::expiryInstant() exists because a calendar day ends where the person naming it lives: the web 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. PATCH /api/v1/files/{id} took the same field, validated it as a date, and stored it exactly as it arrived -- so the same value that meant end-of-the-12th on the web meant start-of-the-12th over the API, and earlier still for a caller west of Greenwich.

A bare YYYY-MM-DD now means the end of that day in the caller's timezone, through the same LocalDay::end() the web path uses. A value carrying a time is unchanged: that is an instant the caller named on purpose, the API can express one where a date input cannot, and it is stored as it arrives. null still clears the expiry, and the validation rule and permission gate are untouched.

Note for the release notes: this lengthens the life of a file whose expiry an existing integration sets with a bare date, by up to a day. That is the correct meaning and the one the web has always had, but it is a behaviour change for callers who were relying on the old one.

Verified before merging: 19 passed on the trial-merge, 1 failed / 18 passed with app/ reset. The timestamp and clearing tests are green either way. The bare-date branch is gated on a strict ^\d{4}-\d{2}-\d{2}$ match, so nothing else takes it. scramble:export on the merged tree reproduces the committed docs/api/openapi.json byte for byte.

Reported and fixed by @denkfabrik-li.
2026-08-28 16:58:34 -03:00
ignacionelson d89807b237 Merge pull request #1726 from denkfabrik-li/fix/rendition-cleanup-independent
FileDiskCleanup::delete() wrapped two deletions in one try: the original upload, on whatever disk the row names, and every cached rendition, which is always on the local files disk. Storage::disk() throws outright for a name with no configured driver -- precisely the state the original's disk is in whenever this fails at all -- so the catch swallowed it and the renditions were never reached. Nothing looks for them afterwards: OrphanFileScanner skips the rendition directories on purpose, as derived artifacts rather than orphaned uploads. A file whose external disk had been removed or renamed therefore kept every cached copy of itself indefinitely on the disk that still worked, including the client-facing ones, which for a shared image may be the only copies anyone ever generated.

The two attempts are now separate, each with the tolerance the class was written for: a storage failure still never turns a delete click into a 500, and the warning is still the whole report.

Also corrected: File::booted() justified deferring the byte removal with "the worst case is bytes left on disk with no row, which OrphanFileScanner already finds and reports". That is not this path -- the row is soft-deleted, and knownPaths() counts a trashed row's path as claimed, deliberately, so a scan never offers to double-adopt a file still inside its erasure grace period. The comment now says what actually happens, which is that FileDiskCleanup's warning is the only record.

Verified before merging: 8 passed on the trial-merge, 1 failed / 7 passed with app/ reset.

Reported and fixed by @denkfabrik-li.
2026-08-28 16:56:47 -03:00
ignacionelson 7ff2674e4f Merge pull request #1725 from denkfabrik-li/fix/rendition-written-atomically
Both thumbnail routes treat "the file exists" as "the rendition is cached", and nothing ever invalidates one: RenderedImageCache::flush() runs on ImageRenderingChanged, which no code in core raises. Whatever sits at the path is what every later viewer gets. ThumbnailGenerator::generate() encoded straight onto that path, so a render that died partway -- a full volume, a killed worker -- left a half-written file that was then served as the rendition indefinitely, and two requests rendering the same file at once encoded into the same path together.

Write side: the image is written beside its destination and renamed into place. rename() within a directory is atomic and replaces what is there, so the path holds either the previous rendition or a complete new one, and the loser of a race leaves a whole image rather than a mixture of two. Renditions always cache on the local files disk and the generator is handed $disk->path(), so both files are on the same filesystem and the atomicity is real. Read side: an empty file is not a rendition, so both routes replace one rather than serve it -- writing through a temporary file means core can no longer create that state, but an installation that ran an older version can already have it on disk and nothing else will ever clear it.

The cache itself is unchanged: a non-empty rendition is still reused without further checks, because decoding every cached image on every request to prove it is intact would cost the cache its point. The RenderingImage seam still fires before the encode.

Verified before merging: 14 passed on the trial-merge, 2 failed / 12 passed with app/ reset. The third test, about the generator's own temporary file, passes either way and the PR says so rather than leaving it to be found.

Reported and fixed by @denkfabrik-li.
2026-08-28 16:55:29 -03:00
ignacionelson 262cb2457a Merge pull request #1723 from denkfabrik-li/fix/api-patch-custom-fields
Api\ClientsController::update() states the rule eighteen lines above the bug: "PATCH semantics, unlike the web form which always submits every field: an absent key means 'leave alone', not 'clear'." Every column obeyed it. The custom fields did not -- they went through saveCustomFieldValues(), which is create()'s pass: it walks every field there is and writes null for the ones the request did not carry. A PATCH naming one field emptied all the others, with nothing in the response to say so and no second copy of the value anywhere.

The write pass is still shared but is now entered two ways: create() keeps writing every field, and update() writes only the fields the request named. Creating a client is deliberately unchanged -- it is not a partial update, and a checkbox nobody ticked is a recorded "no" rather than an absent row. Clearing a field by naming it with an empty value still clears it, and the validation rules are untouched.

Verified before merging: 23 passed on the trial-merge, 1 failed / 22 passed with app/ reset. The two guard tests -- a named empty value still clears, create still records every field -- are green either way, so the write path was not simply switched off. The keys reaching whereIn() are stripped to real field ids by validateCustomFieldValues() before they get there. scramble:export re-run on the merged tree produces a docs/api/openapi.json identical to main's, so the published spec does not move.

Reported and fixed by @denkfabrik-li.
2026-08-28 16:47:40 -03:00
ignacionelson a285f86b93 Merge pull request #1722 from denkfabrik-li/fix/portal-dashboard-visible-files
DashboardController::clientDashboard() built its own whereHas('assignments') query instead of using File::scopeVisibleToClient -- "the single source of truth for client file access", as that scope's own docblock puts it. The copy reproduced the assignment half and stopped there, so the page disagreed with the portal it introduces, in both directions. Over: the scope ends in notExpired(), so an expired file was gone from /my-files and refused on download while the dashboard went on counting it and printing its name. Under: a file in a folder shared with the client, a file the client uploaded through the portal themselves, and a revision -- which owns no assignment row and inherits its original's recipients through SharingIdentity -- were all missing from the count and the list.

The hand-rolled query is gone and the scope is used, one query object cloned for the count exactly as before. groups_count and the storage figures are untouched: they answer different questions and have their own tests.

Verified before merging: 19 passed on the trial-merge, 2 failed / 17 passed with app/ reset. The existing "clients get the portal dashboard with their own numbers" test is unchanged and green either way, so a directly assigned live file counts as it always did. PHPStan level 8 clean on the changed file.

Reported and fixed by @denkfabrik-li.
2026-08-28 16:16:49 -03:00
ignacionelson bc68a24ef5 Merge pull request #1721 from denkfabrik-li/fix/api-dashboard-activity-log-scope
ApiUsage::recentActions() read the activity log without ActivityLogScope::apply(). It was the only ActivityLog::query() outside ActivityLogger and AccountEraser that skipped it. Its only boundary was view_actions_log -- the permission ActivityLogScope's own docblock says "is not the whole answer for a client-scoped staff member", because a log row carries the subject's name. The Client Manager system role is client_scoped and ships with that permission, so this was the default configuration and not an exotic one: the same person who gets a 403 on a file and an empty /activity read that file's name off /api?all=1.

ApiUsage now takes ActivityLogScope and applies it to the recent-actions query, on both sides of the install-wide branch rather than only in the install-wide arm -- the own-actor filter already stays inside what the scope allows, and a boundary that exists in only one arm of an if is one refactor away from not existing. The token inventory, request counts and endpoint table keep ApiUsageScope alone: those rows are about the viewer's own credentials rather than library content.

Verified before merging: 17 passed on the trial-merge and 1 failed / 16 passed with app/ reset. The two tests guarding against narrowing further than /activity does -- a viewer's own actions stay whole, an unscoped viewer's feed is unchanged -- are green either way. ActivityLogScope::apply() wraps its conditions in a single where(Closure), so it composes with the origin and actor_id filters around it without a precedence trap, and ApiUsage is never constructed with new, so the added dependency is wired by the container everywhere.

Reported and fixed by @denkfabrik-li.
2026-08-28 16:14:57 -03:00
ignacionelson 1644d634d5 Merge pull request #1720 from denkfabrik-li/fix/group-reach-expired-file
groupReachesNoFurther() decides whether a client-scoped staff member may edit a group, by asking whether anything shared with it sits outside their library. f1b35cc9 settled that answer for deleted files: start from the live row, because a deleted file is not reach, because nobody can reach it. An expired file is the same case and was not covered. File::scopeVisibleToClient ends in notExpired(), so the moment a file expires it leaves every member's /my-files and the download answers 403 -- but it also leaves files(), where its absence reads as "outside my library". The group then became unmanageable for good: the rep could not add anyone, and could not undo their own membership change either.

The reach query now skips expired files as it already skips deleted ones. File::scopeVisibleToClient is unchanged -- what expiry does to a scoped viewer's library was settled deliberately in c8078f65, and this is about what counts as reach, not about what anyone may open. The folder half needs nothing, because folders do not expire.

The limit this leaves open, stated rather than implied: a membership added while a file was expired outlives the expiry, so if somebody later clears expires_at the client reaches a file that was outside the actor's library when the decision was made. f1b35cc9 leaves exactly the same opening for a file restored from the trash, and closing either would mean the guard weighing rows nobody can currently reach.

Verified before merging: this changes the file half of the same method #1719 changed the folder half of, so the merged tree was read rather than trusted -- both halves now skip expired files consistently. 29 passed on the merged tree, 1 failed / 28 passed with app/ reset. The "an expired file does not excuse a live one that is still out of reach" test is green either way.

Reported and fixed by @denkfabrik-li.
2026-08-28 15:59:24 -03:00
ignacionelson abbe9a3acc Merge pull request #1719 from denkfabrik-li/fix/group-reach-subtree
StaffLibraryScope::groupReachesNoFurther() asks whether anything shared with a group sits outside the viewer's library, and its docblock says the folder half covers "the folders whose subtrees it can browse". It compared the folder ids the assignment names and stopped there. But a folder shared with a group hands its members the whole subtree -- File::scopeVisibleToClient matches on folder placement, and a folder is visible to a client when it or an ancestor is shared with them -- so the guard passed on a subtree it had never looked into. A scoped rep could add their own client to a group holding a folder they own, and a stranger's file inside it went to that client, and then into the rep's own library, because files() is "own uploads plus everything my clients can see". That is exactly the widening the first test in the file exists to refuse.

The folder half now walks each assigned folder's subtree via subtreeFolderIds(), and the files inside it are checked too: a folder can be in the library while a file in it is not, since somebody else's upload into a folder this rep owns is neither their own nor their clients'. Expired files are skipped for the reason deleted ones are -- membership grants nobody access to one, and something nobody can reach is not reach.

Verified before merging: 27 passed on the trial-merge, and 2 failed / 25 passed with app/ reset to main. The "subtree wholly inside the library stays manageable" test is green either way, which is what says the guard was tightened rather than closed. subtreeFolderIds() walks a materialised path prefix, so it is one query per assigned folder with no recursion.

Reported and fixed by @denkfabrik-li.
2026-08-28 15:51:33 -03:00
ignacionelson 3dc407a777 Merge pull request #1718 from denkfabrik-li/fix/reassign-candidates-scope
reassign_candidates is the delete dialog's picker -- every active account in the installation, by name and role label -- and it was narrowed by nothing. Two lines above it on the clients index sits the listing itself, narrowed through StaffLibraryScope with a comment saying why. A client-scoped rep with manage_clients therefore read the name and role of every client in the installation, including the ones they can reach nothing of. The can('delete_clients') filter meant to hide the picker runs in React, which decides what is rendered, not what is sent.

The client half of the candidate list now goes through the same StaffLibraryScope as the listing beside it, and each screen sends the picker only to a viewer holding the delete permission it exists for. Staff accounts are not narrowed, here or anywhere else in the application. Privacy settings keeps the whole installation deliberately: that picker sets the erasure default stored once for everybody, behind edit_settings, so narrowing it by whoever happens to be editing would store the wrong answer.

Verified before merging: the four new tests pass on the trial-merge and go 3 failed / 16 passed with app/ reset to main, so they are testing the fix and not something else. Every call site of the changed candidates() signature was checked.

Reported and fixed by @denkfabrik-li.
2026-08-28 15:46:17 -03:00
ignacionelson d8ef21bb6a Say when the worker check was skipped rather than skipping it quietly
ensure_worker_watches_zips reads the unit file with `systemctl show -p
FragmentPath`, and an empty answer meant an immediate, silent return. The
common cause is a mistyped --worker: systemd does not know the unit, the
check never runs, and the operator finishes the update believing their
worker was inspected.

Which produces precisely the outcome the function exists to prevent. Its
own comment says a worker that does not watch the zips queue finishes no
zip downloads while cheerfully sending every email, and that nothing says
why. Skipping the check in silence is a quieter way to arrive there.

It now warns, names the consequence, and says what to check. The
read-only case is separated out too: a unit file somebody else owns
cannot be repaired, but it can still be read, so a worker that is missing
the queue is diagnosed rather than passed over.

Worth recording why this was looked at. The portal session found a deploy
script that had printed `next run` followed by nothing for its whole
life, because `systemctl show` answers an unknown property with an empty
value and a zero exit -- a line always blank is worse than no line, since
somebody believes a check is being performed. FragmentPath here is
correct, verified against a real unit; the failure was the same shape one
step further on, in what an empty answer was taken to mean.
2026-08-28 14:33:47 -03:00
ignacionelson 479dc61d2d Move branding into core, and leave white-labelling behind
Logo and watermark belonged in the private package for one reason: that
is where they were written. Nothing about them needs a hosted platform,
and an installation wanting its own mark on the pages it serves is the
ordinary case rather than the exotic one. They are core's now, and every
installation has them.

Hiding "Powered by ProjectSend" did not come. That is what a hosted
customer pays for, and its gate is not a capability key but the absence
of the code: cloud-modules keeps the listener, so an installation without
that package holds the column and has nothing able to read it. Flipping
an edition variable buys nothing, which was true before and stays true.
Core renders the switch where Capability::AttributionHide is held and has
no route that can save it -- there is a test asserting exactly that, which
fails the day white-labelling quietly becomes free.

The migrations move with their original filenames on purpose. A Cloud
tenant already ran them under those names, so Laravel skips them there
and the table and its data are untouched; a fresh install or a community
one runs them from here for the first time.

What got better on the way rather than merely moving:

The watermark listeners take core's real RenderingImage and
ResolvingImageRendering instead of duck-typed `object` payloads, and the
tests construct the genuine events rather than anonymous stand-ins that
imitated their shape. The package had to do it that way -- it builds with
no host present -- so three PHPStan ignore entries existed to describe
what the type system could not see. They are gone.

ModuleBoundaryTest asserted "branding is cloud-only, and the suite runs as
community", which was never what it was testing. It now reads the
capability off the route and subtracts it, so the invariant holds for
whichever module is installed.

The 43 branding strings arrived in all sixteen locales from the package's
own catalogues rather than being retranslated, and the package's are
pruned to the one string it still uses.

A hosted plan without branding subtracts branding.customize and
attribution.hide from the instance's environment. The row is never
deleted by that: a downgrade is usually an expired card rather than a
decision, and wiping somebody's artwork over a billing event is a loss
they would find weeks later with no way to know what it used to be.
Hiding reverses; deleting does not.
2026-08-28 13:27:10 -03:00
ignacionelson 530f30606d Let a plan take a capability away, and split branding from white-labelling
Groundwork for moving Branding out of the private package. Two changes,
both about who decides what an installation may do.

An edition grants capabilities; an operator may now take some away, via
PROJECTSEND_CAPABILITIES_DISABLED. Subtractive only, and that asymmetry is
the whole design: a variable that could *add* would put the hosted
edition's proprietary screens one line of .env away on every self-hosted
install, which is not a gate at all. So the list is intersected with what
the edition already allows and can only make the answer smaller.

This is not the plan tier core has always refused to invent. There are
still no billing tiers here to key off -- the objection config/api.php
makes about rate limits stands. It is the operator stating a fact about
this installation, exactly as PROJECTSEND_PLATFORM_MAX_STAFF_USERS does
for seats: the platform knows what it sold, the installation is told and
enforces. Unknown keys are ignored rather than fatal, because the variable
outlives both the plan that wrote it and the release that named the key,
and refusing to boot over a stale one would be an outage on upgrade day.

The registry takes the list as a constructor argument rather than reading
config itself, which keeps it a value object testable without an
application -- the failure that surfaced it was a unit test with no
container.

And branding.customize is now both editions, with the white-label half
split into attribution.hide, which stays Cloud-only. Dressing an
installation in its own logo is not a hosted concern; taking ProjectSend's
name off somebody's public pages is what a hosted customer pays for. The
gate on the second is not the key but that the only code able to answer
"hide it" ships in the private package, so flipping an edition variable
buys nothing.

EnsureCapabilityMiddlewareTest had to pick a new Cloud-only example for
the second time -- branding after users.manage. It now uses
storage.managed, and records what to ask if it ever needs a third.

The code move itself is the next commit; nothing user-visible changes yet,
because the screens still live in cloud-modules.
2026-08-28 13:11:57 -03:00
ignacionelson afc2c74617 Say who depends on the activity log never being pruned
last_staff_login_at is a MAX() over activity_log, and the docblock
already said the log is never pruned. It did not say that anything
depends on it. Something does now: the hosted platform warns, pauses and
finally removes a free instance nobody has signed in to, counting from
this field.

So retention or pruning added to activity_log would break nothing here --
every test would pass, the field would keep answering, and old
installations would quietly start looking dormant to the process that
deletes them. That is the shape of failure worth naming in advance,
because the person adding a retention policy would have no reason to look
at this file.

Same note as the one on SeatAllowance's counting rules and on
ManagedStorageBackend::describe(): an assumption with a reader outside
this repository is a contract, and the place to record it is where
somebody would otherwise change it.
2026-08-28 12:11:29 -03:00
ignacionelson d62c62f788 Let an installation say which build it is
A version string is a decision somebody made. A commit is a fact, and the
two come apart exactly when it matters: an image built from the tag and
one built from the branch that tag sits on carry the same version and
different code. The fleet spent a day reporting 2.2.0 from images that
were not the released 2.2.0, and nothing inside any of them could have
said so -- which is why 2.2.1 was cut for a control plane rather than for
users.

So every artifact now carries config/build.php, written by
build-release.sh and never committed, and projectsend:status reports it
as `build`: the commit, the ref it describes to, the channel and the
build time.

All four are null on a source checkout, because there is no such file
there. That is the honest answer rather than a missing one -- "I was not
built" and "I will not say" are different facts, and this file's whole
null discipline exists because a reader that cannot tell them apart
eventually acts on the wrong one. An empty string is treated as no
answer for the same reason: a build step that ran and produced nothing
must not read as "answered" to anything checking presence.
2026-08-28 11:57:43 -03:00
denkfabrik-li 7be81d3586 Tell the admins the mailbox is dead, even when a send noticed first
The daily refresh doubles as the health check for a connected OAuth
mailbox, and its own docblock says why that matters: a grant can die
silently, "which for a portal whose password-reset mails ride on this
connection must surface as a warning, not as a support ticket weeks
later".

It decided whether to warn by reading last_error -- but the send path
writes that column too. OAuthCodeFlowBroker::refresh() records the
failure and notifies nobody, and freshAccessToken() reaches it from every
send. So on an installation that actually sends mail, the send lands
first, the command reads the column as "already told them", and the
warning never goes out. last_error is cleared only by a successful
refresh, which a dead grant never has, so it never goes out again either.

Measured on main, one dead grant, two orders:

  nobody sends, command first   1 notification, then quiet   correct
  a password-reset mail first   0 ... 0 ... 0                never

The alarm worked on installations that were not using the mailbox and
failed on the ones that were.

The anti-nag rule is not the problem and does not change. The problem is
that last_error answers "is this broken", which any writer may set, while
the command needs "have the admins been told", which only the notifier
can. The table's own comment shows the conflation -- one column described
as "what the settings page's warning and the admin notification read".

So the notification gets its own column. broken_notified_at is stamped
when the command notifies, and cleared wherever last_error is cleared: a
successful refresh, a disconnect, a changed client id. The three call
sites go through MailOAuthConnection::clearFailure() rather than nulling
two columns each, because a connection left marked "already told them"
while healthy would go quiet the next time it died -- the same bug in a
new place.
2026-08-28 14:41:38 +02:00
denkfabrik-li 92f50fdb85 Read a comment's author even after the account is deleted
`author_id` is cascadeOnDelete and the cascade never fires, because users
are soft-deleted: the row behind a deleted commenter is still there and
the column still points at it. The plain relation handed back null
anyway, and every caller invented its own meaning for that absence.

Measured on main, one staff member's staff-only comment, before and after
the account is deleted:

  /comments screen        Dana Staff / staff  ->  Dana Staff / guest
  the file's own thread   Dana Staff / staff  ->  Dana Staff / guest
  GET /api/v1/.../comments      type staff    ->  type client
  filter author_type=staff            1 row   ->  0 rows
  search "Dana"                       1 row   ->  0 rows
  unfiltered                          1 row   ->  1 row

Three surfaces, three different wrong answers, each next to a name that
stayed correct -- so a row can read "Dana Staff" and "guest" at once. A
moderator filtering for staff comments does not see a staff comment that
is sitting in the list in front of them.

This is the author half of what #1717 fixed for client_context_id, and
DeletedClientThreadTest's docblock already describes both columns.

The fix is the relation, not the five call sites: author() reads a
deleted account, which is what authorName() already reached for by hand.
The resource, the filter and the search then need no change at all. The
two authorType() copies now ask author_id rather than the relation --
which after this answers the same either way, and is the rule
isFromGuest() and authorName() already follow.

Nothing that decides who may read a comment goes through this relation.
VisibleCommentScope and FileCommentPolicy both compare author_id
directly, so no visibility widens.
2026-08-28 14:24:03 +02:00
denkfabrik-li 27c289a4d6 Let a password reset know where the account's credentials live
Two accounts reach the same reset with opposite needs, and it treated
both as "write a hash and hope".

A provider-created account is told, on the Connected accounts screen, to
"set a password first, then disconnect Google" -- and doing it changed
nothing, because nothing ever set auth_source back to Local.
AccountConversion is the only writer, and that is an administrator. So the
screen went on asking for something that had already been done, and the
person could not release their last provider without help.

AuthSource states the rule that closes this: `social` means the account
came into existence without anybody choosing a password, and, in as many
words, "a social account may later set a real password". A reset by
emailed token is where somebody does. The screen's has_local_password prop
is literally auth_source === Local, so the write is what completes the
sentence it prints.

A directory account is the opposite case and gets the opposite answer.
isDirectoryAccount() means the local hash is not consulted at all, so the
reset reported success and left the person with a password that cannot
sign them in -- including when the directory it points at is gone, which
is exactly when somebody reaches for a reset. It is refused now, with the
reason, and nothing about the account moves: taking one off its directory
is an administrator's decision through AccountConversion, not a side
effect of a reset.

The refusal sits where the token has already been validated, not where the
link is asked for. That endpoint answers "A reset link will be sent if the
account exists" to everybody on purpose, and refusing there would tell a
stranger both that an address is an account and how it signs in. Throwing
before the write also leaves the token unspent, since PasswordBroker
deletes it after the callback returns.
2026-08-28 14:01:24 +02:00
denkfabrik-li 5e60d2ef88 Say what expiry does to a client-scoped staff member's library
File::isExpired() documents the rule the application is supposed to
follow: once past, the file is hidden from clients and the public site
"but staff keep full access to view, download, and manage it".

The second half is not true of a client-scoped staff member.
StaffLibraryScope::buildFiles() builds their library as their own uploads
union what each assigned client may see, and that second half runs
through File::scopeVisibleToClient, which ends in notExpired() -- a
client-side rule. Measured on main, with a rep holding one client and a
file the administrator uploaded and shared with that client:

    before expiry   in_library true    GET .../download -> 200
    after expiry    in_library false   GET .../download -> 403

    the rep's own expired upload                  in_library true
    an unscoped administrator, same expired file  in_library true

Api\FilesController says it the same way -- "Only the client branch of
the visibility rules drops them" -- which reads as though a staff caller
is unaffected, when a client-scoped one is reached through that very
branch.

This does not change that behaviour. c8078f65 weighed exactly this and
decided against it: widening it would mean a library query that keeps
expired rows, and scopeVisibleToClient is the single source of truth for
client file access, the highest-stakes function to go changing for a
dashboard widget. The widget was relabelled instead.

That decision lives in a commit message and in one widget's label.
Nothing in the code said it, and the docblock nearest the rule went on
promising the opposite -- which is how the next person re-derives "staff
keep full access" and widens something.

So both comments now state the boundary and why it is where it is, and
ExpiredFileStaffAccessTest makes it executable: an unscoped staff member
keeps an expired file, a client-scoped one keeps their own expired
upload, a client-scoped one loses a client's file when it expires.

Not changed: scopeVisibleToClient, StaffLibraryScope, and the
expired-files widget. If the boundary should move, that is a separate
conversation and a separate change.

Counter-check inverted, since these pass on unmodified main by
construction -- there is no behaviour fix for them to prove. What they
have to do is fail if the boundary moves, so the mutation is the widening
itself. Deleting the closing notExpired() call from scopeVisibleToClient
turns the file red, 1 failed / 2 passed, and it is the third case, the
one carrying the decision, that falls.

Suite 2108 passed / 2 skipped, 11416 assertions, PHPStan level 8 clean.
Measured on base 06c364d2, where main itself is 2105 / 2.
2026-08-28 06:56:09 +02:00
denkfabrik-li 21cae2acb1 Stop a version link telling people about a file they already had
FileVersions::link() resolves its notification audience before the merge,
and says why:

    RESOLVED BEFORE THE MERGE, and the ordering is the whole dedupe:
    these are the people who could already see both files, so anyone the
    merge below is about to reach for the first time is excluded here and
    gets file_shared from FileSharing::assign() instead. Resolve it
    afterwards and every newly-added client receives two emails about one
    action.

The merge then undoes it. moveAssignmentsToRoot() hands every one of the
revision's targets to FileSharing::assign(), under a comment claiming
that firstOrCreate makes a target the root already has a no-op. It makes
the assignment row idempotent; the three side effects under it --
activity entry, in-app notification, digest -- run unconditionally.

Measured on main:

    client already holds the root and the revision, then both are linked
      file_shared      (Report)     <- wrong, they have had it all along
      file_new_version (Report v2)  <- right
      assignment rows on the root: 1

    client holds only the revision, then both are linked
      file_shared      (Report)     <- right, the merge does hand it over

Two notifications for one action, for exactly the people the early
resolve was meant to protect.

So a target the root already holds is skipped rather than handed to
assign(). Nobody is gaining access in that case, and the activity entry
would be as untrue as the notification. copyAssignmentsFrom() directly
below already states that rule for its own case, which is why it inserts
directly instead of going through FileSharing. Both stale comments are
corrected with it.

Not changed: FileSharing::assign() itself, and so the behaviour
ShareNotificationsTest pins -- re-posting an existing assignment through
the share endpoint still notifies again. That test names the condition
for ever changing it, "it should stop being sent for both at once", and
that is a decision about files and folders together. This is narrower: a
version merge is not somebody choosing to share again, and it already
had a stated intent to send exactly one notification.

Three cases in ShareNotificationsTest -- the target already on the root,
the target gaining it, and a group already on the root. Reverting
FileVersions alone leaves 2 failed / 8 passed in that file; the middle
case passes without the fix, because it guards against skipping too much
rather than against the duplicate notice.

Suite 2108 passed / 2 skipped, 11415 assertions, PHPStan level 8 clean.
Measured on base 06c364d2, where main itself is 2105 / 2.
2026-08-28 06:56:09 +02:00
denkfabrik-li defe488391 Ask about the zips queue on every path that could answer it
ensure_worker_watches_zips() exists because a worker unit written before
zip downloads had their own queue watches 'default' only, and a zip
enqueued to 'zips' then waits forever with nothing saying why. It is
called from exactly one place: inside the branch that reloads PHP-FPM,
nested inside the branch that found a worker unit.

So it runs only when a PHP-FPM unit was detected. Driving the restart
block through four host shapes, with everything it touches stubbed:

  systemd + fpm + worker      reached, restarted
  systemd + worker, no fpm    silent
  --no-restart                silent
  no systemd at all           silent

The second line is the one that matters. A worker unit is a different
service from PHP-FPM, and not finding one says nothing about the other: a
host running mod_php, or one whose FPM unit is named in a way this script
does not recognise, can still have a systemd worker that predates the
zips queue. That host gets no check and no mention.

The check now runs in the else branch too, where it costs nothing -- its
own first line returns immediately unless systemd and a worker unit are
both present -- and the worker is restarted after it, paired exactly as
the FPM branch pairs them. That pairing is the point rather than a
flourish: the new --queue argument reaches the worker only when systemd
next starts it from ExecStart. The queue:restart that projectsend:update
signals cannot deliver it, because that makes a worker pick up new *code*
and it has already run by the time this block is reached, so the worker
came back on the old command line. Editing the unit without the restart
would leave the operator told that zip downloads were fixed while they
still could not finish -- worse than the silence it replaces, since
silence sends somebody looking.

Under --no-restart it is said rather than done: editing a unit file is
exactly what that flag asks us not to do, but a worker that cannot finish
a zip is broken whether or not we are allowed to touch it, and this is the
only place that knows to mention it.

Same four shapes afterwards:

  systemd + fpm + worker      reached, restarted
  systemd + worker, no fpm    reached, restarted
  --no-restart                not reached, but said so
  no systemd at all           reached, no restart (returns immediately)

No test: the suite cannot drive a shell script that restarts services.
bash -n parses, and the harness above is the evidence.
2026-08-28 06:45:49 +02:00
denkfabrik-li fc5651faad Check the read half of the redirect rule at every door, not one
Three middleware answer before HandleInertiaRequests and so have to
repeat its 302→303 upgrade themselves: EnsureSetupIsComplete,
EnsureUserIsActive and EnforceTwoFactor. This file has a write case for
each, and the rule has a second half -- a read still gets a plain 302,
because a 303 there would be an upgrade nobody asked for.

That half was checked once, on the deactivation door, under a name that
said otherwise: "leaves a read alone in every one of those cases". The
setup door and the two-factor door were not covered at all, so a change
that upgraded reads at either of them would have gone through with the
suite green and this test's name still claiming it would not.

Both are covered now, as a dataset with one case per door. The setup case
reads a guest-reachable GET for the same reason the write case posts to
/timezone: anything behind `auth` is answered by the guest redirect before
EnsureSetupIsComplete sees it.

No production code changes; today all three doors answer a read with 302,
which is what the new cases assert. Demonstrated by mutation rather than
reversion: making EnsureSetupIsComplete upgrade every redirect to 303
fails this file (1 failed / 8 passed) and passes the old one (7 passed).
2026-08-28 06:40:54 +02:00
denkfabrik-li b838036a9a Set the directory permission Flysystem actually reads
FILES_WEB_SERVER_READABLE asks for 0755 on the directories a download has
to be traversed through, and asks for it from a key that is never
consulted.

FilesystemManager::createLocalDriver passes
`directory_visibility ?? visibility ?? private` to
PortableVisibilityConverter::fromArray() as the default visibility for
directories. This disk sets `visibility` to public two lines above, and no
`directory_visibility`, so directories are public and the converter reads
`dir.public`. The configuration names only `dir.private`.

The mode is 0755 regardless, because 0755 is Flysystem's default for a
public directory -- the right answer from the wrong place. Adding
`directory_visibility` to this disk, or a change to that default, is all
it would take for the flag to stop doing what it says. Measured on main,
with the flag on:

  dir.private 0755 → 0750   directory stays 0755   (nothing reads it)
  dir.public  0755 → 0750   directory becomes 0750 (this is the key)

Both are named now, so the intent survives either way round.

FilePermissionsTest could not have caught this, because it was not testing
this configuration. filesDiskWith() restated the shipped branch inline,
verbatim down to the 0755, so it went on passing against its own copy
however the real one changed. It now requires config/filesystems.php and
replaces only the root, which is what makes the mutation above visible to
it.

Two more things in the same helper, both about the suite rather than the
subject: the scratch root is per worker now (Tests\TestCase does the same
for upload parts, and eight workers sharing one directory means one
worker's afterEach deletes another's tree mid-test), and it is cleared
before each test as well as after, so a killed run does not poison the
next one.
2026-08-28 06:40:54 +02:00
denkfabrik-li 5a9133bb07 Give a download's presigned URL a minute rather than an hour
StoredFileResponse hands external storage a presigned URL for an hour,
whatever the delivery is for. That URL is a bearer credential: whoever
holds it fetches the file without passing any of the caller's checks
again, and it outlives them. A download cap spent in the meantime, an
expires_at that falls inside the hour, an assignment withdrawn -- none of
them reach it, and nothing here can revoke one. It is also forwardable,
which the local path is not: X-Accel-Redirect authorises one response to
one request.

The two deliveries do not need the same window, so they no longer share
one.

A download has to survive being followed -- a redirect and a request --
which a minute covers with room to spare. An object store checks the
signature when the request arrives rather than while it runs, so a
transfer that starts inside the window finishes however long it takes.

A preview keeps the hour, because it is watched rather than fetched: the
player holds the URL and issues a Range request every time somebody seeks
past the buffer, so a minute would break playback of anything longer than
a minute. The class docblock now says that this is the trade being made,
instead of leaving it in a single number.

Two tests, one per window. Without the fix the download link is an hour
long.
2026-08-28 06:40:53 +02:00
denkfabrik-li 02eafb473b Refresh a mailbox on the schedule under the lock a send would hold
freshAccessToken() serialises refreshes per connection, and its comment
says why: both providers rotate the refresh token as they hand out an
access token, so the token is good for exactly one use, and "a worker
racing the nightly refresh command means the slower one spends a token the
faster one has already replaced. The provider answers that with
invalid_grant, which is the same thing it says about a genuinely revoked
grant: last_error gets written, the settings page turns red, and every
admin is told to go and re-consent a connection that was never broken."

The nightly refresh command called refresh() directly, outside that lock.
It was the racer the comment names, not a party to the arrangement it
describes.

It now goes through refreshSerially(), which takes the same lock -- named
once, in one place, for both callers -- re-reads the row inside it, and
refreshes. Unlike freshAccessToken() it refreshes a token that is still
usable, which is the point of the daily run: a delegated refresh token
dies of disuse and this keeps the window sliding.

The lock is taken rather than waited for, unlike the send path. Nobody is
standing at a screen for a scheduled job, and a held lock means somebody
is refreshing this very connection right now -- which slides the window
and establishes its health just as well as doing it again would.

One test: with the lock held, the command sends no token request and
leaves the connection untouched. Without the fix it spends the refresh
token the holder is already spending.
2026-08-28 06:40:53 +02:00
denkfabrik-li 674781e57a Claim a TOTP code atomically instead of checking then writing
verify() asked Cache::has(), verified, then Cache::put(). Between the read
and the write the key is free, so two requests carrying the same code
could both be told yes -- which is exactly what the replay guard exists to
prevent, and the window an intercepted code has is the whole of its
validity either side.

The claim is now the answer: Cache::add() writes only if the key is
absent, so of two requests carrying the same valid code exactly one gets
true back. That is the same mechanism, for the same reason, as the
preview log's debounce -- "Cache::add is the whole mechanism: it writes
only if the key is absent ... without a read-then-write race between two
of them".

Verification still happens first, so a wrong code never touches the cache
and cannot burn the window for the code the person is about to type
correctly.

One test, modelling the interleaving it is about: the winner's claim has
landed, and the loser's has() answers from before that write. Without the
fix the loser is signed in.
2026-08-28 06:40:52 +02:00
denkfabrik-li f39ad46dd6 Leave the caches update.sh's own update command needs to see
INSTALL.md tells an operator to cache routes, views and events once, and
promises: "You only run these once: projectsend:update notices they are in
place and rebuilds them for you after every update."

It cannot, for anybody who updates with update.sh. The script wipes
bootstrap/cache/*.php while replacing the application files, and
UpdateInstallation::warmCaches() decides what to rebuild by asking
file_exists() on those very paths -- forty lines later. Every installation
looks like one that never cached anything.

Measured in a copy of the tree, the two orderings:

  wiping everything, as the script does it
    → "Cleared the compiled configuration, events, routes and views."
    → bootstrap/cache is empty afterwards

  keeping the route and event caches
    → "Rebuilt the route, event and view caches — they were in place before."
    → routes-v7.php and events.php are back

So the site quietly loses route, event and view caching on every update,
and the operator is never told.

The wipe now names what it removes rather than taking the directory:

  - packages.php and services.php, because they must not survive the swap:
    they name the old release's package providers, and the first artisan
    run after the copy would try to load classes this version no longer
    ships.
  - config.php, for a sharper reason. It is read at every boot, so leaving
    it means projectsend:update reads the *previous* release's version out
    of it. Measured with a doctored version inside a cached config: the run
    said "Re-applied 2.2.0" while the release on disk was 9.9.9, recorded
    that old version as the one applied, and ran the migrations under the
    old configuration. With it removed: "Updated from 2.2.0 to 9.9.9".

The route and event caches stay, since neither is read at boot -- both are
arrays of class names, consulted when a route is matched or an event
dispatched -- and projectsend:update clears them itself moments later.

Removing config.php here would have taken the command's "a cached
configuration was found" warning with it, since it warns about what it
finds. The script now says it, in the same words, at the moment it removes
the file.

No test: the suite covers the command's decision (UpdateCommandTest pins
the whole rewarm matrix) and cannot run a shell script that replaces an
installation. The measurements above are the evidence; bash -n parses.
2026-08-28 06:40:51 +02:00
denkfabrik-li a1773cad5e Count a shared folder's contents as reach, not just the folder
groupReachesNoFurther() asks whether anything shared with a group sits
outside the viewer's library. Its docblock says the folder half covers
"the folders whose subtrees it can browse". It compares the folder ids the
assignment names and stops there.

A folder shared with a group hands its members the whole subtree --
File::scopeVisibleToClient matches on folder placement, and a folder is
visible to a client when it or an ancestor is shared with them. So the
guard passed on a subtree it had never looked into.

Measured on main: a scoped rep's own folder, a subfolder somebody else
created inside it, and that person's file in the subfolder.

  parent in the rep's library     true
  subfolder in it                 false
  the file in it                  false
  add their own client to a group holding the parent   302, allowed
  the client can then reach the file                   true

And because files() is "own uploads plus everything my clients can see",
the file lands in the rep's own library on the next request. That is the
widening this guard exists to refuse -- the first test in the file is
called "a scoped staff member cannot widen their own library through a
group".

The folder half now walks each assigned folder's subtree, and the files
inside it are checked too: a folder can be in the library while a file in
it is not, since somebody else's upload into a folder this rep owns is
neither their own nor their clients'. Expired files are skipped for the
reason the deleted ones are -- membership grants nobody access to one.

Three tests: the subfolder case, the stranger-file case, and a subtree
wholly inside the library, which stays manageable. The first two go red
without the fix.
2026-08-28 06:40:51 +02:00
denkfabrik-li 17fc9ff4cb Compare the transfers window against the column's own timezone
resolveTransferRange() builds every boundary in the viewer's zone, which
is right and deliberate: "last week" should end when their evening does.
Its docblock then claims the instants "compare against the UTC column
directly". They do not. The query builder formats a Carbon in whatever
zone the object carries and drops the offset, so the viewer's midnight
arrives at the database as a UTC string.

For Asia/Tokyo, measured:

  the instant the window really starts   2026-08-21T15:00:00+00:00
  what the query asked for               2026-08-22 00:00:00

Nine hours at each end, in the same direction: the first nine hours of
the viewer's window are missing from the chart, and the last nine hours
of somebody else's day are counted into it. Every zone east or west of
UTC gets a chart that is quietly wrong at both edges, which is worse than
one that is obviously wrong.

The comparison now converts; the day cursor a few lines below does not,
because that half genuinely is about the viewer's calendar and is what
puts an evening upload on the right bar.

One test, in Asia/Tokyo, with an upload in the first hour of the viewer's
window. Without the fix it is missing from the chart.
2026-08-28 06:40:50 +02:00
denkfabrik-li 776d3d99f4 Put a client on the roster of the scoped staff member who created them
A client-scoped staff member with create_clients creates a client and
loses it immediately. guardTarget() answers 404 for anything off their
roster, and StaffLibraryScope::clients() leaves it out of their list -- so
the record exists, is logged, is welcomed by email, and is invisible to
the person who made it. store() redirects to the edit page, which is where
they land:

  POST /clients        → 302 → /clients/4
  on_creator_roster    → false
  GET /clients/4/edit  → 404
  clients listed       → ["Mine"]     the new client is not there

Their own roster is where a client they created belongs, so it is attached
there. An unscoped creator gains nothing: they see every client already,
and a roster entry would change what assignedClients means for them.

The API twin does the same, for the same reason -- a scoped token gets a
404 from every route that binds the client it just created.

Three tests: the scoped creator can open and list the client, an unscoped
creator gains no roster entry, and the API twin behaves like the web. The
first and third go red without the fix.
2026-08-28 06:40:50 +02:00
denkfabrik-li 9ddd39c41d Refuse to provision over a deleted account's address instead of crashing
The unique index on `email` spans soft-deleted rows -- AvailableEmailRule
is built on exactly that, so a deleted account keeps its address until
erasure removes the row. The registration form learns this from
validation. The machine paths have no form: a directory or an identity
provider hands over an address and provision() inserts it.

Measured on main, a client deleted last week signing in through a
provider that may auto-provision:

  GET /auth/google/callback → 500   (QueryException, unique constraint)

Same shape through LDAP at POST /login. Nothing is created, nothing is
signed in, and what the person meets is a server error.

Both provisioners now ask ClientProvisioning::addressIsFree() first and
refuse. The social flow already has a refusal for an identity it cannot
provision -- "There is no account here for that address." -- which is also
all a stranger should learn: whether an address was once an account here
is not the provider's to publish. The LDAP flow falls through to the
ordinary failed sign-in.

Deliberately not resurrecting the deleted account. Restoring one because
a directory says the address exists is a decision for a person, not a
side effect of somebody logging in.

Two tests, one per path: the sign-in is refused, nothing is created, and
the trashed row is still trashed. Both go red without the fix.
2026-08-28 06:40:50 +02:00
denkfabrik-li 19c449ee20 Stop an editable-once checkbox locking before anybody ticks it
save() writes '0' for an unticked checkbox, and filled('0') is true in
Laravel -- so isLocked(), which asks whether anything is stored, locked
the field the first time the client saved the page it sits on, whatever
they had chosen. A box they never ticked could then never be ticked, and
the one edit the setting promises was spent on a decision they had not
made.

A text field left empty stores null and stays open. That asymmetry is the
bug: '0' is the absence of a decision, which is what null means for every
other type.

So a checkbox locks on a stored '1' and nothing else. Everything else is
unchanged, including the existing case of a client ticking the box and
then being unable to untick it.

Two tests: an unrelated save leaves the box open and the tick that follows
still lands and locks it; and an editable-once text field behaves exactly
as before. Without the fix the first goes red.
2026-08-28 06:40:49 +02:00
denkfabrik-li 4b998cda92 Fail a zip build without handing the requester the server's reason
The write-failure branch already draws the line and says why: "What the
requester sees stays generic: a libzip string means nothing to them and
can name a server path. An operator needs the opposite ... so the reason
goes to the log instead." Thirty-seven lines below it, the catch-all
around the whole build stored $e->getMessage() in the row the requester
polls. Measured, a client asking for an archive of a file on a disk that
is no longer configured was told:

  "Disk [a-disk-that-is-not-configured] does not have a configured driver."

The reason now goes to the log with the exception class, and the row
carries the same kind of sentence fail() already uses.

Second, the temp files. tempnam() creates the file, and $tempFiles[] was
appended only after the copy had finished -- so every throw in between (a
disk that will not resolve, a stream that will not open) left a zip-src-
file in the system temp directory that nothing ever removes. It is now
registered the moment it exists.

Third, in the same method: the copy itself was unchecked. A copy that
stops early is a truncated member added to the archive as though it were
the file, so the build reports ready and the recipient gets something that
opens and is wrong. Both the copy and the fclose that flushes it are
checked now, and both handles close on every path.

Two tests: the failure message names nothing about the server, and a build
that throws mid-copy leaves no temp file behind. Both go red without the
fix.
2026-08-28 06:40:49 +02:00
denkfabrik-li 4164678ebc Delete a file's renditions even when its own disk cannot be resolved
FileDiskCleanup wraps both deletions in one try. The first is the original
upload, on whatever disk the row names; the second is every cached
rendition, always on the local files disk. Storage::disk() throws outright
for a name with no configured driver -- which is the state the original's
disk is in whenever this fails at all -- so the catch swallowed it and the
renditions were never reached.

Nothing looks for them afterwards. OrphanFileScanner skips the rendition
directories on purpose (they are derived artifacts, never orphaned
uploads), so a file whose external disk had been removed or renamed kept
every cached copy of itself, indefinitely, on the disk that was working.

The two attempts are now separate, each with the same tolerance the class
was written for: a storage failure still never turns a delete click into a
500, and the warning is still the report.

While here, the comment in File::booted() that justifies deferring the
byte removal claimed "the worst case is bytes left on disk with no row,
which OrphanFileScanner already finds and reports". Not on this path: the
row is soft-deleted, and knownPaths() counts a trashed row's path as
claimed -- deliberately, so a scan never offers to double-adopt a file
still inside its erasure grace period. The comment now says what actually
happens.

One test: a file whose disk cannot be resolved loses its renditions. It
goes red without the fix, next to the existing test that the delete itself
still succeeds.
2026-08-28 06:40:48 +02:00
denkfabrik-li fc758c701a Write a rendition through a temporary file, and never serve an empty one
Both thumbnail routes treat "the file exists" as "the rendition is
cached", and nothing ever invalidates one: RenderedImageCache::flush()
runs on ImageRenderingChanged, which no core code raises. Whatever is at
the path is what every later viewer gets.

ThumbnailGenerator encoded straight onto that path. A render that died
partway -- a full volume, a killed worker -- left a half-written file
that was then served as the rendition for good, and two requests
rendering the same file at once encoded into one path together.

It now writes beside the destination and renames into place. rename()
within a directory is atomic and replaces what is there, so the path is
either the previous rendition or a complete new one, and the loser of a
race leaves a whole image rather than a mixture of two. The temporary
file is removed on the way out either way.

The read side gets the other half: an empty file is not a rendition, so
both routes replace one rather than serve it. Writing through a temporary
file means this state can no longer be created here, but an installation
that ran an older version can already have it on disk, and nothing else
will ever clear it.

Three tests: an empty rendition is replaced on the signed-in route and on
the public one, and a successful render leaves nothing half-written
behind. Without the fix the first two go red; the third is about the fix's
own temporary file and passes either way.
2026-08-28 06:40:48 +02:00
denkfabrik-li f2b705beee Keep an upload's parts until its bytes are stored
complete() holds a lock whose comment promises "the lock's TTL releases
the claim if a completion dies mid-flight, so a later retry still works".
A retry has nothing to work from but the parts, and assemble() unlinked
each one inside the loop that read it -- so everything that can fail
afterwards took the retry with it.

Measured on main, with a disk refusing the write (the case the guard forty
lines further down was written for, found against a real GCS bucket):

  first complete  → 422, 0 parts left, the half-written copy left behind
  retry           → 422 "Upload is incomplete: missing parts."

For good: listParts() is empty, so no later attempt can ever succeed, and
the client has to send the whole file again. The abandoned copy sat in the
session directory until the sweeper came round.

The parts now go when abort() clears the session directory -- which
already ran on success -- and a failure deletes only the half-written copy
it made. The cost is temp space: peak usage during assembly is the whole
file twice over rather than the file plus one part. The docblock says so.

Also checked while here: every read and every write in the concatenation.
A failing fwrite is loud in practice, since Laravel's error handler turns
the warning into an ErrorException, but loud there is a 500 carrying a PHP
message where this method's other storage failure is a sentence the person
uploading can act on. A short write arriving without a warning would be
worse: the byte count and the checksum describe the buffer that was read,
so an unchecked one records a truncated file with a checksum matching
bytes that were never stored.

Two tests: the retry after a refused write now succeeds, and a temporary
directory that refuses writes (/dev/full, skipped where it does not exist)
fails the upload with this method's own message. Without the fix both go
red.
2026-08-28 06:40:47 +02:00
denkfabrik-li 250e8664d3 Stop a client PATCH clearing custom fields it never mentioned
update() states the rule eighteen lines above the bug: "PATCH semantics,
unlike the web form which always submits every field: an absent key means
'leave alone', not 'clear'." Every column obeys it. The custom fields did
not, because they went through create()'s pass, which walks every field
there is and writes null for the ones the request did not carry.

Two fields filled, a PATCH naming one:

  status         → 200
  named field    → "Robin"
  the other one  → null      (was "ATU12345678")

Nothing says so in the response, and there is no other copy of the value.

The write pass is now shared but entered two ways: create() keeps writing
every field, since a new client has no values and a checkbox nobody ticked
is a recorded "no"; update() writes only the fields the request named.

Three tests: the untouched field survives, a named empty value still
clears, and create still records every field. Without the fix the first
goes red.
2026-08-28 06:40:47 +02:00
denkfabrik-li 640c5db591 Stop an expiry moving because somebody else saved the file
The edit form is given a file's expiry as a calendar date, read back in
the viewer's own zone -- deliberately, so a file set to expire on the 12th
does not reopen showing the 11th. Every save posts that date back, whether
or not anybody touched it, and update() derived a fresh instant from it
every time.

So the expiry drifts by the difference between two people's zones on any
other edit. A date set from Pacific/Auckland stores 2026-09-12T11:59:59Z;
a colleague in UTC-3 opens the file, sees the same 12th, renames it, and
the file now expires at 2026-09-13T06:59:59Z -- 19 hours later, with
nobody having gone near the date.

The instant is now re-derived only when the posted date differs from the
one the form was given, compared against the same string through a named
pair: expiryDateFor() renders it, expiryInstant() reads it back. The edit
screen uses the same method it is compared against, so the two cannot
drift apart.

bulkUpdate() needs nothing: its expiry is an explicit set/clear/no_change
action, so an untouched expiry is never posted in the first place.

Three tests: the rename leaves the instant alone, a real change still
lands in the editor's own zone, and clearing still clears. Without the fix
the first goes red.
2026-08-28 06:40:46 +02:00
denkfabrik-li e1cd010f9d Give an API expiry date the same meaning the web gives it
FilesController::expiryInstant exists because a calendar day ends where
the person naming it lives: the web form posts a bare YYYY-MM-DD, and
storing that as it arrives would cut a file off at midnight UTC -- "expires
on the 12th" ending partway through the 11th for anyone in the Americas.

The API takes the same field, validates it as a date, and stores it raw:

  web  → 2026-09-12T23:59:59+00:00   (end of the day, as the docblock means)
  API  → 2026-09-12T00:00:00+00:00   (raw)

Same value, same field, same file, two meanings -- and the earlier of the
two is a file that dies at the start of the day it was promised.

A bare date now means the end of that day in the caller's timezone, as it
does on the web. A value carrying a time is unchanged: it is an instant
the caller named on purpose, the API can express one and a date input
cannot. The endpoint's docblock says both, so the OpenAPI document does
too.

Three tests: the day, the timestamp, and clearing. Without the fix the
first goes red.
2026-08-28 06:40:46 +02:00
denkfabrik-li c2dd2c758a Debounce the public preview log the way the signed-in one already is
FileThumbnailController::preview() writes at most one FilePreviewed row
per viewer per file per five minutes, because a browser turns one video
into a long tail of Range requests against the same URL. Its docblock
names the anonymous route as the place the same act happens without an
account -- and that route logs unconditionally.

Measured: five requests for the same public file, five
PublicFilePreviewed rows, against one for the signed-in twin. One visitor
watching one clip buries the public half of the activity log, which is
also the half an operator reads to see what the outside world is doing.

The window is now a shared PreviewLog, next to PreviewKind, which the two
preview routes already share for the same reason. Keying is unchanged for
a signed-in viewer; an anonymous one has no account to key on, so the
request IP stands in -- the same substitute the API's rate limiter makes
for an unauthenticated caller. It is a cache key with a five-minute life
and never reaches the log, which keeps its own decision about recording an
IP (ActivityLogger::shouldRecordIp, Setting::DownloadIpLogging).

Three tests: the replay is one row, two visitors are two rows, and the
window is per file. Without the fix the first goes red.
2026-08-28 06:40:45 +02:00
denkfabrik-li 9d4b096c19 Narrow the reassignment picker to what a viewer may see
`reassign_candidates` is the delete dialog's picker: every active account
in the installation, by name and by role label. The same list is shared
on the clients index, the users index, both edit screens and privacy
settings, and it was narrowed by nothing.

Two lines above it on the clients index sits the listing itself, narrowed
through `scope->clients($viewer)` with a comment saying why: "a
client-scoped staff member is not shown the name and email of somebody
they can reach nothing of". The picker beside it handed over every client
in the installation, plus every staff account and its role name. The
filter by `can('delete_clients')` happens in React, which decides what is
rendered, not what is sent.

So the client half of the candidate list goes through the same
StaffLibraryScope as the listing, and each screen sends the picker only to
a viewer holding the delete permission it exists for. Staff accounts are
not narrowed -- they are not narrowed anywhere else either -- and an
unscoped viewer's list is unchanged, because StaffLibraryScope::clients()
returns every client for them.

Privacy settings keeps the whole installation on purpose: that picker sets
the erasure default stored once for everybody, behind edit_settings, so
narrowing it by whoever happens to be editing would store the wrong
answer. The parameter is nullable for that one caller, and the docblock
says so.

Four tests. Without the fix three go red; the fourth is the guard that an
administrator still sees every active account.
2026-08-28 06:40:45 +02:00
denkfabrik-li 763777d282 Say which permission a bulk edit was actually missing
Two different things stop a selected file being changed, and bulkUpdate()
reported both as the first one.

Files dropped by the Gate::allows('update') filter are ones the staff
member may not edit at all. A file that survives the filter and still
changes nothing is a different case: it was editable, and every field they
asked to change was one their role does not let them set -- expiry,
download limit, categories, each behind its own permission, exactly as the
single-file editor treats them.

Measured with edit_files but without set_file_expiration_date, three files
they own, expiry the only change: "0 of 3 selected files were updated. The
rest were skipped because you don't have permission to edit them." They
own all three and editing is precisely what they may do, so the sentence is
both wrong and unactionable.

The two cases now have their own sentences. The existing string is kept
for the case it describes -- every skip a file they may not edit -- so its
sixteen translations stay in use. The new one covers a field permission,
and covers a mixture of both reasons, since "permission to make those
changes" is true either way.

The new key is English only; a locale without it falls back to English,
which is a translated-but-wrong sentence traded for an untranslated
correct one.

Three tests: each reason on its own, and the mixture. Without the fix the
first and third go red.
2026-08-28 06:40:44 +02:00
denkfabrik-li f424fe5365 Decide what is an API request from the route, not from the caller's headers
Two places asked "is this the API?" and got it wrong in opposite ways.

EnsureCapability asked $request->expectsJson(). Whether a feature exists
in this installation's edition is a property of the installation, not of
what the caller is willing to parse, so the same route answered
differently per header: `Accept: application/json` got the 403
`capability_unavailable` routes/api.php promises, `Accept: */*` -- curl's
default -- got a bare 404 `not_found`. The mirror image is worse: an
Inertia visit to a capability-gated *web* screen accepts JSON, so it got
403 with Laravel's default error body, naming the exception class, where
the point of the 404 is that an unavailable feature is absent rather than
teased.

ProblemDetails asked $request->is('api/*'). Two staff pages live under
that prefix -- the API dashboard at /api and the OpenAPI reference at
/api/docs, both registered in routes/web.php -- so a signed-out visitor to
either got 401 problem+json, "Send a valid API token in the Authorization
header as \"Bearer <token>\"", instead of the login redirect every other
page gives them.

Both now ask App\Support\ApiSurface: under the API prefix, and not part of
the `web` middleware group. The group is what actually separates the two
-- sessions and CSRF on one side, tokens on the other -- and it keeps
answering correctly for a future /api/v2 without being edited. An
unmatched path has no route to ask, which is the API's answer anyway: a
404 under its prefix is one it should describe in its own format, and the
existing test for that stays green.

Three tests, in the two files that already own these rules. Without the
fix all three go red.
2026-08-28 06:40:44 +02:00
denkfabrik-li cd8da6a117 Name the quota a client is actually held to when an upload is refused
Both chunked-upload quota checks resolve the limit through
ClientStorageUsage::quotaBytes(), which falls back to the site default
when a client has no quota of their own -- and then print
`$user->storage_quota_mb` in the rejection. For every client who was never
given an explicit quota that column is 0, so the message reads "This
upload would exceed your storage quota of 0 MB." at the one moment
somebody is trying to find out what their limit is.

The API's single-request upload already prints
`$this->storageUsage->quotaMb($user)` for the same sentence
(Api/FilesController.php:208). The two chunked copies now do the same.

Three tests: the inherited default is named at session creation and again
at completion, and a client with a quota of their own still sees their own
number. Without the fix the first two go red, the third stays green.
2026-08-28 06:40:43 +02:00
denkfabrik-li db1dd71f3c Stop an expired file locking a group shut for a scoped staff member
groupReachesNoFurther() asks whether anything shared with a group sits
outside the viewer's library. `f1b35cc9` established the shape of the
answer for deleted files: start from the live row, because "a deleted file
is not reach, because nobody can reach it".

An expired file is the same case. Membership grants nobody access to it --
File::scopeVisibleToClient ends in notExpired(), so it has left every
member's /my-files and the download answers 403 -- but it is equally gone
from files(), where its absence reads as "outside my library". The group
then locks for a scoped staff member: they cannot add a member, cannot
rename it, and cannot remove their own client again.

So the reach query skips expired files as it already skips deleted ones.
Expiry is reversible where deletion is not, and that needs no special
handling: the guard asks what is reachable at the moment somebody is added
or removed, and the file counts again the moment it stops being expired.

Not changed: File::scopeVisibleToClient, whose treatment of expiry was
settled deliberately in c8078f65. This is about what counts as reach, not
about what a scoped viewer may open.

Two tests, next to the deleted-file pair they mirror: the lockout, and the
half that must not soften -- a live out-of-reach file is still reach with
an expired sibling next to it. Without the fix the first goes red.
2026-08-28 06:40:43 +02:00
denkfabrik-li c8de16101f Gate the comment moderation surfaces on reading, not just on the library
FilePolicy::view() has two halves for staff: one of the three file keys
(upload / edit_files / edit_others_files), AND StaffLibraryScope. Every
comment surface that spans files narrowed by the library half alone.

A role holding moderate_comments and no file key therefore got a 403 on
every file in the installation while reading every comment written about
them on /comments: the text, staff-only notes, the client name a
Clients-visibility comment carries, and a visitor's IP address. The API
queue answered the same way, and approving through it hands the body back
in the response, so it was a reading door as well as a writing one.

The class says this is not supposed to happen -- across()'s own docblock
("a moderation screen is not a way around the visibility model"), the
route comment on /comments ("the list itself is still narrowed by
VisibleCommentScope, so holding the permission does not widen what a
viewer may read"), and routes/api.php ("reading and writing a comment is
gated by 'may see this file', the same three keys the file endpoints
use"). FileCommentPolicy::view() enforces it for a single comment, by
running the file's own gate first. Only the cross-file queries did not.

So they now take their files from ViewableFileScope, which is
FilePolicy::view() expressed as a query, instead of from StaffLibraryScope,
which is only its second half: across(), pendingTotal() and the API's
pending list. The permission half moves into a named method on that class,
since three modules now ask the same question.

FileCommentPolicy::moderate() gets it too, in both forms. Its row form is
otherwise unchanged -- the library check still runs by file id, so a
comment on a soft-deleted file behaves exactly as before.

No system role changes behaviour: Account Manager and System Administrator
are the two that ship with moderate_comments, and both hold upload. What
changes is a hand-built role that holds moderation and nothing else.

Seven tests. Without the fix, five go red; the other two are the premise
(that the viewer really is refused the file itself) and the guard that a
moderator who may read files still moderates the whole installation.

docs/api/openapi.json regenerated for the one changed description.
2026-08-28 06:40:42 +02:00
denkfabrik-li cb53120779 Show the portal dashboard the files a client can actually open
clientDashboard() restates the assignment half of
File::scopeVisibleToClient in a whereHas of its own. The scope is the
single source of truth for client file access and ends in notExpired(),
which the copy leaves off, so the two disagree in both directions.

Over: an expired file stays counted and keeps its name on the dashboard
after /my-files has stopped listing it and the download answers 403. Under:
everything that reaches a client another way is missing -- a file inside a
folder shared with them, a file they uploaded through the portal
themselves, and a revision, which owns no assignment row at all and
inherits its original's recipients through SharingIdentity.

Replaced by the scope itself, which is what /my-files runs. The existing
test for the page is unchanged and still passes: a directly assigned,
unexpired file counts exactly as before.

Two tests, one for each direction. Without the fix both go red.
2026-08-28 06:40:42 +02:00
denkfabrik-li 84e9f6e2fe Scope the API dashboard's recent actions to what the viewer may read
ApiUsage::recentActions() is the only ActivityLog query outside
ActivityLogger and AccountEraser that does not run through
ActivityLogScope::apply(). Its whole boundary is view_actions_log -- the
permission whose own scope class says, in as many words, that it "is not
the whole answer for a client-scoped staff member".

The Client Manager system role is client_scoped and ships with that
permission, so this is the default configuration. Such a viewer opening
/api?all=1 reads the fifteen most recent API log rows for the entire
installation, each with its subject_name: the names of files and clients
they get a 403 on. /activity, the download history and the dashboard's
recent-activity widget all narrow the same rows; the API dashboard was
missed.

The scope is applied on both sides of the install-wide branch. The
own-actor filter for the narrow view already stays inside what the scope
allows, and a boundary that exists in only one arm of an `if` is one
refactor away from not existing.

Three tests: the scoped viewer sees only the entry about a file in their
library, their own actions stay whole even when the subject is outside it,
and an unscoped viewer's feed is unchanged. Without the fix the first goes
red; the other two are green either way and guard against narrowing too far.
2026-08-28 06:40:41 +02:00
439 changed files with 41100 additions and 1332 deletions
+28
View File
@@ -4,8 +4,36 @@ PROJECTSEND_EDITION=community
# Emergency off switch for the CAPTCHA on public forms, for an operator who
# has a shell but no working login. Everything else about the feature is
# configured at /system/settings/captcha.
#
# Only "true" or "1" switches it off. Anything else -- including "no",
# "off", and a misspelling -- leaves the CAPTCHA on, deliberately: a flag
# that takes a protection away should not do so because a value was typed
# wrong.
# PROJECTSEND_CAPTCHA_DISABLED=true
# How downloads leave the server. Left unset (or "auto"), ProjectSend hands
# files to nginx when it is running behind nginx, and streams them through
# PHP on anything else -- which works everywhere but holds a PHP worker for
# the whole of each download. Set "xsendfile" for Apache with mod_xsendfile
# (or LiteSpeed) once XSendFilePath allows storage/app/files, "nginx" when
# an nginx proxy in front is the one serving /protected-files/, or "php" to
# stream deliberately. The dashboard's System panel shows which is in use.
# PROJECTSEND_FILE_DELIVERY=auto
# Optional: the virus scanner every upload is checked against, as
# tcp://host:3310 or unix:///path/to/clamd.sock. Naming it here makes
# scanning managed: it is used, it cannot be switched off from the settings
# screen, and the address does not appear there. Leave it unset to
# configure scanning in Settings instead, which is the ordinary way.
# PROJECTSEND_SCANNER_ADDRESS=tcp://clamav:3310
# Optional: the scanner a fresh installation starts out pointed at, written
# into the settings on first boot and ignored on every later one. Unlike the
# variable above it leaves both the address and the switch on the settings
# screen, which is what a self-hosted install wants: configured out of the
# box, and still yours.
# PROJECTSEND_SCANNER_DEFAULT_ADDRESS=tcp://clamav:3310
# Optional: uid/gid the app/web containers' internal user runs as, so the
# bind-mounted repo needs no permission fixes. Defaults to 1000; override
# if your host user's `id -u`/`id -g` differ.
+15 -2
View File
@@ -41,11 +41,18 @@ jobs:
# `issue.pull_request` is present only when the comment is on a pull
# request; comments on ordinary issues have nothing for this action to
# check.
#
# Loose on purpose, never `==`. This only decides whether a runner
# starts; whether a comment is a signature is decided by the action,
# strictly, against `custom-pr-sign-comment` below. An exact match here
# threw away a real signature that arrived with trailing line breaks
# ("...sign the CLA\r\n\r\n"), which the action -- it trims first --
# would have accepted. `contains` and `startsWith` ignore case.
if: >-
github.event_name == 'pull_request_target'
|| (github.event.issue.pull_request
&& (github.event.comment.body == 'recheck'
|| github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA'))
&& (startsWith(github.event.comment.body, 'recheck')
|| contains(github.event.comment.body, 'I have read the CLA Document and I hereby sign the CLA')))
runs-on: ubuntu-latest
steps:
- name: CLA check
@@ -74,6 +81,12 @@ jobs:
Please read the **[CLA]($pathToCLADocument)**, then post exactly this as a comment
on this pull request:
# Not decoration, although it repeats the action's default phrase.
# Set, it makes the action compare the whole comment, trimmed and
# lowercased, against it. Unset, the action searches the comment
# for the phrase instead, and "I LIE, I have read the CLA Document
# and I hereby sign the CLA. I do not sign it" on one line would
# be recorded as a signature.
custom-pr-sign-comment: 'I have read the CLA Document and I hereby sign the CLA'
custom-allsigned-prcomment: 'CLA signed — thanks. A maintainer will review this shortly.'
lock-pullrequest-aftermerge: false
+4
View File
@@ -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
@@ -46,3 +47,6 @@ yarn-error.log
# mkcert certificates and the nginx config that terminates HTTPS on the
# dev `web` container. Machine-specific, and one of them is a private key.
/docker/web/local/
# Written into an artifact by build-release.sh, never into a checkout.
/config/build.php
+441 -4
View File
@@ -6,12 +6,449 @@ Versions follow [SemVer](https://semver.org/): the middle number moves when ther
the last one when there are only fixes, and the first one when an upgrade needs more from you than
dropping in the new files and running the migrations.
Anything under **Upgrade notes** is something you have to do, not something we did.
Anything under **⚠️ Important — do these yourself** is something you have to do, not something
we did. It sits at the top of a release for that reason. Older entries call the same section
**Upgrade notes**.
## Unreleased
## 2.6.0 — 25 September 2026
This section collects changes as they land; the release process turns it into a numbered entry when
a version is cut.
Mostly fixes: files and folders are easier to tidy, the sign-in pages carry your brand better, and
someone who deletes their own account stops being published straight away.
**Added**
- **Delete several files at once** from the selection bar on the Files page, with one confirmation.
- **Upload inside a folder puts the files in that folder**, and brings you back to it afterwards.
- **Show your site name under the logo** on the sign-in and download pages, from Branding → Logo.
- **Choose what happens to someone's files when they delete their own account**: removed right away,
or at the end of the grace period — and whether that applies to everyone or only to clients.
Found under Settings → Privacy.
**Changed**
- **A deleted account's files stop being shared at once.** From the moment someone deletes their own
account, their files are visible only to staff — not to the clients and groups they were shared
with, not through share links, not on the public pages — until the account is erased. Restoring
the account brings them back.
- **Your logo is shown larger on the sign-in and download pages**, so a square logo is clearly
visible. The Branding screen now says what size to use.
**Fixed**
- Saving a settings form could show an error dialog containing `{"count":0}` instead of saving.
- A file upload running several parts at once could be refused near the end with "too large".
- The System card reported the server's free disk space as file storage on installations that keep
files in S3; it now shows the two separately.
- Confirming your password no longer throws away the form you were filling in.
- LDAP accounts can now get past the password confirmation, which kept them from turning on
two-factor authentication.
- With per-client folders on, a client choosing "No folder" moved the file out of their own folder.
- PHP 8.5 no longer prints deprecation warnings from the database configuration.
- The Docker quick start now saves `compose.yaml`, so the `docker compose` commands in the other
guides work as written. If you saved `compose.example.yaml`, rename it to `compose.yaml`.
- The Docker and migration guides now cover installing Docker, and migrating from a Legacy install
on the same machine.
Thanks to [@JensS](https://github.com/JensS), binghuo, [@lukatong](https://github.com/lukatong),
[@jjoelc](https://github.com/jjoelc), [@jiits](https://github.com/jiits),
[@0xVavaldi](https://github.com/0xVavaldi) and [@lolgufdHD](https://github.com/lolgufdHD) for
reporting and fixing.
### Issues closed since 2.5.0
- [#1635](https://github.com/projectsend/projectsend/issues/1635) — Docs: consider moving the Docker quick start above screenshots
- [#1795](https://github.com/projectsend/projectsend/issues/1795) — Slightly confused regarding the reviews
- [#1796](https://github.com/projectsend/projectsend/issues/1796) — PHP 8.5.10: PDO::MYSQL_ATTR_SSL_CA Deprecated Warning
- [#1799](https://github.com/projectsend/projectsend/issues/1799) — Inertia error from notifications/unread-count because of JSON response
- [#1800](https://github.com/projectsend/projectsend/issues/1800) — Add Bulk-Edit for Moving and Deleting files
- [#1801](https://github.com/projectsend/projectsend/issues/1801) — Add Upload into Folder
## 2.5.0 — 18 September 2026
**Added**
- **Uploaded files can be checked for viruses before anyone can download them.**
- **Files the virus scanner refuses go to a Quarantine screen, where you can read the verdict and
release a file if the report is wrong.**
- **You choose what happens to a file the scanner cannot open, and to uploads arriving while the
scanner is unreachable.**
- **A Test button says which scanner answered and how old its virus definitions are, and fails a
scanner that reports password-protected archives as clean.**
- **The dashboard says when virus scanning has stopped protecting anything.**
- **Files uploaded before scanning was switched on can be worked through in the background, at a
pace you set.**
- **Invite a client to set their own password instead of handing them one.** Contributed by
[@mash2k3](https://github.com/mash2k3).
- **Staff who administer clients are notified in the bell when a client account appears.**
- **Client accounts can expire on a date.** Requested by
[@Drardollan](https://github.com/Drardollan) in
[#1310](https://github.com/projectsend/projectsend/issues/1310).
- **Each role, and each person, can choose where they land after signing in.** Requested by
[@Zodiac1978](https://github.com/Zodiac1978) in
[#1777](https://github.com/projectsend/projectsend/issues/1777).
- **Each client can be given a folder of their own, which becomes their root.**
- **The file library filters by uploader, by the uploader's role, by public or private, by whether
a file was ever downloaded, and by current or outdated version.**
- **The same five filters are available through the API.**
- **Files whose bytes have gone missing from storage are found daily and listed on their own
screen.**
- **Clients see on each file the date it stops being available.**
- **Clients can make a public link for a file they uploaded**, where their role may publish files —
the switch that marks a file public now comes with the link it promises, and they can copy or
revoke it. Reported by Ricardo Cazati.
- **A public page says when the file on it was never checked for viruses**, which happens where an
installation scans and chooses to let through what the scanner could not read.
**Closed holes in who can see what**
- **A staff member limited to certain clients can no longer reach past that limit through an
invitation.** The invitation form listed every group on the installation, and an invited client
could be pre-assigned into a group outside that staff member's own clients. Reported by
[@hackchang](https://github.com/hackchang).
**Fixed**
- **Setting a folder inside the bucket no longer breaks every page that touches storage.** Reported
by [@veenone](https://github.com/veenone) in
[#1788](https://github.com/projectsend/projectsend/issues/1788).
- **The Microsoft sign-in settings now say why new accounts still wait for approval.** They wait
until the `xms_edov` optional claim is added to the app registration, because until then Microsoft
does not confirm that the person owns the address — which is a rule ProjectSend already had and
nothing on screen said. Reported by Ricardo Cazati.
- **Someone who signs in with Microsoft, Google or another provider can now set a password.** The
screen asked for the current one, which such an account never had — so it could not get a
password, and therefore could not turn on two-factor authentication. Where two-factor is
compulsory, that locked those accounts out of everything. Reported by Ricardo Cazati.
- **Creating a client with a storage quota typed in no longer answers with an error page.**
- **A file expiry date sent to the API as a number now answers 422 instead of 500.**
- **A quarantined file, or one missing from storage, is no longer listed in the library with a
download that cannot work.**
- A dependency advisory in the bundled `js-yaml`.
### Issues closed since 2.4.1
- [#1310](https://github.com/projectsend/projectsend/issues/1310) — expire client accounts
- [#1658](https://github.com/projectsend/projectsend/issues/1658) — Docker services have no restart
policy
- [#1770](https://github.com/projectsend/projectsend/issues/1770) — Docker upgrade fails with
external storage configured
- [#1776](https://github.com/projectsend/projectsend/issues/1776) — logo gives error 404
- [#1777](https://github.com/projectsend/projectsend/issues/1777) — customization / branding
(start pages)
- [#1778](https://github.com/projectsend/projectsend/issues/1778) — error 500 on install
- [#1779](https://github.com/projectsend/projectsend/issues/1779) — client invitations
- [#1788](https://github.com/projectsend/projectsend/issues/1788) — a bucket folder makes every
storage page a 500
- [#1789](https://github.com/projectsend/projectsend/issues/1789) — S3 not working after an update
## 2.4.1 — 11 September 2026
### ⚠️ Important — do these yourself
Everything else in this release happens on its own. These do not: each one leaves something working
differently from how you expect until you act on it. Nothing here stops the upgrade or the
installation from starting.
- **If you set `PROJECTSEND_CAPTCHA_DISABLED`, check what you set it to.** Only `true` or `1`
switches the CAPTCHA off now. Anything else — including `no`, `off`, `yes` and a misspelling —
used to be read as "yes, disabled" and is now read as "leave it on". If you meant it off, write
`true`.
- **If a staff role uploads into public folders, give it "Upload to public folders".** That
permission was not being asked of staff, and now is. Roles holding "Upload public files" are
unaffected, and ordinary uploads need nothing new.
- **If you use Microsoft sign-in, add the `xms_edov` optional claim to your app registration.** In
the Entra portal: your app registration → Token configuration → Add optional claim → ID →
`xms_edov`. Until you do, Microsoft sign-in keeps working and keeps creating new accounts, but it
will no longer attach itself to an account that already exists.
**Added**
- **Your logo now appears on the sign-in screen.** Requested by
[@Zodiac1978](https://github.com/Zodiac1978) in
[#1777](https://github.com/projectsend/projectsend/issues/1777).
- **An installation on AWS can authenticate as its own IAM role instead of storing an access key.**
- **Clients can now see how often their own files were downloaded, and when.**
**Fixed**
- **A lookalike domain can no longer hand somebody else's account to an OIDC sign-in.** Reported by
[@choewonwoo1817](https://github.com/choewonwoo1817).
- **Changing your own email address now asks for your password.** Reported by
[@Noorkhalel](https://github.com/Noorkhalel).
- **Microsoft sign-in now checks that the person owns the address they presented.** Reported by
[@archnexus707](https://github.com/archnexus707).
- **Two people filling in the first-run setup screen at the same moment can no longer both become
administrators.** Reported by [@ry2811](https://github.com/ry2811).
- **Uploading into a public folder now needs a permission that says so.** Reported by
[@skeletonsec](https://github.com/skeletonsec).
- **Moving a file into a public folder now needs that same permission.** Reported by
[@skeletonsec](https://github.com/skeletonsec).
- **A staff member limited to some clients can no longer see or change other people's groups.**
Reported by [@Drescargot](https://github.com/Drescargot).
- **Deleting a client can no longer hand their files to a client you do not manage.** Reported by
[@skeletonsec](https://github.com/skeletonsec).
- **Erasing a staff account no longer hands their files to a client.**
- **An interrupted upload can no longer park unlimited bytes on the server.** Reported by
[@ry2811](https://github.com/ry2811).
- **The password reset screen no longer says whether an email address has an account here.**
- **An expired password reset link now says so before asking for a new password.**
- **A Docker upgrade no longer fails when external storage is already configured.**
[#1770](https://github.com/projectsend/projectsend/issues/1770).
- **A public gallery no longer renders the same thumbnail several times at once.**
- **`PROJECTSEND_CAPTCHA_DISABLED` no longer reads a "no" as a "yes".**
### Issues closed since 2.4.0
The summary above is what changed. This is the paper trail, for anyone who wants to read the
original report.
- [#1768](https://github.com/projectsend/projectsend/issues/1768) — Search in file not restricted in directory
- [#1773](https://github.com/projectsend/projectsend/issues/1773) — Feature Request : Support AWS IAM roles / default credential provider chain for S3 storage
- [#1774](https://github.com/projectsend/projectsend/issues/1774) — HTTP Error by upload on R2098
- [#1778](https://github.com/projectsend/projectsend/issues/1778) — [Documentation] Error 500 on install
## 2.4.0 — 8 September 2026
Clients can now look after the files they uploaded, and this release closes three ways somebody
could see a little more than they should.
**New**
- **Clients can edit and delete the files they uploaded**, with the name, description, expiry,
categories, download limit and public flag each behind the permission that already governs it.
A file shared *with* a client is still not theirs to touch.
- **A switch to stop this installation fetching the project news**, on Settings → General. On by
default; off means the request is never made.
**Closed holes in who can see what**
- A staff member limited to their assigned clients could read other clients' names, and their IDs,
out of file details and the uploader filter. Reported by
[@Noorkhalel](https://github.com/Noorkhalel) (GHSA-whmp-p9hv-r7j7).
- Download links to external storage now last a minute instead of an hour. Previews keep the hour.
- Eight advisories in bundled dependencies, including an XSS bypass in the markdown renderer that
builds your email templates.
**Fixed**
- A failed upload keeps its parts, so retrying it works instead of needing the whole file again.
- `projectsend:captcha-off` no longer claims success on an installation whose CAPTCHA keys are
supplied centrally, where it changed nothing.
### Upgrade notes
- **Resuming an interrupted download from external storage more than a minute after it started now
fails.** Start it again from ProjectSend. Local-disk installations and zip bundles are unaffected.
- **If your temporary directory is on a small or separate volume, allow headroom for twice your
largest allowed upload.** Only while a file is being assembled, and nothing needs configuring.
Thanks to [@Noorkhalel](https://github.com/Noorkhalel), [@denkfabrik-li](https://github.com/denkfabrik-li)
and [@mehmedturk](https://github.com/mehmedturk) for reporting and fixing.
### Issues closed since 2.3.0
The summary above is what changed. This is the paper trail, for anyone who wants to read the
original report.
- [#1765](https://github.com/projectsend/projectsend/issues/1765) — Projectsend 2.2.1 thumbnail issue after file upload
- [#1771](https://github.com/projectsend/projectsend/issues/1771) — Permissions granted to the Client role are not applied to client accounts
## 2.3.0 — 1 September 2026
If you run ProjectSend on Apache or LiteSpeed, this is the release to take. It installed fine on
both before. Then every download arrived empty and every thumbnail was broken. That is fixed, and
you do not have to configure anything. Installations on nginx were never affected and nothing
changes for them.
The rest is mostly security work. Most of it is the same kind of thing: a screen or an API endpoint
that showed a little more than the person asking was allowed to see.
**New**
- **Downloads work on any web server.** Your files sit outside the web root, so ProjectSend checks
permission on every download before anything is sent. The fast way to finish is to hand the file
to the web server. Each web server wants that asked for differently, and until now ProjectSend
only knew how to ask nginx. On Apache and LiteSpeed it asked anyway, nothing answered, and the
visitor got an empty file. Now it works out what it is talking to. If it cannot hand the file
over, it sends the file itself, which is slower under load but works everywhere.
- **Apache and LiteSpeed can still have the fast version.** Install `mod_xsendfile` (LiteSpeed
needs no module), point `XSendFilePath` at your storage directory, and set
`PROJECTSEND_FILE_DELIVERY=xsendfile`. See the upgrade notes.
- **The dashboard tells you which way downloads are going out.** If PHP is sending them, there is a
warning next to it and a short explanation of what that costs you and how to change it. This is
the kind of thing that is invisible until the day the site falls over, so it says so up front.
- **Your logo and your watermark, on every installation.** Upload a logo and it replaces ours in
the sidebar and on your public pages. Add a watermark and it goes on the thumbnails and previews
your clients and visitors see. Staff still see the originals, and the watermark is never written
into the stored file, so you can turn it off again.
- **You can find out which build you are running.** Two images can say "2.2.1" and contain
different code. `projectsend:status` now reports the commit it was built from.
- **You will know if the nightly jobs stop running.** When the scheduler dies, nothing looks wrong.
You find out weeks later, when a file you expired is still downloadable. ProjectSend now reports
when its scheduled work last ran and whether any of it failed.
- **You get told when the mailbox stops working**, even when a send noticed the problem before the
scheduled check did.
**Closed holes in who can see what**
- [#1745](https://github.com/projectsend/projectsend/pull/1745) — Gate the comment moderation
surfaces on reading, not just on the library. Permission to moderate comments was letting somebody
read them, which is not the same thing: on the moderation screen and through the API, a role that
could moderate comments but could not open any file was shown every comment in the installation —
the text, staff-only notes, the client each conversation belongs to, and a visitor's IP address —
about files it would be refused on. Approving a comment over the API handed back its body the same
way.
**Who this affected.** Only installations with a custom role built that way. None of the roles
ProjectSend ships is affected: Account Manager, the only one that moderates comments, can read
files as well, and so can a System Administrator. If you did build such a role, it can no longer
moderate — give it one of the file permissions (upload, edit files, or edit other people's files)
and it works again, now seeing only the comments on files it can actually open.
- [#1759](https://github.com/projectsend/projectsend/pull/1759) — Publish the example Docker
quickstart on the loopback address instead of every network interface. The example set
`TRUSTED_PROXIES: "*"`, which tells ProjectSend to believe the client address forwarded by
whoever connects to it. That is right behind a reverse proxy and wrong when anyone can reach the
container directly, because then anyone can claim any address: enough to walk past the login
lockout, every rate limit, and the address written to the download log and to guest comments.
**Who this affected.** Installations started from `compose.example.yaml` or from the Docker Hub
page, where port 8080 was reachable from outside the machine. A published Docker port is not
covered by a host firewall such as `ufw`, so this was often open without anyone intending it.
- [#1760](https://github.com/projectsend/projectsend/pull/1760) — Have the Docker image default to
production. On first boot the image copied its settings from the development template, which sets
`APP_ENV=local` and `APP_DEBUG=true`. Two things followed that you could not see from inside the
application: every server error showed its stack trace — file, line and surrounding source — to
whoever triggered it, signed in or not; and **"reject known-breached passwords" never actually
ran**, while the security settings screen went on reporting it as switched on.
**Who this affected.** Anyone who started the container without setting those two values: a plain
`docker run` with a database address, the Portainer, unRAID and TrueNAS templates, or a Kubernetes
manifest naming only the database and `APP_URL`. Installations using `compose.example.yaml`, which
sets both correctly, were never affected.
- The client portal dashboard lists only files that client can open. The API dashboard's recent
activity is cut the same way.
- Three lists were showing more than the viewer was allowed to see: the reassignment picker, the
account conversion list, and the membership an API member write handed back.
- Mail and storage credentials no longer end up in the boot configuration cache. A settings form
that gets rejected no longer sends the credential back to the browser.
- Connecting a sign-in provider asks for your password again. Every password prompt in front of an
account now has its own rate limit instead of sharing one. A two-factor code is claimed in a
single step, so the same code cannot be used twice.
- An expired file no longer locks a whole group shut for staff assigned to particular clients. A
shared folder's contents count towards what a client can reach. A client is added to the roster
of the staff member who created them.
- Whether something is an API request is decided by the route, not by a header the caller sets.
- The interface font is served from your own installation. Loading a page no longer tells a font
CDN who is reading it.
- A stored filename can no longer push a control character into a response header.
**Fixed**
- The zip progress bar stops polling when you leave the page.
- A zip that fails to build no longer tells the person who asked for it why, in the server's words.
- Previews are written to a temporary file first, so a half-written one is never served. A file's
previews are deleted even when its storage cannot be reached.
- An expiry date no longer moves because somebody else saved the file at the same time. Setting one
through the API means what it means on the web form.
- Updating a client through the API no longer wipes custom fields the request never mentioned.
- The transfers chart lines up with the timezone its data is stored in.
- Creating an account over a deleted one's email address is refused instead of crashing.
- A comment still shows who wrote it after that account is deleted.
- Marking a file as a new version no longer emails people about a file they already had.
- The password reset and confirm-password screens say where the account's password actually lives,
which matters if you use LDAP or a sign-in provider.
- A refused upload names the quota you are actually up against. A bulk edit that is refused says
which permission was missing.
- Uploaded folders get the permissions the storage library actually asks for.
- The public preview log no longer records the same view repeatedly.
- Updating with `update.sh` no longer silently switches off route, event and view caching. The
script wiped the compiled caches while replacing the files, which is also how ProjectSend
recognised that you had cached them in the first place — so it rebuilt nothing, and every update
quietly left the site slower than the install instructions promised.
- Every new screen in this release is translated into all sixteen languages.
**Before you upgrade, read the notes below.**
### Upgrade notes
- **This upgrade adds two indexes to the activity log, and on a big installation that takes
minutes.** It is the slowest part. Nothing goes offline while it runs — the application keeps
answering — but do not expect the migration to finish in seconds.
- **On Apache or LiteSpeed you need to do nothing, but there is something worth doing.** Downloads
will start working on their own. PHP will be sending them, which ties up a worker process for the
whole of each download. That is fine on a quiet site and not fine on a busy one. To move to the
fast path: install `mod_xsendfile` (LiteSpeed needs no module), allow your storage directory with
`XSendFilePath`, then set `PROJECTSEND_FILE_DELIVERY=xsendfile` in `.env`. The dashboard will
confirm the change.
- **If you copied the example Docker file, `http://<your-server-ip>:8080` will stop answering.**
That is the change. Reach the application through your reverse proxy, as `APP_URL` describes. If
your proxy runs on a different machine, publish the port on the interface it arrives from and
replace `TRUSTED_PROXIES: "*"` with that address or subnet — the two settings only make sense
together.
- **Docker: `APP_ENV` and `APP_DEBUG` set inside `storage/.env` no longer take effect.** The image
now sets them itself, and a real environment variable always beats that file. If you had turned
debug on by editing `storage/.env`, pass `-e APP_DEBUG=true` (or `environment:` in compose)
instead. Anything you already set that way keeps working unchanged.
Thanks to [@denkfabrik-li](https://github.com/denkfabrik-li), who wrote all forty-four pull
requests in this release, and to [@prbt2016](https://github.com/prbt2016), who reported the Apache
download failure that started the delivery work.
### Pull requests merged since 2.2.1
The summary above is what changed. This is the paper trail, for anyone who wants to read the
original change. No issues were closed in this cycle — the work arrived as pull requests.
- [#1718](https://github.com/projectsend/projectsend/pull/1718) — Narrow the reassignment picker to what a viewer may see
- [#1719](https://github.com/projectsend/projectsend/pull/1719) — Count a shared folder's contents as reach, not just the folder
- [#1720](https://github.com/projectsend/projectsend/pull/1720) — Stop an expired file locking a group shut for a scoped staff member
- [#1721](https://github.com/projectsend/projectsend/pull/1721) — Scope the API dashboard's recent actions to what the viewer may read
- [#1722](https://github.com/projectsend/projectsend/pull/1722) — Show the portal dashboard the files a client can actually open
- [#1723](https://github.com/projectsend/projectsend/pull/1723) — Stop a client PATCH clearing custom fields it never mentioned
- [#1725](https://github.com/projectsend/projectsend/pull/1725) — Write a rendition through a temporary file, and never serve an empty one
- [#1726](https://github.com/projectsend/projectsend/pull/1726) — Delete a file's renditions even when its own disk cannot be resolved
- [#1727](https://github.com/projectsend/projectsend/pull/1727) — Give an API expiry date the same meaning the web gives it
- [#1728](https://github.com/projectsend/projectsend/pull/1728) — Stop an expiry moving because somebody else saved the file
- [#1729](https://github.com/projectsend/projectsend/pull/1729) — Decide what is an API request from the route, not from the caller's headers
- [#1730](https://github.com/projectsend/projectsend/pull/1730) — Refuse to provision over a deleted account's address instead of crashing
- [#1731](https://github.com/projectsend/projectsend/pull/1731) — Fail a zip build without handing the requester the server's reason
- [#1732](https://github.com/projectsend/projectsend/pull/1732) — Debounce the public preview log the way the signed-in one already is
- [#1734](https://github.com/projectsend/projectsend/pull/1734) — Name the quota a client is actually held to when an upload is refused
- [#1735](https://github.com/projectsend/projectsend/pull/1735) — Stop an editable-once checkbox locking before anybody ticks it
- [#1736](https://github.com/projectsend/projectsend/pull/1736) — Put a client on the roster of the scoped staff member who created them
- [#1737](https://github.com/projectsend/projectsend/pull/1737) — Compare the transfers window against the column's own timezone
- [#1738](https://github.com/projectsend/projectsend/pull/1738) — Claim a TOTP code atomically instead of checking then writing
- [#1739](https://github.com/projectsend/projectsend/pull/1739) — Refresh a mailbox on the schedule under the lock a send would hold
- [#1740](https://github.com/projectsend/projectsend/pull/1740) — Leave the caches update.sh's own update command needs to see
- [#1741](https://github.com/projectsend/projectsend/pull/1741) — Ask about the zips queue on every path that could answer it
- [#1742](https://github.com/projectsend/projectsend/pull/1742) — Set the directory permission Flysystem actually reads
- [#1743](https://github.com/projectsend/projectsend/pull/1743) — Check the read half of the redirect rule at every door, not one
- [#1744](https://github.com/projectsend/projectsend/pull/1744) — Stop a version link telling people about a file they already had
- [#1745](https://github.com/projectsend/projectsend/pull/1745) — Gate the comment moderation surfaces on reading, not just on the library
- [#1746](https://github.com/projectsend/projectsend/pull/1746) — Say what expiry does to a client-scoped staff member's library
- [#1747](https://github.com/projectsend/projectsend/pull/1747) — Say which permission a bulk edit was actually missing
- [#1748](https://github.com/projectsend/projectsend/pull/1748) — Let a password reset know where the account's credentials live
- [#1749](https://github.com/projectsend/projectsend/pull/1749) — A deleted account is still the person who wrote the comment
- [#1750](https://github.com/projectsend/projectsend/pull/1750) — Tell the admins the mailbox is dead, even when a send noticed first
- [#1751](https://github.com/projectsend/projectsend/pull/1751) — Keep the mail and storage credentials out of the boot-config cache
- [#1752](https://github.com/projectsend/projectsend/pull/1752) — Bound the two preference endpoints by their own registries
- [#1753](https://github.com/projectsend/projectsend/pull/1753) — Narrow the conversion list to the clients its own refusal allows
- [#1754](https://github.com/projectsend/projectsend/pull/1754) — Narrow the membership an API member write hands back
- [#1755](https://github.com/projectsend/projectsend/pull/1755) — Give every password check in front of an account its own bucket
- [#1756](https://github.com/projectsend/projectsend/pull/1756) — Make linking a provider re-prove the password
- [#1757](https://github.com/projectsend/projectsend/pull/1757) — Stop a rejected settings form flashing the credential it carried
- [#1758](https://github.com/projectsend/projectsend/pull/1758) — Let the confirm-password screen ask where the password lives
- [#1759](https://github.com/projectsend/projectsend/pull/1759) — Publish the quickstart on loopback, since it trusts any proxy
- [#1760](https://github.com/projectsend/projectsend/pull/1760) — Have the production image default to production
- [#1761](https://github.com/projectsend/projectsend/pull/1761) — Serve the interface font from the installation, not from a font CDN
- [#1762](https://github.com/projectsend/projectsend/pull/1762) — Run the auth and settings screens through the translator
- [#1763](https://github.com/projectsend/projectsend/pull/1763) — Stop the zip poll when its page goes away
- [#1764](https://github.com/projectsend/projectsend/pull/1764) — Honour Laravel's placeholder case convention in t()
## 2.2.1 — 28 August 2026
+36 -2
View File
@@ -121,6 +121,13 @@ Without it every visitor appears to come from the proxy. The login rate limiter
your users as one attacker, and the download log records the proxy's address instead of the
person's. `compose.example.yaml` already sets this.
`"*"` means "trust whoever connected to me", so it belongs with a published port only the proxy can
reach — which is why `compose.example.yaml` publishes on `127.0.0.1`. If anybody can open the
container's port directly, they are the proxy as far as this setting is concerned, and the
`X-Forwarded-For` they send is the address the rate limiters and the download log will use. Where
the proxy runs on another host, publish on the interface it arrives from and name that address or
subnet here instead of `"*"`.
Leaving it unset does not cause a `502` — that means your proxy could not get a usable response out
of the container at all, which is a different problem with a different fix. It does cause a **419
"page expired"**. Without it the application never learns the proxy terminated TLS, so it builds
@@ -177,7 +184,7 @@ is the quickest way to separate "the app is down" from "the proxy cannot reach t
during an outage, from the same machine:
```sh
curl -s -o /dev/null -w '%{http_code}\n' http://<host-ip>:8080/up # straight at the container
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/up # straight at the container
curl -s -o /dev/null -w '%{http_code}\n' https://files.example.com/up
```
@@ -211,7 +218,7 @@ its own directory the first time it starts.
### 2. Point the compose file at them
`compose.example.yaml` is yours — you downloaded and edited it — so change the volumes in place
Your `compose.yaml` is yours — you downloaded and edited it — so change the volumes in place
rather than layering an override on top:
```yaml
@@ -355,6 +362,33 @@ restored is a hypothesis, not a backup.
---
## Virus scanning
Uploads can be checked before anybody can download them. The scanner is an extra container, off
unless you ask for it:
```sh
docker compose --profile scanner up -d
```
Then go to **System → Settings → Virus scanning**, switch it on, and use `tcp://clamav:3310` as the
address. On a brand-new installation you can skip that step: uncomment
`PROJECTSEND_SCANNER_DEFAULT_ADDRESS` in the compose file before the first start and the site comes
up already pointed at the scanner. It is a starting value, not a lock — the address and the switch
stay on that screen. **Test scanner** sends EICAR, a harmless file made only for testing that every antivirus
recognises, and tells you whether it was actually detected. It also sends a password-protected zip, and fails if the scanner calls it clean.
The compose file gives the scanner the settings ProjectSend needs, in the `configs` section at the
bottom. Keep them if you change that file. On its own defaults ClamAV reports an archive it cannot
open as clean, so a password-protected zip would get through unchecked.
Two things to know before you turn it on. It needs about **1–1.5 GB of memory**, because the virus
definitions are held in memory. And the first start downloads those definitions, which takes a few
minutes — until it finishes, the scanner does not answer, and the Test button says so.
The scanner is reachable only from the application's own network. That is deliberate: ClamAV has no
password of any kind, so anything that can reach it can use it.
## Upgrading
With the data outside the containers, an upgrade touches only the containers:
+192 -54
View File
@@ -21,7 +21,7 @@ to create a database — this is not an install you can do over FTP alone.
| **PHP** | 8.4 or newer, both the command-line PHP and PHP-FPM |
| **PHP extensions** | `bcmath` `ctype` `curl` `dom` `fileinfo` `filter` `gd` `iconv` `intl` `json` `ldap` `mbstring` `openssl` `pcntl` `pdo_mysql` `session` `simplexml` `tokenizer` `zip` |
| **Database** | MySQL 8.0 or newer (we test on 8.4 LTS) |
| **Web server** | **nginx**, with PHP-FPM — see the note below |
| **Web server** | Any, with PHP-FPM. **nginx is strongly recommended** — see the note below |
| **Disk space** | The app itself is small; plan for whatever your users will upload |
A few notes on that list:
@@ -29,66 +29,113 @@ A few notes on that list:
- **`ldap` is required even if you never use LDAP.** One of the libraries ProjectSend depends on
declares it, so PHP will refuse to start the app without it. On Debian/Ubuntu it is
`php8.4-ldap`; on RHEL-family systems, `php-ldap`.
- **nginx is not a preference, it is a requirement.** See [Why nginx](#why-nginx) — it is worth
two minutes of reading before you commit to a server, because Apache cannot be made to work by
configuring it differently.
- **nginx is recommended, not required.** ProjectSend runs on Apache and LiteSpeed too, and
downloads work on them out of the box. What differs is *how* the bytes are sent: on nginx the
web server sends them, and everywhere else PHP does, which costs a worker process for the
duration of every download. See [How downloads are sent](#how-downloads-are-sent) before you
commit to a server — it is a capacity decision, not a compatibility one.
- **Redis is optional.** The Docker setup uses it, but a manual install works fine with the
database for sessions, cache and queues. If you already have Redis, see
[Optional extras](#optional-extras) below.
### Why nginx
### How downloads are sent
Your uploaded files do not live under `public/`. They sit in `storage/app/files/`, outside the web
root, where no URL can reach them — which is the whole point: a file is only yours to download if
ProjectSend says so, and a file sitting in a guessable public folder has already lost that
argument.
So every download has to pass through a permission check. The obvious way to do that is to let PHP
read the file and echo it back to the browser, and that is what most PHP applications do. It works,
and it is a bad idea at any real size: a single 5 GB download occupies a PHP process for its entire
duration, so a handful of people downloading at once can exhaust every worker your server has while
the CPU sits idle. Resumable downloads, byte ranges and progress bars all have to be reimplemented
by hand, usually incorrectly.
So every download has to pass through a permission check in PHP first. What happens *after* that
check passes is the thing this section is about, and ProjectSend can do it two ways.
ProjectSend does the other thing. PHP checks permissions, logs the download, and then answers with
an empty response carrying a header that says *"nginx, please send this file."* nginx streams the
bytes with the same code it uses for any static file — sendfile, byte ranges, resume support, no
PHP process held open — and the visitor never sees the real path. The header is
`X-Accel-Redirect`, and the matching `location /protected-files/` block in
[step 6](#step-6--point-your-web-server-at-it) is marked `internal`, which is what stops anyone
from requesting that path directly.
**PHP sends the file.** It opens the file and writes it out to the visitor. This works on every
web server and needs no configuration, which is why it is what ProjectSend falls back to. The cost
is that one PHP worker process is occupied for the whole of each download — three minutes for a
large file on a slow connection is three minutes that worker cannot answer anything else. A
handful of concurrent large downloads can therefore occupy every worker you have and the site
stops responding, with the processor idle and the workers all waiting on network transfers.
**Apache has no equivalent that ProjectSend can use.** Apache's closest feature, `mod_xsendfile`,
reads a differently-named header (`X-Sendfile`) that ProjectSend does not send, and it is not
installed by default anyway. LiteSpeed has its own third spelling. On any of them the application
installs fine and every page works — you can log in, upload, manage clients, browse the library —
but **every download returns an empty response or a 404**, because nothing is listening for the
instruction PHP just gave. There is no setting to change; the header names simply do not match.
**The web server sends the file.** PHP answers with an empty response and a header naming the
file, and finishes immediately; the web server streams the bytes with the same code it uses for
any static file — `sendfile`, byte ranges, resume support, no PHP process held open — and the
visitor never sees the real path. This is what you want on anything busy.
Two ways out, if nginx really is impossible on your hosting:
The second option needs a header, and **each web server reads a different one**, which is why
ProjectSend has to know which one it is talking to. It works this out from the server itself and
you can override it.
- Put nginx in front of Apache as a reverse proxy, serving `/protected-files/` itself. This works
but is more moving parts than just using nginx. Give the proxy some header headroom while you are
there — the same headroom the reference configuration in Step 6 gives PHP-FPM, in the directives a
proxy uses instead:
| Your server | What ProjectSend does | What you need to configure |
|---|---|---|
| nginx | `X-Accel-Redirect` | The `location /protected-files/` block in [step 6](#step-6--point-your-web-server-at-it). Detected automatically |
| Apache | PHP sends the file, unless you enable `mod_xsendfile` | See below |
| LiteSpeed / OpenLiteSpeed | PHP sends the file, unless you turn on X-Sendfile | See below |
| Anything else | PHP sends the file | Nothing |
```nginx
proxy_buffer_size 32k;
proxy_buffers 8 32k;
proxy_busy_buffers_size 64k;
```
**The dashboard tells you which one is in use.** The System panel has a "Downloads sent by" line,
with a warning icon and an explanation whenever PHP is doing the sending. You do not have to
remember to check this file.
nginx buffers a response's headers into a single block that defaults to one memory page — 4 KB on
most systems — and answers `502 Bad Gateway` with `upstream sent too big header` when they do not
fit. The page that goes over is not always the same one, so it presents as an intermittent fault
rather than as a misconfiguration. This applies to any proxy in front of ProjectSend, not just
this one: Nginx Proxy Manager, Traefik and a hand-written nginx vhost all ship the same default.
([#1664](https://github.com/projectsend/projectsend/issues/1664))
- Store your files in object storage instead — S3-compatible or Google Cloud Storage (see
[Storing files somewhere other than this server](#storing-files-somewhere-other-than-this-server)).
Files kept there are never on your server's disk, so downloads become a signed, expiring redirect
to the storage provider and the web server is not involved at all. This is a genuine, supported
path — just decide it before people start uploading, not after.
#### Enabling X-Sendfile on Apache or LiteSpeed
Apache needs [`mod_xsendfile`](https://github.com/nmaier/mod_xsendfile) installed and enabled, and
a directive allowing it to serve your storage directory:
```apache
XSendFile On
XSendFilePath /home/projectsend/storage/app/files
```
LiteSpeed and OpenLiteSpeed read the same header without an extra module; enable it in the server
configuration.
Then tell ProjectSend to use it, in `.env`:
```dotenv
PROJECTSEND_FILE_DELIVERY=xsendfile
```
**ProjectSend will not switch this on by itself**, even when it can see the module is loaded,
because it cannot see whether `XSendFilePath` allows the storage directory. Guessing wrong there
produces empty downloads rather than slow ones, and an empty download is a much worse failure than
a slow one — so this stays something you turn on having configured it.
#### Choosing explicitly
`PROJECTSEND_FILE_DELIVERY` accepts:
| Value | Meaning |
|---|---|
| `auto` | The default. nginx if the server says it is nginx, PHP otherwise |
| `nginx` | Always `X-Accel-Redirect`. Use this if nginx is proxying another server |
| `xsendfile` | Always `X-Sendfile`, for Apache with `mod_xsendfile`, or LiteSpeed |
| `php` | Always PHP. Correct and slow, and never wrong |
The one case `auto` gets wrong is **nginx reverse-proxying Apache**: PHP is talking to Apache, so
it picks PHP streaming, and downloads work but do not use the nginx in front. Set
`PROJECTSEND_FILE_DELIVERY=nginx` and make sure the front nginx serves `/protected-files/`. While
you are there, give the proxy some header headroom — the same headroom the reference configuration
in Step 6 gives PHP-FPM, in the directives a proxy uses instead:
```nginx
proxy_buffer_size 32k;
proxy_buffers 8 32k;
proxy_busy_buffers_size 64k;
```
nginx buffers a response's headers into a single block that defaults to one memory page — 4 KB on
most systems — and answers `502 Bad Gateway` with `upstream sent too big header` when they do not
fit. The page that goes over is not always the same one, so it presents as an intermittent fault
rather than as a misconfiguration. This applies to any proxy in front of ProjectSend, not just
this one: Nginx Proxy Manager, Traefik and a hand-written nginx vhost all ship the same default.
([#1664](https://github.com/projectsend/projectsend/issues/1664))
#### Or take your server out of it entirely
Store your files in object storage — S3-compatible or Google Cloud Storage (see
[Storing files somewhere other than this server](#storing-files-somewhere-other-than-this-server)).
Files kept there are never on your server's disk, so downloads become a signed, expiring redirect
to the storage provider and the web server is not involved at all. Decide this before people start
uploading, not after.
---
@@ -216,10 +263,10 @@ FILES_WEB_SERVER_READABLE=true
Uploaded files are written `0600` inside `0700` directories, readable only by the user that wrote
them. That is deliberate, and on a same-user server it is the safer setting. But a download is not
served by PHP: PHP checks permissions and then hands the web server the path with `X-Accel-Redirect`
(see [Why nginx](#why-nginx)), so the web server has to open a file PHP owns. When it cannot, **the
whole site works and only downloads fail** — the browser reports `ERR_INVALID_RESPONSE` and the
nginx error log says:
served by PHP on nginx: PHP checks permissions and then hands the web server the path with
`X-Accel-Redirect` (see [How downloads are sent](#how-downloads-are-sent)), so the web server has
to open a file PHP owns. When it cannot, **the whole site works and only downloads fail** — the
browser reports `ERR_INVALID_RESPONSE` and the nginx error log says:
```
open() ".../storage/app/files/..." failed (13: Permission denied)
@@ -277,8 +324,9 @@ like your logo reachable from the web.
## Step 6 — Point your web server at it
A complete nginx server block. Change `server_name`, and change `/var/www/projectsend` to wherever
you unpacked the files (there are **three** places, including one inside `/protected-files/`):
A complete nginx server block below; [Apache is further down](#if-you-are-using-apache). Change
`server_name`, and change `/var/www/projectsend` to wherever you unpacked the files (there are
**three** places, including one inside `/protected-files/`):
```nginx
server {
@@ -327,6 +375,38 @@ server {
}
```
### If you are using Apache
Two things matter, and both are easy to get wrong:
- **The document root is the `public/` directory**, not the directory you unpacked into. Everything
above `public/` — your `.env`, your uploaded files, the application code — has to stay out of
reach of any URL.
- **`AllowOverride All`, and `mod_rewrite` enabled** (`sudo a2enmod rewrite`). ProjectSend ships a
`public/.htaccess` that sends every address to the front controller. If Apache is told to ignore
it, every page except the home page is a 404.
```apache
<VirtualHost *:80>
ServerName files.example.com
DocumentRoot /var/www/projectsend/public
<Directory /var/www/projectsend/public>
AllowOverride All
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/projectsend-error.log
CustomLog ${APACHE_LOG_DIR}/projectsend-access.log combined
</VirtualHost>
```
Downloads work as they are: PHP sends the bytes. If that becomes a capacity problem, `mod_xsendfile`
hands the job to Apache — see [How downloads are sent](#how-downloads-are-sent).
On shared hosting you usually cannot edit any of this, and `public/.htaccess` is all you have. If
the site returns a 500 on every page, see [When something goes wrong](#when-something-goes-wrong).
Then check your PHP settings. Large uploads are sent in 20 MB pieces, so PHP never has to handle a
whole 5 GB file at once — but the pieces still need room. In your `php.ini`:
@@ -437,6 +517,36 @@ the place to configure it — the settings screen also has a "send test email" b
save you a lot of guessing. The `MAIL_*` values in `.env` are only used until you fill that screen
in.
### Virus scanning
ProjectSend can check every upload before anyone can download it. It needs ClamAV, which you install
from your distribution's packages:
```sh
sudo apt install clamav-daemon # Debian/Ubuntu
sudo dnf install clamav-server # Fedora/RHEL
```
Then set these in `/etc/clamav/clamd.conf` (the paths differ per distribution) and restart the
daemon:
```
StreamMaxLength 512M
AlertExceedsMax yes
AlertEncrypted yes
AlertEncryptedArchive yes
AlertEncryptedDoc yes
```
Those `Alert` lines matter more than they look. Without them ClamAV answers "clean" for a file it
could not actually open — an encrypted zip, or one past a size limit — and ProjectSend would record
a scan that never happened.
Log in, go to **System → Settings → Virus scanning**, switch it on, and give it the socket, usually
`unix:///var/run/clamav/clamd.ctl`. Press **Test scanner**: it sends EICAR, a harmless file made only for testing,
and tells you whether the scanner actually detected it. Scanning happens in the background, so the
queue worker below must be running.
### Redis
If you have Redis available, it is faster than the database for sessions, cache and queues. Install
@@ -534,6 +644,28 @@ names the exact command to run; do that, then reload.
Look in `storage/logs/` — open the newest file, the real error is at the bottom. Nine times out of ten it is
folder permissions (step 4) or a wrong database password (step 3).
**Every page is a 500, and `storage/logs/` is empty.**
The empty log is the answer, not a dead end: nothing reached PHP, so ProjectSend had nothing to
write. The error is your web server's, and it is in your web server's log — on Apache
`/var/log/apache2/error.log`, or wherever your host puts it. On Apache two causes account for
almost all of these, and both are about `public/.htaccess`:
- **`Options not allowed here`.** The file starts by turning off directory listings and content
negotiation, and your `AllowOverride` does not permit that. Allow it (`AllowOverride All`), or
delete the `Options` line — it is hardening, not a requirement.
- **`Request exceeded the limit of 10 internal redirects`.** Apache cannot work out which directory
the file is serving, so the rule that sends every address to `index.php` rewrites to a path that
does not exist, and tries again. Uncomment the `RewriteBase` line in `public/.htaccess` and set it
to the path ProjectSend is served from — `/` at the domain root, `/projectsend` in a subdirectory.
Reported on IONOS by [@Zodiac1978](https://github.com/Zodiac1978) in
[#1778](https://github.com/projectsend/projectsend/issues/1778).
**If you edit `public/.htaccess`, write down what you changed.** Updating replaces every file the
release ships, that one included, so a change that made your site work will be gone after the next
update and the 500 will come back. If the Apache configuration is yours to edit, put the directives
in a `<Directory>` block in the vhost instead: they do the same job there, and no update can touch
them. On shared hosting, where it is not yours, keep the note and re-apply it.
**"Please provide a valid cache path" or "failed to open stream".**
`storage/` or `bootstrap/cache/` is not writable by the web server user. Step 4.
@@ -546,9 +678,15 @@ That is correct behaviour until the first administrator exists. Finish step 7. I
created one and it still happens, ProjectSend cannot reach your database — check `storage/logs/`.
**Pages load but downloads give a 404, or download a 0-byte file.**
The `/protected-files/` block is missing from your nginx config, or its `alias` path does not match
where you installed ProjectSend. It must point at `storage/app/files/` and end with a slash. If you
are on Apache or LiteSpeed, no configuration will fix this — see [Why nginx](#why-nginx).
On nginx, the `/protected-files/` block is missing from your config, or its `alias` path does not
match where you installed ProjectSend. It must point at `storage/app/files/` and end with a slash.
On any server, check the "Downloads sent by" line in the dashboard's System panel against the
server you are actually running. A 0-byte download means ProjectSend sent a header the server did
not act on — most often `PROJECTSEND_FILE_DELIVERY` set to `nginx` or `xsendfile` on a server that
is neither, or set to `xsendfile` without `XSendFilePath` allowing the storage directory. Setting
`PROJECTSEND_FILE_DELIVERY=php` always works and is the quickest way to confirm that is the
problem. See [How downloads are sent](#how-downloads-are-sent).
**Uploads fail partway through.**
`client_max_body_size` in nginx, or `upload_max_filesize` / `post_max_size` in `php.ini`, is
+67 -4
View File
@@ -107,10 +107,13 @@ section on its own.
### If you installed from a release zip
```sh
composer require projectsend/v1-migration-tool
composer require projectsend/v1-migration-tool --update-no-dev
php artisan migrate # creates the tool's two tables
```
`--update-no-dev` keeps Composer from also installing the tools ProjectSend is developed and tested
with. A release zip leaves them out, and a server has no use for them.
That is the whole installation — there is no `npm run build` to run. The zip ships its assets
already compiled and deliberately without the toolchain that compiled them, so there is no
`package.json` to build from.
@@ -177,6 +180,7 @@ are listed at each step.
|---|---|
| Legacy and ProjectSend are on the **same machine** | [**Direct**](#step-3a--direct-same-machine) |
| Legacy is on **another server**, or on hosting you cannot reach from the new box | [**Bundle**](#step-3b--bundle-different-machines) |
| Legacy runs on this machine's own web server, and ProjectSend runs **in Docker** | **Bundle** — see [below](#legacy-on-this-machine-projectsend-in-docker) |
Direct is faster and simpler. It copies your files by default, and it can also *hardlink* them
instead when you ask it to — on a single filesystem that writes no bytes at all, so 400 GB migrates
@@ -205,8 +209,63 @@ file bytes:
| `move` | Takes the bytes out of Legacy. Fast and frees disk — and **cannot be undone** |
| `defer` | Writes no bytes at all. For importing the database now and moving half a terabyte overnight |
If ProjectSend runs in Docker, the Legacy directory has to be visible **inside the app container**
— bind-mount it there, and use the container's path, not the host's.
If ProjectSend runs in Docker, Direct needs two things the container does not have by default: the
Legacy directory mounted inside it, and a way to reach Legacy's database. That database is usually
at `localhost` in Legacy's config, and inside a container `localhost` is the container itself.
Hardlinks also cannot cross into a mount, so `--files=hardlink` quietly becomes a copy. When
Legacy runs on the same machine's own web server, the bundle route below is simpler and needs
none of that.
### Legacy on this machine, ProjectSend in Docker
The usual shape of an upgrade: Legacy was copied into a LAMP server, and the new install follows
[Getting started](README.md#getting-started). Use a bundle. The exporter runs with the PHP your
Legacy site already uses, on the host, where `localhost` really is Legacy's database. Nothing has
to reach across into the container except one directory at the end.
Every command below runs from the directory that holds your `compose.yaml`, after the tool is
installed as described in [Step 1](#if-you-are-running-the-official-docker-image).
**1. Take the exporter out of the container:**
```sh
docker compose cp \
app:/var/www/html/vendor/projectsend/v1-migration-tool/bin/projectsend-v1-export.php .
```
**2. Export, on the host.** Point `--install` at the directory Legacy runs from, the one that holds
`includes/sys.config.php`. `--files=copy` puts the files in the bundle too, so this needs free
disk space about the size of Legacy's `upload/files` directory:
```sh
php projectsend-v1-export.php --install=/var/www/projectsend-legacy --preflight
php projectsend-v1-export.php --install=/var/www/projectsend-legacy --out=/srv/ps-export --files=copy
```
If `php` says it cannot connect to the database, run it as a user who can read Legacy's config and
use the same `php` your web server uses.
**3. Put the bundle inside the container**, and give it to the user the application runs as:
```sh
docker compose cp /srv/ps-export app:/tmp/ps-export
docker compose exec app chown -R www-data:www-data /tmp/ps-export
```
For a very large install, mount it instead of copying it: add `- /srv/ps-export:/tmp/ps-export:ro`
under the app service's `volumes:` in `compose.yaml`, and run `docker compose up -d`. Take the line
out again when you are done.
**4. Carry on from [Step 4](#step-4--read-the-preflight)** with the bundle's path inside the
container:
```sh
docker compose exec -u www-data app php artisan projectsend:migrate:preflight --bundle=/tmp/ps-export
docker compose exec -u www-data app php artisan projectsend:migrate:import --bundle=/tmp/ps-export
```
When the import is verified, delete `/srv/ps-export` on the host and the copy in the container
(`docker compose exec app rm -rf /tmp/ps-export`). Your Legacy install is never written to.
---
@@ -356,9 +415,13 @@ Once you are satisfied:
```sh
php artisan projectsend:migrate:reset --drop # also drops the tool's own tables
composer remove projectsend/v1-migration-tool
composer remove projectsend/v1-migration-tool --update-no-dev
```
Removing a package makes Composer update the rest, so it needs `--update-no-dev` for the same reason
as installing did. Leave it off only on a git checkout you develop on. On the Docker image, run it
the way Step 1 ran `require`: `php /tmp/composer.phar remove …` as `www-data`.
`--drop` throws away the Legacy → ProjectSend id map. **Keep it** if you may ever want to redirect
old `download.php?id=…` links, because it is the only thing that can resolve them. Removing the
package without `--drop` leaves the two tables behind harmlessly.
+37 -20
View File
@@ -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**
@@ -49,34 +54,29 @@ per-seat pricing. It runs on your server, and the files stay there.
- Privacy controls, including GDPR-grade account erasure with a grace period
- Local disk, S3-compatible storage, or Google Cloud Storage
## Screenshots
<p align="center">
<img src=".github/screenshots/dashboard.png" alt="The dashboard: counters for files, clients and groups, the clients using the most storage against their quotas, a month of uploads and downloads as a line chart, and recent activity" width="900">
</p>
<p align="center"><em>The dashboard — what is in the installation, and what has been happening in it.</em></p>
<p align="center">
<img src=".github/screenshots/files.png" alt="The file library, showing folders and files with thumbnails, sharing status and download counts" width="900">
</p>
<p align="center"><em>Your library — folders, categories, and who each file is shared with.</em></p>
<p align="center">
<img src=".github/screenshots/portal.png" alt="A client's own page, listing the files shared with them with download buttons" width="900">
</p>
<p align="center"><em>What your client sees — only their files, nothing else.</em></p>
## Getting started
**With Docker** — the quickest path, and the one we recommend. Nothing to build: the published
image ships with its dependencies and its frontend already compiled.
You need Docker Engine with the Compose plugin. If the machine does not have it yet,
[install it](https://docs.docker.com/engine/install/) and then
[let your own user run it](https://docs.docker.com/engine/install/linux-postinstall/): add yourself
to the `docker` group, then log out and back in. Without that step every command below fails with
"permission denied", and putting `sudo` in front of it is not the fix.
Then, in an empty directory of your choosing — `/srv/projectsend` is a good one — run:
```sh
curl -O https://raw.githubusercontent.com/projectsend/projectsend/main/docker/production/compose.example.yaml
# edit the passwords and APP_URL in it, then:
docker compose -f compose.example.yaml up -d
curl -o compose.yaml https://raw.githubusercontent.com/projectsend/projectsend/main/docker/production/compose.example.yaml
# edit the passwords and APP_URL in compose.yaml, then:
docker compose up -d
```
Every `docker compose` command in these guides runs from that same directory, and finds the file
because it is called `compose.yaml`. If you saved it under its original name, `compose.example.yaml`,
rename it: `mv compose.example.yaml compose.yaml`.
Open `APP_URL` and the first thing you see is a setup screen that creates your administrator
account — or uncomment `ADMIN_EMAIL` and `ADMIN_PASSWORD` in the file first, with a password of
your own, and it is created for you.
@@ -98,6 +98,23 @@ installation: the dependencies and the compiled frontend are deliberately not in
needs Composer and npm before it runs. **[CONTRIBUTING.md](CONTRIBUTING.md)** has the sequence, and
it is short.
## Screenshots
<p align="center">
<img src=".github/screenshots/dashboard.png" alt="The dashboard: counters for files, clients and groups, the clients using the most storage against their quotas, a month of uploads and downloads as a line chart, and recent activity" width="900">
</p>
<p align="center"><em>The dashboard — what is in the installation, and what has been happening in it.</em></p>
<p align="center">
<img src=".github/screenshots/files.png" alt="The file library, showing folders and files with thumbnails, sharing status and download counts" width="900">
</p>
<p align="center"><em>Your library — folders, categories, and who each file is shared with.</em></p>
<p align="center">
<img src=".github/screenshots/portal.png" alt="A client's own page, listing the files shared with them with download buttons" width="900">
</p>
<p align="center"><em>What your client sees — only their files, nothing else.</em></p>
## Coming from ProjectSend Legacy?
The previous generation of ProjectSend lives on at
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use App\Modules\Identity\StartPages;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Http\RedirectResponse;
@@ -30,7 +31,7 @@ class AuthenticatedSessionController extends Controller
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
public function store(LoginRequest $request, StartPages $startPages): RedirectResponse
{
if ($request->authenticate()) {
return redirect()->route('two-factor.challenge');
@@ -38,7 +39,10 @@ class AuthenticatedSessionController extends Controller
$request->session()->regenerate();
return redirect()->intended(route('dashboard', absolute: false));
$user = $request->user();
assert($user !== null);
return redirect()->intended($startPages->pathFor($user));
}
/**
@@ -3,9 +3,11 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Modules\Identity\AuthSource;
use App\Modules\Identity\PasswordVerification;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Http\Response as HttpResponse;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
@@ -15,23 +17,48 @@ class ConfirmablePasswordController extends Controller
/**
* Show the confirm password page.
*/
public function show(): Response
{
return Inertia::render('auth/confirm-password');
}
/**
* Confirm the user's password.
*/
public function store(Request $request): RedirectResponse
public function show(Request $request): Response
{
$user = $request->user();
assert($user !== null);
if (! Auth::guard('web')->validate([
'email' => $user->email,
'password' => $request->password,
])) {
return Inertia::render('auth/confirm-password', [
// An account provisioned by a provider has no password to
// confirm with — its stored hash is a generated string nobody
// has seen. The screen offers to set one instead of asking for
// it, which is the only way past this for those accounts, and
// this screen stands in front of two-factor enrolment.
//
// Social, not "anything but Local": a directory account has a
// password -- the directory's -- and store() accepts it. Asking
// whether the account was Local told those accounts to set one
// here instead, which /settings/password refuses them, and left
// them no way past this screen at all.
'has_password' => $user->auth_source !== AuthSource::Social,
]);
}
/**
* Confirm the user's password.
*
* Through PasswordVerification, so this asks the same question the
* sign-in form asks: is this the account's password, from wherever
* that account's password lives. Checking only the local hash refused
* every directory-provisioned account the password it actually has --
* their local hash is a Str::password(64) nobody has ever seen -- and
* this screen stands in front of enrolling in two-factor, so those
* accounts could not enrol at all.
*
* Asked for JSON, it answers with a bare 204: that is the password
* dialog (RequirePasswordConfirmation), which stays on the page and
* sends the refused request again itself, so there is nowhere to go.
*/
public function store(Request $request, PasswordVerification $passwords): RedirectResponse|HttpResponse
{
$user = $request->user();
assert($user !== null);
if (! $passwords->verify($user, (string) $request->string('password'))) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
@@ -39,6 +66,10 @@ class ConfirmablePasswordController extends Controller
$request->session()->put('auth.password_confirmed_at', time());
if ($request->expectsJson()) {
return response()->noContent();
}
return redirect()->intended(route('dashboard', absolute: false));
}
}
@@ -3,6 +3,8 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Modules\Identity\AuthSource;
use App\Modules\Identity\Ldap\LdapAuthenticator;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -16,6 +18,10 @@ use Inertia\Response;
class NewPasswordController extends Controller
{
public function __construct(
private readonly LdapAuthenticator $ldap,
) {}
/**
* Show the password reset page.
*/
@@ -24,9 +30,53 @@ class NewPasswordController extends Controller
return Inertia::render('auth/reset-password', [
'email' => $request->email,
'token' => $request->route('token'),
'expired' => $this->linkIsSpent(
(string) $request->string('email'),
(string) $request->route('token'),
),
]);
}
/**
* Whether this link is one store() is certain to refuse.
*
* The scaffolding renders the form without looking at the token, so an
* expired link asked for a new password, asked for it a second time to
* confirm, and only then answered "this password reset token is
* invalid" — naming a word nobody outside the code knows, after the
* work rather than before it. Reset links last an hour and people open
* them late; that is ordinary, not an error to be scolded for.
*
* store() still validates and remains the rule. This is the screen
* being honest a minute earlier.
*
* **Anything that will not validate reads as expired, whether or not
* the address is one we know.** That is the whole of the rule and it
* exists for one reason: a page answering "expired" for a real address
* and drawing the form for an unknown one tells anybody who types a
* guess whether an account is here — the exact property
* /forgot-password protects by saying "a link will be sent if the
* account exists".
*
* The first version of this method described that oracle in a comment
* and then built it: unknown address returned false and drew the form,
* known address returned true and said expired. Two branches, two
* answers, and the difference *was* the account. Now both answer the
* same, so the page reveals nothing and the message is still right in
* every case somebody real will meet — a mistyped address gets "ask
* for a new link", which is what they should do anyway.
*/
private function linkIsSpent(string $email, string $token): bool
{
$broker = Password::broker();
$user = $email === '' ? null : $broker->getUser(['email' => $email]);
// One answer for "no such account", "wrong token" and "spent
// token", because telling them apart is telling somebody which
// addresses exist here.
return $user === null || ! $broker->tokenExists($user, $token);
}
/**
* Handle an incoming new password request.
*
@@ -46,10 +96,55 @@ class NewPasswordController extends Controller
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function ($user) use ($request) {
$user->forceFill([
// A directory account's password lives in the directory and
// the local hash is not consulted at all, which is what
// isDirectoryAccount() means. Writing one here reported
// success and changed nothing anybody could use -- including
// when the directory it points at is gone, which is exactly
// when somebody reaches for a reset.
//
// Refused here rather than where the link is asked for: that
// endpoint answers "A reset link will be sent if the account
// exists" to everybody on purpose, and a refusal there would
// tell a stranger both that an address is an account and how
// it signs in. By this point the caller holds a token that
// was emailed to the address, so the explanation reaches the
// account holder and nobody else.
//
// Throwing before the write also leaves the token unspent:
// PasswordBroker deletes it after the callback returns, so
// the link still works if an administrator converts the
// account in the meantime.
if ($this->ldap->isDirectoryAccount($user)) {
throw ValidationException::withMessages([
'email' => [__('This account signs in through your directory, so its password is not set here. Ask an administrator if you cannot sign in.')],
]);
}
$attributes = [
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
];
// `social` records that the account came into existence
// without anybody choosing a password, which AuthSource
// states outright -- along with "a social account may later
// set a real password". This is that moment, and nothing
// else in the application writes it: the Connected accounts
// screen reads `auth_source === Local` as
// `has_local_password`, so without this line its refusal
// goes on asking for a password that has just been set.
//
// The two branches of this method are the same rule read
// twice: `social` is where the account came from and the
// hash here is what signs it in, so choosing one settles it;
// `ldap` is the authentication path itself, so nothing
// chosen here settles anything.
if ($user->auth_source === AuthSource::Social) {
$attributes['auth_source'] = AuthSource::Local;
}
$user->forceFill($attributes)->save();
event(new PasswordReset($user));
}
@@ -62,8 +157,21 @@ class NewPasswordController extends Controller
return to_route('login')->with('status', __($status));
}
// One sentence for every way this can fail, and deliberately not
// Laravel's own. The scaffolding answers `passwords.user` for an
// address it cannot find and `passwords.token` for a real one whose
// token is dead — two different sentences, which is the same
// account-enumeration oracle the screen above was fixed for,
// reachable through the write instead. `passwords.throttled` is the
// third and the sharpest: the broker throttles per *user*, so an
// address nobody holds can never be throttled, and being told to
// wait is being told the account is there.
//
// Nothing is lost by collapsing them. The action is the same in
// every case — ask for a new link — and /forgot-password already
// refuses to say whether an address has an account.
throw ValidationException::withMessages([
'email' => [__($status)],
'email' => [__('This password reset link is no longer valid. Ask for a new one and try again.')],
]);
}
}
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Identity\AuthSource;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -21,9 +22,21 @@ class PasswordController extends Controller
*/
public function edit(Request $request): Response
{
$user = $request->user();
assert($user !== null);
return Inertia::render('settings/password', [
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
'mustVerifyEmail' => $user instanceof MustVerifyEmail,
'status' => $request->session()->get('status'),
// Whether there is a password here at all. An account
// provisioned by a provider has a generated one nobody was
// ever told, so asking for "your current password" asks for
// something that does not exist — and until this, that was
// the only door to a password, which is the only way to reach
// two-factor enrolment. See update().
'has_local_password' => $user->auth_source === AuthSource::Local,
// A directory's password is not this installation's to change.
'managed_elsewhere' => $user->auth_source === AuthSource::Ldap,
]);
}
@@ -32,18 +45,41 @@ class PasswordController extends Controller
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$user = $request->user();
assert($user !== null);
$user->update([
'password' => Hash::make($validated['password']),
// An LDAP account's password lives in the directory. Changing the
// hash here would change nothing anybody signs in with, so the
// honest answer is to refuse rather than to appear to work.
abort_if($user->auth_source === AuthSource::Ldap, 403);
$setsFirstPassword = $user->auth_source === AuthSource::Social;
$validated = $request->validate([
// Not asked of an account that has never had one: it signs in
// through a provider, and its stored hash is a generated
// string nobody has seen. Asking anyway left those accounts
// with no way to set a password — and so no way to enrol in
// two-factor, which an installation can make compulsory.
'current_password' => $setsFirstPassword ? ['nullable'] : ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$attributes = ['password' => Hash::make($validated['password'])];
// The same line NewPasswordController writes when a provider
// account resets its password, for the same reason: the hash is
// now what signs this account in, and `has_local_password` is read
// off this column all over the settings screens.
if ($setsFirstPassword) {
$attributes['auth_source'] = AuthSource::Local;
}
// forceFill, not update(): `auth_source` is guarded, so a mass
// assignment drops it silently — which left the account still
// reading as passwordless after it had a password.
$user->forceFill($attributes)->save();
// Changing a password is how someone reacts to a session they think
// is stolen, so it has to actually end that session. AuthenticateSession
// (registered on the web group) compares each request's stored
@@ -8,8 +8,12 @@ use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientFieldContext;
use App\Modules\Clients\ClientPortalCustomFields;
use App\Modules\Files\DeletedAccountContent;
use App\Modules\Identity\Erasure\ErasureSchedule;
use App\Modules\Identity\Erasure\SelfDeletion;
use App\Modules\Identity\StaffAccounts;
use App\Modules\Identity\StartPage;
use App\Modules\Identity\StartPages;
use App\Modules\Platform\Localization\TimezoneRegistry;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
@@ -17,6 +21,7 @@ use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
@@ -26,6 +31,7 @@ class ProfileController extends Controller
private readonly ClientPortalCustomFields $customFields,
private readonly TimezoneRegistry $timezones,
private readonly StaffAccounts $accounts,
private readonly StartPages $startPages,
) {}
/**
@@ -44,6 +50,12 @@ class ProfileController extends Controller
// browser was detected as, not something they ever chose.
'timezone' => $this->timezones->resolve($user),
'timezones' => $this->timezones->options(),
// Stored, not resolved: an empty choice means "follow my role",
// and the form has to be able to say that rather than show the
// role's page as if the person had picked it.
'start_page' => $user->start_page,
'start_page_options' => $this->startPages->personalOptions($user),
'role_start_page' => (string) __(($this->startPages->roleDefault($user) ?? StartPage::Dashboard)->label($user->type)),
'custom_fields' => $user->isClient() ? $this->customFields->rows(ClientFieldContext::AccountEdit, $user) : [],
'custom_field_values' => $user->isClient() ? $this->customFields->values(ClientFieldContext::AccountEdit, $user) : [],
]);
@@ -58,10 +70,20 @@ class ProfileController extends Controller
* you scroll past on the way to saving your email address. The delete
* itself still goes to destroy() below.
*/
public function deleteAccount(): Response
public function deleteAccount(Request $request): Response
{
$user = $request->user();
$selfDeletion = app(SelfDeletion::class);
$applies = $user !== null && $selfDeletion->appliesTo($user);
return Inertia::render('settings/delete-account', [
'erasureGraceDays' => (int) app(Settings::class)->get(Setting::AccountErasureGraceDays),
// What happens to their files, said before they confirm. Both
// follow the account's own type (SelfDeletion::appliesTo), so a
// staff member on a "clients only" installation is told
// neither, because neither happens to them.
'filesWithdrawn' => $applies,
'filesDeletedImmediately' => $applies && $selfDeletion->deletesFilesImmediately(),
]);
}
@@ -123,10 +145,27 @@ class ProfileController extends Controller
// Self-deletion: soft delete now, permanent GDPR erasure after
// the disclosed grace period (Setting::AccountErasureGraceDays).
app(ErasureSchedule::class)->apply($user);
$user->delete();
//
// One transaction with the files, for the reason
// ClientsController::destroy gives: a deletion whose second half
// failed must not leave the account gone and the files it
// promised to delete still there.
DB::transaction(function () use ($user): void {
app(ErasureSchedule::class)->apply($user);
$user->delete();
app(ActivityLogger::class)->log(Action::UserDeleted, $user, context: ['name' => $user->name]);
app(ActivityLogger::class)->log(Action::UserDeleted, $user, context: ['name' => $user->name]);
// Only what they own, by the rule an administrator's delete
// uses: their uploads, and their folders only if nothing else
// is left inside them. See SelfDeletion.
$selfDeletion = app(SelfDeletion::class);
if ($selfDeletion->appliesTo($user) && $selfDeletion->deletesFilesImmediately()) {
$result = app(DeletedAccountContent::class)->cascadeDelete($user);
app(ActivityLogger::class)->log(Action::AccountContentCascadeDeleted, context: ['name' => $user->name, ...$result]);
}
});
$request->session()->invalidate();
$request->session()->regenerateToken();
@@ -15,7 +15,9 @@ use App\Modules\Platform\Attribution\Attribution;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use App\Modules\Platform\Captcha\Captcha;
use App\Modules\Platform\Installation\Installation;
use App\Modules\Files\Models\File;
use App\Modules\Files\Queue\StalledZipBuilds;
use App\Modules\Files\Scanning\ScanStatus;
use App\Modules\Platform\Localization\LocaleRegistry;
use App\Modules\Platform\Localization\TimezoneRegistry;
use App\Modules\Platform\OfficialLinks;
@@ -24,7 +26,10 @@ use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Updates\LatestReleaseInfo;
use App\Modules\Platform\Updates\RunningCodeState;
use Illuminate\Foundation\Inspiring;
use App\Modules\Platform\Announcements\Events\ResolvingAnnouncement;
use App\Modules\Platform\Navigation\Events\ResolvingNavigationLinks;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
@@ -84,6 +89,19 @@ class HandleInertiaRequests extends Middleware
// ignore this and always show it.
'attribution' => app(Attribution::class)->visible(),
'capabilities' => $capabilities->enabledKeys(),
// Sidebar entries a package asked for. Shared rather than
// passed per page because the sidebar is on every page, and
// dispatched unconditionally so that with nothing listening
// the list is empty and the sidebar is exactly what it was.
// See ResolvingNavigationLinks for why core never learns what
// is in it.
'extra_nav_links' => $this->extraNavLinks($request),
// Shared rather than a dashboard prop, because it is shown in
// two places — the band on the dashboard and the icon beside
// the notification bell everywhere else — and "the same
// message" is the requirement. Two props would drift the day
// somebody edited one.
'announcement' => $this->announcement($request),
// Shared rather than passed by each page: the sign-in buttons,
// the registration form and the Connected accounts nav entry
// all need the same list, and a nav entry to a screen with
@@ -167,6 +185,18 @@ class HandleInertiaRequests extends Middleware
$counts['comments'] = app(VisibleCommentScope::class)->pendingTotal($user);
}
if ($checker->allows($user, Permission::ReleaseQuarantinedFiles)) {
// Deliberately not library-scoped, unlike the comments count
// above: a quarantined file is not a file anybody is working
// with, it is one somebody has to decide about, and the
// permission is already narrow enough that whoever holds it
// is meant to see all of them.
$counts['quarantine'] = File::query()->whereIn('scan_status', [
ScanStatus::Infected->value,
ScanStatus::UnscannableBlocked->value,
])->count();
}
// Unlike the counts above, every authenticated user (staff or
// client) has their own personal notifications — no permission
// gate here.
@@ -301,4 +331,48 @@ class HandleInertiaRequests extends Middleware
/** @var array<string, string> */
return app('translator')->getLoader()->load($locale, '*', '*');
}
/**
* @return list<array{title: string, url: string, external: bool, icon: string|null}>
*/
private function extraNavLinks(Request $request): array
{
$user = $request->user();
// Staff only, decided here rather than in each listener: these
// render in the administration area, and a client's portal shows
// their own files and nothing about the installation.
$event = new ResolvingNavigationLinks(isStaff: $user !== null && $user->isStaff());
if (! $event->isStaff) {
return [];
}
Event::dispatch($event);
return $event->links;
}
/**
* @return array{title: string, body: string, action_label: string|null, action_url: string|null, tone: string}|null
*/
private function announcement(Request $request): ?array
{
$user = $request->user();
if ($user === null) {
return null;
}
// Dispatched for clients too, unlike the sidebar links beside it.
// A client is somebody a shared instance may legitimately need to
// address — about their own account, not about the installation —
// and the event refuses anything not aimed at them, so widening
// this does not widen what reaches them.
$event = new ResolvingAnnouncement(isStaff: $user->isStaff());
Event::dispatch($event);
return $event->announcement;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Illuminate\Session\Middleware\StartSession as FrameworkStartSession;
use Illuminate\Contracts\Session\Session;
/**
* The framework's session middleware, except that a request asking for
* JSON is never remembered as "the previous page".
*
* `back()` prefers the Referer header and falls back to the URL the
* session recorded last. Laravel records every GET not marked as Ajax,
* and a plain fetch() is not marked. So the notification bell's poll for
* its unread count became the previous page. Wherever the Referer did not
* arrive, because a proxy or a browser stripped it, saving any settings
* form redirected to /notifications/unread-count, and Inertia showed the
* raw {"count":0} in an error dialog (#1799).
*
* The rule is about the request, not about that one route: nothing that
* asked for JSON is a page anybody goes back to. Inertia visits ask for
* HTML, so they are recorded exactly as before.
*/
class StartSession extends FrameworkStartSession
{
protected function storeCurrentUrl(Request $request, $session): void
{
if ($request->wantsJson()) {
return;
}
/** @var Session $session */
parent::storeCurrentUrl($request, $session);
}
}
+15 -68
View File
@@ -3,13 +3,13 @@
namespace App\Http\Requests\Auth;
use App\Models\User;
use App\Modules\Identity\Ldap\LdapAuthenticator;
use App\Modules\Identity\AccountLookup;
use App\Modules\Identity\Ldap\LdapProvisioner;
use App\Modules\Identity\PasswordVerification;
use App\Modules\Identity\SignIn;
use App\Modules\Platform\Captcha\CaptchaForm;
use App\Support\Rules;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Auth\SessionGuard;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
@@ -72,7 +72,13 @@ class LoginRequest extends FormRequest
{
$this->ensureIsNotRateLimited();
$user = User::query()->where('email', $this->string('email'))->first();
// Exact, for the reason SocialAuthenticator is: a collation that
// folds accents would otherwise let somebody typing
// admin@éxample.com be *identified* as admin@example.com. A
// password still gates this one, so it was never the takeover the
// social path was — but identifying the wrong account is the bug,
// and the credential check is a second line rather than the rule.
$user = app(AccountLookup::class)->byEmail((string) $this->string('email'));
// A directory identity with no local account yet. Returns null
// unless LDAP is on, auto-provisioning is on, and the bind
@@ -115,10 +121,9 @@ class LoginRequest extends FormRequest
/**
* The account whose password checks out, or null.
*
* The local hash is tried first and the directory only on failure, so
* a login that succeeds locally never generates directory traffic.
* The exception is an account whose credentials are known to live in
* the directory, where the local hash is a placeholder nobody holds.
* The rule itself -- local hash first, directory when the credentials
* live there -- is PasswordVerification's, because this is no longer
* the only screen that has to ask it. See that class.
*/
private function verifyCredentials(?User $user): ?User
{
@@ -126,67 +131,9 @@ class LoginRequest extends FormRequest
return null;
}
$ldap = app(LdapAuthenticator::class);
if (! $ldap->isDirectoryAccount($user)
&& Auth::validate($this->only('email', 'password'))) {
$this->upgradeHashIfStale($user);
return $user;
}
$identity = $ldap->attempt(
(string) $this->string('email'),
(string) $this->string('password'),
$user,
);
if ($identity === null) {
return null;
}
$ldap->stamp($user, $identity);
return $user;
}
/**
* Re-hash a password stored under weaker settings than this
* installation now uses.
*
* Laravel does this for you inside SessionGuard::attempt(), but this
* form does not use attempt() — it verifies with Auth::validate() and
* hands the account to SignIn, which calls Auth::login(). Neither
* re-hashes, so without this an account keeps whatever cost it was
* created under forever, and raising BCRYPT_ROUNDS would quietly
* apply to new accounts only.
*
* That is not hypothetical: every account the v1 migration carries
* across arrives as `$2y$08$…`, because v1 hashed at cost 8, and
* would otherwise stay four times cheaper to attack than an account
* created here.
*
* **Only ever called on the local branch.** On the directory branch
* the submitted plaintext is the *LDAP* password and the local hash
* is a `Str::password(64)` placeholder nobody holds; writing the
* directory credential into it would mint a second way into the
* account that keeps working after LDAP is switched off.
*/
private function upgradeHashIfStale(User $user): void
{
$guard = Auth::guard('web');
// getProvider() is on SessionGuard rather than on the StatefulGuard
// contract. This guard is a SessionGuard in every configuration this
// application ships; the check is here so a custom driver degrades
// to "no re-hash" instead of a fatal on the login path.
if (! $guard instanceof SessionGuard) {
return;
}
// No-ops unless the hasher says the stored digest needs it, so
// this costs an already-current account nothing.
$guard->getProvider()->rehashPasswordIfRequired($user, $this->only('password'));
return app(PasswordVerification::class)->verify($user, (string) $this->string('password'))
? $user
: null;
}
/**
@@ -5,9 +5,12 @@ namespace App\Http\Requests\Settings;
use App\Models\User;
use App\Modules\Clients\ClientFieldContext;
use App\Modules\Clients\ClientPortalCustomFields;
use App\Modules\Identity\AuthSource;
use App\Modules\Identity\StartPages;
use App\Support\Rules;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Closure;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
@@ -31,6 +34,26 @@ class ProfileUpdateRequest extends FormRequest
Rule::unique(User::class)->ignore($this->user()?->id),
],
// Changing this address is a credential change, not a detail:
// it is where a password reset is sent, so whoever can change
// it owns the account from the next reset onwards. A stolen
// session used to be enough (GHSA-f32x-fgmp-q353) — temporary
// access became permanent ownership with one PATCH.
//
// `exclude_if` rather than a flat rule, so the rest of the
// screen keeps saving with nothing extra: a name, a timezone
// or a custom field is not a credential and must not start
// asking for a password. Only a *different* address does.
//
// The same rule destroy() one controller away has always
// asked, for the same reason: both doors lead to owning the
// account.
'current_password' => [
Rule::excludeIf(! $this->changesEmail()),
'required',
'current_password',
],
// Saved with the rest of the profile so the screen keeps one
// Save button. `timezone` is fillable, so ProfileController's
// fill() picks it up with no special handling.
@@ -43,6 +66,28 @@ class ProfileUpdateRequest extends FormRequest
];
$user = $this->user();
// Only the pages this person can open right now. `sometimes` for
// the same reason as timezone; empty clears the choice and follows
// the role again.
$rules['start_page'] = ['sometimes', 'nullable', 'string', Rule::in(
$user === null ? [] : array_column(app(StartPages::class)->personalOptions($user), 'value'),
)];
// An account whose credentials live in a directory or at an
// identity provider holds a local password nobody knows — see
// LdapProvisioner, which stores Str::password(64) exactly so it
// can never be used. Asking such a person to confirm "your current
// password" is a dead end dressed as a form error, and the address
// is not theirs to change here in any case: it is what the
// directory or the provider says it is, and a local edit would
// either be overwritten or break the link.
if ($this->changesEmail() && $user !== null && $user->auth_source !== AuthSource::Local) {
$rules['email'][] = function (string $attribute, mixed $value, Closure $fail): void {
$fail(__('Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.'));
};
}
if ($user?->isClient() === true) {
$rules = [
...$rules,
@@ -52,4 +97,29 @@ class ProfileUpdateRequest extends FormRequest
return $rules;
}
/**
* Whether this request asks for an address other than the stored one.
*
* Compared lowercased and trimmed because the `lowercase` rule runs
* beside this one rather than before it: without that, re-saving the
* profile with the address typed in a different case would be read as
* a change and demand a password for nothing.
*/
private function changesEmail(): bool
{
$user = $this->user();
if ($user === null) {
return false;
}
$submitted = $this->input('email');
if (! is_string($submitted)) {
return false;
}
return mb_strtolower(trim($submitted)) !== mb_strtolower(trim((string) $user->email));
}
}
+52
View File
@@ -29,9 +29,11 @@ use Laravel\Sanctum\HasApiTokens;
* @property bool $account_requested
* @property string|null $locale
* @property string|null $timezone
* @property string|null $start_page a StartPage value; see StartPages
* @property int|null $dashboard_columns
* @property int $storage_quota_mb
* @property Carbon|null $erase_after
* @property \Carbon\Carbon|null $expires_at
* @property-read Role|null $role
*/
class User extends Authenticatable implements HasLocalePreference
@@ -54,6 +56,9 @@ class User extends Authenticatable implements HasLocalePreference
'password',
'locale',
'timezone',
// A personal preference, like timezone: the profile form fills it
// from its own validated request. See StartPages.
'start_page',
'dashboard_columns',
'storage_quota_mb',
];
@@ -96,6 +101,32 @@ class User extends Authenticatable implements HasLocalePreference
return $this->isStaff() && $this->role?->client_scoped === true;
}
/**
* Whether this account's expiry date has passed. Only client accounts
* are given one (see the client screens and /api/v1/clients).
*/
public function hasExpired(): bool
{
return $this->expires_at !== null && $this->expires_at->isPast();
}
/**
* The one question every door into the application asks of an account
* that has already proved who it is: sign-in, every web request, every
* API request, and the second-factor challenge.
*
* Expiry is checked here as well as by the hourly sweep that switches
* `active` off, and neither is enough alone. The sweep is what keeps
* everything else that reads `active` — lists, filters, seat counts —
* in step. But a sweep runs on a schedule, and a scheduler that is not
* running would leave an expired account working forever. So access
* is refused the moment the date passes, whatever the flag says.
*/
public function maySignIn(): bool
{
return $this->active && ! $this->hasExpired();
}
public function hasTwoFactorEnabled(): bool
{
return $this->two_factor_confirmed_at !== null;
@@ -161,11 +192,32 @@ class User extends Authenticatable implements HasLocalePreference
// credentials live is a security decision, not an attribute a
// form or an API payload may set. Written with forceFill by
// the code that provisions the account.
//
// Same for 'email_verified_at' below, and it is worth saying
// what absence from $fillable does and does not buy. It stops
// a request smuggling the value in. It does not tell the code
// that meant to set it deliberately that it failed: a key in a
// create() array is dropped in silence, so every path that
// provisions an account had one and lost it — staff accounts,
// client accounts, the setup screen and projectsend:admin, all
// fixed in September 2026. Not fillable only helps when the
// writer knows it has to be deliberate.
'auth_source' => AuthSource::class,
'ldap_synced_at' => 'datetime',
'active' => 'boolean',
'account_requested' => 'boolean',
// The column is an unsignedInteger and the docblock above
// already promises int. Saying so here is what makes that true
// for a reader as well: it is passed straight into typed
// signatures (ClientAccounts::create, ClientProvisioning::
// provision), and whether a driver hands back 2048 or "2048"
// is not something those call sites should depend on.
'storage_quota_mb' => 'integer',
'erase_after' => 'datetime',
// Deliberately absent from $fillable too: when an account stops
// working is decided by staff, never by a payload the account
// itself could send (the profile form fills from its request).
'expires_at' => 'datetime',
'email_verified_at' => 'datetime',
'password' => 'hashed',
'two_factor_secret' => 'encrypted',
+19 -1
View File
@@ -8,6 +8,7 @@ use App\Models\User;
use App\Modules\Api\Auth\ApiTokens;
use App\Modules\Api\Models\ApiRequestLog;
use App\Modules\Audit\ActivityLog;
use App\Modules\Audit\ActivityLogScope;
use App\Modules\Audit\ActivityOrigin;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
@@ -27,6 +28,7 @@ class ApiUsage
{
public function __construct(
private readonly ApiUsageScope $scope,
private readonly ActivityLogScope $activityLog,
) {}
/**
@@ -145,7 +147,23 @@ class ApiUsage
*/
public function recentActions(User $viewer, bool $installWide, int $limit = 15): array
{
$query = ActivityLog::query()->where('origin', ActivityOrigin::Api);
// Narrowed through ActivityLogScope, exactly as the activity page,
// the download history and the dashboard widget are.
// `view_actions_log` decides whether the install-wide view opens at
// all, but it is not the whole answer for a client-scoped viewer: a
// row carries the subject's name, so an unscoped feed reads out file
// and client names to somebody who gets a 403 on the files
// themselves. The Client Manager role ships with the permission, so
// this is the default configuration, not an exotic one.
//
// Applied on both sides of the branch rather than only in the
// install-wide one: the own-actor filter below already stays inside
// what the scope allows, and a boundary that only exists in one arm
// of an `if` is one refactor away from not existing.
$query = $this->activityLog->apply(
ActivityLog::query()->where('origin', ActivityOrigin::Api),
$viewer,
);
if (! $installWide) {
$query->where('actor_id', $viewer->id);
@@ -10,8 +10,8 @@ use Symfony\Component\HttpFoundation\Response;
/**
* The token twin of Identity's EnsureAccountIsActive: deactivating an
* account revokes its API access on the very next request, without anyone
* having to hunt down the tokens it minted.
* account, or its expiry date passing, revokes its API access on the very
* next request, without anyone having to hunt down the tokens it minted.
*
* Deleted accounts need no equivalent — users are soft-deleted and the
* default query scope means Sanctum simply fails to resolve the tokenable,
@@ -26,7 +26,7 @@ class EnsureApiAccountIsActive
{
$user = $request->user();
if ($user !== null && ! $user->active) {
if ($user !== null && ! $user->maySignIn()) {
abort(401);
}
+4 -2
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Modules\Api\Support;
use App\Modules\Platform\Capabilities\CapabilityUnavailable;
use App\Support\ApiSurface;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
@@ -16,7 +17,8 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable;
/**
* RFC 7807 error bodies for /api/* only.
* RFC 7807 error bodies for the API surface only -- see ApiSurface, which
* is the same question the capability middleware asks.
*
* Two properties this class exists to guarantee:
*
@@ -55,7 +57,7 @@ class ProblemDetails
public function shouldHandle(Request $request): bool
{
return $request->is('api/*');
return ApiSurface::matches($request);
}
public function render(Request $request, Throwable $e): JsonResponse
+45
View File
@@ -40,6 +40,14 @@ enum Action: string
case SocialAccountUnlinked = 'social.account_unlinked';
case ClientApproved = 'client.approved';
case ClientDenied = 'client.denied';
case ClientInvited = 'client.invited';
case ClientInvitationRedeemed = 'client.invitation_redeemed';
case ClientInvitationRevoked = 'client.invitation_revoked';
case ClientInvitationResent = 'client.invitation_resent';
// Logged by the hourly sweep, so it has no actor: nobody switched the
// account off, its date passed. Distinct from UserDeactivated for the
// same reason TwoFactorReset is distinct from TwoFactorDisabled.
case ClientExpired = 'client.expired';
// Files
case FileUploaded = 'file.uploaded';
case FileUpdated = 'file.updated';
@@ -67,6 +75,21 @@ enum Action: string
case FolderMadePrivate = 'folder.made_private';
case UploadAborted = 'upload.aborted';
case FileImported = 'file.imported';
// Virus scanning. A clean result is not logged: it is the ordinary
// outcome of every upload, and a row per upload would bury the ones
// that matter. Only the three that need answering are.
case FileQuarantined = 'file.quarantined';
case FileReleased = 'file.released';
// Allowed through without being checked — because the scanner could
// not be reached, or the file was too large or encrypted and this
// installation allows those. The context says which.
case FileNotScanned = 'file.not_scanned';
// The row is here and the bytes are not. Written by the daily check,
// so it has no actor: nobody did this, or nobody who was using the
// application did.
case FileMissing = 'file.missing';
case OrphanFileDeleted = 'orphan_file.deleted';
case OrphanFileAutoDeleted = 'orphan_file.auto_deleted';
case ExpiredFileDeleted = 'file.expired_deleted';
@@ -165,6 +188,11 @@ enum Action: string
self::ClientSelfRegistered => 'Registered a new client account',
self::ClientApproved => 'Approved the account request of ":subject"',
self::ClientDenied => 'Denied the account request of ":name"',
self::ClientInvited => 'Invited :email to register a client account',
self::ClientInvitationRedeemed => 'Registered a client account from an invitation',
self::ClientInvitationRevoked => 'Revoked the invitation sent to :email',
self::ClientInvitationResent => 'A new invitation link was requested for :email',
self::ClientExpired => 'The client account ":name" expired and was deactivated',
self::FileUploaded => 'Uploaded the file ":subject"',
self::FileUpdated => 'Updated the file ":subject"',
self::FileDeleted => 'Deleted the file ":name"',
@@ -199,6 +227,14 @@ enum Action: string
self::CommentDeleted => 'Deleted a comment on the file ":subject"',
self::CommentApproved => 'Approved a comment on the file ":subject"',
self::FileImported => 'Imported the orphan file ":subject"',
// :name rather than :subject, unlike the file actions above
// it: these two are written by the scan job, which has no
// actor and attaches no subject, so the name has to travel in
// the context or the line reads 'The file "" was quarantined'.
self::FileQuarantined => 'The file ":name" was quarantined: :threat',
self::FileReleased => 'Released the quarantined file ":subject" (:reason)',
self::FileNotScanned => 'The file ":name" was not scanned for viruses: :reason',
self::FileMissing => 'The file ":name" is no longer on the server',
self::OrphanFileDeleted => 'Deleted the orphan file ":name"',
self::OrphanFileAutoDeleted => 'Deleted the orphan file ":name"',
self::ExpiredFileDeleted => 'Deleted the expired file ":name"',
@@ -267,6 +303,11 @@ enum Action: string
self::ClientSelfRegistered => 'A client registered an account',
self::ClientApproved => 'A client account request was approved',
self::ClientDenied => 'A client account request was denied',
self::ClientInvited => 'A client was invited to register an account',
self::ClientInvitationRedeemed => 'A client registered an account from an invitation',
self::ClientInvitationRevoked => 'An invitation was revoked before it was used',
self::ClientInvitationResent => 'An invited person asked for a replacement link',
self::ClientExpired => 'A client account reached its expiry date and was deactivated',
self::FileUploaded => 'A file was uploaded',
self::FileUpdated => 'A file was updated',
self::FileDeleted => 'A file was deleted',
@@ -298,6 +339,10 @@ enum Action: string
self::CommentDeleted => 'A comment was deleted',
self::CommentApproved => 'A comment was approved',
self::FileImported => 'An orphan file was imported',
self::FileQuarantined => 'A file was quarantined by the virus scanner',
self::FileReleased => 'A quarantined file was released by an administrator',
self::FileNotScanned => 'A file was allowed through without being scanned',
self::FileMissing => 'A file in the library was found to be missing from storage',
self::OrphanFileDeleted => 'An orphan file was deleted',
self::OrphanFileAutoDeleted => 'An orphan file was automatically deleted after its retention grace period passed',
self::ExpiredFileDeleted => 'An expired file was automatically deleted after its retention grace period passed',
@@ -12,9 +12,14 @@ use App\Modules\Audit\ActivityLog;
use App\Modules\Audit\ActivityLogScope;
use App\Modules\Audit\ActivityPresenter;
use App\Modules\Audit\DashboardWidgetPreferences;
use Illuminate\Support\Facades\Event;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Delivery\FileDelivery;
use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\ScanningConfig;
use App\Modules\Files\Scanning\ScanStatus;
use App\Modules\Files\Scanning\VirusScanner;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Capabilities\Capability;
@@ -25,6 +30,7 @@ use App\Modules\Platform\News\NewsItems;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Storage\StorageDurability;
use App\Modules\Platform\Storage\StorageCapacity;
use App\Modules\Platform\System\SystemEnvironment;
use App\Modules\Platform\Updates\LatestReleaseInfo;
use Illuminate\Database\Eloquent\Builder;
@@ -51,6 +57,8 @@ class DashboardController extends Controller
private readonly Settings $settings,
private readonly ApiUsage $apiUsage,
private readonly StorageDurability $storageDurability,
private readonly StorageCapacity $storageCapacity,
private readonly FileDelivery $fileDelivery,
private readonly Installation $installation,
private readonly TimezoneRegistry $timezones,
private readonly SystemEnvironment $environment,
@@ -92,7 +100,7 @@ class DashboardController extends Controller
: null,
'largest_files' => $canStatistics && $prefs->isEnabled($user, 'largest_files') ? $this->largestFiles($user) : null,
'recent' => $canActionsLog && $prefs->isEnabled($user, 'recent') ? $this->recentActivity($user) : null,
'system' => $canSystem && $prefs->isEnabled($user, 'system') ? $this->systemInfo() : null,
'system' => $canSystem && $prefs->isEnabled($user, 'system') ? $this->systemInfo($user) : null,
// Both editions — informational content, not an update action,
// so no Capability check alongside the permission (unlike
// 'system' above).
@@ -155,8 +163,12 @@ class DashboardController extends Controller
*
* Every boundary is built in the viewer's zone, so "last week" ends
* when their evening does and not at whatever hour UTC midnight falls
* on for them. The returned instants are still absolute — only the
* day edges moved — so they compare against the UTC column directly.
* on for them. The instants are absolute, but they carry that zone —
* and a Carbon handed to the query builder is formatted in its own
* zone, offset discarded, so comparing one against a UTC column asks
* a question nine hours out for a viewer in Tokyo. transferSeries()
* converts before it compares; the day cursor there keeps them as
* they are, because that half really is about the viewer's calendar.
*
* @return array{0: Carbon, 1: Carbon, 2: string}
*/
@@ -248,7 +260,13 @@ class DashboardController extends Controller
$rows = ActivityLog::query()
->whereIn('action', [Action::FileUploaded->value, ...array_map(fn (Action $a): string => $a->value, $downloadActions)])
->whereBetween('created_at', [$from, $to])
// In UTC, because that is what the column is. The query
// builder formats a Carbon in whatever zone the object holds
// and drops the offset, so passing the viewer's midnight
// straight in compares "2026-08-22 00:00:00" against a UTC
// column — nine hours of somebody else's day, at both ends,
// for a viewer in Tokyo.
->whereBetween('created_at', [$from->copy()->utc(), $to->copy()->utc()])
->get(['action', 'actor_type', 'created_at'])
// Bucketed by the viewer's calendar day. Grouping on the UTC
// one puts an evening upload from anywhere west of Greenwich
@@ -467,12 +485,10 @@ class DashboardController extends Controller
}
/**
* @return array<string, string|int|bool|array<string, string|null>|null>
* @return array<string, array<string, bool|int|string|null>|bool|int|string|null>
*/
private function systemInfo(): array
private function systemInfo(User $viewer): array
{
$freeBytes = @disk_free_space(storage_path('app/files'));
// Cached by CheckForUpdatesCommand (daily) — never a live HTTP
// call from the request path. null means either no successful
// check yet, or the current version is already the latest.
@@ -481,7 +497,7 @@ class DashboardController extends Controller
return [
...$this->environment->toArray(),
'storage_used_bytes' => (int) File::query()->sum('size'),
'storage_free_bytes' => $freeBytes === false ? -1 : (int) $freeBytes,
...$this->storageCapacity->inspect($viewer),
'update_available' => $release !== null,
'latest_version' => $release['version'] ?? null,
'release_url' => $release['url'] ?? null,
@@ -492,28 +508,104 @@ class DashboardController extends Controller
// Installation. Always present, unlike storage_durability, which
// is null whenever the durability question does not apply.
'install_kind' => $this->installation->kind()->value,
// How downloads leave the server, and whether that was
// detected or stated. Reported even when it is the fast path:
// "my downloads are handed to the web server" is worth being
// able to confirm at a glance, not only worth warning about
// when it is false — the same reasoning as storage_durability.
'file_delivery' => $this->fileDelivery->describe(),
// Always stated, like delivery and storage above it: "my
// uploads are checked by ClamAV" is worth confirming at a
// glance, not only worth mentioning when it is false. Null
// only where this installation does not connect its own
// scanner at all.
'scanning' => $this->scanningState(),
// Rows this installation lists and cannot produce. Zero is the
// ordinary answer and says nothing on screen; anything else is
// somebody's files gone, which is worth interrupting for.
'missing_files' => File::query()->where('scan_status', ScanStatus::Missing)->count(),
];
}
/**
* Where this installation stands with virus scanning.
*
* Reported whether or not anything is wrong: the System card states
* how downloads leave and where files are stored for the same reason,
* and "nothing is checking my uploads" is exactly the fact an
* administrator will not go looking for.
*
* Null only when this installation does not connect its own scanner —
* on a hosted one that is the platform's infrastructure, and a tenant
* reading about it could neither confirm nor fix it. See
* Capability::VirusScanningConnect.
*
* @return array{configured: bool, reachable: bool, engine: string|null, definitions_age_hours: int|null, let_through_24h: int, pending: int}|null
*/
private function scanningState(): ?array
{
if (! $this->capabilities->has(Capability::VirusScanningConnect)) {
return null;
}
$config = app(ScanningConfig::class);
if (! $config->enabled()) {
return [
'configured' => false,
'reachable' => false,
'engine' => null,
'definitions_age_hours' => null,
'let_through_24h' => 0,
'pending' => 0,
];
}
$scanner = app(VirusScanner::class)->status();
return [
'configured' => true,
'reachable' => $scanner->reachable,
'engine' => $scanner->engine,
'definitions_age_hours' => $scanner->definitionsAgeHours(),
// Files let through unchecked in the last day that can still
// be downloaded. Zero is the only number that means
// "protected"; anything else is a scanner that was down, or
// files nobody could open. See File::scopeLetThrough().
'let_through_24h' => File::query()
->letThrough()
->where('scanned_at', '>=', now()->subDay())
->count(),
// Waiting more than an hour: on an installation set to hold,
// this is what an outage looks like.
'pending' => File::query()
->where('scan_status', ScanStatus::Pending)
->where('created_at', '<=', now()->subHour())
->count(),
];
}
private function clientDashboard(User $client): Response
{
$assignedFiles = File::query()->whereHas('assignments', function ($query) use ($client): void {
$query->where(function ($direct) use ($client): void {
$direct->where('assignable_type', User::class)->where('assignable_id', $client->id);
})->orWhere(function ($viaGroup) use ($client): void {
$viaGroup->where('assignable_type', Group::class)
->whereIn('assignable_id', $client->memberOfGroups()->pluck('groups.id'));
});
});
// File::scopeVisibleToClient is the single source of truth for
// client file access, and this page has to agree with the portal it
// introduces. Restating the assignment half here made it disagree
// in both directions: it counted expired files, which the scope
// ends by excluding and /my-files therefore never shows, and it
// missed everything that reaches a client another way — a file in a
// folder shared with them, their own portal upload, and a revision,
// which owns no assignment row and inherits its original's
// recipients.
$visibleFiles = File::query()->visibleToClient($client);
return Inertia::render('portal/dashboard', [
'files_count' => (clone $assignedFiles)->count(),
'files_count' => (clone $visibleFiles)->count(),
'groups_count' => $client->memberOfGroups()->where('public', true)->count(),
'storage' => [
'used_bytes' => $this->storageUsage->usedBytes($client),
'quota_bytes' => $this->storageUsage->quotaBytes($client) ?: null,
],
'latest_files' => $assignedFiles->orderByDesc('created_at')->limit(5)->get()
'latest_files' => $visibleFiles->orderByDesc('created_at')->limit(5)->get()
->map(fn (File $file): array => [
'id' => $file->id,
'name' => $file->name,
@@ -45,8 +45,14 @@ class DashboardWidgetPreferencesController extends Controller
$validated = $request->validate([
'columns' => ['required', 'integer', 'between:1,4'],
'widgets' => ['required', 'array'],
'widgets.*.widget_key' => ['required', 'string', Rule::in(self::WIDGET_KEYS)],
// Bounded by the allowlist itself, and unique on the key. The
// Rule::in below checks each value; it says nothing about how
// many there are or whether they repeat, and the loop writes
// one row per element. A layout has at most one entry per
// widget, so anything longer than the registry is not a layout
// this screen could have produced.
'widgets' => ['required', 'array', 'max:'.count(self::WIDGET_KEYS)],
'widgets.*.widget_key' => ['required', 'string', 'distinct', Rule::in(self::WIDGET_KEYS)],
'widgets.*.enabled' => ['required', 'boolean'],
'widgets.*.column_index' => ['required', 'integer', 'between:0,3'],
'widgets.*.position' => ['required', 'integer', 'min:0'],
+139
View File
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\Notifications\ClientWelcomeNotification;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Seats\SeatAllowance;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Carbon\Carbon;
use Illuminate\Validation\ValidationException;
/**
* Creating a client account — the rules and the side effects, shared by
* every surface that makes one.
*
* The same argument StaffAccounts makes for staff. What a client account
* *is* — its type, its role, an active flag, a quota where zero means
* "inherit the site default" rather than "none" — is a set of invariants,
* and an invariant enforced in one controller and re-implemented in
* another is one that will eventually hold in only one of them. There are
* three surfaces onto this now: the staff screens, `/api/v1/clients`, and
* the platform control plane in the private package, which reaches this
* by name because it cannot import a host class.
*
* What stays with the caller is what genuinely differs: the shape of the
* request, its validation rules, its response, and anything about *who is
* asking* — a client-scoped staff member gaining the client on their own
* roster is a fact about the creator, not about the account created.
*
* **Not to be confused with ClientProvisioning**, which sits beside it and
* handles the other half: an account that comes into existence without
* anybody deciding to create it — the public registration form, and a
* first successful LDAP sign-in. The policies genuinely differ rather than
* merely duplicating. An account made here is approved and verified by
* construction, because somebody who already knows who this is asked for
* it; one made there may wait for approval, joins a configured group, and
* tells the administrators it arrived.
*/
class ClientAccounts
{
public function __construct(
private readonly ActivityLogger $activity,
private readonly SeatAllowance $seats,
private readonly Settings $settings,
) {}
/**
* @param int $storageQuotaMb 0 means no per-account quota and
* inherits the site default at
* enforcement time — see
* ClientStorageUsage::quotaMb(). It does
* not mean unlimited.
* @param Carbon|null $expiresAt when the account stops working;
* null for never. Must be in the
* future — see guardExpiry().
* @param bool $welcome whether this installation should email the
* new account. A caller that sends its own
* welcome passes false rather than having the
* customer receive two.
*/
public function create(
string $name,
string $email,
string $password,
int $storageQuotaMb = 0,
bool $welcome = true,
string $emailField = 'email',
?Carbon $expiresAt = null,
): User {
// Before anything is written, and deliberately not left to the
// caller. The platform sets this cap and the platform is also what
// calls the control plane — so enforcing it here is what stops a
// leaked control token minting accounts without limit. A guard
// that only ran on the surfaces that remembered it would not be a
// guard.
$this->seats->guardClient($emailField);
$this->guardExpiry($expiresAt, active: true);
$client = User::create([
'type' => UserType::Client,
'active' => true,
'account_requested' => false,
'role_id' => Role::query()->where('name', SystemRole::Client->value)->value('id'),
'name' => $name,
'email' => $email,
'password' => $password,
'storage_quota_mb' => $storageQuotaMb,
]);
// forceFill, and not part of the create() array above: like
// StaffAccounts, email_verified_at is deliberately absent from
// User::$fillable — where an account stands is a security decision
// rather than an attribute — so mass assignment drops it in
// silence. Every client-creation path used to pass it in that
// array and lose it. The intent is real: an account created by
// somebody who already knows who this is has no address to
// confirm and nobody to confirm it to. (Inert today, since
// MustVerifyEmail is not enabled on the model, but the column is
// what a later switch would read.)
//
// expires_at is written the same way for its own reason: see the
// note on its cast in User.
$client->forceFill(['email_verified_at' => now(), 'expires_at' => $expiresAt])->save();
$this->activity->log(Action::UserCreated, subject: $client);
if ($welcome && $this->settings->get(Setting::EmailNotificationsEnabled) === true) {
$client->notify(new ClientWelcomeNotification);
}
return $client;
}
/**
* An account cannot be both active and past its expiry date.
*
* Every surface that writes either value asks this before saving,
* because the combination is not a state anybody means: an account
* that looks switched on and refuses every sign-in, until the hourly
* sweep quietly switches it off again. Somebody reactivating an
* expired client has to give them a new date, or none.
*/
public function guardExpiry(?Carbon $expiresAt, bool $active, string $field = 'expires_at'): void
{
if ($active && $expiresAt !== null && $expiresAt->isPast()) {
throw ValidationException::withMessages([
$field => __('This date has already passed. Choose a later date, or leave it empty for an account that never expires.'),
]);
}
}
}
@@ -158,7 +158,23 @@ class ClientPortalCustomFields
*/
private function isLocked(ClientCustomField $field, BaseCollection $values): bool
{
return $field->client_editability === ClientFieldEditability::EditableOnce
&& filled($values->get($field->id));
if ($field->client_editability !== ClientFieldEditability::EditableOnce) {
return false;
}
$stored = $values->get($field->id);
// A checkbox has a stored value from the first save onwards: an
// unticked box is written as '0', and filled('0') is true. Asking
// "is anything stored" therefore locked the field on the first save
// of the form it sits on, whatever the client had chosen — and a
// box they never ticked can then never be ticked. '0' is the
// absence of a decision, which is the state the other types express
// as null, so it is what an unlocked checkbox looks like.
if ($field->type === ClientCustomFieldType::Checkbox) {
return $stored === '1';
}
return filled($stored);
}
}
+64 -1
View File
@@ -12,9 +12,12 @@ use App\Modules\Groups\Models\Group;
use App\Modules\Identity\AuthSource;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\Permissions\Permission;
use App\Modules\Identity\Permissions\PermissionChecker;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Notifications\Notifier;
use App\Modules\Platform\Seats\SeatAllowance;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Support\Facades\Notification;
@@ -38,6 +41,8 @@ class ClientProvisioning
private readonly Settings $settings,
private readonly ActivityLogger $activity,
private readonly SeatAllowance $seats,
private readonly Notifier $notifier,
private readonly PermissionChecker $permissions,
) {}
/**
@@ -48,6 +53,22 @@ class ClientProvisioning
return $this->settings->get(Setting::ClientsAutoApprove) === true;
}
/**
* Whether an address is free for a new account.
*
* The unique index on `email` spans soft-deleted rows — AvailableEmailRule
* is built on exactly that, so a deleted account keeps its address until
* erasure takes the row away. The registration form learns this from
* validation. The machine paths have no form to validate: a directory or
* an identity provider hands over an address and provision() inserts it,
* so without asking first the insert raises a QueryException in the
* middle of somebody's sign-in.
*/
public function addressIsFree(string $email): bool
{
return ! User::withTrashed()->where('email', $email)->exists();
}
/**
* @param bool|null $autoApprove Null asks Setting::ClientsAutoApprove,
* which is the right question for the
@@ -59,6 +80,14 @@ class ClientProvisioning
* @param array<string, mixed> $context Placeholders for the action's
* log template, e.g. which
* provider an account came from.
* @param int $storageQuotaMb 0 means no per-account quota and
* inherits the site default at
* enforcement time — see
* ClientStorageUsage::quotaMb(). Same
* meaning as ClientAccounts::create()'s
* parameter of the same name; a caller
* with no quota to offer (the public
* registration form, LDAP) leaves it at 0.
*/
public function provision(
string $name,
@@ -69,6 +98,7 @@ class ClientProvisioning
?string $ldapDn = null,
?bool $autoApprove = null,
array $context = [],
int $storageQuotaMb = 0,
): User {
$autoApprove ??= $this->autoApproves();
@@ -89,6 +119,7 @@ class ClientProvisioning
'name' => $name,
'email' => $email,
'password' => $password,
'storage_quota_mb' => $storageQuotaMb,
]);
// Not mass-assignable: where an account's credentials live is a
@@ -105,6 +136,7 @@ class ClientProvisioning
$this->joinAutoGroup($client);
$this->notifyAdministrators($client, pending: ! $autoApprove);
$this->notifyStaffInApp($client);
return $client;
}
@@ -127,6 +159,37 @@ class ClientProvisioning
$group?->members()->syncWithoutDetaching([$client->id]);
}
/**
* The bell, for staff who administer clients.
*
* Separate from notifyAdministrators() above, and not a replacement for
* it: that one emails a list of raw addresses an operator typed into a
* setting, which need not correspond to any account in this
* installation. This one reaches the people actually signed in, which
* is the only place an account arriving unannounced was ever going to
* be noticed.
*
* Recipients are resolved here rather than inside Notifier, which
* authorizes nothing by design — see its security contract. Two rules,
* and the second is the one worth stating: a client-scoped staff member
* is not told. Their whole view is the clients assigned to them, and a
* brand-new account is assigned to nobody, so the notification would
* link them to a screen they are refused.
*/
private function notifyStaffInApp(User $client): void
{
$recipients = User::query()
->where('type', UserType::Staff)
->get()
->filter(fn (User $staff): bool => ! $staff->isClientScoped()
&& $this->permissions->allows($staff, Permission::ManageClients));
$this->notifier->send('client_registered', $recipients, subject: $client, data: [
'clientName' => $client->name,
'clientEmail' => $client->email,
]);
}
private function notifyAdministrators(User $client, bool $pending): void
{
if ($this->settings->get(Setting::EmailNotificationsEnabled) !== true) {
+43 -5
View File
@@ -28,10 +28,9 @@ class ClientStorageUsage
/**
* A client's own storage_quota_mb of 0 means "no custom quota set" —
* it inherits Setting::DefaultClientStorageQuotaMb instead of being
* unlimited, so a site-wide default (once set) also protects clients
* who never got an explicit quota, including self-registered ones.
* The site default itself being 0 is what actually means unlimited.
* it inherits the installation's default instead of being unlimited,
* so a default (once set) also protects clients who never got an
* explicit quota, including self-registered ones.
*
* @return int 0 means unlimited.
*/
@@ -39,7 +38,46 @@ class ClientStorageUsage
{
return $client->storage_quota_mb > 0
? $client->storage_quota_mb
: (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb);
: $this->defaultQuotaMb();
}
/**
* What a client with no quota of their own actually gets.
*
* Three sources, narrowest first, and the third is why this is a
* method rather than a `Settings::get()` at the point of use.
*
* `Setting::DefaultClientStorageQuotaMb` belongs to whoever runs the
* installation, and its default is 0 — which means unlimited. That is
* the right default for somebody setting up their own install, and the
* wrong one for an installation a platform operates on other people's
* behalf: there, an account that arrived without an explicit quota has
* no ceiling at all, which on a shared installation is one account
* away from unmetered hosting.
*
* So a platform may set a floor in the environment, exactly as it sets
* the seat caps, and for the same reason those are not settings: it is
* not a preference the installation's administrator is expressing, it
* is the shape of what was sold. It applies only where the setting says
* nothing, so an administrator who has chosen a number keeps it, and an
* install with no platform behind it is unaffected.
*
* Unset and zero are the same answer here, on purpose: a platform that
* wanted no ceiling would not set the variable.
*
* @return int 0 means unlimited.
*/
public function defaultQuotaMb(): int
{
$site = (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb);
if ($site > 0) {
return $site;
}
$floor = config('projectsend.platform.default_client_quota_mb');
return is_numeric($floor) ? max(0, (int) $floor) : 0;
}
/**
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients;
use App\Modules\Notifications\NotificationTypeDefinition;
use App\Modules\Notifications\NotificationTypeRegistry;
use Illuminate\Support\ServiceProvider;
class ClientsServiceProvider extends ServiceProvider
{
public function boot(): void
{
// In-app only, the same reasoning client_uploaded gives: email for
// this event is already sent separately, to whatever raw addresses
// Setting::AdminNotificationEmails lists, via
// AdminClientRegisteredNotification. Routing it through Notifier's
// mail dispatch as well would risk double-emailing any staff member
// who also appears in that list.
//
// One type for both doors, deliberately. A client arriving through
// the public form and one arriving through an invitation are the
// same event to the person being told — an account now exists that
// did not — and a second type would buy nothing: preferences here
// govern email only (see NotificationPreferences), so it could not
// be switched off separately, and which door it came through is one
// click away in the activity log and on the invitations screen.
$this->app->make(NotificationTypeRegistry::class)->register(new NotificationTypeDefinition(
key: 'client_registered',
label: 'A new client registered an account',
template: ':clientName (:clientEmail) registered a client account',
// The list, filtered to this address — not clients.edit, which
// is gated by edit_clients while the recipients below are chosen
// by manage_clients. A notification that refuses the person it
// was sent to is worse than one that lands a click short.
url: fn (array $data): string => route('clients.index', ['search' => $data['clientEmail']]),
));
if ($this->app->runningInConsole()) {
$this->commands([
Console\ExpireClientAccountsCommand::class,
]);
}
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients\Console;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Identity\UserType;
use Illuminate\Console\Command;
/**
* Switches off client accounts whose expiry date has passed.
*
* Not what stops an expired client getting in — User::maySignIn() does
* that the moment the date passes, with or without this. What this does is
* make `active` tell the truth, so everything that reads the flag rather
* than asking the account agrees: the client list and its status filter,
* the API's `active` field, and a managed plan's seat count. Hourly for the
* same reason purge-stale-uploads is: a seat held by an account that can
* no longer use it is a seat somebody else cannot have.
*/
class ExpireClientAccountsCommand extends Command
{
protected $signature = 'projectsend:expire-client-accounts';
protected $description = 'Deactivate client accounts whose expiry date has passed (runs hourly)';
public function handle(ActivityLogger $activity): int
{
$now = now();
$expired = 0;
$due = User::query()
->where('type', UserType::Client)
->where('active', true)
->whereNotNull('expires_at')
->where('expires_at', '<=', $now)
->get(['id', 'name', 'expires_at']);
foreach ($due as $client) {
// Conditional on the row still being due, not a plain save():
// an administrator who moved the date forward between the read
// above and this write has just decided the account should keep
// working, and must not be overruled by a list that is a few
// milliseconds old.
$switchedOff = User::query()
->whereKey($client->id)
->where('active', true)
->where('expires_at', '<=', $now)
->update(['active' => false]);
if ($switchedOff === 1) {
$activity->logSystem(Action::ClientExpired, ['name' => $client->name, 'id' => $client->id]);
$expired++;
}
}
$this->info("Deactivated {$expired} expired client account(s).");
return self::SUCCESS;
}
}
@@ -9,9 +9,11 @@ use App\Models\User;
use App\Modules\Api\Support\PollingQuery;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientAccounts;
use App\Modules\Clients\ClientCustomFieldType;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Platform\Localization\DateInput;
use App\Modules\Platform\Seats\SeatAllowance;
use App\Modules\Clients\Http\Resources\Api\ClientResource;
use App\Modules\Clients\Models\ClientCustomField;
@@ -22,13 +24,11 @@ use App\Modules\Files\DeletedAccountContent;
use App\Modules\Identity\AccountContentDeletion;
use App\Modules\Identity\Erasure\AvailableEmailRule;
use App\Modules\Identity\Erasure\ErasureSchedule;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -61,7 +61,9 @@ class ClientsController extends Controller
private readonly AccountContentDeletion $accountDeletion,
private readonly StaffLibraryScope $scope,
private readonly SeatAllowance $seats,
private readonly ClientAccounts $clients,
private readonly ErasureSchedule $erasure,
private readonly DateInput $dates,
) {}
public function index(Request $request): AnonymousResourceCollection
@@ -117,8 +119,6 @@ class ClientsController extends Controller
public function store(Request $request): JsonResponse
{
$this->seats->guardClient();
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
@@ -132,29 +132,55 @@ class ClientsController extends Controller
// installation may be refused on another.
'password' => ['required', Password::defaults()],
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
// When the account stops working; omit or send null for never.
// A bare date (`2026-12-31`) means the end of that day in the
// token owner's timezone; a full timestamp is used as given.
// Must be in the future.
'expires_at' => ['nullable', 'string', 'date'],
'custom_field_values' => ['array'],
]);
$validated['custom_field_values'] = $this->validateCustomFieldValues($request);
$client = User::create([
'type' => UserType::Client,
'active' => true,
'account_requested' => false,
'role_id' => Role::query()->where('name', SystemRole::Client->value)->value('id'),
'name' => $validated['name'],
'email' => $validated['email'],
'password' => $validated['password'],
// 0 means "no custom quota" and inherits the site default at
// enforcement time — see ClientStorageUsage::quotaMb().
'storage_quota_mb' => $validated['storage_quota_mb'] ?? 0,
'email_verified_at' => now(),
]);
$creator = $request->user();
assert($creator !== null);
$this->activity->log(Action::UserCreated, subject: $client);
// The invariants — the seat guard, the type, the role, the quota's
// "0 means inherit" — live in ClientAccounts, shared with the staff
// screens and with the platform control plane. What stays here is
// this surface's own business: its validation, its custom fields,
// and who the creator is.
$client = $this->clients->create(
name: $validated['name'],
email: $validated['email'],
password: $validated['password'],
// As on the staff screen, and for the same reason: the
// `integer` rule accepts a numeric string and does not convert
// it. A JSON number arrives as an int and was fine; a
// form-encoded body or a quoted JSON value is a string, and
// this file is strict_types.
storageQuotaMb: (int) ($validated['storage_quota_mb'] ?? 0),
welcome: false,
expiresAt: $this->dates->instant($validated['expires_at'] ?? null, $creator),
);
// A client-scoped creator would otherwise lose the client they just
// made. guardTarget() answers 404 for anything off their roster, so
// the record they created is not theirs to open, and
// StaffLibraryScope::clients() leaves it out of their list as well —
// the client exists, is welcomed by email, and is invisible to the
// person who made it. Their own roster is where a client they
// created belongs; an unscoped creator has no roster to add to.
if ($creator->isClientScoped()) {
$creator->assignedClients()->attach($client->id);
}
$this->saveCustomFieldValues($client, $validated['custom_field_values'] ?? []);
// Sent here rather than inside ClientAccounts so the custom fields
// are already saved when it goes: a welcome that arrives before
// the account is finished describes an account that does not quite
// exist yet.
if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) {
$client->notify(new ClientWelcomeNotification);
}
@@ -173,6 +199,11 @@ class ClientsController extends Controller
'active' => ['sometimes', 'boolean'],
'password' => ['sometimes', 'nullable', Password::defaults()],
'storage_quota_mb' => ['sometimes', 'nullable', 'integer', 'min:0'],
// Send null to remove the expiry. Read the same way as on
// create. An account cannot be active with a date that has
// passed, so reactivating an expired client needs a new date
// (or null) in the same request.
'expires_at' => ['sometimes', 'nullable', 'string', 'date'],
'custom_field_values' => ['sometimes', 'array'],
]);
@@ -191,6 +222,26 @@ class ClientsController extends Controller
$client->storage_quota_mb = $validated['storage_quota_mb'] ?? 0;
}
// Asked only when this request touches one of the two values, so a
// PATCH renaming a client whose date passed an hour ago is not
// refused over a field it never sent. Resolved with boolean() for
// the reason given in the web controller.
if (array_key_exists('expires_at', $validated) || array_key_exists('active', $validated)) {
$editor = $request->user();
assert($editor !== null);
$expiresAt = array_key_exists('expires_at', $validated)
? $this->dates->instant($validated['expires_at'], $editor)
: $client->expires_at;
$this->clients->guardExpiry(
$expiresAt,
active: array_key_exists('active', $validated) ? $request->boolean('active') : $client->active,
);
$client->expires_at = $expiresAt;
}
// Approval, and so the moment the seat is spent — same rule the
// web edit screen and approve() answer to. Inside the branch, so a
// capped installation can still edit a client it already holds.
@@ -206,7 +257,7 @@ class ClientsController extends Controller
$client->save();
if (array_key_exists('custom_field_values', $validated)) {
$this->saveCustomFieldValues($client, $validated['custom_field_values']);
$this->patchCustomFieldValues($client, $validated['custom_field_values']);
}
$this->activity->log(Action::UserUpdated, subject: $client);
@@ -364,11 +415,43 @@ class ClientsController extends Controller
}
/**
* Every field, whether or not the request named it — a new client has
* no values yet, and create() is not a partial update.
*
* @param array<int, mixed> $values field id => submitted value
*/
private function saveCustomFieldValues(User $client, array $values): void
{
foreach (ClientCustomField::query()->get() as $field) {
$this->writeCustomFieldValues($client, ClientCustomField::query()->get(), $values);
}
/**
* Only the fields the request actually named.
*
* PATCH semantics, the same rule update() applies to every other
* column: an absent key means "leave alone", not "clear". Sharing
* create()'s "write every field" pass here emptied every custom field
* the caller had not mentioned, which is silent data loss on a request
* that looked like it changed one thing.
*
* @param array<int, mixed> $values field id => submitted value
*/
private function patchCustomFieldValues(User $client, array $values): void
{
$this->writeCustomFieldValues(
$client,
ClientCustomField::query()->whereIn('id', array_keys($values))->get(),
$values,
);
}
/**
* @param Collection<int, ClientCustomField> $fields
* @param array<int, mixed> $values field id => submitted value
*/
private function writeCustomFieldValues(User $client, Collection $fields, array $values): void
{
foreach ($fields as $field) {
$submitted = $values[$field->id] ?? null;
$value = $field->type === ClientCustomFieldType::Checkbox
? ($submitted ? '1' : '0')
@@ -7,6 +7,7 @@ namespace App\Modules\Clients\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Folders\ClientHomeFolders;
use App\Modules\Groups\Models\Group;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
@@ -21,6 +22,7 @@ class ClientSettingsController extends Controller
public function __construct(
private readonly Settings $settings,
private readonly ActivityLogger $activity,
private readonly ClientHomeFolders $homeFolders,
) {}
public function edit(): Response
@@ -31,8 +33,14 @@ class ClientSettingsController extends Controller
'clients_auto_group' => $this->settings->get(Setting::ClientsAutoGroup),
'clients_can_select_group' => $this->settings->get(Setting::ClientsCanSelectGroup),
'clients_membership_deny_cooldown_days' => $this->settings->get(Setting::ClientsMembershipDenyCooldownDays),
'client_invitation_expiry_hours' => $this->settings->get(Setting::ClientInvitationExpiryHours),
'default_client_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
'clients_can_preview_files' => $this->settings->get(Setting::ClientsCanPreviewFiles),
'clients_home_folders' => $this->settings->get(Setting::ClientsHomeFolders),
// What the button beside the switch would actually do, so it can
// say "3 clients have no folder yet" instead of asking somebody
// to press it and find out.
'clients_without_home' => $this->homeFolders->pendingCount(),
'groups' => Group::query()->orderBy('name')->get()
->map(fn (Group $group): array => ['id' => $group->id, 'name' => $group->name])
->all(),
@@ -47,8 +55,10 @@ class ClientSettingsController extends Controller
'clients_auto_group' => ['required', 'integer', Rule::in([0, ...Group::query()->pluck('id')->all()])],
'clients_can_select_group' => ['required', Rule::in(['none', 'public'])],
'clients_membership_deny_cooldown_days' => ['required', 'integer', 'min:0', 'max:365'],
'client_invitation_expiry_hours' => ['required', 'integer', 'min:1', 'max:720'],
'default_client_storage_quota_mb' => ['required', 'integer', 'min:0'],
'clients_can_preview_files' => ['required', 'boolean'],
'clients_home_folders' => ['required', 'boolean'],
]);
$this->settings->set(Setting::ClientsCanRegister, $validated['clients_can_register']);
@@ -56,11 +66,54 @@ class ClientSettingsController extends Controller
$this->settings->set(Setting::ClientsAutoGroup, (int) $validated['clients_auto_group']);
$this->settings->set(Setting::ClientsCanSelectGroup, $validated['clients_can_select_group']);
$this->settings->set(Setting::ClientsMembershipDenyCooldownDays, (int) $validated['clients_membership_deny_cooldown_days']);
$this->settings->set(Setting::ClientInvitationExpiryHours, (int) $validated['client_invitation_expiry_hours']);
$this->settings->set(Setting::DefaultClientStorageQuotaMb, (int) $validated['default_client_storage_quota_mb']);
$this->settings->set(Setting::ClientsCanPreviewFiles, $validated['clients_can_preview_files']);
// Saving the switch deliberately creates nothing. Existing clients
// get a folder when somebody presses the button, so that turning
// this on, looking at it, and turning it off again leaves the
// library exactly as it was.
$this->settings->set(Setting::ClientsHomeFolders, $validated['clients_home_folders']);
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'clients']);
return back();
}
/**
* Create the missing home folders, on request.
*
* Its own endpoint rather than part of saving the form, because it is a
* different kind of act: the form records a preference, this one writes
* a folder for every client on the installation. Wrapping the second
* inside the first would mean an administrator could not try the
* setting without committing to it.
*/
public function backfillHomeFolders(Request $request): RedirectResponse
{
abort_unless($this->homeFolders->enabled(), 403, 'Client folders are switched off.');
$result = $this->homeFolders->backfill();
$this->activity->log(Action::SettingsUpdated, context: [
'section' => 'clients',
'action' => 'client_home_folders_backfill',
'created' => $result['created'],
'total' => $result['total'],
]);
// Counts rather than "Done": on an installation with hundreds of
// clients the administrator wants to know how many there were and
// how many are new, and the difference between "created 200" and
// "created 0, they already had one" is the whole answer.
return back()->with('success', trans_choice(
'{0}Every client already had a folder.|[1,*]:created of :total clients got a folder. :existing already had one.',
$result['created'],
[
'created' => (string) $result['created'],
'total' => (string) $result['total'],
'existing' => (string) $result['existing'],
],
));
}
}
@@ -8,9 +8,11 @@ use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientAccounts;
use App\Modules\Clients\ClientCustomFieldType;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Platform\Localization\DateInput;
use App\Modules\Platform\Seats\SeatAllowance;
use App\Modules\Clients\Models\ClientCustomField;
use App\Modules\Clients\Models\ClientCustomFieldValue;
@@ -20,10 +22,7 @@ use App\Modules\Files\DeletedAccountContent;
use App\Modules\Identity\AccountContentDeletion;
use App\Modules\Identity\Erasure\AvailableEmailRule;
use App\Modules\Identity\Erasure\ErasureSchedule;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Support\Pagination;
@@ -51,7 +50,9 @@ class ClientsController extends Controller
private readonly AccountContentDeletion $accountDeletion,
private readonly StaffLibraryScope $scope,
private readonly SeatAllowance $seats,
private readonly ClientAccounts $clients,
private readonly ErasureSchedule $erasure,
private readonly DateInput $dates,
) {}
public function index(Request $request): Response
@@ -90,6 +91,12 @@ class ClientsController extends Controller
'email' => $client->email,
'active' => $client->active,
'account_requested' => $client->account_requested,
// A calendar day in the viewer's zone, as the edit form shows
// it, plus whether it has passed: the sweep that switches an
// expired account off runs hourly, and the list should not
// call an account that already refuses sign-ins "Active".
'expires_on' => $this->dates->asShown($client->expires_at, $viewer),
'expired' => $client->hasExpired(),
'created_at' => $client->created_at?->toIso8601String(),
'content' => $content[$client->id] ?? ['files' => 0, 'folders' => 0],
]);
@@ -98,7 +105,12 @@ class ClientsController extends Controller
'clients' => $clients->items(),
'pagination' => Pagination::meta($clients),
'filters' => $filters,
'reassign_candidates' => $this->accountDeletion->candidates(),
// Only for somebody who may actually reassign: the picker is
// part of the delete dialog, and React filtering it out of the
// page is not the same as it never being on the page.
'reassign_candidates' => $viewer->can('delete_clients')
? $this->accountDeletion->candidates($viewer)
: [],
// Null on a self-hosted install: no limit, nothing to say.
'seats' => $this->seats->clientState(),
]);
@@ -118,41 +130,67 @@ class ClientsController extends Controller
return Inertia::render('clients/create', [
'custom_fields' => $this->customFieldDefinitions(),
'default_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
// The resolved default, not the raw setting: a platform can put
// a floor under it from the environment, and both screens
// present this as what will actually happen rather than as a
// value being edited. The edit screen mirrors quotaMb()'s
// resolution client-side to draw the usage bar, and handing it
// the effective number is what keeps that mirror correct
// without it having to know floors exist.
//
// The Client settings form deliberately still reads the raw
// setting (ClientSettingsController): that field is edited and
// saved back, so prefilling it with a floor would write the
// platform's number into the setting as the administrator's own
// choice, where it would outlive the floor.
'default_storage_quota_mb' => $this->storageUsage->defaultQuotaMb(),
]);
}
public function store(Request $request): RedirectResponse
{
// A client created here is approved by construction, so it counts
// immediately — unlike a self-registration awaiting a decision.
$this->seats->guardClient();
$validated = $request->validate(array_merge([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
'password' => ['required', 'confirmed', Password::defaults()],
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
'expires_at' => ['nullable', 'string', 'date'],
], $this->customFieldRules()));
$client = User::create([
'type' => UserType::Client,
'active' => true,
'account_requested' => false,
'role_id' => Role::query()->where('name', SystemRole::Client->value)->value('id'),
'name' => $validated['name'],
'email' => $validated['email'],
'password' => $validated['password'],
// 0 (including an omitted field) means "no custom quota" —
// it inherits Setting::DefaultClientStorageQuotaMb at
// enforcement time (see ClientStorageUsage::quotaMb()), not
// baked in here, so a later change to the site default
// keeps applying to this client automatically.
'storage_quota_mb' => $validated['storage_quota_mb'] ?? 0,
'email_verified_at' => now(),
]);
$creator = $request->user();
assert($creator !== null);
$this->activity->log(Action::UserCreated, subject: $client);
// The seat guard, the type, the role, and the quota's "0 means
// inherit the site default" all live in ClientAccounts, shared
// with the API and the control plane. A client created here is
// approved by construction, so it counts against the cap
// immediately — unlike a self-registration awaiting a decision.
// The welcome waits until the custom fields are saved below.
$client = $this->clients->create(
name: $validated['name'],
email: $validated['email'],
password: $validated['password'],
// Cast, because `integer` validates without converting:
// $request->validate() hands back the raw input, so a form
// field arrives as the string "2048" and this file is
// strict_types. Filling the quota in was a 500; leaving it
// blank went through null ?? 0 as an int, which is why it
// survived to the fleet.
storageQuotaMb: (int) ($validated['storage_quota_mb'] ?? 0),
welcome: false,
expiresAt: $this->dates->instant($validated['expires_at'] ?? null, $creator),
);
// A client-scoped creator would otherwise lose the client they just
// made. guardTarget() answers 404 for anything off their roster, so
// the record they created is not theirs to open, and
// StaffLibraryScope::clients() leaves it out of their list as well —
// the client exists, is welcomed by email, and is invisible to the
// person who made it. Their own roster is where a client they
// created belongs; an unscoped creator has no roster to add to.
if ($creator->isClientScoped()) {
$creator->assignedClients()->attach($client->id);
}
$this->saveCustomFieldValues($client, $validated['custom_field_values'] ?? []);
@@ -166,7 +204,7 @@ class ClientsController extends Controller
// Fall back to the create form: it shares this route's own gate, so
// it is reachable by exactly whoever just created the record, and
// the success toast shows there.
$target = $request->user()?->can('edit_clients')
$target = $creator->can('edit_clients')
? redirect()->route('clients.edit', $client)
: redirect()->route('clients.create');
@@ -206,15 +244,22 @@ class ClientsController extends Controller
'account_requested' => $client->account_requested,
'storage_quota_mb' => $client->storage_quota_mb,
'two_factor_enabled' => $client->hasTwoFactorEnabled(),
// update() compares the posted value against this same
// string — see there.
'expires_at' => $this->dates->asShown($client->expires_at, $request->user()),
'expired' => $client->hasExpired(),
],
'default_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
// Resolved, not raw — see create() above.
'default_storage_quota_mb' => $this->storageUsage->defaultQuotaMb(),
'storage_used_mb' => (int) ceil($this->storageUsage->usedBytes($client) / 1024 / 1024),
'custom_fields' => $this->customFieldDefinitions(),
'custom_field_values' => ClientCustomFieldValue::query()
->where('user_id', $client->id)
->pluck('value', 'client_custom_field_id'),
'content' => $this->accountContent->summarize($client),
'reassign_candidates' => $this->accountDeletion->candidates($client->id),
'reassign_candidates' => $request->user()?->can('delete_clients') === true
? $this->accountDeletion->candidates($request->user(), $client->id)
: [],
]);
}
@@ -229,8 +274,27 @@ class ClientsController extends Controller
'active' => ['required', 'boolean'],
'password' => ['nullable', 'confirmed', Password::defaults()],
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
'expires_at' => ['nullable', 'string', 'date'],
], $this->customFieldRules()));
$editor = $request->user();
assert($editor !== null);
// The form was rendered with the stored instant read back as a day
// in the editor's zone, and posts that string again with every
// other edit. Only a different string is a new date; re-deriving
// an unchanged one would move the expiry by the difference between
// two editors' zones each time either of them renamed the client.
// Same rule as a file's expiry (FilesController::update).
$postedExpiry = $validated['expires_at'] ?? null;
$expiresAt = $postedExpiry !== $this->dates->asShown($client->expires_at, $editor)
? $this->dates->instant($postedExpiry, $editor)
: $client->expires_at;
// Request::boolean(), not the validated value: `boolean` accepts
// "1" and "0" without converting them.
$this->clients->guardExpiry($expiresAt, active: $request->boolean('active'));
$wasActive = $client->active;
$passwordChanged = is_string($validated['password'] ?? null) && $validated['password'] !== '';
@@ -245,6 +309,8 @@ class ClientsController extends Controller
'storage_quota_mb' => $validated['storage_quota_mb'] ?? 0,
]);
$client->expires_at = $expiresAt;
// Activating a pending account through the edit screen counts as
// approval and clears the request flag — which is the moment a
// seat is spent, so the cap is asked here for the same reason
@@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Clients\Models\Invitation;
use App\Modules\Clients\Notifications\ClientInvitationNotification;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\Erasure\AvailableEmailRule;
use App\Modules\Platform\Seats\SeatAllowance;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Support\Pagination;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Notification;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
/**
* Staff sending a client an invitation to register, ahead of the public
* form — the "New client" button's sibling for an installation that
* would rather have somebody set their own password than hand them one.
*/
class InvitationController extends Controller
{
public function __construct(
private readonly ActivityLogger $activity,
private readonly StaffLibraryScope $scope,
private readonly Settings $settings,
private readonly ClientStorageUsage $storageUsage,
private readonly SeatAllowance $seats,
) {}
/**
* Every state the status filter accepts. What each one means lives in
* applyStateFilter() alone: two of them narrow the same stored status
* by the clock, and a second copy of that rule is how the filter and
* the badge start disagreeing about a row whose expiry just passed.
*
* @var list<string>
*/
private const FILTERABLE_STATES = [
'pending',
'expired',
Invitation::STATUS_REDEEMED,
Invitation::STATUS_REVOKED,
Invitation::STATUS_SUPERSEDED,
];
public function index(Request $request): Response
{
$validated = $request->validate([
'status' => ['nullable', 'string', Rule::in(self::FILTERABLE_STATES)],
]);
$status = $validated['status'] ?? null;
// Every invitation ever sent, not only the live ones. The list is a
// history: what was sent, what became of it, and who is still
// waiting. A screen that showed only what is outstanding cannot
// answer "did we ever invite this person", which is the question
// somebody actually arrives with.
$invitations = Invitation::query()
->when($status !== null, fn (Builder $query) => $this->applyStateFilter($query, (string) $status))
->with(['group:id,name', 'invitedBy:id,name'])
// Newest first, the order a history is read in. What is urgent
// rather than recent is reachable through the status filter,
// and the Expires column says the rest.
->orderByDesc('created_at')
->orderByDesc('id')
->paginate(25)
->withQueryString()
->through(fn (Invitation $invitation): array => [
'id' => $invitation->id,
'name' => $invitation->name,
'email' => $invitation->email,
'group' => $invitation->group?->name,
'invited_by' => $invitation->invitedBy?->name,
'created_at' => $invitation->created_at?->toIso8601String(),
'expires_at' => $invitation->expires_at->toIso8601String(),
// What the screen labels the row, and what the filter above
// selects on — one definition, so the badge and the filter
// cannot disagree about a row whose expiry just passed.
'state' => $invitation->state(),
]);
return Inertia::render('clients/invitations', [
'invitations' => $invitations->items(),
'pagination' => Pagination::meta($invitations),
'filters' => ['status' => $status],
]);
}
/**
* @param Builder<Invitation> $query
* @return Builder<Invitation>
*/
private function applyStateFilter(Builder $query, string $state): Builder
{
return match ($state) {
'pending' => $query->pending()->where('expires_at', '>=', now()),
'expired' => $query->pending()->where('expires_at', '<', now()),
default => $query->where('status', $state),
};
}
public function create(Request $request): Response
{
$viewer = $request->user();
assert($viewer instanceof User);
return Inertia::render('clients/invite', [
// Scoped, exactly as GroupsController::index() is: a
// client-scoped staff member is told about a group because one
// of their clients is in it. Unscoped, this form listed every
// group on the installation to a viewer who can reach none of
// them — the hole GHSA-r3hg-3fxw-rcmr closed everywhere else,
// left open here because invitations were written after it.
'groups' => $this->scope->groups($viewer)->orderBy('name')->get(['id', 'name']),
// Resolved, not raw — see ClientsController::create()'s note on
// the same prop: this is what will actually happen, and the
// form's own field mirrors this resolution to draw its hint.
'default_storage_quota_mb' => $this->storageUsage->defaultQuotaMb(),
]);
}
public function store(Request $request): RedirectResponse
{
$viewer = $request->user();
assert($viewer instanceof User);
$validated = $request->validate([
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
'name' => ['nullable', 'string', 'max:255'],
// Against the groups this person may actually put somebody in,
// not against every group there is: the list above is only what
// the form drew, and a request does not have to come from it.
'group_id' => ['required', 'integer', Rule::in([0, ...$this->scope->groups($viewer)->pluck('id')->all()])],
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
]);
// Asked here as well as at redemption. An outstanding invitation
// is not a client and is not counted as one — the same rule a
// pending account request follows — so this refuses sending a link
// a full installation could not honour, rather than reserving
// anything. The redemption door still guards, because the seat can
// be taken by somebody else in the days between.
$this->seats->guardClient();
$group = $validated['group_id'] > 0
? Group::query()->whereKey($validated['group_id'])->first()
: null;
$invitation = Invitation::issue(
email: $validated['email'],
name: $validated['name'] ?? null,
group: $group,
invitedBy: $request->user(),
expiresAt: now()->addHours((int) $this->settings->get(Setting::ClientInvitationExpiryHours)),
// The `integer` rule above validates the shape but does not
// cast it — this arrives as a numeric string from the request,
// same as group_id, and issue() takes a real int.
storageQuotaMb: (int) ($validated['storage_quota_mb'] ?? 0),
);
Notification::route('mail', $invitation->email)->notify(
new ClientInvitationNotification($invitation->name ?? $invitation->email, $invitation->token),
);
$this->activity->log(Action::ClientInvited, context: ['email' => $invitation->email]);
return redirect()->route('invitations.index')->with('success', __('Invitation sent.'));
}
/**
* Cancels an invitation nobody has used yet.
*
* Until this existed, letting one expire was the only way to take it
* back — and the expired page's own "send me a new one" button undid
* that, silently, for anybody still holding the link. Revoking is the
* decision that button cannot reverse: STATUS_REVOKED is outside
* pending(), which is the scope both the redemption and the resend
* doors look through.
*
* The row is kept rather than deleted, for the reason
* Invitation::STATUS_SUPERSEDED is kept: the activity log names who
* invited this address and when, and that trail should still lead
* somewhere.
*/
public function destroy(Invitation $invitation): RedirectResponse
{
// Already spent, already superseded, already revoked: there is
// nothing left to cancel, and saying so is better than reporting a
// success that changed nothing.
abort_unless($invitation->status === Invitation::STATUS_PENDING, 404);
$invitation->forceFill(['status' => Invitation::STATUS_REVOKED])->save();
$this->activity->log(Action::ClientInvitationRevoked, context: ['email' => $invitation->email]);
return back()->with('success', __('Invitation revoked.'));
}
}
@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientProvisioning;
use App\Modules\Clients\Models\Invitation;
use App\Modules\Clients\Notifications\ClientInvitationNotification;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
/**
* A client redeeming the link an invitation emailed them — the invited
* counterpart to RegistrationController's public form. Reaching the form
* at all is the whole difference: it is gated by a specific address
* having a live token rather than by Setting::ClientsCanRegister, and the
* account that comes out of it is provisioned exactly the way any other
* self-registration is (ClientProvisioning), so an installation with
* auto-approve off still puts one in the same queue as everybody else.
*/
class InvitationRedemptionController extends Controller
{
/**
* How many times an invitation may be renewed by the person holding
* it, before a staff member has to send a new one.
*
* Without a limit, expiry stops meaning anything: whoever holds a dead
* link can re-arm it, so the window an operator configured is only as
* short as the longest anybody bothers to wait. Three is enough for
* somebody who genuinely keeps missing it, and short enough that a link
* sitting somewhere it should not be — a forwarded thread, a shared
* inbox, a mailbox that changed hands — eventually stops answering on
* its own, as the expiry setting says it will.
*
* Revoking is the immediate version of the same decision, and does not
* wait for this: see InvitationController::destroy().
*/
private const SELF_RESEND_LIMIT = 3;
public function __construct(
private readonly ClientProvisioning $provisioning,
private readonly ActivityLogger $activity,
private readonly Settings $settings,
private readonly StaffLibraryScope $scope,
) {}
public function create(Request $request): Response
{
$token = (string) $request->route('token');
$invitation = $this->findUsable($token);
return Inertia::render('auth/invite', [
'token' => $token,
'email' => $invitation->email ?? '',
'name' => $invitation->name ?? '',
'status' => $request->session()->get('status'),
// Same shape as NewPasswordController::create()'s $expired: one
// answer for "no such token" and "spent or expired token",
// because telling them apart would tell a guesser which
// addresses this installation has invited.
'expired' => $invitation === null,
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'token' => ['required', 'string'],
'name' => ['required', 'string', 'max:255'],
'password' => ['required', 'confirmed', Password::defaults()],
]);
$invitation = $this->findUsable($validated['token']);
if ($invitation === null) {
throw ValidationException::withMessages([
'token' => [__('This invitation is no longer valid. Ask whoever invited you to send a new one.')],
]);
}
// An invitation is live for days, and the address it names can be
// taken in the meantime — staff got impatient and made the account
// by hand, or the person registered through the public form. The
// unique index on users.email spans trashed rows, so provision()
// would raise a QueryException here rather than refusing: a 500 on
// the screen of somebody who has just typed a password. Every other
// caller with no form to validate asks this first, for this reason
// — see ClientProvisioning::addressIsFree().
if (! $this->provisioning->addressIsFree($invitation->email)) {
throw ValidationException::withMessages([
// Says what happened, because the person holding this link
// already knows the address is theirs — it is the one the
// invitation was sent to. There is nothing here to disclose
// that the invitation itself did not.
'token' => [__('An account already exists for this email address. Try signing in instead, or reset your password.')],
]);
}
$client = $this->provisioning->provision(
name: $validated['name'],
email: $invitation->email,
password: $validated['password'],
action: Action::ClientInvitationRedeemed,
// Always, regardless of Setting::ClientsAutoApprove: an
// invitation names a specific address a staff member already
// decided to let in, which is the trust an approval queue
// exists to establish for the address it never named.
autoApprove: true,
storageQuotaMb: $invitation->storage_quota_mb,
);
if ($invitation->group !== null && $this->mayJoin($invitation, $client)) {
$invitation->group->members()->syncWithoutDetaching([$client->id]);
}
$invitation->forceFill(['status' => Invitation::STATUS_REDEEMED])->save();
return redirect()->route('login')->with(
'status',
$client->account_requested
? __('Your account has been created. You will be able to log in once it is approved.')
: __('Your account has been created. You can log in now.'),
);
}
/**
* Resends a fresh link to the same address without anybody deciding
* to — the invited person asked for it, not an administrator. A spent
* or genuinely unknown token answers the same as an expired one: this
* is the one door on the flow an anonymous visitor can knock on
* repeatedly, so it must not become a way to learn which addresses
* were ever invited.
*
* The new link goes to the address on the invitation, never to whoever
* asked, so holding a leaked URL gets nobody a working one. What this
* does spend is the operator's expiry window, which is why
* SELF_RESEND_LIMIT caps how often it can be spent, and why staff can
* end it outright by revoking.
*/
public function resend(Request $request): RedirectResponse
{
$token = (string) $request->route('token');
$invitation = $this->findUsable($token, includingExpired: true);
// Spent, unknown, revoked, or renewed as often as it may be: all
// four answer the same sentence below, and none of them sends
// anything. Only the last of the four is a link whose holder did
// nothing wrong, and telling them apart here would tell a guesser
// which addresses this installation has invited.
if ($invitation !== null && $invitation->resends < self::SELF_RESEND_LIMIT) {
$fresh = Invitation::issue(
email: $invitation->email,
name: $invitation->name,
group: $invitation->group,
invitedBy: $invitation->invitedBy,
expiresAt: now()->addHours((int) $this->settings->get(Setting::ClientInvitationExpiryHours)),
storageQuotaMb: $invitation->storage_quota_mb,
resends: $invitation->resends + 1,
);
Notification::route('mail', $fresh->email)->notify(
new ClientInvitationNotification($fresh->name ?? $fresh->email, $fresh->token),
);
// Nobody is signed in, so this records no actor — which is the
// point of logging it. Sending and redeeming were already in
// the trail; renewing was the one step that moved an invitation
// along with no staff member behind it and left no trace.
// Bounded by the limit above, so an anonymous door cannot flood
// the log.
$this->activity->log(Action::ClientInvitationResent, context: ['email' => $fresh->email]);
}
return back()->with('status', __('If that invitation can still be resent, a new one is on its way.'));
}
/**
* The invitation $token names, if it is still one store() would
* accept — pending and not expired, unless $includingExpired asks for
* the resend door's wider question instead.
*/
/**
* Whether the membership this invitation carries is one its sender may
* grant — the same question GroupMembersController::store() asks before
* adding anybody to a group.
*
* Asked here as well as when the invitation was written, and this is
* the half that matters: an invitation is a grant that lands days
* later, when the person who sent it is not present to be checked, and
* one written before this check existed can still be outstanding. A
* refused membership is dropped rather than failing the redemption —
* the account is what the person holding the link came for, and it is
* theirs either way.
*
* An invitation whose sender is gone (the account was deleted and the
* column nulls out) keeps its group: there is no longer a reach to
* exceed, and dropping it would quietly undo what an administrator
* arranged.
*/
private function mayJoin(Invitation $invitation, User $client): bool
{
$inviter = $invitation->invitedBy;
$group = $invitation->group;
if ($inviter === null || $group === null) {
return true;
}
if ($this->scope->allowsGroupMembership($inviter, $group, $client)) {
return true;
}
Log::warning('An invitation named a group its sender may not add anybody to; the account was created without it.', [
'invitation' => $invitation->id,
'group' => $group->id,
]);
return false;
}
private function findUsable(string $token, bool $includingExpired = false): ?Invitation
{
$invitation = Invitation::query()->pending()->where('token', $token)->first();
if ($invitation === null) {
return null;
}
if (! $includingExpired && $invitation->isExpired()) {
return null;
}
return $invitation;
}
}
@@ -79,6 +79,10 @@ class ClientResource extends JsonResource
// caller needs to see before removing it. The secret and the
// recovery codes stay where they are.
'two_factor_enabled' => $this->hasTwoFactorEnabled(),
// Null when the account never expires. Once this passes the
// client can no longer sign in, and `active` turns false within
// the hour.
'expires_at' => $this->expires_at?->toIso8601String(),
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
];
+156
View File
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients\Models;
use App\Models\User;
use App\Modules\Groups\Models\Group;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
/**
* A staff-sent invitation for a specific address to register a client
* account, ahead of the public registration form. Redeeming one is
* handled by ClientProvisioning, the same as any other self-provisioned
* account — an invitation only settles who is allowed to reach the form
* and with which address, not the account's own policy.
*
* **The token is the whole authorization**, the same as a file's share
* link: the redemption route has nothing else to look it up by, so it is
* stored the way CreateShareLink stores one — Str::random(40), plain,
* queried directly — rather than hashed the way a password is.
*
* @property int $id
* @property string|null $name
* @property string $email
* @property string $token
* @property string $status
* @property int $resends
* @property int $storage_quota_mb
* @property int|null $group_id
* @property int|null $invited_by_id
* @property Carbon $expires_at
*/
class Invitation extends Model
{
public const STATUS_PENDING = 'pending';
public const STATUS_REDEEMED = 'redeemed';
// Retired by a fresh invitation to the same address, issued either
// because staff sent another one or because the invited person asked
// for a new link — see issue(). Never redeemable, but kept rather than
// deleted so the activity log's trail of who invited this address,
// and when, stays intact.
public const STATUS_SUPERSEDED = 'superseded';
// Cancelled by staff before anybody used it — the wrong address, or a
// decision taken back. Distinct from superseded because it is the only
// one of the two that was somebody's intention: a revoked invitation is
// never re-issued, where a superseded one was retired precisely so a
// fresh link could take its place. Both are outside pending(), so the
// redemption and resend doors refuse either without asking which.
public const STATUS_REVOKED = 'revoked';
protected $guarded = [];
protected function casts(): array
{
return [
'expires_at' => 'datetime',
// Read straight into provision()'s `int $storageQuotaMb` when
// the invitation is redeemed -- see the same cast on User.
'storage_quota_mb' => 'integer',
];
}
/**
* A fresh invitation for $email, retiring any other still-pending one
* for the same address first — one live token per address at a time,
* whether this is staff sending a second invite or the invited person
* asking for a new link after the first expired.
*
* @param int $resends How many self-resends this link already stands
* on. Staff leave it at zero; the resend door
* passes the previous invitation's count plus
* one, which is what makes the limit apply to
* the chain rather than to a single row.
*/
public static function issue(string $email, ?string $name, ?Group $group, ?User $invitedBy, Carbon $expiresAt, int $storageQuotaMb = 0, int $resends = 0): self
{
self::query()->pending()->where('email', $email)->update(['status' => self::STATUS_SUPERSEDED]);
return self::query()->create([
'name' => $name,
'email' => $email,
'token' => Str::random(40),
'status' => self::STATUS_PENDING,
// Zero from staff, and deliberately: sending an invitation is
// somebody deciding to, which starts the allowance again. Only
// a self-resend carries the previous count forward.
'resends' => $resends,
'storage_quota_mb' => $storageQuotaMb,
'group_id' => $group?->id,
'invited_by_id' => $invitedBy?->id,
'expires_at' => $expiresAt,
]);
}
public function isExpired(): bool
{
return $this->expires_at->isPast();
}
/**
* What a person reading a list of invitations should be told this one
* is — which is not quite `status`.
*
* "Expired" is not a stored status and deliberately is not one: nothing
* writes it, a row becomes expired by the clock passing rather than by
* anybody acting, and a stored value would need a scheduled task to
* stay true. But it is the distinction somebody scanning the list cares
* about most, so it is derived here, once, rather than in the screen
* and again in the filter — the two would eventually disagree about the
* edge.
*
* @return 'pending'|'expired'|'redeemed'|'revoked'|'superseded'
*/
public function state(): string
{
return match ($this->status) {
self::STATUS_PENDING => $this->isExpired() ? 'expired' : 'pending',
self::STATUS_REDEEMED => 'redeemed',
self::STATUS_REVOKED => 'revoked',
default => 'superseded',
};
}
/**
* @param Builder<Invitation> $query
* @return Builder<Invitation>
*/
public function scopePending(Builder $query): Builder
{
return $query->where('status', self::STATUS_PENDING);
}
/**
* @return BelongsTo<Group, $this>
*/
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
/**
* @return BelongsTo<User, $this>
*/
public function invitedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'invited_by_id');
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients\Notifications;
use App\Modules\Platform\Notifications\Concerns\RendersOverridableMail;
use App\Modules\Platform\Notifications\EmailTemplateSlot;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
/**
* Sent on-demand (Notification::route('mail', ...)), never via
* $client->notify() — there is no account yet to notify, only an address
* somebody typed into the invite form.
*/
class ClientInvitationNotification extends Notification implements ShouldQueue
{
use Queueable, RendersOverridableMail;
public function __construct(
private readonly string $name,
private readonly string $token,
) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$url = route('invitations.show', $this->token);
if (($override = $this->overrideOrNull(EmailTemplateSlot::ClientInvited)) !== null) {
return $this->mailFromOverride($override, [':name' => $this->name])->action(__('Register'), $url);
}
return (new MailMessage)
->subject(__("You've been invited to register"))
->greeting(__('Hello :name,', ['name' => $this->name]))
->line(__("You've been invited to register a client account. The link below will let you set your own password."))
->action(__('Register'), $url);
}
}
@@ -10,6 +10,7 @@ use App\Modules\Comments\GuestCommentIdentity;
use App\Modules\Comments\Models\FileComment;
use App\Modules\Files\Access\ShareTargets;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Access\ViewableFileScope;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Modules\Identity\UserType;
@@ -51,6 +52,7 @@ class VisibleCommentScope
{
public function __construct(
private readonly StaffLibraryScope $scope,
private readonly ViewableFileScope $viewable,
private readonly ShareTargets $shareTargets,
private readonly GuestCommentIdentity $guests,
) {}
@@ -123,6 +125,13 @@ class VisibleCommentScope
* way around the visibility model** — moderating means deciding about
* comments you can already see.
*
* Which is why the files come from ViewableFileScope rather than from
* StaffLibraryScope: FilePolicy::view() is a permission half AND a
* library half, and narrowing by the library alone would hand every
* comment in the installation to a role holding moderate_comments and
* none of the three file keys — somebody who gets a 403 on every file
* these comments are about.
*
* Staff only. A client has no cross-file view of comments and asking
* for one is a mistake rather than an empty result, but returning
* nothing is the safe way to be wrong.
@@ -136,7 +145,7 @@ class VisibleCommentScope
}
return $this->applyVisibility(
FileComment::query()->whereIn('file_id', $this->scope->files($viewer)->select('files.id')),
FileComment::query()->whereIn('file_id', $this->viewable->for($viewer)->select('files.id')),
$viewer,
// Publicness is a property of each file, so it cannot be one
// value for a query spanning many. It does not have to be: the
@@ -156,6 +165,12 @@ class VisibleCommentScope
* than about what this viewer may read, and a moderator who cannot see
* a particular client's thread must still be told the file has
* something waiting.
*
* The file boundary is still the same one, though. ViewableFileScope
* rather than StaffLibraryScope: which files is the part that varies
* per client, whether any is the part that does not, and a badge
* counting the whole installation for somebody who may open none of it
* is a number about other people's files.
*/
public function pendingTotal(User $viewer): int
{
@@ -165,7 +180,7 @@ class VisibleCommentScope
return FileComment::query()
->whereNull('approved_at')
->whereIn('file_id', $this->scope->files($viewer)->select('files.id'))
->whereIn('file_id', $this->viewable->for($viewer)->select('files.id'))
->count();
}
+9 -4
View File
@@ -141,14 +141,19 @@ class CommentPresenter
];
}
/**
* Asked of the column, not of the relation — the same rule
* isFromGuest() and authorName() follow. Since author() reads a
* deleted account too this would now answer correctly either way; it
* is written this way so the next reader does not re-derive "no
* author row means guest", which is what it used to mean here.
*/
private function authorType(FileComment $comment): string
{
$author = $comment->author;
if ($author === null) {
if ($comment->isFromGuest()) {
return 'guest';
}
return $author->isStaff() ? 'staff' : 'client';
return $comment->author?->isStaff() === true ? 'staff' : 'client';
}
}
@@ -8,6 +8,7 @@ use App\Models\User;
use App\Modules\Comments\Access\VisibleCommentScope;
use App\Modules\Comments\Models\FileComment;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Access\ViewableFileScope;
use Illuminate\Support\Facades\Gate;
/**
@@ -22,6 +23,7 @@ class FileCommentPolicy
private readonly VisibleCommentScope $scope,
private readonly CommentingRules $rules,
private readonly StaffLibraryScope $library,
private readonly ViewableFileScope $viewable,
) {}
public function view(User $user, FileComment $comment): bool
@@ -69,6 +71,15 @@ class FileCommentPolicy
return false;
}
// Moderating is deciding about comments you can already see, so the
// permission half of file reading is part of the answer in both
// forms. Without one of the three file keys this user gets a 403 on
// every file these comments are about, and approving one hands back
// its body — so this is a reading door, not only a writing one.
if (! $this->viewable->permitsAnyFile($user)) {
return false;
}
if ($comment === null || ! $user->isClientScoped()) {
return true;
}
@@ -8,7 +8,7 @@ use App\Http\Controllers\Controller;
use App\Modules\Comments\FileComments;
use App\Modules\Comments\Http\Resources\Api\FileCommentResource;
use App\Modules\Comments\Models\FileComment;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Access\ViewableFileScope;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Gate;
@@ -30,16 +30,17 @@ class CommentModerationController extends Controller
{
public function __construct(
private readonly FileComments $comments,
private readonly StaffLibraryScope $library,
private readonly ViewableFileScope $viewable,
) {}
/**
* List comments awaiting approval.
*
* Scoped by the same library boundary as everything else: a
* client-scoped token sees pending comments only on files its owner
* could already open. Oldest first, so working through the list means
* working through the backlog.
* Scoped by the same file boundary as everything else — the whole of
* it, not just its library half: a client-scoped token sees pending
* comments only on files its owner could already open, and a token
* whose owner holds no file key at all sees none. Oldest first, so
* working through the list means working through the backlog.
*/
public function index(Request $request): AnonymousResourceCollection
{
@@ -49,7 +50,7 @@ class CommentModerationController extends Controller
$pending = FileComment::query()
->whereNull('approved_at')
->whereIn('file_id', $this->library->files($viewer)->select('id'))
->whereIn('file_id', $this->viewable->for($viewer)->select('id'))
->with(['author', 'clientContext'])
->orderBy('created_at')
->orderBy('id')
@@ -147,15 +147,17 @@ class CommentsController extends Controller
];
}
/**
* See CommentPresenter::authorType(): asked of the column, because
* that is what decides whether a comment is a guest's.
*/
private function authorType(FileComment $comment): string
{
$author = $comment->author;
if ($author === null) {
if ($comment->isFromGuest()) {
return 'guest';
}
return $author->isStaff() ? 'staff' : 'client';
return $comment->author?->isStaff() === true ? 'staff' : 'client';
}
/**
@@ -61,7 +61,9 @@ class FileCommentsController extends Controller
$viewer,
CommentVisibility::from($validated['visibility']),
$validated['body'],
$this->replyTarget($viewer, $file, $validated['reply_to'] ?? null),
// Cast for the reason ShareLinksController gives: `integer`
// does not convert, and replyTarget() takes a strict ?int.
$this->replyTarget($viewer, $file, isset($validated['reply_to']) ? (int) $validated['reply_to'] : null),
);
return response()->json($this->payload($viewer, $file), 201);
@@ -125,7 +125,11 @@ class PublicFileCommentsController extends Controller
private function guard(string $publicSlug, File $file): void
{
abort_unless($this->settings->get(Setting::PublicListingSlug) === $publicSlug, 404);
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 404);
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired() && ! $file->isWithdrawn(), 404);
// Same answer as the file's own public page, which 404s a file
// that is not available: otherwise a pending or quarantined file
// could be discussed, and found to exist, by anybody.
abort_unless($file->scan_status->isAvailable(), 404);
abort_unless($this->rules->enabled(), 404);
}
}
+20 -1
View File
@@ -76,11 +76,30 @@ class FileComment extends Model
}
/**
* The account that wrote this comment, deleted or not.
*
* `author_id` is cascadeOnDelete and the cascade never fires, because
* a user is soft-deleted: the row behind a deleted commenter is still
* there and the column still points at it. Handing back null for one
* left every caller to invent a meaning for the absence, and they
* invented different ones — the author type became "guest" on two
* screens and "client" in the API, while the name beside it stayed
* correct, and the author filter and the name search stopped matching
* the comment at all.
*
* Whether a comment is from a guest is decided by `author_id` alone.
* isFromGuest() and authorName() already say so; this makes the
* relation agree with them.
*
* Nothing that decides who may *read* a comment goes through here —
* VisibleCommentScope and FileCommentPolicy both compare `author_id`
* directly — so this widens no visibility.
*
* @return BelongsTo<User, $this>
*/
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'author_id');
return $this->belongsTo(User::class, 'author_id')->withTrashed();
}
/**
@@ -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);
}
}
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Access;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Files\Models\File;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
/**
* How often a client's own file has been taken, and when it last was.
*
* The question somebody asks about a file they sent: did it arrive? On a
* hosted free account the link is the whole of the sharing, so "3
* downloads, last one on Tuesday" is the only evidence there is that it
* worked.
*
* **Own files only, and that is a privacy rule rather than a scoping
* convenience.** A download entry says somebody fetched the file, and on
* a file shared with several clients, telling one of them the count tells
* them about the others' activity. Nobody is entitled to that except the
* person who put the file there. So a file shared *with* this client
* carries no numbers at all — not zero, which would be a claim, but
* nothing.
*
* Counts come from the activity log through File::downloads(), the same
* source every other download count in the interface uses. There is
* deliberately no counter column — see DownloadAllowance for the whole
* argument, which applies unchanged here.
*
* One query for a page, whatever it holds.
*/
class OwnFileDownloads
{
/**
* @param Collection<int, File> $files
* @return array<int, array{count: int, last_at: string|null}> keyed by
* file id, only for files this client uploaded
*/
public function forMany(Collection $files, User $client): array
{
$own = $files
->filter(fn (File $file): bool => $file->uploaded_by === $client->id)
->pluck('id')
->map(fn ($id): int => (int) $id)
->all();
if ($own === []) {
return [];
}
// Every own file gets an entry, including the ones with nothing to
// report: the difference between "nobody has downloaded this" and
// "this is not yours to know" is exactly what the caller renders,
// and a missing key would collapse the two.
$stats = [];
foreach ($own as $id) {
$stats[$id] = ['count' => 0, 'last_at' => null];
}
$rows = ActivityLog::query()
->selectRaw('subject_id, count(*) as downloads, max(created_at) as last_at')
->where('subject_type', (new File)->getMorphClass())
->whereIn('subject_id', $own)
->whereIn('action', [
Action::FileDownloaded->value,
Action::ShareLinkDownloaded->value,
Action::PublicFileDownloaded->value,
])
->groupBy('subject_id')
->get();
foreach ($rows as $row) {
$id = (int) $row->getAttribute('subject_id');
$lastAt = $row->getAttribute('last_at');
$stats[$id] = [
'count' => (int) $row->getAttribute('downloads'),
'last_at' => $lastAt === null ? null : Carbon::parse((string) $lastAt)->toIso8601String(),
];
}
return $stats;
}
}
+30 -2
View File
@@ -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'],
+147 -9
View File
@@ -159,15 +159,52 @@ class StaffLibraryScope
return null;
}
$clientIds = $this->assignableClientIds($user) ?? [];
return array_values($this->groups($user)->pluck('id')->map(fn ($id): int => (int) $id)->all());
}
if ($clientIds === []) {
return [];
/**
* Every group this staff member may be told about, as a query.
*
* The listing half of assignableGroupIds(), and the same rule: a
* group counts as theirs because one of their clients is in it. The
* two were not the same code, and the listing simply had none — so
* `/groups` and `/api/v1/groups` showed a scoped staff member every
* group on the installation, name, description and member count,
* including groups whose every member was somebody else's client
* (GHSA-r3hg-3fxw-rcmr).
*
* Deliberately the *sharing* rule rather than the change rule below.
* A scoped staff member may already share a file with a mixed group,
* so its existence is not news to them; what they may not do is
* rename, publish or delete it.
*
* @return Builder<Group>
*/
public function groups(User $user): Builder
{
$query = Group::query();
$clientIds = $this->assignableClientIds($user);
if ($clientIds === null) {
return $query;
}
return array_values(Group::query()
->whereHas('members', fn (Builder $members) => $members->whereIn('users.id', $clientIds))
->pluck('id')->map(fn ($id): int => (int) $id)->all());
return $query->whereHas('members', fn (Builder $members) => $members->whereIn('users.id', $clientIds));
}
/**
* Whose uploads a staff member may be told about when the file is in
* nobody's library — a quarantined upload, which no client can see,
* so files() never reaches it. Their own and their assigned clients',
* or null when unrestricted.
*
* @return list<int>|null
*/
public function uploaderIds(User $user): ?array
{
$clientIds = $this->assignableClientIds($user);
return $clientIds === null ? null : [$user->id, ...$clientIds];
}
public function canAssignClient(User $user, User $client): bool
@@ -249,7 +286,53 @@ class StaffLibraryScope
*/
public function allowsGroupChange(User $user, Group $group): bool
{
return $this->groupReachesNoFurther($user, $group);
return $this->groupIsNotWhollySomebodyElses($user, $group)
&& $this->groupReachesNoFurther($user, $group);
}
/**
* Whether this group is somebody else's entirely — every member
* outside the staff member's roster, and none of theirs in it.
*
* The half allowsGroupChange() was missing. Reach answers "what would
* this group hand somebody", which is the right question for putting a
* client *into* it; it says nothing about who is already there. So a
* group with nothing shared with it yet passed the reach check
* vacuously, and a scoped staff member could rename it, delete it, or
* publish it — a group made entirely of clients they had never been
* assigned (GHSA-r3hg-3fxw-rcmr).
*
* **Not "every member is mine", which is the obvious reading and is
* wrong.** A mixed group has to stay changeable: GHSA-whmp-p9hv-r7j7
* settled that a scoped staff member opens such a group's edit screen
* and is shown only their own clients in it, rather than being refused
* the screen. Requiring every member to be theirs turns that narrowing
* back into a 404 and undoes the earlier fix. What is left over — a
* mixed group whose shared content reaches past their library — is
* refused by groupReachesNoFurther() beside this, which is the check
* that has always covered it.
*
* **And deliberately not folded into groupReachesNoFurther() either.**
* That predicate is shared with allowsGroupMembership(), where a group
* nobody has joined must stay usable so its creator can put the first
* member in — the case that method's own docblock calls out.
*
* An empty group is nobody else's, so whoever just made it can still
* name it.
*/
private function groupIsNotWhollySomebodyElses(User $user, Group $group): bool
{
$clientIds = $this->assignableClientIds($user);
if ($clientIds === null) {
return true;
}
if (! $group->members()->exists()) {
return true;
}
return $group->members()->whereIn('users.id', $clientIds)->exists();
}
/**
@@ -268,6 +351,15 @@ class StaffLibraryScope
* row rather than from the assignment ignores the dead ones by
* construction, which is also the right answer: a deleted file is
* not reach, because nobody can reach it.
*
* An expired file is the same answer for the same reason. Membership
* in this group grants nobody access to it — File::scopeVisibleToClient
* ends in notExpired(), so it is gone from every member's /my-files and
* the download is refused — while its absence from files() otherwise
* reads as "outside my library" and locks the group exactly as a
* deleted file used to. Expiry is reversible where deletion is not, so
* the file counts as reach again the moment it does: this asks what is
* reachable now, at the moment somebody is added or removed.
*/
private function groupReachesNoFurther(User $user, Group $group): bool
{
@@ -282,6 +374,7 @@ class StaffLibraryScope
$outside = File::query()
->whereIn('id', $assignedFiles)
->notExpired()
->whereNotIn('id', $this->files($user)->select('id'))
->exists();
@@ -292,9 +385,54 @@ class StaffLibraryScope
$assignedFolders = FolderAssignment::query()->select('folder_id')
->where('assignable_type', $morph)->where('assignable_id', $group->id);
return ! Folder::query()
->whereIn('id', $assignedFolders)
// The whole subtree, not the folder the assignment names. A folder
// shared with a group hands its members everything inside it —
// File::scopeVisibleToClient matches on folder placement, and a
// folder is visible to a client when it or an ancestor is shared
// with them — so "is anything shared with this group outside my
// library" has to ask about the contents, which is what the
// docblock above already claims ("the folders whose subtrees it
// can browse").
//
// Measured: a scoped staff member's own folder, with a subfolder
// somebody else created inside it and somebody else's file in
// that. The folder is theirs, its contents are not, and adding
// their own client to a group holding the parent handed that
// client the file — which then enters the staff member's own
// library too, because files() is "everything my clients can
// see". That is the widening this guard exists to refuse, and the
// test above it says so in as many words.
$reachable = Folder::query()->whereIn('id', $assignedFolders)->get()
->flatMap(fn (Folder $folder): array => $folder->subtreeFolderIds())
->unique()
->values()
->all();
if ($reachable === []) {
return true;
}
if (Folder::query()
->whereIn('id', $reachable)
->whereNotIn('id', $this->folders($user)->select('id'))
->exists()
) {
return false;
}
// And the files sitting in them. A folder can be inside the
// library while a file in it is not: files() is own uploads plus
// what an assigned client may see, and neither covers somebody
// else's upload into a folder this staff member happens to own.
//
// notExpired() for the same reason the assignment half above skips
// deleted files: membership in this group grants nobody access to
// an expired file, because scopeVisibleToClient ends by excluding
// them, and something nobody can reach is not reach.
return ! File::query()
->whereIn('folder_id', $reachable)
->notExpired()
->whereNotIn('id', $this->files($user)->select('id'))
->exists();
}
}
+16 -6
View File
@@ -40,15 +40,25 @@ class ViewableFileScope
return File::query()->visibleToClient($user);
}
// Mirrors FilePolicy::view()'s staff branch: the permission half is
// a property of the viewer, not the row, so it either opens the
// whole scope or closes it entirely.
$permitted = $user->can('upload') || $user->can('edit_files') || $user->can('edit_others_files');
if (! $permitted) {
if (! $this->permitsAnyFile($user)) {
return File::query()->whereRaw('1 = 0');
}
return $this->scope->files($user);
}
/**
* Whether a staff member holds any of the three keys that open file
* reading at all — the permission half of FilePolicy::view()'s staff
* branch, named once because more than one module has to ask it.
*
* It is a property of the viewer rather than of a row, so it either
* opens the whole scope or closes it entirely. That is also why a
* query narrowed by StaffLibraryScope alone is only half the check:
* the library says *which* files, this says *whether any*.
*/
public function permitsAnyFile(User $user): bool
{
return $user->can('upload') || $user->can('edit_files') || $user->can('edit_others_files');
}
}
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Console;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Jobs\ScanFileJob;
use App\Modules\Files\MissingFileScanner;
use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\NotScannedReason;
use App\Modules\Files\Scanning\ScanningConfig;
use App\Modules\Files\Scanning\ScanStatus;
use Illuminate\Console\Command;
/**
* Checks daily that the files this installation lists are actually there.
*
* Its own command rather than part of scanning, because a missing file is
* not a virus question and an installation with no scanner has exactly
* the same problem. It was only ever noticed when something tried to read
* the bytes — a download, or a scan — which means the first person to
* find out was a client clicking a link.
*
* Recovery is part of the job: storage comes back, and a library that
* kept insisting every file was gone would be its own bug.
*/
class CheckMissingFilesCommand extends Command
{
protected $signature = 'projectsend:check-missing-files';
protected $description = 'Check that every file in the database is still on disk (runs daily)';
public function handle(MissingFileScanner $scanner, ActivityLogger $activity, ScanningConfig $scanning): int
{
$gone = $scanner->scan();
$newlyGone = 0;
foreach (array_chunk($gone, 200) as $chunk) {
// Not a quarantined file. It is unavailable already, and
// marking it missing would wipe the threat name and then, when
// the bytes came back, send it round as a fresh upload — out
// of quarantine with nobody having released it.
$candidates = File::query()
->whereIn('id', $chunk)
->whereNotIn('scan_status', [
ScanStatus::Missing->value,
ScanStatus::Infected->value,
ScanStatus::UnscannableBlocked->value,
])
->get();
foreach ($candidates as $file) {
// Stamped like any other verdict: this is the moment the
// file was last looked at, and without it a missing file
// never appears in the Activity list — which is exactly
// where somebody watching would look for it.
$file->forceFill([
'scan_status' => ScanStatus::Missing,
'scan_note' => null,
'scanned_at' => now(),
])->save();
$activity->logSystem(Action::FileMissing, ['id' => $file->id, 'name' => $file->name]);
$newlyGone++;
}
}
$back = $scanner->recovered();
foreach (array_chunk($back, 200) as $chunk) {
// Back to the start rather than to whatever it was before:
// nothing here knows what the scanner had decided about bytes
// that have since been away, and re-checking them is cheap
// next to trusting a verdict about a file that left.
File::query()->whereIn('id', $chunk)->update($scanning->enabled()
? ['scan_status' => ScanStatus::Pending->value, 'scan_note' => null]
: ['scan_status' => ScanStatus::NotScanned->value, 'scan_note' => NotScannedReason::BeforeScanning->value]);
// Straight to the scanner rather than left for the hourly
// sweep, which kept a file that had come back unavailable for
// up to an hour for no reason.
if ($scanning->enabled()) {
foreach ($chunk as $id) {
ScanFileJob::dispatch($id);
}
}
}
$this->info(sprintf(
'%d file(s) are missing from storage (%d newly), %d came back.',
count($gone),
$newlyGone,
count($back),
));
return self::SUCCESS;
}
}
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Console;
use App\Modules\Files\Jobs\ScanFileJob;
use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\NotScannedReason;
use App\Modules\Files\Scanning\ScanningConfig;
use App\Modules\Files\Scanning\ScanStatus;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
/**
* Sends files back to the scanner: the ones still waiting, the ones that
* went through unscanned because it was down, and — when asked — the
* library that was already here before any of this existed.
*
* Hourly rather than daily. A file stuck pending is a file nobody can
* download, and an installation set to hold has no other way forward
* once its worker restarted and the job with it.
*/
class ScanFilesCommand extends Command
{
protected $signature = 'projectsend:scan-files
{--existing : also work through files that were never scanned because scanning was off}
{--all : check every file again, whatever it said last}';
protected $description = 'Scan files that are waiting, were missed, or were never checked (runs hourly)';
public function handle(ScanningConfig $config): int
{
if (! $config->enabled()) {
$this->info('Virus scanning is switched off.');
return self::SUCCESS;
}
$waiting = $this->dispatchFor(File::query()->where('scan_status', ScanStatus::Pending));
// Allowed through while the scanner was unreachable. Now that it
// may be back, they are asked again — a file found infected at
// this point is quarantined like any other, and its quarantine
// notice says it was available in the meantime.
$missed = $this->dispatchFor(
File::query()
->where('scan_status', ScanStatus::NotScanned)
->where('scan_note', NotScannedReason::ScannerUnavailable->value),
rescan: true,
);
$this->info("Re-queued {$waiting} waiting file(s) and {$missed} that were missed while the scanner was down.");
if ($this->option('all')) {
// Every file somebody can have today. Not a file waiting for
// its first verdict, not one with no bytes, and not one in or
// released from quarantine — a scan is not how a file leaves
// quarantine, and a release is not undone by one. Files keep
// their current state, and stay downloadable, until a new
// verdict arrives.
$limit = $config->existingScanRatePerMinute() * 60;
$checked = $this->dispatchFor(
File::query()->whereIn('scan_status', ScanFileJob::rescannableValues()),
$limit,
rescan: true,
);
$this->info("Queued {$checked} file(s) to be checked again.");
return self::SUCCESS;
}
if ($this->option('existing')) {
// Paced, because this can be a whole library at once and the
// scanner is also serving today's uploads. An hour's worth per
// run, since that is how often this command runs.
$limit = $config->existingScanRatePerMinute() * 60;
$old = $this->dispatchFor(File::query()->neverScanned(), $limit, rescan: true);
$this->info("Queued {$old} file(s) that had never been scanned.");
}
return self::SUCCESS;
}
/**
* Nothing here changes a file's state before the scanner has spoken.
*
* An earlier version marked each file pending first, which reads as
* tidy and is wrong twice over: pending means "withheld", so a
* backfill would have hidden an entire library from its clients for
* as long as it ran, and every file would then have been announced to
* its recipients a second time when it came back. The job knows which
* state it expects instead — see its $rescan.
*
* @param Builder<File> $query
*/
private function dispatchFor(Builder $query, ?int $limit = null, bool $rescan = false): int
{
if ($limit !== null) {
$query->limit($limit);
}
$ids = $query->orderBy('id')->pluck('id');
foreach ($ids as $id) {
ScanFileJob::dispatch((int) $id, $rescan);
}
return $ids->count();
}
}
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Delivery;
/**
* How a file's bytes get from this server's disk to the visitor.
*
* Uploads live outside the web root, so every download passes through a
* permission check in PHP first. What differs is what happens after that
* check passes: PHP can read the file and write it out itself, or it can
* answer with an empty body and a header telling the web server to send
* the file instead.
*
* The header is the fast path and it is not portable — each server reads
* a different one, and a server reading none of them serves the empty
* body, which is how an installation ends up handing out 0-byte
* downloads while every other page works. ProjectSend v1 had this as a
* four-way setting with PHP as the default; v2 hard-coded nginx's
* spelling for its first releases, which is
* https://github.com/projectsend/projectsend/issues/1765.
*/
enum DeliveryMethod: string
{
/**
* nginx: `X-Accel-Redirect`, carrying a *URL path* that the
* `location /protected-files/` block maps back onto the storage
* directory. That block is marked `internal`, which is what stops a
* visitor requesting the path directly.
*/
case Nginx = 'nginx';
/**
* Apache with `mod_xsendfile`, and LiteSpeed, which reads the same
* header: `X-Sendfile`, carrying an *absolute filesystem path*.
*
* Never chosen automatically. The module also needs `XSendFilePath`
* to whitelist the storage directory, and there is no way to detect
* that from here — picking this on the strength of the module being
* loaded would trade one silent failure for another.
*/
case XSendFile = 'xsendfile';
/**
* PHP reads the file and streams it.
*
* Works on every server, and costs a worker process for the duration
* of each download — a handful of large concurrent downloads can
* occupy every worker while the CPU sits idle. That is why it is the
* fallback rather than the default, and why an installation using it
* says so on the dashboard rather than being quietly slow.
*/
case Php = 'php';
}
+273
View File
@@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Delivery;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* Puts a file that lives on this server's local disk on the wire.
*
* The single place that knows how the bytes travel. Four routes used to
* decide that for themselves and all four hard-coded nginx's header, so
* an Apache or LiteSpeed installation served four different flavours of
* empty response — uploads worked, thumbnails were broken images, and
* downloads arrived as 0 bytes. Callers now say *what* to send and this
* decides *how*.
*
* It authorizes nothing. Every caller has already done that its own way
* — a policy, a share token, a public-listing check — and the path it
* passes is always derived from a row it just authorized, never from the
* request. That is load-bearing: `serve()` will send any file under the
* storage root, so a caller that passed user input would have built a
* file-disclosure bug. The root check below is the backstop, not the
* rule.
*
* ### Choosing the method
*
* `PROJECTSEND_FILE_DELIVERY` picks one explicitly. Left at `auto` — the
* default — nginx gets its own fast path and everything else gets PHP
* streaming.
*
* Auto deliberately never chooses `xsendfile`. Apache's `mod_xsendfile`
* needs `XSendFilePath` to whitelist the storage directory as well as
* being loaded, and nothing here can see whether it does; choosing it
* because the module is present would swap a silent failure anybody can
* diagnose from the dashboard for one nobody can. So it stays something
* an operator turns on having configured it.
*
* A value that is not a method falls back to auto rather than throwing.
* A typo in an environment variable should cost speed, not every
* download on the installation.
*/
class FileDelivery
{
/**
* The disk uploads live on. Named rather than injected because the
* whole class is about the local-disk case: a file on S3 never
* reaches here, it is a signed redirect from StoredFileResponse.
*/
private const DISK = 'files';
/** The internal nginx location that maps back onto the storage root. */
private const NGINX_LOCATION = '/protected-files/';
public function __construct(private readonly Request $request) {}
/**
* The method in force, and whether it was detected or stated.
*
* @return array{method: DeliveryMethod, detected: bool, observed: bool}
*/
public function resolve(): array
{
$configured = config('projectsend.file_delivery');
$explicit = is_string($configured) ? DeliveryMethod::tryFrom($configured) : null;
if ($explicit !== null) {
return ['method' => $explicit, 'detected' => false, 'observed' => true];
}
// Whether there was anything to detect *from*. detect() reads
// SERVER_SOFTWARE, which only exists inside a request — so a
// console process has nothing to look at and falls to the `php`
// default. That default is right for the console (no web server is
// handling this, so nothing could hand a file off), and wrong as a
// statement about the installation, which is how somebody reading
// it from `artisan tinker` will take it.
//
// Reported rather than papered over: a reader who runs
// `describe()` from a shell on a perfectly good nginx box was
// being told `php`, with `detected: true` vouching for it. That
// cost somebody an afternoon before it was recognised as an
// artefact of asking outside a request.
$observed = is_string($this->request->server('SERVER_SOFTWARE'));
return ['method' => $this->detect(), 'detected' => true, 'observed' => $observed];
}
public function method(): DeliveryMethod
{
return $this->resolve()['method'];
}
/**
* The same answer as a plain array, for a screen or a probe.
*
* Spelled out rather than leaning on a backed enum encoding itself,
* because this shape is read by the dashboard and by whatever watches
* the installation from outside, and neither should change meaning if
* the enum ever grows a JsonSerializable of its own.
*
* `observed` is false only outside an HTTP request, where nothing can
* be detected and `method` is a default rather than a finding. Both
* screens that read this run in a request, so they always see true;
* it exists for whoever asks from a console.
*
* @return array{method: string, detected: bool, observed: bool}
*/
public function describe(): array
{
$resolved = $this->resolve();
return [
'method' => $resolved['method']->value,
// True when nobody said which to use. The distinction matters
// to the reader: a detected `php` is an installation that
// could be faster, a stated one is somebody's decision.
'detected' => $resolved['detected'],
// And whether the detection had anything to work with.
'observed' => $resolved['observed'],
];
}
/**
* What the server says it is.
*
* `SERVER_SOFTWARE` is set by the web server itself through the
* FastCGI parameters, so it describes the process actually holding
* the connection to PHP. That is the right thing to ask: the header
* has to be understood by *that* server, not by whatever sits in
* front of it.
*
* The known-wrong case is nginx reverse-proxying Apache, which
* INSTALL.md offers as a way to keep an existing Apache. This reads
* Apache and picks PHP streaming, so downloads work and are slower
* than they need to be — the safe direction, and the reason the
* override exists.
*/
private function detect(): DeliveryMethod
{
$software = $this->request->server('SERVER_SOFTWARE');
$software = strtolower(is_string($software) ? $software : '');
return str_contains($software, 'nginx') ? DeliveryMethod::Nginx : DeliveryMethod::Php;
}
/**
* @param string $path disk-relative, and always derived from an
* already-authorized row — never from the request
* @param int|null $length when the caller already knows it; PHP
* streaming ignores it and measures the file
*/
public function serve(string $path, string $mimeType, string $disposition, ?int $length = null): Response|BinaryFileResponse
{
$this->assertRelative($path);
$headers = array_filter([
'Content-Type' => $mimeType,
'Content-Disposition' => $disposition,
'Content-Length' => $length === null ? null : (string) $length,
], static fn (?string $value): bool => $value !== null);
return match ($this->method()) {
DeliveryMethod::Nginx => response('', 200, [
'X-Accel-Redirect' => self::NGINX_LOCATION.$path,
...$headers,
]),
DeliveryMethod::XSendFile => response('', 200, [
// An absolute filesystem path, unlike nginx's URL path.
// Renaming the header without changing the value is the
// obvious way to "add Apache support" and produces a
// second broken install.
'X-Sendfile' => $this->absolutePathWithin($path),
...$headers,
]),
DeliveryMethod::Php => $this->stream($this->absolutePathWithin($path), $headers),
};
}
/**
* @param array<string, string> $headers
*/
private function stream(string $absolute, array $headers): BinaryFileResponse
{
// A large download can outlive max_execution_time, and the visitor
// sees a truncated file rather than an error. The web server is
// not holding this one open for us.
if (function_exists('set_time_limit')) {
@set_time_limit(0);
}
// BinaryFileResponse rather than a hand-written readfile loop: it
// answers Range requests, which is what makes seeking through a
// long video work. nginx does that for itself on the fast path, so
// rolling our own here would break preview scrubbing on exactly
// the installations this fallback exists for.
//
// Content-Length is deliberately dropped from the headers: the
// response sets its own from the file, and a caller's figure that
// disagrees — a stale `files.size`, or a range being served —
// truncates the download.
unset($headers['Content-Length']);
return new BinaryFileResponse($absolute, 200, $headers);
}
/**
* The path must stay a path *inside* the storage area.
*
* Checked for every method, and without touching the filesystem,
* because nginx resolves `..` in the URL it is handed just as
* happily as a filesystem call would -- and because every method
* puts this value into a response header. Callers pass paths from rows
* they authorized rather than from the request, so this is a
* backstop; it is here because the cost of being wrong about that,
* once, is handing over any file the web server can read.
*/
private function assertRelative(string $path): void
{
abort_if(
$path === ''
|| str_starts_with($path, '/')
|| preg_match('#(^|/)\.\.(/|$)#', $path) === 1
// A control character in the path is header injection, not
// traversal: this value is written into X-Accel-Redirect or
// X-Sendfile, and a CR or LF in a header value splits the
// response. PHP's header() refuses to emit one, so the real
// effect is a 500 on every download, preview and thumbnail
// of that file rather than a split -- a file permanently
// broken by its own name.
//
// Paths are `Y/m/{uuid}.{ext}` and generated here, so this
// should be unreachable. It is checked because the
// extension is not: it is taken from the uploader's
// filename, and on a migrated installation from a v1
// database, which is somebody else's data.
|| preg_match('/[\x00-\x1F\x7F]/', $path) === 1,
404,
);
}
/**
* The absolute path, proven to resolve inside the storage root.
*
* Only the two methods that hand over a *filesystem* path need this,
* and only they can afford it: it resolves symlinks, so it answers
* the question `assertRelative()` cannot — whether the file is really
* where the path says it is.
*
* It also requires the file to exist, which is why nginx does not go
* through it. On that path PHP never opens the file, and adding a
* stat to every download to discover something nginx is about to
* discover anyway would be a cost with no answer attached.
*/
private function absolutePathWithin(string $path): string
{
$disk = Storage::disk(self::DISK);
$absolute = realpath($disk->path($path));
$root = realpath($disk->path(''));
abort_if(
$absolute === false || $root === false || ! str_starts_with($absolute, rtrim($root, '/').'/'),
404,
);
return $absolute;
}
}
@@ -7,8 +7,8 @@ namespace App\Modules\Files\Delivery;
use App\Modules\Files\Models\File;
use App\Support\ContentDisposition;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\Response;
/**
* A stored file's own bytes, put on the wire for whichever disk it lives
@@ -20,15 +20,33 @@ use Illuminate\Support\Facades\Storage;
* asking. The one thing it knows is the thing each caller kept getting
* wrong on its own: that `$file->disk` decides how the bytes travel.
*
* Local disk: X-Accel-Redirect, so nginx streams the file and PHP never
* touches the bytes. Anything else — S3, GCS and friends — gets a
* short-lived presigned URL carrying the disposition, which an object
* store ranges just as well.
* Local disk: handed to FileDelivery, which decides whether the web
* server sends the bytes or PHP does. Anything else — S3, GCS and
* friends — gets a short-lived presigned URL carrying the disposition,
* which an object store ranges just as well.
*
* That distinction matters most for inline(): a <video> seeking through
* an hour of footage issues a long tail of Range requests, and nginx's
* static handler answers those with 206s on its own, dropping the
* Content-Length below in favour of the range it actually served.
* an hour of footage issues a long tail of Range requests. Every local
* delivery method answers those — nginx's static handler on the fast
* path, BinaryFileResponse when PHP is streaming — each dropping the
* Content-Length passed here in favour of the range actually served.
*
* The two paths are not equally revocable, which is why the lifetimes
* below differ. Every local delivery method authorises one response and
* no more — nginx's X-Accel-Redirect, Apache's X-Sendfile, or PHP
* streaming the bytes itself: these bytes, now, to this request, and
* nothing that outlives it. A presigned URL is a bearer
* credential — whoever holds it can fetch the file without passing the
* caller's checks again, and it outlives them: a download cap that is
* spent in the meantime, an expires_at that falls in between, an
* assignment that is withdrawn. Nothing here can revoke one, so the only
* dial is how long it lasts.
*
* A download needs to survive being followed, which is a redirect and a
* request: a minute is generous. A preview is held by the player for as
* long as somebody watches, and each seek outside the buffer is a fresh
* Range request against the same URL, so it keeps the hour. That is the
* trade, stated rather than left in a single number.
*
* Callers of inline() must have established that the mime type is
* inline-safe first; PreviewKind is the allowlist, and the reason there
@@ -36,35 +54,74 @@ use Illuminate\Support\Facades\Storage;
*/
class StoredFileResponse
{
/**
* Long enough for a browser, a download manager or a queued transfer
* to follow the redirect and start the request. An object store
* checks the signature when the request arrives, not while it runs,
* so a transfer that begins inside this window finishes however long
* it takes.
*/
private const DOWNLOAD_LINK_SECONDS = 60;
/**
* A preview is watched, not fetched: the player holds this URL and
* issues a Range request every time somebody seeks past the buffer,
* so it has to outlive the viewing rather than the redirect.
*/
private const PREVIEW_LINK_SECONDS = 3600;
public function __construct(private readonly FileDelivery $delivery) {}
/** Shown in place — a preview. */
public function inline(File $file): Response|RedirectResponse
{
return $this->make($file, ContentDisposition::inline($file->original_name));
return $this->make($file, ContentDisposition::inline($file->original_name), self::PREVIEW_LINK_SECONDS);
}
/** Handed over — a download. */
public function attachment(File $file): Response|RedirectResponse
{
return $this->make($file, ContentDisposition::attachment($file->original_name));
return $this->make($file, ContentDisposition::attachment($file->original_name), self::DOWNLOAD_LINK_SECONDS);
}
private function make(File $file, string $disposition): Response|RedirectResponse
private function make(File $file, string $disposition, int $linkSeconds): Response|RedirectResponse
{
if ($file->disk !== 'files') {
$url = Storage::disk($file->disk)->temporaryUrl(
$url = Storage::disk($this->signingDisk($file->disk))->temporaryUrl(
$file->path,
now()->addHour(),
now()->addSeconds($linkSeconds),
['ResponseContentDisposition' => $disposition],
);
return redirect()->away($url);
}
return response('', 200, [
'X-Accel-Redirect' => '/protected-files/'.$file->path,
'Content-Type' => $file->mime_type,
'Content-Disposition' => $disposition,
'Content-Length' => (string) $file->size,
]);
return $this->delivery->serve($file->path, $file->mime_type, $disposition, $file->size);
}
/**
* The disk whose credentials sign the link: the file's own, unless
* that disk names another in `signing_disk`.
*
* A signed URL carries every restriction of the key that signed it.
* A hosted instance's read-write key only works from our own servers,
* which is right for the key and wrong for a link a browser follows:
* every download, preview and public link got AccessDenied from the
* bucket. So a platform can give the disk a second, read-only key,
* free of that restriction and used for nothing but signing. The
* signing disk must point at the same bucket and prefix. The platform
* that configures one is also responsible for that.
*
* A name that points at no configured disk is ignored rather than
* obeyed. Failing every download over a typo would be worse than
* signing with the key the file was stored with.
*/
private function signingDisk(string $disk): string
{
$signing = config("filesystems.disks.{$disk}.signing_disk");
return is_string($signing) && $signing !== '' && is_array(config("filesystems.disks.{$signing}"))
? $signing
: $disk;
}
}
@@ -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);
}
}
}
+46
View File
@@ -0,0 +1,46 @@
<?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\DateInput;
use Carbon\Carbon;
/**
* Reading and writing a file's expiry in the zone of whoever is looking.
*
* The rule itself — a posted day means the end of that day where the
* setter lives, and a form posts back what asShown() gave it — is
* DateInput's, shared with a client account's expiry. This stays as the
* file-shaped door onto it.
*
* Was three private copies — the staff editor, the API, and the client
* portal — of which the API's was the only one that could read a
* timestamp.
*/
class FileExpiry
{
public function __construct(
private readonly DateInput $dates,
) {}
/**
* 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 $this->dates->asShown($file->expires_at, $viewer);
}
/**
* The instant a submitted value actually names. See DateInput::instant().
*/
public function instant(?string $value, ?User $setter): ?Carbon
{
return $this->dates->instant($value, $setter);
}
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Events;
use App\Modules\Files\Models\File;
/**
* A file that could not be handed to anyone now can be.
*
* Dispatched by FileAvailability::markAvailable(), from all three ways a
* file gets there: a clean scan, a scan this installation gave up waiting
* for, and an administrator releasing a quarantined file.
*
* It exists so that "tell the recipients" is written once rather than at
* each of those three, and so the private package can hook the same
* moment — the same reasoning FileWasStored is dispatched under.
*/
final class FileBecameAvailable
{
public function __construct(
public readonly File $file,
) {}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Events;
use App\Models\User;
use App\Modules\Files\Models\File;
/**
* A file's bytes are on a disk and its row exists.
*
* Dispatched from StoreUploadedFile, which every upload path goes through
* — the chunked flow that staff and clients share, and the synchronous
* POST beside it — so a listener sees every upload once and does not have
* to know which route produced it.
*
* A notification rather than a filter: nothing here is mutable and no
* listener can change what was stored. Anything that needs to influence
* the upload has to do so before the bytes land, which is what
* ResolvingUploadDisk is for.
*
* Fired after the row is created and before the caller has linked a
* version or answered the request, so a listener sees a complete File and
* can safely read it back.
*/
class FileWasStored
{
public function __construct(
public readonly File $file,
public readonly User $uploader,
) {}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Events;
use App\Models\User;
/**
* Something a client should read before they upload.
*
* Asked each time the portal's upload page is rendered, and shown above
* the uploader in every theme. The upload page is one page for all of
* them, so this is the one place a rule about what happens to an upload
* can be said where the upload happens — the announcement band is not,
* since only one theme's shell draws it.
*
* **Core knows nothing about what it says.** The first caller is the
* hosted edition's shared instance, telling a free customer how long
* their files are kept, which is a rule of one offering and belongs in
* that offering's code.
*
* Lines rather than one message: two unrelated rules can both apply, and
* neither listener can know the other exists. Each line is a sentence,
* already translated.
*/
class ResolvingUploadNotice
{
/** @var list<string> */
public array $lines = [];
public function __construct(
public readonly User $uploader,
) {}
public function add(string $line): void
{
$this->lines[] = $line;
}
}
+27 -5
View File
@@ -24,15 +24,37 @@ class FileDiskCleanup
{
public function delete(File $file): void
{
try {
Storage::disk($file->disk)->delete($file->path);
$this->attempt($file, fn () => Storage::disk($file->disk)->delete($file->path));
// Every rendition, for every audience — a deleted file's bytes
// must not survive on disk because whoever wrote the cleanup
// only knew about the one copy they had in mind.
// Every rendition, for every audience — a deleted file's bytes must
// not survive on disk because whoever wrote the cleanup only knew
// about the one copy they had in mind.
//
// Attempted separately from the original above, not because the two
// are unrelated but because they are on different disks: renditions
// are always local, and Storage::disk() throws outright for a name
// with no configured driver — which is exactly the state the
// original's disk is in when this fails at all. Sharing one `try`
// meant a file whose source disk had been removed kept every cached
// copy of itself, and nothing looks for those again:
// OrphanFileScanner skips the rendition directories on purpose.
$this->attempt($file, function () use ($file): void {
foreach (ThumbnailGenerator::pathsFor($file->id, $file->mime_type) as $renditionPath) {
Storage::disk('files')->delete($renditionPath);
}
});
}
/**
* Deliberately tolerant, as the class docblock says: the warning is the
* whole report. Nothing else will find these bytes -- the row is
* soft-deleted, and OrphanFileScanner::knownPaths() counts a trashed
* row's path as claimed, so a scan never lists it.
*/
private function attempt(File $file, callable $work): void
{
try {
$work();
} catch (Throwable $exception) {
Log::warning('Could not remove disk bytes for deleted file '.$file->id.': '.$exception->getMessage());
}
+31 -5
View File
@@ -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');
+107
View File
@@ -4,13 +4,24 @@ declare(strict_types=1);
namespace App\Modules\Files;
use App\Modules\Files\Access\ClientIdentityScope;
use App\Models\User;
use App\Modules\Files\Events\FileBecameAvailable;
use App\Modules\Files\Folders\ClientHomeFolders;
use App\Modules\Files\Events\FileWasStored;
use App\Modules\Files\Listeners\AnnounceAvailableFile;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Jobs\ScanFileJob;
use App\Modules\Files\Notifications\FileShareDigestNotification;
use App\Modules\Files\Notifications\FileSharedNotification;
use App\Modules\Files\Notifications\NewVersionAvailableNotification;
use App\Modules\Files\Notifications\NewVersionDigestNotification;
use App\Modules\Files\Scanning\ClamAvScanner;
use App\Modules\Files\Scanning\ScanningConfig;
use App\Modules\Files\Scanning\ScanStatus;
use App\Modules\Files\Scanning\VirusScanner;
use App\Modules\Files\Thumbnails\Events\ImageRenderingChanged;
use App\Modules\Files\Thumbnails\RenderedImageCache;
use App\Modules\Notifications\NotificationTypeDefinition;
@@ -30,6 +41,22 @@ 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);
// Scoped, so the settings screen and the scanner it resolves share
// one instance: that is what lets the Test button try the address
// being typed rather than the one on file. Scoped rather than a
// singleton so a queue worker starts each job with a clean one.
$this->app->scoped(ScanningConfig::class);
// One implementation ships, and the interface exists so the test
// suite can state a verdict instead of producing a file that
// provokes one — and so a commercial engine can be added later
// without touching the job or the policy.
$this->app->bind(VirusScanner::class, ClamAvScanner::class);
}
public function boot(): void
@@ -37,6 +64,8 @@ class FilesServiceProvider extends ServiceProvider
Gate::policy(File::class, FilePolicy::class);
Gate::policy(Folder::class, FolderPolicy::class);
$this->keepClientHomeFolders();
// Cached renditions are written once and never revisited, so
// whoever changes how they render has to say so — otherwise the
// change is invisible on every file anyone has already looked at.
@@ -92,8 +121,47 @@ class FilesServiceProvider extends ServiceProvider
url: fn (array $data): string => route('my-files.index'),
));
// Two audiences, two types, because they need different words and
// different links. Staff get a queue to act on; the person who
// uploaded gets told their file did not go through.
$registry = $this->app->make(NotificationTypeRegistry::class);
$registry->register(new NotificationTypeDefinition(
key: 'file_quarantined',
label: 'A file was quarantined by the virus scanner',
template: 'A virus was found in ":itemName", uploaded by :uploaderName',
url: fn (array $data): string => route('files.quarantine'),
));
$registry->register(new NotificationTypeDefinition(
key: 'upload_blocked',
label: 'One of your uploads was blocked',
template: 'Your file ":itemName" was blocked: :threat',
// Their own files list. Deliberately not the quarantine
// screen, which they cannot open.
url: fn (array $data): string => route('my-files.index'),
));
// The other half of holding an announcement back while a file is
// being checked — see FileSharing::assign and
// AnnounceAvailableFile.
Event::listen(FileBecameAvailable::class, AnnounceAvailableFile::class);
// Every upload path converges on FileWasStored, so this is the
// one place a scan is started from. Dispatched rather than run
// inline: a 5 GB file takes minutes to read, and an upload must
// not wait for it — the file is already withheld until the
// verdict arrives.
Event::listen(FileWasStored::class, function (FileWasStored $event): void {
if ($event->file->scan_status === ScanStatus::Pending) {
ScanFileJob::dispatch($event->file->id);
}
});
if ($this->app->runningInConsole()) {
$this->commands([
Console\ScanFilesCommand::class,
Console\CheckMissingFilesCommand::class,
Console\PurgeStaleUploadsCommand::class,
Console\PurgeZipDownloadsCommand::class,
Console\PurgeExpiredFilesCommand::class,
@@ -102,4 +170,43 @@ class FilesServiceProvider extends ServiceProvider
]);
}
}
/**
* Give a new client their home folder, and keep its name in step.
*
* On model events rather than in the handful of services that create
* and rename clients, because there are more of those than anyone
* remembers: ClientAccounts for the staff screens, the API and the
* control plane; ClientProvisioning for self-registration, LDAP,
* social sign-in and invitation redemption; the profile screen and two
* update endpoints for a rename; AccountConversion for a staff member
* becoming a client. A rule that had to be repeated in nine places
* would be missing from the tenth.
*
* Here rather than in User::booted() so the identity model does not
* have to know the files module exists -- the dependency points one
* way, and this is the end that cares.
*
* Both listeners are cheap when the feature is off: `created` asks the
* setting and returns, and `updated` asks whether the name actually
* changed before it asks anything else.
*/
private function keepClientHomeFolders(): void
{
User::created(function (User $user): void {
if ($user->isClient()) {
$this->app->make(ClientHomeFolders::class)->ensureFor($user);
}
});
User::updated(function (User $user): void {
// wasChanged, not isDirty: by `updated` the write has happened
// and isDirty is empty. A save that did not touch the name --
// which is most of them, every sign-in timestamp included --
// costs one array lookup and stops here.
if ($user->isClient() && $user->wasChanged('name')) {
$this->app->make(ClientHomeFolders::class)->syncName($user);
}
});
}
}
+21
View File
@@ -38,6 +38,17 @@ class FolderPolicy
public function update(User $user, Folder $folder): bool
{
if (! $user->isStaff()) {
// A client owns their home folder -- created_by is them, which
// is how they can see it at all -- so ownership alone would let
// them rename it. It is structure rather than something of
// theirs to arrange: its name follows the account, and the
// administrator reading /files relies on that. Renaming it is
// refused rather than allowed and then silently overwritten the
// next time the account is edited.
if ($folder->isHome()) {
return false;
}
return $folder->isOwnedBy($user) && $user->can('create_own_folders');
}
@@ -48,6 +59,16 @@ class FolderPolicy
public function delete(User $user, Folder $folder): bool
{
// Nobody deletes a home folder from a folder screen, staff
// included. Deleting one cascades over everything the client has,
// and it would leave their portal pointing at a folder that is not
// there -- an account still gets erased through the erasure flow,
// which is where destroying somebody's content is the declared
// intent rather than a side effect of tidying a tree.
if ($folder->isHome()) {
return false;
}
if (! $user->isStaff()) {
return $folder->isOwnedBy($user) && $user->can('create_own_folders');
}
@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Folders;
use App\Models\User;
use App\Modules\Identity\UserType;
use App\Modules\Files\Models\Folder;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Support\Facades\DB;
/**
* A folder per client, named after them, standing in for the root.
*
* ## What it is for
*
* Without it, a client who may create folders creates them at the top of
* the library, beside the ones staff made. Their uploads land at the root
* too. An administrator opening /files sees one flat pile with no clue
* which parts belong to whom. With it, each client gets one folder and
* everything of theirs goes inside, so /files reads as a list of clients.
*
* ## What it is NOT
*
* It is not a boundary, and this is the important sentence in the file. A
* folder staff shared with a client stays visible to that client, sitting
* beside their own — Folder::scopeVisibleToClient is untouched by any of
* this. Treating the home as a jail would silently revoke every share that
* already exists, which is a data-access change wearing the clothes of a
* tidying-up feature. "Root" here means *where new things go by default*,
* nothing more.
*
* ## Why created_by is the client
*
* scopeVisibleToClient grants a client their own folders through
* `created_by`. Creating the home as the client makes it theirs by the
* rule that already exists, rather than needing an assignment row that
* would then have to be kept in step with it. That is also why this writes
* the row itself instead of calling FolderService::create(), which takes
* `created_by` from `auth()->id()` — the creator here is whoever pressed a
* button, and the owner has to be the client.
*/
class ClientHomeFolders
{
public function __construct(
private readonly Settings $settings,
) {}
public function enabled(): bool
{
return (bool) $this->settings->get(Setting::ClientsHomeFolders);
}
/**
* This client's home, or null if they have none.
*
* Asked of the column and not of the setting: a home that exists keeps
* working after the switch is turned off again. The folder is real,
* it holds real files, and pretending it is not there would strand
* them somewhere no listing looks.
*/
public function for(?User $client): ?Folder
{
if ($client === null || ! $client->isClient()) {
return null;
}
return Folder::query()->where('home_for_user_id', $client->id)->first();
}
/**
* Give this client a home if the installation wants them to have one.
*
* Idempotent, and safe to call on a client who already has one. Returns
* the folder either way, or null when the feature is off.
*/
public function ensureFor(User $client): ?Folder
{
if (! $client->isClient() || ! $this->enabled()) {
return null;
}
return $this->create($client);
}
/**
* Create the row, or hand back the one that is already there.
*
* The unique index on home_for_user_id is what actually guarantees one
* home per client; this check only avoids raising on the ordinary
* second call. Two administrators pressing the backfill button at the
* same moment is exactly the race the index is there for.
*/
private function create(User $client): Folder
{
return DB::transaction(function () use ($client): Folder {
$existing = $this->for($client);
if ($existing !== null) {
return $existing;
}
return Folder::query()->create([
'name' => $this->nameFor($client),
'parent_id' => null,
// Root, so an administrator sees it at the top of /files --
// which is the whole point of the feature.
'path' => '/',
'created_by' => $client->id,
'home_for_user_id' => $client->id,
]);
});
}
/**
* Keep the folder's name in step with the client's.
*
* Always, including over a name somebody typed by hand. That was the
* product decision (2026-09-17) and it is the defensible one: the
* folder exists to say whose things these are, so a folder still
* called "Acme Ltd" after the account became "Acme Holdings" is
* actively misleading to the administrator the feature is for. A
* client cannot rename it anyway -- see FolderPolicy.
*/
public function syncName(User $client): void
{
$home = $this->for($client);
if ($home === null) {
return;
}
$name = $this->nameFor($client);
if ($home->name !== $name) {
$home->update(['name' => $name]);
}
}
/**
* How many clients would get a folder if the button were pressed.
*
* A count and not the rows: the settings screen only needs the number,
* and an installation with thousands of clients should not load them
* all to render one sentence.
*/
public function pendingCount(): int
{
return User::query()
->where('type', UserType::Client)
->whereNotExists(fn ($q) => $q
->selectRaw('1')
->from('folders')
->whereColumn('folders.home_for_user_id', 'users.id')
->whereNull('folders.deleted_at'))
->count();
}
/**
* Every client without a home gets one.
*
* Deliberately a button rather than something switching the setting on
* does by itself: it writes a folder per client, and an administrator
* trying the feature out should be able to turn it on, look, and change
* their mind without having reorganised anything.
*
* Reports counts rather than staying quiet, because on an installation
* with hundreds of clients "it worked" is not a useful answer -- the
* administrator wants to know how many there were and how many are new.
*
* @return array{total: int, created: int, existing: int}
*/
public function backfill(): array
{
$clients = User::query()->where('type', UserType::Client)->orderBy('id')->get();
$created = 0;
$existing = 0;
foreach ($clients as $client) {
if ($this->for($client) !== null) {
$existing++;
continue;
}
$this->create($client);
$created++;
}
return [
'total' => $clients->count(),
'created' => $created,
'existing' => $existing,
];
}
/**
* A blank name would render as an unclickable sliver in the tree, so
* the address stands in -- every account has one, and it identifies
* the person as well as a name does.
*/
private function nameFor(User $client): string
{
$name = trim($client->name);
return $name !== '' ? $name : $client->email;
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Folders;
use App\Models\User;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Models\Folder;
/**
* The ancestors of a whole page of folders, in two queries however long the
* page is, trimmed to what the viewer may see.
*
* BreadcrumbBuilder answers this for one folder on a screen. A list
* endpoint needs it for every row, and a query per row is the cost a
* listing must not have.
*
* Trimmed the way BreadcrumbBuilder::visible() trims the client portal's
* trail: the list starts at the first ancestor the viewer can reach, since
* a client-scoped staff member holding a client's folder deep in somebody
* else's tree has no business reading the names of the folders above it.
* An unscoped staff member reaches every folder, so for them nothing is
* ever trimmed.
*/
class FolderTrails
{
public function __construct(
private readonly StaffLibraryScope $scope,
) {}
/**
* @param iterable<Folder> $folders
* @return array<int, list<array{id: int, name: string}>> folder id => its visible ancestors, root first, itself excluded
*/
public function ancestors(iterable $folders, User $viewer): array
{
$chains = [];
$allIds = [];
foreach ($folders as $folder) {
$ids = $folder->ancestorIds();
$chains[$folder->id] = $ids;
array_push($allIds, ...$ids);
}
$allIds = array_values(array_unique($allIds));
if ($allIds === []) {
return array_map(fn (): array => [], $chains);
}
$names = Folder::query()->whereIn('id', $allIds)->pluck('name', 'id')->all();
$visible = $viewer->isClientScoped()
? array_flip($this->scope->folders($viewer)->whereIn('folders.id', $allIds)->pluck('folders.id')->all())
: array_flip($allIds);
$out = [];
foreach ($chains as $folderId => $ids) {
$trail = [];
$reached = false;
foreach ($ids as $id) {
$reached = $reached || isset($visible[$id]);
if ($reached && isset($names[$id])) {
$trail[] = ['id' => $id, 'name' => (string) $names[$id]];
}
}
$out[$folderId] = $trail;
}
return $out;
}
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Folders;
use App\Models\User;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use Illuminate\Database\Eloquent\Builder;
/**
* How many files in a folder's subtree a staff member may not delete.
*
* Deleting a folder cascades to every file in its subtree, and a File's
* `deleted` hook removes the bytes from disk — there is no restore.
* Authorizing the folder is not authorizing its contents: FilePolicy::delete
* asks for `delete_others_files` on somebody else's upload, and for the
* library boundary on top of that, and neither question is asked by
* FolderPolicy. Every staff path that deletes a folder asks this first, so
* the web screen and the API cannot disagree about what a cascade may take.
*
* Asked as one count rather than FilePolicy::delete per file: a folder can
* hold thousands, Gate resolves a fresh policy for every check, and a
* per-row policy check on a listing is the cost 0a8b609e went to some
* trouble to remove. The two halves of FilePolicy::delete are expressible
* in SQL — the permission half is constant for this viewer, and the
* library half is the query StaffLibraryScope already memoises per request.
*
* Somebody holding both delete permissions and no library scope can delete
* anything in the subtree by construction, so they never pay for the query
* at all.
*
* The client half of the same rule is MyFoldersController::destroy.
*/
class UndeletableFiles
{
public function __construct(
private readonly StaffLibraryScope $scope,
) {}
public function count(User $viewer, Folder $folder): int
{
$mayDeleteOwn = $viewer->can('delete_files');
$mayDeleteOthers = $viewer->can('delete_others_files');
$scoped = $viewer->isClientScoped();
if ($mayDeleteOwn && $mayDeleteOthers && ! $scoped) {
return 0;
}
return File::query()
->whereIn('folder_id', $folder->subtreeFolderIds())
->where(function (Builder $outer) use ($viewer, $mayDeleteOwn, $mayDeleteOthers, $scoped): void {
if (! $mayDeleteOwn) {
$outer->orWhere('uploaded_by', $viewer->id);
}
if (! $mayDeleteOthers) {
$outer->orWhere(fn (Builder $others): Builder => $others
->whereNull('uploaded_by')->orWhere('uploaded_by', '!=', $viewer->id));
}
if ($scoped) {
$outer->orWhereNotIn('id', $this->scope->files($viewer)->select('id'));
}
})
->count();
}
}
@@ -10,13 +10,15 @@ 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\Scanning\ScanStatus;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Storage\ResolvingUploadDisk;
use App\Modules\Files\Uploads\StoreUploadedFile;
@@ -57,8 +59,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 ClientIdentityScope $identity,
private readonly ApplyFileEdits $fileEdits,
private readonly FileExpiry $expiry,
) {}
/**
@@ -99,7 +103,15 @@ class FilesController extends Controller
'uploaded_by' => ['nullable', 'integer'],
'search' => ['nullable', 'string', 'max:255'],
'public' => ['nullable', 'boolean'],
'visibility' => ['nullable', 'in:public,private'],
'role_id' => ['nullable', 'integer'],
'downloads' => ['nullable', 'in:none,any'],
'version' => ['nullable', 'in:current,outdated'],
'expired' => ['nullable', 'boolean'],
// One of pending, clean, infected, released, not_scanned or
// unscannable_blocked — so an integration can wait for a file
// it just uploaded, or collect what is in quarantine.
'scan_status' => ['nullable', 'string', Rule::enum(ScanStatus::class)],
]);
$query = $this->viewable->for($user)
@@ -110,6 +122,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']);
}
@@ -125,18 +148,73 @@ class FilesController extends Controller
->orWhere('files.original_name', 'like', "%{$search}%"));
}
// Two overlapping questions, kept apart on purpose.
//
// `public` has always tested the column, and callers depend on that,
// so its meaning is left exactly as it was -- changing what an
// existing filter answers is a breaking change for everybody already
// asking it, whatever the new answer is.
//
// `visibility` is the question the staff library's own filter asks:
// File::isEffectivelyPublic(), the flag *or* a public folder anywhere
// above the file. That is what the badge on a row means, so it is
// what an integration comparing itself to the screen will expect.
// Prefer it; `public` remains for compatibility.
if ($request->has('public') && ($filters['public'] ?? null) !== null) {
$query->where('files.public', $request->boolean('public'));
}
if (($filters['visibility'] ?? null) !== null) {
$query->effectivelyPublic($filters['visibility'] === 'public');
}
// No identity guard here, unlike `uploaded_by` directly above, and
// the difference is what the answer discloses. `uploaded_by` names a
// person: a non-empty result confirms *which* client uploaded a file
// whose uploader the response is redacting, which is the redaction
// undone. A role names nobody. The files in the result are ones this
// caller may already read, and learning that one of them came from
// somebody holding the Client role narrows to a set the caller could
// have guessed. Same reasoning, and same absence of a guard, as the
// staff library's own role filter -- the two surfaces must not
// disagree about what a role reveals.
if (($filters['role_id'] ?? null) !== null) {
$query->whereHas('uploader', fn (Builder $uploader) => $uploader->where('role_id', (int) $filters['role_id']));
}
// has/doesn't-have rather than a comparison on a count: an aggregate
// cannot be filtered in a WHERE, and a HAVING would be applied after
// the page has already been sliced.
if (($filters['downloads'] ?? null) !== null) {
$filters['downloads'] === 'none'
? $query->whereDoesntHave('downloads')
: $query->whereHas('downloads');
}
// "current" includes a file that was never versioned at all -- it is
// the current version of itself. "outdated" is the word the version
// badge uses, so the filter and the row agree.
if (($filters['version'] ?? null) !== null) {
$filters['version'] === 'current'
? $query->whereDoesntHave('nextVersion')
: $query->whereHas('nextVersion');
}
// Expiry is a filter, not a default: staff see expired files in the
// UI too (that is how they notice and act on them). Only the client
// branch of the visibility rules drops them, and it does so inside
// ViewableFileScope where it belongs.
// UI too (that is how they notice and act on them). Dropping them
// is the client branch's rule, applied inside the visibility scopes
// where it belongs — which is also why a client-scoped caller does
// not get their clients' expired files back here whatever this
// filter says: their library is built on that same branch. See
// File::isExpired.
if ($request->has('expired') && ($filters['expired'] ?? null) !== null) {
$request->boolean('expired') ? $query->expired() : $query->notExpired();
}
if (($filters['scan_status'] ?? null) !== null) {
$query->where('files.scan_status', $filters['scan_status']);
}
return FileResource::collection($this->polling->paginate($request, $query, 'files'));
}
@@ -263,6 +341,12 @@ class FilesController extends Controller
* without the matching permission leaves that field untouched rather
* than failing the whole request, which mirrors the web interface.
*
* `expires_at` accepts either a calendar day (`2026-09-12`) or a full
* timestamp. A day means the end of that day in the caller's timezone,
* which is what the same value means on the web and what the file's
* own `expires_at` reads back as; a timestamp is taken as the instant
* it names.
*
* `commentable` only has an effect while the installation's comment
* setting is "only files marked as commentable"; under any other
* setting it is ignored, again rather than failing.
@@ -283,14 +367,17 @@ class FilesController extends Controller
'slug' => Rules::slug('files', $file->id),
'categories' => ['sometimes', 'array'],
'categories.*' => ['integer', 'exists:categories,id'],
'expires_at' => ['sometimes', 'nullable', 'date'],
'expires_at' => ['sometimes', 'nullable', 'string', 'date'],
'download_limit' => ['sometimes', 'nullable', 'integer', 'min:1'],
'download_limit_scope' => ['sometimes', Rule::enum(DownloadLimitScope::class)],
]);
// Reparenting through update() must respect the same library scope as
// Reparenting through update() must respect the same two rules as
// the web move()/bulkUpdate() paths: the destination folder must be
// one this user can see. Only enforced when folder_id actually
// one this user can see, and one they may put content into. A public
// destination publishes what lands in it, so the second question is
// the one `upload_public` exists to ask and store() above already
// asks (GHSA-rxf8-wh8v-jm9j). Only enforced when folder_id actually
// changes, so re-saving a file that already sits in an out-of-scope
// folder (reachable via a direct client share) still works. The
// integer rule admits numeric strings, so cast before the strict
@@ -299,48 +386,38 @@ class FilesController extends Controller
$validated['folder_id'] = (int) $validated['folder_id'];
if ($validated['folder_id'] !== $file->folder_id) {
$this->scope->folders($user)->findOrFail($validated['folder_id']);
$destination = $this->scope->folders($user)->whereKey($validated['folder_id'])->firstOrFail();
abort_unless(Folder::uploadableBy($user, $destination), 403);
}
}
$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'] = $validated['expires_at'];
// 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);
}
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Folders\FolderTrails;
use App\Modules\Files\Http\Controllers\Concerns\ResolvesShareTargets;
use App\Modules\Files\Http\Resources\Api\FolderResource;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Sharing\FolderSharing;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
/**
* Sharing a folder with a client or a group: `{type: client|group, id}`.
*
* A client a folder is shared with sees everything inside it, including
* folders and files added later.
*
* Both the target resolution (ResolvesShareTargets) and the effects
* (FolderSharing — the row, the activity entry, the in-app notification,
* the digest email) are shared with the web controller, so the two surfaces
* cannot drift. "May share" is "may edit", as on the web.
*/
class FolderAssignmentsController extends Controller
{
use ResolvesShareTargets;
public function __construct(
private readonly StaffLibraryScope $scope,
private readonly FolderSharing $sharing,
private readonly FolderTrails $trails,
) {}
/**
* Share a folder.
*
* Sharing it again with the same client or group leaves one share.
*/
public function store(Request $request, Folder $folder): FolderResource
{
Gate::authorize('update', $folder);
[$assignable, $targetName] = $this->resolveRequestedTarget(
$request,
__('Folders can only be shared with clients or groups.'),
);
$this->sharing->assign($folder, $assignable, $targetName);
return $this->resource($request, $folder);
}
/**
* Stop sharing a folder.
*/
public function destroy(Request $request, Folder $folder): FolderResource
{
Gate::authorize('update', $folder);
[$assignable, $targetName] = $this->resolveRequestedTarget(
$request,
__('Folders can only be shared with clients or groups.'),
);
$this->sharing->unassign($folder, $assignable, $targetName);
return $this->resource($request, $folder);
}
private function resource(Request $request, Folder $folder): FolderResource
{
$folder = $folder->fresh() ?? $folder;
$folder->load('assignments.assignable');
$user = $request->user();
if ($user !== null) {
$folder->setRelation('trail', collect($this->trails->ancestors([$folder], $user)[$folder->id] ?? []));
}
return new FolderResource($folder);
}
}
@@ -0,0 +1,282 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Api\Support\PollingQuery;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Folders\FolderService;
use App\Modules\Files\Folders\FolderTrails;
use App\Modules\Files\Folders\UndeletableFiles;
use App\Modules\Files\Http\Resources\Api\FolderResource;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Support\Rules;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\Rule;
/**
* The staff library's folders.
*
* Which folders a token sees is the same question the library screen
* answers, so a staff member limited to their assigned clients gets exactly
* the folders they see on the web. Every write goes through the same
* service, policy and placement rule as the web screen.
*/
class FoldersController extends Controller
{
public function __construct(
private readonly StaffLibraryScope $scope,
private readonly PollingQuery $polling,
private readonly FolderService $folders,
private readonly FolderTrails $trails,
private readonly UndeletableFiles $undeletable,
private readonly ActivityLogger $activity,
) {}
/**
* List folders.
*
* Cursor paginated, like every list. Pass `updated_since` to poll for
* folders created, renamed or moved since a point in time. `parent_id`
* lists the folders directly inside one folder, and `top_level=1` the
* folders at the top of the library.
*
* Moving a folder updates the folder itself and every folder under it,
* so a poll sees the whole moved subtree.
*/
public function index(Request $request): AnonymousResourceCollection
{
$user = $request->user();
assert($user !== null);
$filters = $request->validate($this->polling->rules() + [
'parent_id' => ['nullable', 'integer'],
'top_level' => ['nullable', 'boolean'],
'search' => ['nullable', 'string', 'max:255'],
]);
$query = $this->scope->folders($user);
if (($filters['parent_id'] ?? null) !== null) {
$query->where('folders.parent_id', (int) $filters['parent_id']);
}
if ($request->boolean('top_level')) {
$query->whereNull('folders.parent_id');
}
if (($filters['search'] ?? null) !== null) {
$query->where('folders.name', 'like', '%'.$filters['search'].'%');
}
$page = $this->polling->paginate($request, $query, 'folders');
/** @var Collection<int, Folder> $items */
$items = collect($page->items());
$this->attachTrails($items, $user);
return FolderResource::collection($page);
}
/**
* Show a folder, with the clients and groups it is shared with.
*/
public function show(Request $request, Folder $folder): FolderResource
{
Gate::authorize('view', $folder);
return $this->resource($folder, $request->user());
}
/**
* Create a folder.
*
* At the top of the library, or inside `parent_id`. Requires the
* `create_own_folders` ability, and `upload` with it.
*
* If a folder with the same name already exists in the same place, that
* folder is returned with a 200 instead of a second one being made, so
* retrying a request is safe. A new folder answers 201.
*/
public function store(Request $request): JsonResponse
{
$user = $request->user();
assert($user !== null);
// The same pair FoldersController::store asks on the web: a folder
// nobody can put anything into is no use.
abort_unless($user->can('create_own_folders') && $user->can('upload'), 403);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'parent_id' => Rules::folderId(),
]);
$parent = $this->resolveParent($user, $validated['parent_id'] ?? null);
// A folder inside a public one is public, so creating one there is
// placing content into it (Folder::uploadableBy).
abort_unless(Folder::uploadableBy($user, $parent), 403);
$existing = $this->scope->folders($user)
->where('folders.parent_id', $parent?->id)
->where('folders.name', $validated['name'])
->orderBy('folders.id')
->first();
if ($existing instanceof Folder) {
return $this->resource($existing, $user)->response()->setStatusCode(200);
}
$folder = $this->folders->create($validated['name'], $parent);
$this->activity->log(Action::FolderCreated, subject: $folder);
return $this->resource($folder, $user)->response()->setStatusCode(201);
}
/**
* Rename or move a folder.
*
* Only the fields you send change. `parent_id: null` moves the folder to
* the top of the library. A folder moves with everything inside it, and
* cannot be moved into itself or one of its own subfolders.
*/
public function update(Request $request, Folder $folder): FolderResource
{
$user = $request->user();
assert($user !== null);
Gate::authorize('update', $folder);
$validated = $request->validate([
'name' => ['sometimes', 'required', 'string', 'max:255'],
'parent_id' => ['sometimes', ...Rules::folderId()],
]);
if (array_key_exists('name', $validated) && $validated['name'] !== $folder->name) {
$folder->update(['name' => $validated['name']]);
$this->activity->log(Action::FolderRenamed, subject: $folder);
}
if (array_key_exists('parent_id', $validated)) {
$newParentId = $validated['parent_id'] === null ? null : (int) $validated['parent_id'];
if ($newParentId !== $folder->parent_id) {
$newParent = $this->resolveParent($user, $newParentId);
// Dropping a folder into a public parent publishes its whole
// subtree, the act FoldersController::move refuses without
// `upload_public` (GHSA-rxf8-wh8v-jm9j).
abort_unless(Folder::uploadableBy($user, $newParent), 403);
$this->folders->move($folder, $newParent);
$this->activity->log(Action::FolderMoved, subject: $folder);
}
}
return $this->resource($folder->fresh() ?? $folder, $user);
}
/**
* Delete a folder.
*
* An empty folder is deleted straight away. A folder holding files or
* other folders answers 409 unless you send
* `content_action=cascade_delete`, which deletes the folder, every folder
* under it and every file inside them, as the web screen does. There is
* no restore.
*
* A cascade is refused with 403 if the folder holds any file this token
* may not delete itself.
*/
public function destroy(Request $request, Folder $folder): JsonResponse
{
$user = $request->user();
assert($user !== null);
Gate::authorize('delete', $folder);
$validated = $request->validate([
'content_action' => ['nullable', Rule::in(['cascade_delete'])],
]);
$subtree = $folder->subtreeFolderIds();
$hasContent = count($subtree) > 1
|| File::query()->whereIn('folder_id', $subtree)->exists();
// A sync job with a bug in it must not be one request away from
// emptying a client's folder: the cascade has to be asked for.
abort_if(
$hasContent && ($validated['content_action'] ?? null) !== 'cascade_delete',
409,
__('This folder is not empty. Send content_action=cascade_delete to delete it with everything inside it.'),
);
$blocked = $this->undeletable->count($user, $folder);
abort_if($blocked > 0, 403, trans_choice(
'This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.',
$blocked,
['count' => (string) $blocked],
));
$name = $folder->name;
$this->folders->delete($folder);
$this->activity->log(Action::FolderDeleted, context: ['name' => $name]);
return response()->json(status: 204);
}
private function resource(Folder $folder, ?User $user): FolderResource
{
$folder->load('assignments.assignable');
if ($user !== null) {
$this->attachTrails(collect([$folder]), $user);
}
return new FolderResource($folder);
}
/**
* @param Collection<int, Folder> $folders
*/
private function attachTrails(Collection $folders, User $user): void
{
$trails = $this->trails->ancestors($folders, $user);
foreach ($folders as $folder) {
$folder->setRelation('trail', collect($trails[$folder->id] ?? []));
}
}
/**
* The parent must be a folder this caller's library shows them — the
* same lookup the web screen makes, answering 404 otherwise.
*/
private function resolveParent(User $user, ?int $parentId): ?Folder
{
if ($parentId === null) {
return null;
}
/** @var Builder<Folder> $folders */
$folders = $this->scope->folders($user);
return $folders->findOrFail($parentId);
}
}
@@ -10,6 +10,7 @@ use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Files\Models\File;
use App\Modules\Files\Folders\ClientHomeFolders;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Notifications\AdminClientUploadedNotification;
use App\Modules\Files\Uploads\LocalPartStore;
@@ -26,6 +27,7 @@ use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Support\Rules;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -56,6 +58,7 @@ class ChunkedUploadsController extends Controller
private readonly Notifier $notifier,
private readonly PermissionChecker $permissions,
private readonly ActivityLogger $activity,
private readonly ClientHomeFolders $homeFolders,
private readonly FileVersions $versions,
) {}
@@ -89,16 +92,60 @@ class ChunkedUploadsController extends Controller
assert($user !== null);
$folder = isset($validated['folder_id']) ? Folder::query()->whereKey($validated['folder_id'])->first() : null;
// A client uploading without naming a folder lands in their own,
// where this installation gives them one. That is what makes the
// home a root rather than just another folder: nothing in the
// portal has to be told about it for their files to end up there.
//
// Only when no folder was named. A client who picked a destination
// picked it, and uploadableBy() below is still what decides whether
// they may -- this chooses a default, it never grants anything.
if ($folder === null) {
$folder = $this->homeFolders->for($user);
}
abort_unless(Folder::uploadableBy($user, $folder), 403);
// One session per file, and a person uploads a handful at a time.
// A cap is here because nothing else counts sessions: for anyone
// without a quota to spend — staff, and clients on an installation
// that sets no quotas — the number of sessions is the only thing
// standing between a declared size and any multiple of it.
$openSessions = UploadSession::query()->where('user_id', $user->id)->count();
$maxOpen = max(1, (int) config('projectsend.uploads.max_open_sessions'));
if ($openSessions >= $maxOpen) {
throw ValidationException::withMessages([
'filename' => __('Too many uploads are already in progress. Finish or cancel one and try again.'),
]);
}
// The declared size here is client-supplied and unverified until
// complete()'s real assembled byte count — re-checked there too.
if ($user->isClient()) {
$quotaBytes = $this->storageUsage->quotaBytes($user);
if ($quotaBytes > 0 && $this->storageUsage->usedBytes($user) + (int) $validated['size'] > $quotaBytes) {
// Sessions already open count too, at the size they declared.
// A quota measured against stored files alone is spent twice
// over by opening the sessions one after another: each one is
// told there is room, because the ones before it had not
// finished and so had not become files. putPart() holds each
// session to its declaration, so reserving the declarations
// here is what puts bytes waiting on the temporary volume
// under the same ceiling as bytes that landed.
$pendingBytes = (int) UploadSession::query()->where('user_id', $user->id)->sum('size');
if ($quotaBytes > 0 && $this->storageUsage->usedBytes($user) + $pendingBytes + (int) $validated['size'] > $quotaBytes) {
throw ValidationException::withMessages([
'size' => __('This upload would exceed your storage quota of :quota MB.', ['quota' => (string) $user->storage_quota_mb]),
'size' => __('This upload would exceed your storage quota of :quota MB.', [
// The resolved quota, not the column: a client who
// was never given one of their own carries 0 there
// and inherits the site default, so printing the
// column reads "your storage quota of 0 MB" at the
// moment somebody is asking what their limit is.
'quota' => (string) $this->storageUsage->quotaMb($user),
]),
]);
}
}
@@ -180,13 +227,7 @@ class ChunkedUploadsController extends Controller
// ownership of the session is still enforced below.
$this->authorizeSession($request, $session);
// signPart() bounds the part number; bound the part body too, or a
// session can absorb unlimited bytes. The quota is only enforceable
// at complete(), against the assembled size — until then nothing
// stops a client declaring a 1-byte upload and streaming gigabytes
// of parts, which never becomes a File row and so never counts
// against anything. Stale sessions are purged daily, so without a
// cap here the exposure is a day's worth of disk.
// signPart() bounds the part number; bound the part body too.
abort_unless($part >= 1 && $part <= 10000, 422);
$maxPartBytes = max(1, (int) config('projectsend.upload_part_size_mb')) * 1024 * 1024;
@@ -194,20 +235,61 @@ class ChunkedUploadsController extends Controller
// chooses its own chunking and only the last part is short.
$limit = $maxPartBytes * 2;
if ($request->header('Content-Length') !== null && (int) $request->header('Content-Length') > $limit) {
$contentLength = $request->header('Content-Length');
$reservationLimit = $contentLength !== null ? (int) $contentLength : $limit;
if ($reservationLimit < 1 || $reservationLimit > $limit) {
abort(413);
}
$stream = $request->getContent(true);
// Bounding one request bounds one request, and nothing else. Ten
// thousand part numbers at twice a 20 MB part is about 400 GB per
// session, sessions were not counted against anything, and none of
// it becomes a File row — so a client with a 1 MB quota could
// declare a one-byte upload and fill the temporary volume, then do
// it again. The session needs a ceiling of its own, and the room
// for a part has to be claimed before the part is read: a body's
// length is not known until it has arrived, and by then it is on
// the disk this is protecting.
//
// The ceiling is the size the session declared, which store() has
// already weighed against the file-size limit and the quota. So
// what a part gets is whatever the session has left, and the write
// is then capped at exactly that — an over-long body is cut off
// mid-stream as it always was, just against a smaller number.
// Reserve the declared request length when available. Reserving the
// full per-part ceiling (40 MiB for a normal 20 MiB chunk) makes
// concurrent final parts exhaust the session allowance prematurely.
// Unknown-length requests retain the conservative ceiling, and the
// streamed byte count is still enforced against the reservation.
$reserve = $this->reservePartRoom($session, $part, $reservationLimit);
if ($reserve < 1) {
// 413 rather than 422: this is about the size of what is being
// sent, and a client's resume logic already understands it. The
// session survives — the parts it holds are untouched, and it
// can still be completed or aborted.
abort(413);
}
$stream = null;
try {
$etag = $this->parts->storePart($session, $part, $stream, $limit);
$stream = $request->getContent(true);
$etag = $this->parts->storePart($session, $part, $stream, $reserve);
} catch (PartTooLargeException) {
abort(413);
} finally {
if (is_resource($stream)) {
fclose($stream);
}
// In the finally, because every way out of here needs it: the
// refused part was deleted and weighs nothing, a client that
// hung up left a short one, and a clean write leaves exactly
// what it reserved. Without this a client's own retries would
// slowly exhaust a session that has plenty of room.
$session->settleStaged($reserve, $this->parts->partSize($session, $part));
}
return response('', 200, [
@@ -224,6 +306,52 @@ class ChunkedUploadsController extends Controller
]);
}
/**
* Claim room for one part, returning how many bytes were claimed — 0
* when the session has none left.
*
* Read-then-claim, under a lock held for the two statements and not
* for the transfer. The protocol sends parts in parallel and how many
* is the client's choice, so without it every part in flight reads the
* same "room left" and they all claim it; and making the claim alone
* atomic is no better, because then the honest parallel upload is the
* one that gets refused. The lock is the same per-session shape
* complete() already uses, and it is released before a byte is read.
*/
private function reservePartRoom(UploadSession $session, int $part, int $limit): int
{
$lock = Cache::lock('upload-part:'.$session->id, 30);
try {
$lock->block(15);
} catch (LockTimeoutException) {
// Nothing is wrong with the upload — the queue for this one
// session just did not clear. 503 with Retry-After is what the
// client's own backoff is for.
abort(503, headers: ['Retry-After' => '5']);
}
try {
$existing = $this->parts->partSize($session, $part);
$session->refresh();
// Re-sending a part replaces it rather than adding to it, so
// what it already holds is room this request may spend again.
// That is an ordinary resume.
$room = max(0, $session->size - ($session->staged_bytes - $existing));
$reserve = min($limit, $room);
if ($reserve > 0 && ! $session->reserveStaged($reserve, $existing)) {
return 0;
}
return $reserve;
} finally {
$lock->release();
}
}
/**
* List the parts already received, so an interrupted upload can resume
* rather than start again.
@@ -306,7 +434,9 @@ class ChunkedUploadsController extends Controller
$session->delete();
throw ValidationException::withMessages([
'size' => __('This upload would exceed your storage quota of :quota MB.', ['quota' => (string) $user->storage_quota_mb]),
'size' => __('This upload would exceed your storage quota of :quota MB.', [
'quota' => (string) $this->storageUsage->quotaMb($user),
]),
]);
}
}
@@ -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 => [
@@ -7,6 +7,7 @@ namespace App\Modules\Files\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Delivery\FileDelivery;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Http\RedirectResponse;
@@ -24,12 +25,21 @@ class DownloadSettingsController extends Controller
public function __construct(
private readonly Settings $settings,
private readonly ActivityLogger $activity,
private readonly FileDelivery $delivery,
) {}
public function edit(): Response
{
return Inertia::render('system/settings/downloads', [
'max_zip_download_size_mb' => $this->settings->get(Setting::MaxZipDownloadSizeMb),
// Not a setting, and shown here because this is where somebody
// coming from v1 looks for one: v1 had a "Download method"
// dropdown on its uploads options screen. It is an environment
// variable now rather than a stored setting, because it
// describes the server the installation is running on rather
// than a preference — a value in the database can be restored
// onto a different server and be wrong there.
'file_delivery' => $this->delivery->describe(),
]);
}
@@ -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,17 +10,19 @@ use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Access\DownloadAllowance;
use App\Modules\Files\Delivery\StoredFileResponse;
use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\FileAvailability;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpFoundation\Response;
/**
* Authorized downloads without the bytes ever traversing PHP: the app
* checks the policy, and StoredFileResponse answers with either an
* X-Accel-Redirect for nginx to stream from the protected location
* (brief §3) or a presigned URL when the file lives on external storage,
* since nginx has no way to serve bytes it doesn't have on disk.
* Authorized downloads: the app checks the policy, and StoredFileResponse
* decides how the bytes travel — a presigned URL when the file lives on
* external storage, and otherwise whichever local delivery method this
* installation's web server understands (see FileDelivery). On nginx that
* is an X-Accel-Redirect and the bytes never traverse PHP at all; on a
* server with no such header PHP streams them, which is slower and works.
*/
class FileDownloadController extends Controller
{
@@ -28,12 +30,18 @@ class FileDownloadController extends Controller
private readonly ActivityLogger $activity,
private readonly DownloadAllowance $allowance,
private readonly StoredFileResponse $bytes,
private readonly FileAvailability $availability,
) {}
public function __invoke(Request $request, File $file): Response|RedirectResponse
{
Gate::authorize('view', $file);
// Before the download limit and before the log: a file the scanner
// has not cleared is not served to anybody, and a refusal here is
// not a download to count.
$this->availability->guardDelivery($file);
// Separate from the policy on purpose: a spent download limit is
// not "you may not see this file" — the file stays listed, and
// the same person may still open its details. It is only the
@@ -6,11 +6,13 @@ namespace App\Modules\Files\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Access\DownloadAllowance;
use App\Modules\Files\Delivery\FileDelivery;
use App\Modules\Files\Delivery\StoredFileResponse;
use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\FileAvailability;
use App\Modules\Files\Preview\PreviewKind;
use App\Modules\Files\Preview\PreviewLog;
use App\Modules\Files\Thumbnails\Events\ResolvingImageRendering;
use App\Modules\Files\Thumbnails\ImageAudience;
use App\Modules\Files\Thumbnails\ImageRendition;
@@ -21,15 +23,14 @@ use App\Modules\Platform\Settings\Settings;
use App\Support\ContentDisposition;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\Response;
/**
* Two inline (never `attachment`) views of a file, same X-Accel-Redirect
* pattern as FileDownloadController: a bounded thumbnail for listing rows,
* Two inline (never `attachment`) views of a file, delivered the same way
* FileDownloadController delivers one: a bounded thumbnail for listing rows,
* and a larger view opened in a new tab when a thumbnail is clicked.
* `thumbnail()` stays unlogged — it fires automatically as an `<img src>`
* for every row on every listing render, not a deliberate action, and
@@ -72,17 +73,24 @@ class FileThumbnailController extends Controller
{
public function __construct(
private readonly ThumbnailGenerator $thumbnails,
private readonly ActivityLogger $activity,
private readonly PreviewLog $previews,
private readonly DownloadAllowance $allowance,
private readonly StoredFileResponse $bytes,
private readonly LocalSourceFile $source,
private readonly Settings $settings,
private readonly FileDelivery $delivery,
private readonly FileAvailability $availability,
) {}
public function thumbnail(Request $request, File $file): Response
{
Gate::authorize('view', $file);
// A rendition is made by an image library reading the file, which
// is itself a way in — so an unchecked file is not rendered, not
// even as 300 pixels.
$this->availability->guardDelivery($file);
// This one route serves both the staff file manager and the client
// portal — the same URL, told apart only by who is asking. A client
// and a staff member looking at the same file get different cached
@@ -118,6 +126,8 @@ class FileThumbnailController extends Controller
{
Gate::authorize('view', $file);
$this->availability->guardDelivery($file);
// The inline allowlist. See the class docblock and PreviewKind —
// the stored mime type is sniffed from the bytes, so an allowed
// extension is not evidence of a safe-to-render payload.
@@ -144,7 +154,10 @@ class FileThumbnailController extends Controller
// file.
abort_unless($this->allowance->allows($file, $request->user()), 403);
$this->logPreview($file, $request);
// Debounced, because a browser turns one video into dozens of
// Range requests — see PreviewLog, which the anonymous twin in
// PublicGroupsController::preview shares.
$this->previews->record(Action::FilePreviewed, $file, $request->user());
if ($kind === PreviewKind::Image) {
$audience = ImageAudience::forViewer($request->user());
@@ -164,29 +177,6 @@ class FileThumbnailController extends Controller
return $this->bytes->inline($file);
}
/**
* One log row per viewer per file per five minutes.
*
* Watching a video is a single deliberate act that the browser turns
* into dozens of Range requests against this route, and each one
* arrives here indistinguishable from someone clicking preview again.
* Cache::add is the whole mechanism: it writes only if the key is
* absent, so the first request through the window logs and the rest
* are silent, without a read-then-write race between two of them.
*
* Keyed by viewer, so one client's playback never suppresses another
* person's preview of the same file. Anonymous viewers do not reach
* this route at all — see PublicGroupsController::preview.
*/
private function logPreview(File $file, Request $request): void
{
$key = 'file-preview-logged:'.$file->id.':'.($request->user()->id ?? 'guest');
if (Cache::add($key, true, now()->addMinutes(5))) {
$this->activity->log(Action::FilePreviewed, subject: $file);
}
}
/**
* The cached rendition's path on the local disk, generating it first
* if this is the first time anyone has asked for it. Null only when
@@ -202,8 +192,19 @@ class FileThumbnailController extends Controller
$disk = Storage::disk('files');
// Existence is the cache, and an empty file is not a rendition: it
// is what a render that died before writing anything leaves behind,
// and serving it hands the viewer a broken image for as long as the
// file lives — nothing invalidates a rendition once it is there.
// ThumbnailGenerator writes through a temporary file now, so this
// state can no longer be created here; it can still be inherited
// from an installation that ran an older version.
if ($disk->exists($path)) {
return $path;
if ($disk->size($path) > 0) {
return $path;
}
$disk->delete($path);
}
$disk->makeDirectory(dirname($path));
@@ -221,10 +222,12 @@ class FileThumbnailController extends Controller
private function serve(File $file, string $path): Response
{
return response('', 200, [
'X-Accel-Redirect' => '/protected-files/'.$path,
'Content-Type' => $file->mime_type,
'Content-Disposition' => ContentDisposition::inline($file->original_name),
]);
// No Content-Length: this is the rendition's size, not the
// original file's, and $file->size is the wrong number for it.
return $this->delivery->serve(
$path,
$file->mime_type,
ContentDisposition::inline($file->original_name),
);
}
}
@@ -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
@@ -60,7 +62,22 @@ class FilesController extends Controller
$user = $request->user();
assert($user !== null);
// Opened from inside a folder, the upload goes into it (#1801).
// The same two checks the portal's upload page makes, with the
// staff library in place of the client's: a folder this person
// cannot see is a 404, one they may not upload into is a 403.
// ChunkedUploadsController checks the destination again when the
// upload starts, so this decides what the page offers, not what
// is allowed.
$folder = null;
if ($request->integer('folder') > 0) {
$folder = Folder::query()->find($request->integer('folder'));
abort_if($folder === null || ! app(StaffLibraryScope::class)->allowsFolder($user, $folder), 404);
abort_unless(Folder::uploadableBy($user, $folder), 403);
}
return Inertia::render('files/create', [
'folder' => $folder === null ? null : ['id' => $folder->id, 'name' => $folder->name],
'max_file_size_mb' => app(Settings::class)->get(Setting::MaxFileSizeMb),
'part_size_mb' => (int) config('projectsend.upload_part_size_mb'),
'allowed_extensions' => app(UploadExtensionPolicy::class)->hintFor($user),
@@ -164,7 +181,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,8 +190,20 @@ 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' => $file->expires_at?->copy()->setTimezone($this->timezones->resolve($request->user()))->toDateString(),
'expires_at' => $this->expiry->asShown($file, $request->user()),
'expired' => $file->isExpired(),
// Said on the one screen that still shows a quarantined or
// missing file, since the library no longer lists it: a
// staff member who followed a link from Quarantine should
// not have to work out why the download refuses.
'scan_status' => $file->scan_status->value,
'scan_note' => $file->scan_note,
// Decided here rather than by the page comparing six
// states: whether there are bytes to hand over at all.
// Every button that would produce them is hidden when
// there are not — a download that answers 423 is not an
// affordance, it is a trap.
'scan_available' => $file->scan_status->isAvailable(),
'download_limit' => $file->download_limit,
'download_limit_scope' => ($file->download_limit_scope ?? DownloadLimitScope::Total)->value,
// The file's total downloads, so the editor can see what
@@ -265,7 +294,7 @@ class FilesController extends Controller
'slug' => Rules::slug('files', $file->id),
'categories' => ['array'],
'categories.*' => ['integer', 'exists:categories,id'],
'expires_at' => ['nullable', 'date'],
'expires_at' => ['nullable', 'string', 'date'],
'download_limit' => ['nullable', 'integer', 'min:1'],
'download_limit_scope' => ['nullable', Rule::enum(DownloadLimitScope::class)],
]);
@@ -274,6 +303,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,68 +312,49 @@ 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) {
$this->scope->folders($user)->findOrFail($folderId);
if ($folderId !== null && $folderId !== $file->folder_id) {
$destination = $this->scope->folders($user)->whereKey($folderId)->firstOrFail();
// And one they may publish into, if it is public. Reparenting
// through the edit form is the same privileged write as move().
abort_unless(Folder::uploadableBy($user, $destination), 403);
}
$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) {
$attributes['expires_at'] = $this->expiryInstant($validated['expires_at'] ?? null, $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.'));
}
@@ -363,9 +375,18 @@ class FilesController extends Controller
$folderId = $validated['folder_id'] ?? null;
$user = $request->user();
// The target folder must be one the mover can actually see.
if ($folderId !== null && $user !== null) {
$this->scope->folders($user)->findOrFail($folderId);
// The target folder must be one the mover can actually see, and one
// they are allowed to put content into. Those are two questions:
// a file in a public folder is published by being there, so the
// destination reaches the property `upload_public` guards without
// anybody touching the switch. Asking only the first let an editor
// who is deliberately not allowed to publish do it by dragging
// (GHSA-rxf8-wh8v-jm9j — the move half of GHSA-237r-jx85-j3hr,
// whose fix was wired into the upload paths and no further).
if ($folderId !== null && $user !== null && $folderId !== $file->folder_id) {
$destination = $this->scope->folders($user)->whereKey($folderId)->firstOrFail();
abort_unless(Folder::uploadableBy($user, $destination), 403);
}
$file->update(['folder_id' => $folderId]);
@@ -399,7 +420,7 @@ class FilesController extends Controller
'description' => ['nullable', 'string', 'max:2000'],
'expiration_action' => ['required', Rule::in(['no_change', 'set', 'clear'])],
'expires_at' => ['nullable', 'date', 'required_if:expiration_action,set'],
'expires_at' => ['nullable', 'string', 'date', 'required_if:expiration_action,set'],
// `sometimes` rather than `required` like the fields above:
// a browser still running the previous build would start
@@ -422,13 +443,19 @@ class FilesController extends Controller
&& ($validated['remove_category_ids'] ?? []) === [];
abort_if($touchesNothing, 422, __('Change at least one field before applying a bulk edit.'));
// The target folder must be one this user can actually see — same
// rule move() already applies to a single file's target.
// The target folder must be one this user can actually see, and one
// they may put content into — the same two questions move() asks of
// a single file's target. Checked once, on the destination, rather
// than per file: the destination is one folder for the whole batch,
// and if putting content there publishes it then no file in the
// batch may go.
$targetFolderId = null;
if ($validated['folder_action'] === 'move') {
$targetFolderId = $validated['folder_id'] ?? null;
if ($targetFolderId !== null) {
$this->scope->folders($user)->findOrFail($targetFolderId);
$destination = $this->scope->folders($user)->whereKey($targetFolderId)->firstOrFail();
abort_unless(Folder::uploadableBy($user, $destination), 403);
}
}
@@ -463,7 +490,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;
}
@@ -504,9 +531,23 @@ class FilesController extends Controller
});
$requested = count($validated['file_ids']);
$message = $updated < $requested
? __(':updated of :requested selected files were updated. The rest were skipped because you don\'t have permission to edit them.', ['updated' => $updated, 'requested' => $requested])
: trans_choice(':count file updated.|:count files updated.', $updated, ['count' => $updated]);
// Two different reasons a selected file can go unchanged, and they
// are not the same sentence. Files dropped by the Gate::allows
// filter above are ones this user may not edit at all. A file that
// survived the filter and still changed nothing was editable --
// every field they asked to change was one their role does not let
// them set, which is the case the single-file editor states
// separately too. Reporting the first reason for the second told a
// staff member with edit_files but without set_file_expiration_date
// that three files they own are not theirs to edit.
$unreachable = $requested - $files->count();
$message = match (true) {
$updated === $requested => trans_choice(':count file updated.|:count files updated.', $updated, ['count' => $updated]),
$updated + $unreachable === $requested => __(':updated of :requested selected files were updated. The rest were skipped because you don\'t have permission to edit them.', ['updated' => $updated, 'requested' => $requested]),
default => __(':updated of :requested selected files were updated. The rest were skipped because you don\'t have permission to make those changes.', ['updated' => $updated, 'requested' => $requested]),
};
return back()->with('success', $message);
}
@@ -516,8 +557,13 @@ 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]);
@@ -526,18 +572,39 @@ class FilesController extends Controller
}
/**
* The instant a `<input type="date">` expiry actually falls on.
* Delete several files at once, from the staff selection bar (#1800).
*
* 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.
* Each file is asked exactly what destroy() asks, through the same
* policy, and gets the same soft delete and the same activity entry: a
* batch is a shorthand for single deletes, never a way around one. A
* file the person may not delete is dropped from the batch rather than
* failing it, the convention bulkUpdate() follows. Nothing left to
* delete is a 422, so the page does not report a success.
*/
private function expiryInstant(?string $date, ?User $setter): ?Carbon
public function bulkDestroy(Request $request): RedirectResponse
{
return $date === null
? null
: LocalDay::end($date, $this->timezones->resolve($setter));
$user = $request->user();
assert($user !== null);
$validated = $request->validate([
'file_ids' => ['required', 'array', 'min:1'],
'file_ids.*' => ['integer', 'distinct'],
]);
$files = File::query()->whereIn('id', $validated['file_ids'])->get()
->filter(fn (File $file): bool => Gate::forUser($user)->allows('delete', $file));
abort_if($files->isEmpty(), 422, __('None of the selected files could be deleted.'));
DB::transaction(function () use ($files): void {
foreach ($files as $file) {
$name = $file->name;
$file->delete();
$this->activity->log(Action::FileDeleted, context: ['name' => $name]);
}
});
return back()->with('success', trans_choice(':count file deleted.|:count files deleted.', $files->count(), ['count' => $files->count()]));
}
}
@@ -5,31 +5,26 @@ declare(strict_types=1);
namespace App\Modules\Files\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Http\Controllers\Concerns\ResolvesShareTargets;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Models\FolderAssignment;
use App\Modules\Notifications\NotificationDigester;
use App\Modules\Notifications\Notifier;
use App\Modules\Files\Sharing\FolderSharing;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
/**
* Sharing a folder with a client or group grants live access to its
* whole subtree. Mirrors FileAssignmentsController.
* whole subtree. Mirrors FileAssignmentsController; the effects live in
* FolderSharing, shared with the API.
*/
class FolderAssignmentsController extends Controller
{
use ResolvesShareTargets;
public function __construct(
private readonly ActivityLogger $activity,
private readonly StaffLibraryScope $scope,
private readonly NotificationDigester $digester,
private readonly Notifier $notifier,
private readonly FolderSharing $sharing,
) {}
public function store(Request $request, Folder $folder): RedirectResponse
@@ -41,20 +36,7 @@ class FolderAssignmentsController extends Controller
__('Folders can only be shared with clients or groups.'),
);
FolderAssignment::query()->firstOrCreate([
'folder_id' => $folder->id,
'assignable_type' => $this->assignableType($assignable),
'assignable_id' => $assignable->getKey(),
]);
$this->activity->log(Action::FolderShared, subject: $folder, context: ['target' => $targetName]);
$recipients = $this->shareRecipients($assignable);
$this->notifier->send('file_shared', $recipients, subject: $folder, data: ['itemName' => $folder->name]);
// The master switch and each recipient's own preference are the
// digester's job now — every caller was repeating them.
$this->digester->queue('file_shared', $recipients, $folder->name, ['is_folder' => true]);
$this->sharing->assign($folder, $assignable, $targetName);
return back();
}
@@ -68,15 +50,7 @@ class FolderAssignmentsController extends Controller
__('Folders can only be shared with clients or groups.'),
);
$deleted = FolderAssignment::query()
->where('folder_id', $folder->id)
->where('assignable_type', $this->assignableType($assignable))
->where('assignable_id', $assignable->getKey())
->delete();
if ($deleted > 0) {
$this->activity->log(Action::FolderUnshared, subject: $folder, context: ['target' => $targetName]);
}
$this->sharing->unassign($folder, $assignable, $targetName);
return back();
}
@@ -10,16 +10,22 @@ 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;
use App\Modules\Files\Folders\BreadcrumbBuilder;
use App\Modules\Files\Folders\FolderService;
use App\Modules\Files\Folders\UndeletableFiles;
use App\Modules\Files\Models\Category;
use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\NotScannedReason;
use App\Modules\Files\Scanning\ScanningConfig;
use App\Modules\Files\Scanning\ScanStatus;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Versions\FileVersionLinks;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\Models\Role;
use App\Support\ConcatenatedPagination;
use App\Support\Pagination;
use App\Support\PublicUrl;
@@ -54,11 +60,13 @@ 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,
private readonly FileVersionLinks $versionLinks,
private readonly DownloadAllowance $allowance,
private readonly UndeletableFiles $undeletable,
) {}
/**
@@ -82,6 +90,15 @@ class FoldersController extends Controller
'search' => ['nullable', 'string', 'max:255'],
'folder' => ['nullable', 'integer'],
'category' => ['nullable', 'integer', 'exists:categories,id'],
'uploader' => ['nullable', 'integer', 'exists:users,id'],
'visibility' => ['nullable', 'in:public,private'],
'downloads' => ['nullable', 'in:none,any'],
'role' => ['nullable', 'integer', 'exists:roles,id'],
// "current" is every file nothing has replaced, which includes
// a file that was never versioned at all -- it is the current
// version of itself. "outdated" is the same word the version
// badge uses, so the filter and the row agree.
'version' => ['nullable', 'in:current,outdated'],
// Not a 'boolean' rule: that only accepts true/false/0/1/'0'/'1',
// rejecting the literal "true"/"" the frontend checkbox sends.
// $request->boolean() below coerces any of those safely, so
@@ -90,12 +107,25 @@ class FoldersController extends Controller
$search = trim($validated['search'] ?? '');
$searching = $search !== '';
$categoryId = $validated['category'] ?? null;
// Cast, because `integer` validates a numeric string without
// converting it -- so these arrive as "5" from the query string.
// permitsClientId() below takes a strict ?int and 500s on a string,
// and the props these become are typed `number | null` on the page.
$uploaderId = isset($validated['uploader']) ? (int) $validated['uploader'] : null;
$visibility = $validated['visibility'] ?? null;
$downloads = $validated['downloads'] ?? null;
$roleId = isset($validated['role']) ? (int) $validated['role'] : null;
$version = $validated['version'] ?? null;
$expired = $request->boolean('expired');
// A search term, a category filter, or the expired-only filter all
// switch to a flat view across the whole visible library;
// otherwise it's folder browsing.
$flat = $searching || $categoryId !== null || $expired;
// A search term or any filter switches to a flat view across the
// whole visible library; otherwise it's folder browsing. Every
// filter here is a property of a *file*, so in flat mode the folder
// sequence stays empty unless there is a search term to match names
// against -- which is what the existing branch below already does.
$flat = $searching || $categoryId !== null || $expired
|| $uploaderId !== null || $visibility !== null
|| $downloads !== null || $roleId !== null || $version !== null;
$folderQuery = $this->scope->folders($user)->withCount(['children', 'files']);
// `downloads` unconditionally — the library has always shown a
@@ -103,7 +133,18 @@ class FoldersController extends Controller
// something on this install is actually limited.
$fileQuery = $this->allowance->withOwnCount(
$this->scope->files($user)->with('uploader.role', 'categories', 'folder')
->withCount(['assignments', 'downloads']),
->withCount(['assignments', 'downloads'])
// A file the scanner refused, or one whose bytes are gone,
// is not a file anybody can work with: every button on its
// row leads somewhere that refuses, and the download leads
// to an error page. They are listed on the two screens
// that exist to act on them — Quarantine, and Files
// missing from storage — and left out here.
->whereNotIn('scan_status', [
ScanStatus::Infected->value,
ScanStatus::UnscannableBlocked->value,
ScanStatus::Missing->value,
]),
$user,
);
@@ -118,6 +159,29 @@ class FoldersController extends Controller
->when($categoryId !== null, fn (Builder $q) => $q
->whereHas('categories', fn (Builder $c) => $c->where('categories.id', $categoryId)))
->when($expired, fn (Builder $q) => $q->expired())
// The same guard /api/v1/files puts on `uploaded_by`, and it
// is needed for the same reason. A filter is a question, and
// this one asks "did user N put anything into my library".
// fileRow() already withholds an uploader's name from a
// viewer who may not identify them -- so answering this
// plainly would hand back, as a row count, precisely the
// identity the row itself is redacting. An id this caller
// may not identify matches nothing, which is
// indistinguishable from someone who has uploaded nothing.
->when($uploaderId !== null && ! $this->identity->permitsClientId($user, $uploaderId),
fn (Builder $q) => $q->whereRaw('1 = 0'))
->when($uploaderId !== null, fn (Builder $q) => $q->where('uploaded_by', $uploaderId))
->when($roleId !== null, fn (Builder $q) => $q
->whereHas('uploader', fn (Builder $u) => $u->where('role_id', $roleId)))
// has/doesn't-have rather than a comparison on the
// withCount alias: an aggregate cannot be filtered in a
// WHERE, and `downloads_count = 0` in a HAVING would be
// applied after the pagination slice above.
->when($downloads === 'none', fn (Builder $q) => $q->whereDoesntHave('downloads'))
->when($downloads === 'any', fn (Builder $q) => $q->whereHas('downloads'))
->when($version === 'current', fn (Builder $q) => $q->whereDoesntHave('nextVersion'))
->when($version === 'outdated', fn (Builder $q) => $q->whereHas('nextVersion'))
->when($visibility !== null, fn (Builder $q) => $q->effectivelyPublic($visibility === 'public'))
->orderBy('name');
} else {
$current = $request->integer('folder') > 0
@@ -160,6 +224,11 @@ class FoldersController extends Controller
'search' => $search !== '' ? $search : null,
'folder' => $current?->id,
'category' => $categoryId,
'uploader' => $uploaderId,
'visibility' => $visibility,
'downloads' => $downloads,
'role' => $roleId,
'version' => $version,
'expired' => $expired ? 'true' : null,
'page' => Pagination::redirectPage($sliced['paginator']),
]));
@@ -174,15 +243,29 @@ class FoldersController extends Controller
// as the comment counts above.
$versions = $this->versionLinks->forMany($fileRows, $user, fn (File $other): string => route('files.edit', $other, false));
// Two queries for the whole page, not one per row. `distinct` on an
// indexed foreign key rather than a join, because all this needs is
// the set of ids -- the names come back with the roles in one go.
$uploaders = User::query()
->whereIn('id', $this->scope->files($user)->whereNotNull('uploaded_by')->distinct()->pluck('uploaded_by'))
->with('role')
->orderBy('name')
->get(['id', 'name', 'role_id']);
return Inertia::render('files/index', [
'folder' => $current === null ? null : ['id' => $current->id, 'name' => $current->name],
'breadcrumb' => $flat ? [] : $this->breadcrumbs->for($current),
'breadcrumb' => $flat ? [] : $this->breadcrumb($user, $current),
'folders' => $folderRows->map(fn (Folder $folder): array => $this->folderRow($user, $folder))->all(),
'files' => $fileRows->map(fn (File $file): array => $this->fileRow($user, $file, $commentCounts, $pendingCounts, $versions))->all(),
'pagination' => Pagination::meta($sliced['paginator']),
'search' => $search,
'searching' => $flat,
'category' => $categoryId,
'uploader' => $uploaderId,
'visibility' => $visibility,
'downloads' => $downloads,
'role' => $roleId,
'version' => $version,
'expired' => $expired,
'categories' => Category::query()->orderBy('name')->get(['id', 'name', 'color'])
->map(fn (Category $category): array => ['id' => $category->id, 'name' => $category->name, 'color' => $category->color])->all(),
@@ -192,6 +275,18 @@ class FoldersController extends Controller
// every folder name and id on the installation.
'folder_options' => $this->scope->folders($user)->orderBy('path')->orderBy('name')->get()
->map(fn (Folder $folder): array => ['id' => $folder->id, 'name' => $folder->name])->all(),
// Only people who actually uploaded something *this viewer can
// see*, and their roles taken from the same set. Narrowed for
// the reason folder_options directly above is: an unscoped list
// would hand a client-scoped staffer the name and id of every
// account on the installation, through a filter dropdown.
// Through filterClientPairs, so the dropdown never offers a name
// this viewer may not be told -- the same rule fileRow() applies
// to the uploader on each row, asked once for the whole list.
'uploader_options' => $this->identity->filterClientPairs($user, array_values($uploaders
->map(fn (User $uploader): array => ['id' => $uploader->id, 'name' => $uploader->name])->all())),
'role_options' => $uploaders->pluck('role')->filter()->unique('id')->sortBy('name')->values()
->map(fn (Role $role): array => ['id' => $role->id, 'name' => $role->name])->all(),
'can_create_folders' => $user->can('create_own_folders'),
'can_upload' => $user->can('upload'),
'can_manage_public' => $user->can('upload_public'),
@@ -199,6 +294,39 @@ class FoldersController extends Controller
]);
}
/**
* What the scanner made of a file, for a staff member's list.
*
* Staff see every file they always saw, with its state on it —
* withholding applies to recipients, not to the library. Null while
* scanning is off so nothing is decorated on an installation that does
* not use it.
*
* @return array{status: string, note: string|null}|null
*/
private function scanState(File $file): ?array
{
if (! app(ScanningConfig::class)->enabled() && $file->scan_status === ScanStatus::NotScanned) {
return null;
}
// A file from before the scanner existed carries no reason — see
// File::scopeNeverScanned — and "Not scanned" with no explanation
// is the one badge somebody would have to come and ask about.
$note = $file->scan_note ?? ($file->scan_status === ScanStatus::NotScanned
? NotScannedReason::BeforeScanning->value
: null);
return [
'status' => $file->scan_status->value,
// A reason is a key and is translated here; a threat name is
// the scanner's own words and is passed through.
'note' => $note === null ? null : (NotScannedReason::tryFrom($note)?->label() !== null
? (string) __(NotScannedReason::from($note)->label())
: $note),
];
}
/**
* @return array<string, mixed>
*/
@@ -240,13 +368,20 @@ 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,
] : null,
'public' => $file->isEffectivelyPublic(),
'expired' => $file->isExpired(),
// Null while scanning is off, so a library that does not use
// it carries no badge.
'scan' => $this->scanState($file),
// No link at all once expired — the public route 404s past
// expiry too (see File::scopeNotExpired's callers), so there's
// no point offering a button that leads to a dead page.
@@ -293,7 +428,7 @@ class FoldersController extends Controller
'public_url' => $folder->public
? $this->publicUrl->for($folder)
: null,
'breadcrumb' => $this->breadcrumbs->for($folder),
'breadcrumb' => $this->breadcrumb($user, $folder),
'can_update' => Gate::forUser($user)->allows('update', $folder),
'can_manage_public' => $user->can('upload_public'),
...$this->shareTargets->forSubject($folder, $user),
@@ -317,6 +452,11 @@ class FoldersController extends Controller
$parent = $this->resolveParent($user, $validated['parent_id'] ?? null);
// A folder inside a public one is public, so creating it there is
// placing content into a public folder: the question every other
// write of a parent_id already asks (Folder::uploadableBy).
abort_unless(Folder::uploadableBy($user, $parent), 403);
$folder = $this->folders->create($validated['name'], $parent);
// Only a user who can manage public state may set it on create —
@@ -396,7 +536,20 @@ class FoldersController extends Controller
'parent_id' => Rules::folderId(),
]);
$newParent = $this->resolveParent($request->user(), $validated['parent_id'] ?? null);
$user = $request->user();
$newParent = $this->resolveParent($user, $validated['parent_id'] ?? null);
// A folder carries its contents with it, and a folder inside a
// public one is public — isEffectivelyPublic() reads the whole
// ancestry. So dropping a private folder into a public parent
// publishes every file in its subtree at once, which is the same
// act the upload path refuses without `upload_public`. The flag on
// this screen is already guarded (update() above leaves public
// state alone without the permission); the placement was not
// (GHSA-rxf8-wh8v-jm9j).
if ($user !== null) {
abort_unless(Folder::uploadableBy($user, $newParent), 403);
}
$this->folders->move($folder, $newParent);
@@ -412,16 +565,9 @@ class FoldersController extends Controller
$viewer = $request->user();
assert($viewer !== null);
// Deleting a folder cascades to every file in its subtree, and a
// File's `deleted` hook removes the bytes from disk — there is no
// restore. Authorizing the folder is not authorizing its contents:
// FilePolicy::delete asks for `delete_others_files` on somebody
// else's upload, and for the library boundary on top of that, and
// neither question is asked anywhere on this path.
//
// MyFoldersController::destroy already refuses for the client half
// of the same cascade, in the same words. This is the staff half.
$blocked = $this->undeletableFileCount($viewer, $folder);
// Authorizing the folder is not authorizing the files the cascade
// takes with it — see UndeletableFiles, which the API asks too.
$blocked = $this->undeletable->count($viewer, $folder);
if ($blocked > 0) {
return back()->with('error', trans_choice(
@@ -442,47 +588,28 @@ class FoldersController extends Controller
}
/**
* How many files in this folder's subtree the viewer may not delete.
* The trail to $folder, trimmed for a client-scoped staff member to
* start at the first folder their library shows them: one of their
* clients' folders can sit inside somebody else's tree, and the names
* above it are not theirs to read. The client portal trims the same way.
*
* Asked as one count rather than FilePolicy::delete per file: a folder
* can hold thousands, Gate resolves a fresh policy for every check, and
* a per-row policy check on a listing is the cost 0a8b609e went to
* some trouble to remove. The two halves of FilePolicy::delete are
* expressible in SQL — the permission half is constant for this
* viewer, and the library half is the query StaffLibraryScope already
* memoises per request.
*
* Somebody holding both delete permissions and no library scope can
* delete anything in the subtree by construction, so they never pay for
* the query at all.
* @return list<array{id: int, name: string}>
*/
private function undeletableFileCount(User $viewer, Folder $folder): int
private function breadcrumb(User $user, ?Folder $folder): array
{
$mayDeleteOwn = $viewer->can('delete_files');
$mayDeleteOthers = $viewer->can('delete_others_files');
$scoped = $viewer->isClientScoped();
if ($mayDeleteOwn && $mayDeleteOthers && ! $scoped) {
return 0;
if ($folder === null || ! $user->isClientScoped()) {
return $this->breadcrumbs->for($folder);
}
return File::query()
->whereIn('folder_id', $folder->subtreeFolderIds())
->where(function (Builder $outer) use ($viewer, $mayDeleteOwn, $mayDeleteOthers, $scoped): void {
if (! $mayDeleteOwn) {
$outer->orWhere('uploaded_by', $viewer->id);
}
$visibleIds = array_values(array_map(
'intval',
$this->scope->folders($user)
->whereIn('folders.id', [...$folder->ancestorIds(), $folder->id])
->pluck('folders.id')
->all(),
));
if (! $mayDeleteOthers) {
$outer->orWhere(fn (Builder $others): Builder => $others
->whereNull('uploaded_by')->orWhere('uploaded_by', '!=', $viewer->id));
}
if ($scoped) {
$outer->orWhereNotIn('id', $this->scope->files($viewer)->select('id'));
}
})
->count();
return $this->breadcrumbs->visible($folder, $visibleIds);
}
private function resolveParent(?User $user, ?int $parentId): ?Folder
@@ -5,14 +5,25 @@ 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\Access\OwnFileDownloads;
use App\Modules\Files\DownloadLimitScope;
use App\Modules\Files\Editing\ApplyFileEdits;
use App\Modules\Files\Editing\FileExpiry;
use App\Modules\Files\Events\ResolvingUploadNotice;
use App\Modules\Files\Folders\BreadcrumbBuilder;
use App\Modules\Files\Folders\ClientHomeFolders;
use App\Modules\Files\Models\Category;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Models\ShareLink;
use App\Modules\Files\Sharing\ClientShareLinks;
use App\Modules\Files\Uploads\UploadExtensionPolicy;
use App\Modules\Files\Versions\FileVersionLinks;
use App\Modules\Files\Versions\FileVersions;
@@ -22,6 +33,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;
@@ -61,11 +73,17 @@ class MyFilesController extends Controller
private readonly PublicThemeRegistry $themes,
private readonly CapabilityRegistry $capabilities,
private readonly BreadcrumbBuilder $breadcrumbs,
private readonly ClientHomeFolders $homeFolders,
private readonly CommentingRules $commenting,
private readonly VisibleCommentScope $comments,
private readonly DownloadAllowance $allowance,
private readonly FileVersions $versions,
private readonly FileVersionLinks $versionLinks,
private readonly ClientShareLinks $shareLinks,
private readonly OwnFileDownloads $ownDownloads,
private readonly ApplyFileEdits $fileEdits,
private readonly FileExpiry $expiry,
private readonly ActivityLogger $activity,
) {}
public function index(Request $request): Response|RedirectResponse
@@ -92,6 +110,17 @@ class MyFilesController extends Controller
// folder they created themselves, anywhere in that visible tree.
$visibleIds = array_values(Folder::query()->visibleToClient($client)->pluck('id')->map(fn ($id): int => (int) $id)->all());
// Where this installation gives clients a folder of their own, it
// stands in for the root: the client opens the portal and sees what
// is inside it, not a folder named after themselves that they have
// to click through. Their own name is not information to them.
//
// It does not replace what else they can see. Folders staff shared
// with them still sit alongside -- the home is where their own
// things live, not a boundary around them.
$home = $this->homeFolders->for($client);
$homeId = $home?->id;
// A search term, a category filter, or an owner filter all switch to
// a flat, global view across everything the client may see — same
// convention as the staff library (FoldersController) uses for
@@ -132,7 +161,24 @@ class MyFilesController extends Controller
if ($current === null) {
$folders = Folder::query()
->whereIn('id', $visibleIds)
->where(fn ($q) => $q->whereNull('parent_id')->orWhereNotIn('parent_id', $visibleIds))
->where(function ($q) use ($visibleIds, $homeId): void {
// The home's own children, standing in for the root's.
if ($homeId !== null) {
$q->where('parent_id', $homeId);
}
// Plus the top of every other subtree they can see,
// with the home itself removed -- it is the level
// they are looking at, not something inside it.
$q->{$homeId === null ? 'where' : 'orWhere'}(function ($outer) use ($visibleIds, $homeId): void {
$outer->where(fn ($inner) => $inner
->whereNull('parent_id')->orWhereNotIn('parent_id', $visibleIds));
if ($homeId !== null) {
$outer->where('id', '!=', $homeId);
}
});
})
->orderBy('name');
} else {
$folders = Folder::query()
@@ -147,7 +193,12 @@ class MyFilesController extends Controller
// own listing) show here with no folder context.
$filesQuery = File::query()->visibleToClient($client);
if ($current === null) {
$filesQuery->where(fn (Builder $q) => $q->whereNull('folder_id')->orWhereNotIn('folder_id', $visibleIds));
$filesQuery->where(fn (Builder $q) => $q
->whereNull('folder_id')
->orWhereNotIn('folder_id', $visibleIds)
// Files sitting directly in the home belong to this
// level too, for the same reason its subfolders do.
->when($homeId !== null, fn (Builder $w) => $w->orWhere('folder_id', $homeId)));
} else {
$filesQuery->where('folder_id', $current->id);
}
@@ -207,15 +258,28 @@ 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())));
// One query for the page. Already narrowed to links this client
// minted on files this client uploaded — see ClientShareLinks for
// why both halves are required.
$shareUrls = $this->shareLinks->forMany($fileRows, $client);
// Also one query for the page, and also own files only — see
// OwnFileDownloads for why telling a recipient the count would be
// telling them about the other recipients.
$downloads = $this->ownDownloads->forMany($fileRows, $client);
return Inertia::render("portal/themes/{$this->themeKey()}/my-files", [
'folder' => $current === null ? null : ['id' => $current->id, 'name' => $current->name],
'breadcrumb' => $flat ? [] : $this->breadcrumbs->visible($current, $visibleIds),
// Trimmed of the home, which is the root here and so is not a
// step in the trail -- a client browsing their own subfolder
// should see "Invoices", not "Acme Ltd / Invoices".
'breadcrumb' => $flat ? [] : $this->trimHome($this->breadcrumbs->visible($current, $visibleIds), $home),
'folders' => $folderRows->map(fn (Folder $folder): array => [
'id' => $folder->id,
'name' => $folder->name,
@@ -236,7 +300,19 @@ class MyFilesController extends Controller
'mime_type' => $file->mime_type,
'size' => $file->size,
'created_at' => $file->created_at?->toIso8601String(),
// When it stops being available. Shown on the row rather
// than only on the editor, because a file that is about to
// go should say so where the client looks for it.
'expires_at' => $file->expires_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.
@@ -247,6 +323,19 @@ class MyFilesController extends Controller
// counterpart they were not given is null, not hidden by
// the theme. A theme must never filter this itself.
'version' => $versions[$file->id] ?? ['previous' => null, 'next' => null],
// The public URL for a file of their own, where one
// exists. Null on a file somebody shared with them, and
// null on their own file that has no link — a client has
// no way to mint one, so this is populated only where the
// installation did it for them. Never derived from
// is_mine: a theme renders what is here and nothing else.
'share_url' => $shareUrls[$file->id] ?? null,
// How often this went out and when it last did — the
// answer to "did it arrive?", which on a link-only
// account is the only evidence there is. Null on a file
// somebody shared with this client: not zero, which would
// be a claim about other people's activity, but nothing.
'downloads' => $downloads[$file->id] ?? null,
'categories' => $file->categories->map(fn (Category $category): array => [
'id' => $category->id, 'name' => $category->name, 'color' => $category->color,
])->values()->all(),
@@ -291,7 +380,13 @@ class MyFilesController extends Controller
abort_unless(Folder::uploadableBy($client, $folder), 403);
}
$notice = new ResolvingUploadNotice($client);
event($notice);
return Inertia::render('portal/upload', [
// Rules a package wants read before the upload — see
// ResolvingUploadNotice. An empty list shows nothing.
'notice' => $notice->lines,
'allowed_extensions' => $this->extensionPolicy->hintFor($client),
'max_file_size_mb' => (int) $this->settings->get(Setting::MaxFileSizeMb),
'part_size_mb' => (int) config('projectsend.upload_part_size_mb'),
@@ -306,6 +401,226 @@ 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),
// Their own root, where the installation gives them one. The
// form offers no "No folder" beside it: there is no such place
// for this client, and update() resolves it here anyway.
'home_folder_id' => $this->homeFolders->for($client)?->id,
'can_publish' => $client->can('upload_public'),
// The public links on this file, and where to make and revoke
// one — the same shape the staff screen uses. A file marked
// public used to say "anyone with the link can open it" and
// then show no link at all.
'share_links' => $file->shareLinks()->orderByDesc('created_at')->get()
->map(fn (ShareLink $link): array => [
'id' => $link->id,
'url' => route('share.show', $link->token),
'expires_at' => $link->expires_at?->toIso8601String(),
'downloads_count' => $link->downloads_count,
'revoke_url' => route('share-links.destroy', $link, false),
])->values()->all(),
'share_link_store_url' => route('files.share-links.store', $file, false),
'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', 'string', '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;
// "No folder" means the top of what this client sees, which on an
// installation that gives them a home folder is inside it — not the
// root of the library, beside the staff folders. Uploading and
// creating a folder already resolve it this way; the editor did
// not, so a client could move their own file out of their home and
// into the administrator's root by choosing "No folder" (reported
// by binghuo).
$folderId ??= $this->homeFolders->for($client)?->id;
// 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.
@@ -339,4 +654,23 @@ class MyFilesController extends Controller
return $this->themes->resolve(is_string($value) ? $value : 'default', $this->capabilities);
}
/**
* Drop the home folder from the front of a breadcrumb.
*
* Only from the front, and only when it is actually there: a folder
* shared with the client from elsewhere in the library has a trail of
* its own that the home has nothing to do with.
*
* @param list<array{id: int, name: string}> $trail
* @return list<array{id: int, name: string}>
*/
private function trimHome(array $trail, ?Folder $home): array
{
if ($home === null || $trail === [] || $trail[0]['id'] !== $home->id) {
return $trail;
}
return array_slice($trail, 1);
}
}
@@ -7,6 +7,7 @@ namespace App\Modules\Files\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Folders\ClientHomeFolders;
use App\Modules\Files\Folders\FolderService;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
@@ -30,6 +31,7 @@ class MyFoldersController extends Controller
public function __construct(
private readonly FolderService $folders,
private readonly ActivityLogger $activity,
private readonly ClientHomeFolders $homeFolders,
) {}
public function store(Request $request): RedirectResponse
@@ -52,6 +54,13 @@ class MyFoldersController extends Controller
$parent = Folder::query()->visibleToClient($client)->whereKey($validated['parent_id'])->firstOrFail();
}
// No parent named means the top of what this client sees -- which,
// where the installation gives them a home, is inside it rather
// than at the root of the library. Without this a client creating a
// folder would put it beside the staff folders, which is precisely
// the mess the home folder exists to end.
$parent ??= $this->homeFolders->for($client);
$folder = $this->folders->create($validated['name'], $parent);
$this->activity->log(Action::FolderCreated, subject: $folder);
@@ -7,7 +7,9 @@ namespace App\Modules\Files\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Models\File;
use App\Modules\Files\OrphanFileScanner;
use App\Modules\Files\Scanning\ScanStatus;
use App\Modules\Files\Uploads\StoreUploadedFile;
use App\Support\Pagination;
use Illuminate\Contracts\Filesystem\Filesystem;
@@ -45,6 +47,14 @@ class OrphanFilesController extends Controller
$validated = $request->validate(['search' => ['nullable', 'string', 'max:255']]);
$search = trim($validated['search'] ?? '');
// The mirror image of this screen, on the same screen: bytes with
// no row, and rows with no bytes. They are the same fault seen
// from either end, and an administrator looking into one has
// every reason to look at the other.
if ($request->query('tab') === 'missing') {
return $this->missing($request);
}
// A full disk scan (potentially thousands of entries, across
// every scanned disk) happens once per request regardless of
// page — Storage::allFiles() has no server-side paging of its
@@ -75,10 +85,51 @@ class OrphanFilesController extends Controller
);
return Inertia::render('files/orphans', [
'tab' => 'orphans',
'orphans' => $paginator->items(),
'pagination' => Pagination::meta($paginator),
'search' => $search,
'scanned_disks' => $this->scanner->scannedDisks(),
'missing_count' => File::query()->where('scan_status', ScanStatus::Missing)->count(),
]);
}
/**
* Files this installation lists and cannot produce.
*
* Read from the rows rather than from the disk: the daily check
* (projectsend:check-missing-files) has already done the comparing,
* and repeating a full disk listing on every page load would make
* this screen slower the worse the problem is.
*/
private function missing(Request $request): Response
{
$missing = File::query()
->where('scan_status', ScanStatus::Missing)
->with('uploader')
->orderBy('name')
->paginate(self::PER_PAGE)
->withQueryString();
$missing->through(fn (File $file): array => [
'id' => $file->id,
'name' => $file->name,
'original_name' => $file->original_name,
'size' => $file->size,
'disk' => $file->disk,
'path' => $file->path,
'uploader' => $file->uploader?->name,
'created_at' => $file->created_at?->toIso8601String(),
]);
return Inertia::render('files/orphans', [
'tab' => 'missing',
'orphans' => [],
'pagination' => Pagination::meta($missing),
'search' => '',
'scanned_disks' => $this->scanner->scannedDisks(),
'missing' => $missing->items(),
'missing_count' => $missing->total(),
]);
}
@@ -11,11 +11,14 @@ use App\Modules\Files\Access\DownloadAllowance;
use App\Modules\Files\Delivery\StoredFileResponse;
use App\Modules\Files\Models\Category;
use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\FileAvailability;
use App\Modules\Files\Scanning\ScanningConfig;
use App\Modules\Files\Scanning\ScanStatus;
use App\Modules\Files\Models\ShareLink;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response;
use Inertia\Inertia;
use Inertia\Response as InertiaResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* The public, unauthenticated side of a share link: no Gate/policy is
@@ -29,6 +32,8 @@ class PublicShareController extends Controller
private readonly ActivityLogger $activity,
private readonly DownloadAllowance $allowance,
private readonly StoredFileResponse $bytes,
private readonly FileAvailability $availability,
private readonly ScanningConfig $scanning,
) {}
public function show(string $token): InertiaResponse
@@ -36,7 +41,12 @@ class PublicShareController extends Controller
$shareLink = ShareLink::query()->where('token', $token)->first();
$file = $shareLink?->shareable;
if ($shareLink === null || ! $file instanceof File) {
// A withdrawn file answers exactly as a link that never existed.
// Its uploader deleted their account, and "this was here once" is
// itself something they asked to stop saying. The link row stays,
// so an account that is restored is served again. See
// SelfDeletion.
if ($shareLink === null || ! $file instanceof File || $file->isWithdrawn()) {
return Inertia::render('share/show', ['status' => 'not_found']);
}
@@ -47,6 +57,16 @@ class PublicShareController extends Controller
return Inertia::render('share/show', ['status' => 'expired']);
}
// A link can be minted the moment a file is stored — the hosted
// free plan does exactly that — so the link routinely exists
// before the scanner has finished. It says so rather than 404ing:
// the visitor was sent a real link and it will work shortly.
if (! $this->availability->isAvailable($file)) {
return Inertia::render('share/show', [
'status' => $file->scan_status === ScanStatus::Pending ? 'checking' : 'unavailable',
]);
}
// Two separate caps reach the same page: the link's own
// max_downloads, and the file's. A visitor here has no account,
// so the file's limit is measured against the whole file — see
@@ -71,6 +91,11 @@ class PublicShareController extends Controller
])->values()->all(),
],
'download_url' => route('share.download', $token),
// Said to the one person who can neither see the setting nor
// chose it. The uploader and the staff library both show this
// file as "not scanned"; whoever follows the link had no way
// of knowing.
'unscanned' => $this->scanning->enabled() && $file->wasLetThrough(),
]);
}
@@ -79,7 +104,13 @@ class PublicShareController extends Controller
$shareLink = ShareLink::query()->where('token', $token)->first();
$file = $shareLink?->shareable;
if ($shareLink === null || ! $file instanceof File || $shareLink->isExpired() || $file->isExpired()) {
if ($shareLink === null || ! $file instanceof File || $file->isWithdrawn() || $shareLink->isExpired() || $file->isExpired()) {
return redirect()->route('share.show', $token);
}
// Same for a file still being checked, and for the same reason
// the limit is asked before the counter moves.
if (! $this->availability->isAvailable($file)) {
return redirect()->route('share.show', $token);
}
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\FileAvailability;
use App\Modules\Files\Scanning\NotScannedReason;
use App\Modules\Files\Scanning\ScanStatus;
use App\Support\Pagination;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
/**
* The files the virus scanner refused, and the one decision a person can
* make about them.
*
* Nothing is deleted here automatically and nothing expires out of this
* list: a quarantined file waits for somebody. Deleting one is the
* ordinary file deletion, with its ordinary permission — this screen only
* adds the other answer, which is that the scanner was wrong.
*
* Releasing is gated by a permission of its own that only the
* administrator role holds by default, and by password confirmation on
* top of it, because it is the one action in the application that
* deliberately hands out a file something reported as malicious.
*/
class QuarantineController extends Controller
{
public function __construct(
private readonly ActivityLogger $activity,
private readonly FileAvailability $availability,
private readonly StaffLibraryScope $scope,
) {}
public function index(Request $request): Response
{
$user = $request->user();
assert($user !== null);
$files = $this->quarantined($user)
->with('uploader')
->orderByDesc('scanned_at')
->paginate(25)
->withQueryString();
$files->through(fn (File $file): array => [
'id' => $file->id,
'name' => $file->name,
'original_name' => $file->original_name,
'size' => $file->size,
'uploader' => $file->uploader?->name,
// The threat name, or — for a file nothing could open — what
// stopped it being read.
'threat' => $file->scan_status === ScanStatus::UnscannableBlocked
? __(NotScannedReason::tryFrom((string) $file->scan_note)?->label() ?? 'Could not be scanned')
: $file->scan_note,
'status' => $file->scan_status->value,
'scanned_at' => $file->scanned_at?->toIso8601String(),
// True only for a file that went out unscanned while the
// scanner was unreachable and was caught later — which is the
// one case where somebody may already have a copy.
'was_available' => $file->scan_was_available,
'downloads_count' => $file->downloads()->count(),
]);
return Inertia::render('files/quarantine', [
'files' => $files->items(),
'pagination' => Pagination::meta($files),
]);
}
/**
* Overrule the scanner for one file.
*
* The reason is required and is recorded against the person who gave
* it. A release is not undone by a later scan: the file stays
* released until somebody deletes it, which is the point — an
* administrator who has decided a detection is wrong should not have
* to decide it again every hour.
*/
public function release(Request $request, File $file): RedirectResponse
{
$actor = $request->user();
assert($actor !== null);
abort_unless($this->quarantined($actor)->whereKey($file->id)->exists(), 404);
$validated = $request->validate([
'reason' => ['required', 'string', 'max:500'],
]);
$file->forceFill([
'scan_status' => ScanStatus::Released,
'released_by' => $actor->id,
'released_at' => now(),
])->save();
$this->activity->log(Action::FileReleased, subject: $file, context: [
'reason' => $validated['reason'],
'threat' => $file->scan_note,
]);
// Everything that was waiting on this file — a share email, a new
// version notice — goes out now, exactly as it would have if the
// scan had passed.
$this->availability->markAvailable($file);
return back()->with('success', __('The file has been released.'));
}
/**
* The quarantined files this person may see and release.
*
* A client-scoped staff member gets their own clients' uploads and
* their own, the same boundary as the rest of the library. The
* permission alone let one read every quarantined file on the
* installation, and release a file belonging to a client they could
* not otherwise open.
*
* @return Builder<File>
*/
private function quarantined(User $user): Builder
{
$query = File::query()
->whereIn('scan_status', [ScanStatus::Infected->value, ScanStatus::UnscannableBlocked->value]);
$uploaders = $this->scope->uploaderIds($user);
return $uploaders === null ? $query : $query->whereIn('uploaded_by', $uploaders);
}
}
@@ -9,6 +9,7 @@ use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\ShareLink;
use App\Modules\Files\Sharing\CreateShareLink;
use App\Modules\Platform\Localization\LocalDay;
use App\Modules\Platform\Localization\TimezoneRegistry;
use Illuminate\Http\RedirectResponse;
@@ -30,19 +31,29 @@ class ShareLinksController extends Controller
public function __construct(
private readonly ActivityLogger $activity,
private readonly TimezoneRegistry $timezones,
private readonly CreateShareLink $links,
) {}
public function store(Request $request, File $file): RedirectResponse
{
Gate::authorize('update', $file);
$user = $request->user();
assert($user !== null);
// Making a link is publishing, so it asks the publishing key. Staff
// are not asked for it, as they never have been: `update` on the
// file is their boundary and this would be a new refusal on every
// installation that upgraded.
abort_unless($user->isStaff() || $user->can('upload_public'), 403);
$validated = $request->validate([
// Deliberately not `after:now`: that rule reads the bare
// YYYY-MM-DD the picker posts as midnight UTC, so a creator
// far enough east would be told today's date is in the past
// while it is plainly still today where they are. The check
// moves below, onto the instant the date actually resolves to.
'expires_at' => ['nullable', 'date'],
'expires_at' => ['nullable', 'string', 'date'],
'max_downloads' => ['nullable', 'integer', 'min:1'],
// A custom token is optional — leave blank for a random one,
// same as before. Must not collide with the file's own
@@ -80,16 +91,27 @@ class ShareLinksController extends Controller
]);
}
ShareLink::query()->create([
'shareable_type' => $file->getMorphClass(),
'shareable_id' => $file->id,
'token' => $validated['token'] ?? Str::random(32),
'created_by' => $user->id,
'expires_at' => $user->can('set_file_expiration_date') ? $expiresAt : null,
'max_downloads' => $user->can('limit_downloads') ? $validated['max_downloads'] ?? null : null,
]);
$this->activity->log(Action::ShareLinkCreated, subject: $file);
// The permission gates stay here, where the request is: whether
// this person may set an expiry or a cap is a fact about them,
// not about link creation, and the action has no viewer to ask.
$this->links->for(
file: $file,
creator: $user,
expiresAt: $user->can('set_file_expiration_date') ? $expiresAt : null,
// Cast, and null kept as null rather than falling through a
// bare (int) that would turn "no cap" into a cap of zero. The
// `integer` rule validates a numeric string without converting
// it, and this file is strict_types, so an uncast "5" is a
// TypeError against `?int $maxDownloads`. Nothing sends one
// today only because files/edit.tsx calls Number() first --
// which is a fact about a frontend file, not a guarantee this
// signature has. It cost a 500 on the client form, where the
// same field was typed as a string.
maxDownloads: $user->can('limit_downloads') && ($validated['max_downloads'] ?? null) !== null
? (int) $validated['max_downloads']
: null,
token: $validated['token'] ?? null,
);
return back()->with('success', __('Public link created.'));
}
@@ -99,6 +121,9 @@ class ShareLinksController extends Controller
$file = $shareLink->shareable;
abort_unless($file instanceof File, 404);
// Deliberately without the publishing key that store() asks for:
// revoking takes access away. Somebody whose permission to publish
// was withdrawn must still be able to undo what they published.
Gate::authorize('update', $file);
$shareLink->delete();
@@ -0,0 +1,428 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Jobs\ScanFileJob;
use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\NotScannedReason;
use App\Modules\Files\Scanning\ScannerAddress;
use App\Modules\Files\Scanning\ScanningConfig;
use App\Modules\Files\Scanning\ScanOutcome;
use App\Modules\Files\Scanning\ScanStatus;
use App\Modules\Files\Scanning\VirusScanner;
use App\Modules\Platform\Capabilities\Capability;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Queue;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
/**
* The virus scanning screen.
*
* Two of these settings decide what happens when the scanner cannot
* answer, and both default to letting files through. That is a
* deliberate choice (see docs/feature-virus-scanning.md) and it is the
* reason this screen states the count of files currently allowed through
* unscanned rather than leaving it to be discovered: a scanner that has
* quietly stopped protecting anything looks exactly like one that is
* working.
*
* Where a managed configuration names a scanner, the connection is not
* this screen's to change and scanning cannot be switched off — the
* policies still are. Same shape as the CAPTCHA screen under managed
* keys.
*/
class VirusScanningSettingsController extends Controller
{
/**
* A zip holding check.txt ("ProjectSend checks that the scanner
* reports encrypted archives."), encrypted with the password
* "projectsend". Made with `zip -P`.
*/
private const ENCRYPTED_ARCHIVE = 'UEsDBBQACQAIAACon1toMefdSgAAAEAAAAAJAAAAY2hlY2sudHh0prvUiniNGfEwEalXOcDbsYylfm2yAcyjplSfHJqk2sSxcVWFx0omz5AvASvSRdDbfeSQ+CC2qu6JEP/NYbBKy+g5t2nJr4swpv9QSwcIaDHn3UoAAABAAAAAUEsBAh4DFAAJAAgAAKifW2gx591KAAAAQAAAAAkAAAAAAAAAAQAAALSBAAAAAGNoZWNrLnR4dFBLBQYAAAAAAQABADcAAACBAAAAAAA=';
public function __construct(
private readonly Settings $settings,
private readonly ScanningConfig $config,
private readonly ActivityLogger $activity,
private readonly CapabilityRegistry $capabilities,
) {}
public function edit(Request $request): Response
{
return Inertia::render('system/settings/virus-scanning', [
// Which half of the screen is open. The connection and the
// policies are two different jobs — one is done once when the
// scanner is set up, the other is revisited — and a single
// column of fields with two Save buttons reads as one form
// that saves half of itself.
'tab' => in_array($request->query('tab'), ['options', 'activity'], true)
? (string) $request->query('tab')
: 'scanner',
// Read from the session here rather than shared as a flash
// prop: HandleInertiaRequests shares `success` and `error` and
// nothing else, which is why the Test button appeared to do
// nothing at all. Same shape the CAPTCHA screen uses.
'test_result' => $request->session()->get('scanner_test_result'),
'enabled' => $this->config->enabled(),
// Two different reasons the connection is not this screen's to
// change: a managed configuration names the scanner, or this
// edition does not connect scanners at all. The screen says
// the same thing for both, since to the person reading it
// they are the same fact.
'managed' => $this->config->isManaged() || ! $this->canConnect(),
// Distinct from `managed`, which covers two different reasons
// the address is not editable. A managed installation still
// has a scanner worth testing; one that does not connect
// scanners at all has nothing to test, and the endpoint says
// so with a 403.
'can_test' => $this->canConnect(),
'address' => $this->config->isManaged() ? '' : $this->settings->get(Setting::VirusScannerAddress),
'max_size_mb' => $this->settings->get(Setting::VirusScanMaxSizeMb),
'unscannable_policy' => $this->settings->get(Setting::VirusUnscannablePolicy),
'scanner_down_policy' => $this->settings->get(Setting::VirusScannerDownPolicy),
'wait_minutes' => $this->settings->get(Setting::VirusScannerWaitMinutes),
'existing_rate_per_minute' => $this->settings->get(Setting::VirusScanExistingRatePerMinute),
'counts' => $this->counts(),
]);
}
public function update(Request $request): RedirectResponse
{
$validated = $request->validate([
'enabled' => ['required', 'boolean'],
'address' => ['nullable', 'string', 'max:255'],
'max_size_mb' => ['required', 'integer', 'min:0', 'max:4096'],
'unscannable_policy' => ['required', Rule::in(['allow', 'block'])],
'scanner_down_policy' => ['required', Rule::in(['allow', 'hold'])],
'wait_minutes' => ['required', 'integer', 'min:1', 'max:1440'],
'existing_rate_per_minute' => ['required', 'integer', 'min:1', 'max:6000'],
]);
// A managed installation may still choose its policies. The
// connection and the switch are not on the screen there, and a
// request that sends them anyway changes nothing.
if (! $this->config->isManaged() && $this->canConnect()) {
$address = trim((string) ($validated['address'] ?? ''));
// Refused rather than saved and quietly inert: switching this
// on with nowhere to send files would leave every upload
// waiting for a scanner that does not exist.
if ($request->boolean('enabled') && $address === '') {
return back()->withErrors(['address' => __('Enter the address of your scanner first.')]);
}
// Checked here rather than left to the socket, which accepts
// more than it should — see ScannerAddress.
if ($address !== '' && ! ScannerAddress::isValid($address)) {
return back()->withErrors(['address' => __(ScannerAddress::message())]);
}
$this->settings->set(Setting::VirusScannerAddress, $address);
$this->settings->set(Setting::VirusScanningEnabled, $request->boolean('enabled'));
}
$this->settings->set(Setting::VirusScanMaxSizeMb, (int) $validated['max_size_mb']);
$this->settings->set(Setting::VirusUnscannablePolicy, $validated['unscannable_policy']);
$this->settings->set(Setting::VirusScannerDownPolicy, $validated['scanner_down_policy']);
$this->settings->set(Setting::VirusScannerWaitMinutes, (int) $validated['wait_minutes']);
$this->settings->set(Setting::VirusScanExistingRatePerMinute, (int) $validated['existing_rate_per_minute']);
// The scans worker is the one process that acts on every setting
// above, and it holds them in memory from the job it started on.
// Without this, switching scanning off or pointing it at another
// scanner changed the screen and nothing else until somebody
// restarted the worker.
Artisan::call('queue:restart');
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'virus_scanning']);
return back();
}
/**
* Prove the scanner is there, and that it is actually detecting.
*
* Three steps, reported separately, because "cannot connect" and
* "connects and finds nothing" are different problems and the second
* is the one that looks fine from the outside. The third sends the
* EICAR test string — a harmless sequence every engine recognises by
* agreement — so the answer is "it detected something" rather than
* "it did not complain".
*/
public function test(Request $request, VirusScanner $scanner): RedirectResponse
{
// Nothing to test where the connection is not this installation's
// to make.
abort_unless($this->canConnect(), 403);
$typed = trim((string) $request->input('address', ''));
// What the button is for: the address on screen, which on a first
// attempt has never been saved. Falls back to the stored one when
// the field is empty, so the button still answers on a screen
// somebody has not touched.
if ($typed !== '') {
if (! ScannerAddress::isValid($typed)) {
// Answered as a test result rather than as a field error:
// the person pressed Test, and this is what the test
// found. Nothing is dialled.
return back()->with('scanner_test_result', [
'ok' => false,
'message' => __(ScannerAddress::message()),
]);
}
$this->config->preview($typed);
}
$status = $scanner->status();
if (! $status->reachable) {
return back()->with('scanner_test_result', [
'ok' => false,
'message' => $status->error ?? __('The scanner could not be reached.'),
]);
}
$stream = fopen('php://temp', 'r+');
assert($stream !== false);
fwrite($stream, $this->eicar());
rewind($stream);
$verdict = $scanner->scan($stream, strlen($this->eicar()));
fclose($stream);
if ($verdict->outcome === ScanOutcome::Infected) {
// Detecting is half of it. A clamd left on its own defaults
// answers "OK" for an archive it could not open, and every
// encrypted zip would be recorded as clean — while this test
// passed. So ask it about one.
if ($this->passesEncryptedArchives($scanner)) {
return back()->with('scanner_test_result', [
'ok' => false,
'message' => __(':engine detects viruses, but reports encrypted archives as clean, so a password-protected zip would get through unchecked. Add AlertEncrypted, AlertEncryptedArchive, AlertEncryptedDoc and AlertExceedsMax, each set to yes, to its clamd.conf and restart it.', [
'engine' => $status->engine ?? __('The scanner'),
]),
]);
}
return back()->with('scanner_test_result', [
'ok' => true,
'message' => __('Working. :engine detected the EICAR test file as ":threat". EICAR is a harmless file made only for testing, and every antivirus recognises it.', [
'engine' => $status->engine ?? __('The scanner'),
'threat' => $verdict->detail ?? '',
]),
]);
}
// Reachable, and did not recognise a file every engine is supposed
// to. Almost always empty or broken virus definitions, which is
// exactly the failure nothing else would show.
return back()->with('scanner_test_result', [
'ok' => false,
'message' => __(':engine answered but did not detect the EICAR test file, a harmless file made only for testing. Check that its virus definitions are installed and up to date.', [
'engine' => $status->engine ?? __('The scanner'),
]),
]);
}
/**
* Check every file people can download again.
*
* The work itself is the command's, so this button does not hold a
* request open for a library of any size, and the pace is the setting
* above rather than "as fast as the queue will go".
*/
public function scanExisting(): RedirectResponse
{
abort_unless($this->config->enabled(), 422);
// The screen disables the button while a scan is working through
// the queue. Refused here too, because each press queues the whole
// library again, and the throttle alone allows six a minute.
if ($this->scansInQueue() > 0) {
return redirect()
->route('system-settings.virus-scanning.edit', ['tab' => 'activity'])
->with('error', __('A scan is already running. Wait for it to finish.'));
}
// --all rather than --existing: this is "New scan", and on a
// library already scanned once --existing finds nothing to do.
Artisan::queue('projectsend:scan-files', ['--all' => true]);
// Onto the tab that shows it happening rather than back where they
// were: somebody who just started a scan wants to watch it, and a
// screen that looks unchanged reads as a button that did nothing.
return redirect()
->route('system-settings.virus-scanning.edit', ['tab' => 'activity'])
->with('success', __('The scan has started.'));
}
/**
* What the scanner is doing right now, and what it last decided.
*
* Polled by the Activity tab rather than rendered with the page: a
* backfill takes minutes to hours, and a screen that only tells you
* where things stood when you opened it is the screen somebody
* reloads repeatedly instead of watching.
*
* JSON rather than an Inertia partial, the way the notification bell
* and the zip builder already poll — see use-notification-poll.ts.
*/
public function activity(): JsonResponse
{
$recent = File::query()
->whereNotNull('scanned_at')
->orderByDesc('scanned_at')
->limit(20)
->get(['id', 'name', 'scan_status', 'scan_note', 'scanned_at', 'scan_engine']);
$waiting = File::query()->where('scan_status', ScanStatus::Pending)->count();
// Counted as well as the files above, and this is the half that
// makes a backfill visible: re-scanning a file that already went
// out unchecked deliberately leaves it available, so it is not
// "pending" and a screen watching only that count says nothing is
// happening while the queue works through a whole library.
$queued = $this->scansInQueue();
return response()->json([
// "Something is happening" is the one thing a person watching
// this screen wants to know, and it is worth being explicit
// about rather than left to be inferred from a count.
'running' => $waiting > 0 || $queued > 0,
'waiting' => $waiting,
'queued' => $queued,
'checked_last_hour' => File::query()->where('scanned_at', '>=', now()->subHour())->count(),
'last_scanned_at' => $recent->first()?->scanned_at?->toIso8601String(),
'never_scanned' => File::query()->neverScanned()->count(),
'quarantined' => File::query()->whereIn('scan_status', [
ScanStatus::Infected->value,
ScanStatus::UnscannableBlocked->value,
])->count(),
'recent' => $recent->map(fn (File $file): array => [
'id' => $file->id,
'name' => $file->name,
'status' => $file->scan_status->value,
// A reason is a key and is translated; a threat name is
// the scanner's own words and is passed through.
'note' => $this->noteFor($file),
'scanned_at' => $file->scanned_at?->toIso8601String(),
'engine' => $file->scan_engine,
])->all(),
]);
}
/**
* Scan jobs waiting to run or running now.
*
* Not size(), which counts delayed jobs too. A file held while the
* scanner was down leaves a retry scheduled for up to five minutes
* after the scanner is back and the file already checked, and for
* that long the screen said "Scanning now" and refused a new scan.
*/
private function scansInQueue(): int
{
$queue = Queue::connection();
if (method_exists($queue, 'pendingSize') && method_exists($queue, 'reservedSize')) {
return (int) $queue->pendingSize('scans') + (int) $queue->reservedSize('scans');
}
return $queue->size('scans');
}
/**
* Whether this installation connects its own scanner.
*
* Community only, through the registry rather than an edition check —
* see Capability::VirusScanningConnect for the division.
*/
private function canConnect(): bool
{
return $this->capabilities->has(Capability::VirusScanningConnect);
}
private function noteFor(File $file): ?string
{
$note = $file->scan_note;
if ($note === null) {
return $file->scan_status === ScanStatus::NotScanned
? (string) __(NotScannedReason::BeforeScanning->label())
: null;
}
$reason = NotScannedReason::tryFrom($note);
return $reason === null ? $note : (string) __($reason->label());
}
/**
* @return array<string, int>
*/
private function counts(): array
{
return [
'pending' => File::query()->where('scan_status', ScanStatus::Pending)->count(),
'quarantined' => File::query()->whereIn('scan_status', [
ScanStatus::Infected->value,
ScanStatus::UnscannableBlocked->value,
])->count(),
'never_scanned' => File::query()->neverScanned()->count(),
'let_through' => File::query()->letThrough()->count(),
// What a New scan would actually check — see
// ScanFileJob::rescannableValues().
'scannable' => File::query()
->whereIn('scan_status', ScanFileJob::rescannableValues())
->count(),
// So the New scan button can refuse a second scan while one is
// still working through the queue.
'queued' => $this->scansInQueue(),
];
}
/**
* Whether the scanner calls a password-protected zip clean.
*
* The archive holds one line of text and nothing else; what matters
* is only that it cannot be opened without the password. A scanner
* set up as documented answers "encrypted".
*/
private function passesEncryptedArchives(VirusScanner $scanner): bool
{
$archive = (string) base64_decode(self::ENCRYPTED_ARCHIVE, true);
$stream = fopen('php://temp', 'r+');
assert($stream !== false);
fwrite($stream, $archive);
rewind($stream);
$verdict = $scanner->scan($stream, strlen($archive));
fclose($stream);
return $verdict->outcome === ScanOutcome::Clean;
}
private function eicar(): string
{
// Assembled rather than written out, so the repository itself
// never contains the literal string: antivirus software on a
// developer's machine quarantines files that do, and a checkout
// that deletes its own test fixtures is a bad afternoon.
return 'X5O!P%@AP[4\\PZX54(P^)7CC)7}$'.'EICAR-STANDARD-'.'ANTIVIRUS-TEST-FILE!'.'$H+H*';
}
}
@@ -8,10 +8,13 @@ use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Delivery\FileDelivery;
use App\Modules\Files\Access\DownloadAllowance;
use App\Modules\Files\Access\ViewableFileScope;
use App\Modules\Files\Jobs\BuildZipDownloadJob;
use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\ScanStatus;
use App\Modules\Files\Scanning\FileAvailability;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Models\ZipDownload;
use App\Modules\Files\Uploads\StoreUploadedFile;
@@ -21,10 +24,10 @@ use App\Support\ContentDisposition;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Number;
use Symfony\Component\HttpFoundation\Response;
/**
* A folder's "Download as zip" button and the file listing's multi-select
@@ -44,7 +47,9 @@ class ZipDownloadsController extends Controller
private readonly ActivityLogger $activity,
private readonly ViewableFileScope $viewable,
private readonly DownloadAllowance $allowance,
private readonly FileAvailability $availability,
private readonly Settings $settings,
private readonly FileDelivery $delivery,
) {}
public function store(Request $request): JsonResponse
@@ -100,7 +105,11 @@ class ZipDownloadsController extends Controller
// as many times as they were meant to is the whole point of not
// hiding exhausted files.
$selected = $files->count();
$files = $files->filter(fn (File $file): bool => $this->allowance->allows($file, $user));
// A file still being checked, or quarantined, is left out of the
// selection the same way a spent allowance leaves one out: the zip
// is bytes leaving the server, and nothing unchecked goes into one.
$files = $files->filter(fn (File $file): bool => $this->availability->isAvailable($file)
&& $this->allowance->allows($file, $user));
abort_if(
$files->isEmpty() && $folders->isEmpty() && $selected > 0,
@@ -178,6 +187,21 @@ class ZipDownloadsController extends Controller
$path = $zipDownload->path;
abort_unless($zipDownload->status === ZipDownload::STATUS_READY && $path !== null, 404);
// The build left out anything not yet available, but a file can be
// quarantined after its archive was built — a rescan with newer
// definitions, say. Nothing can be taken out of a finished zip, so
// the whole archive is refused and a fresh one leaves the file out.
$contained = $zipDownload->contained_file_ids;
abort_if(
$contained !== null && File::query()
->whereIn('id', $contained)
->whereNotIn('scan_status', ScanStatus::availableValues())
->exists(),
423,
__('A file in this archive is no longer available. Download the selection again.'),
);
// Only the first time. Re-fetching one prepared archive is the
// same delivery, not a fresh download of everything inside it.
if ($zipDownload->delivered_at === null) {
@@ -186,12 +210,12 @@ class ZipDownloadsController extends Controller
$size = Storage::disk('files')->size($path);
return response('', 200, [
'X-Accel-Redirect' => '/protected-files/'.$path,
'Content-Type' => 'application/zip',
'Content-Disposition' => ContentDisposition::attachment($this->filenameFor($zipDownload)),
'Content-Length' => (string) $size,
]);
return $this->delivery->serve(
$path,
'application/zip',
ContentDisposition::attachment($this->filenameFor($zipDownload)),
$size,
);
}
/**
@@ -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,
@@ -51,6 +78,18 @@ class FileResource extends JsonResource
'expires_at' => $this->expires_at?->toIso8601String(),
'expired' => $this->isExpired(),
// What the virus scanner made of this file. `pending` and
// `infected` mean the bytes are not available: the download
// endpoint answers 423 for both, and a caller that has just
// uploaded should poll this rather than the download. `note`
// carries the threat name, or why a file was not scanned.
'scan' => [
'status' => $this->scan_status->value,
'available' => $this->scan_status->isAvailable(),
'note' => $this->scan_note,
'scanned_at' => $this->scanned_at?->toIso8601String(),
],
// Null when the file may be downloaded any number of times.
// `download_limit_scope` says what the number counts —
// "total" across everyone, or "per_user" for each person
@@ -90,17 +129,24 @@ class FileResource extends JsonResource
'name' => $this->nextVersion->name,
]),
// GET /folders/{id} has the rest, its place in the tree included.
'folder' => $this->whenLoaded('folder', fn (): ?array => $this->folder === null ? null : [
'id' => $this->folder->id,
'name' => $this->folder->name,
'parent_id' => $this->folder->parent_id,
]),
// Name only. The uploader is a user record; their email address
// 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 +155,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' => [
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Http\Resources\Api;
use App\Modules\Files\Access\ClientIdentityScope;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Models\FolderAssignment;
use App\Modules\Groups\Models\Group;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Folder
*
* Every field is listed explicitly, never $folder->toArray(), for the same
* reason as FileResource: the next migration must not publish itself.
*
* `ancestors` and `path` come from FolderTrails, loaded by the controller
* for a whole page at once, and are trimmed to the folders the caller may
* see. The assignment list is narrowed per entry by ClientIdentityScope,
* exactly as FileResource narrows a file's.
*/
class FolderResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$viewer = $request->user();
$identity = app(ClientIdentityScope::class);
$groupMorph = (new Group)->getMorphClass();
$ancestors = $this->ancestors();
return [
'id' => $this->id,
'name' => $this->name,
'parent_id' => $this->parent_id,
// The folders above this one, root first, as far up as the
// caller may see. Empty for a folder at the top of the library.
'ancestors' => $ancestors,
// The same trail as one string, this folder included:
// "Clients / Acme / 2026". For display; match on ids, since a
// folder name may itself contain " / ".
'path' => implode(' / ', [...array_column($ancestors, 'name'), $this->name]),
// Read-only here. Making a folder public publishes everything
// inside it, and is done on the web.
'public' => (bool) $this->public,
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
'assignments' => $this->whenLoaded('assignments', fn (): array => $this->assignments
->filter(fn (FolderAssignment $assignment): bool => $assignment->assignable_type === $groupMorph
? $identity->permitsGroupId($viewer, (int) $assignment->assignable_id)
: $identity->permitsClientId($viewer, (int) $assignment->assignable_id))
->map(fn (FolderAssignment $assignment): array => [
'type' => $assignment->assignable_type === $groupMorph ? 'group' : 'client',
'id' => $assignment->assignable_id,
'name' => $assignment->assignable?->getAttribute('name'),
])
->values()
->all()),
];
}
/**
* @return list<array{id: int, name: string}>
*/
private function ancestors(): array
{
if (! $this->resource->relationLoaded('trail')) {
return [];
}
/** @var list<array{id: int, name: string}> $trail */
$trail = $this->resource->getRelation('trail')->all();
return $trail;
}
}
+76 -9
View File
@@ -8,8 +8,11 @@ use App\Models\User;
use App\Modules\Files\Access\DownloadAllowance;
use App\Modules\Files\Access\ViewableFileScope;
use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\FileAvailability;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Models\ZipDownload;
use App\Modules\Platform\Capabilities\Capability;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Bus\Queueable;
@@ -86,6 +89,23 @@ class BuildZipDownloadJob implements ShouldQueue
return;
}
// A build queued before this installation was told to stop
// offering zips. The route refuses new ones; this refuses the ones
// already waiting, so the work the key exists to save is not done
// anyway. Checked before started_at is stamped, so the row goes
// straight from waiting to failed and never looks like a build in
// hand. Failed, not left pending: pending is polled by the page
// and counted by StalledZipBuilds, and neither should wait on a
// build that will never run.
if (! app(CapabilityRegistry::class)->has(Capability::ZipDownloads)) {
$zipDownload->update([
'status' => ZipDownload::STATUS_FAILED,
'error' => 'Zip downloads are not available on this site.',
]);
return;
}
// Stamped before any of the work, because the only thing this is
// for is telling "a worker has this in hand" apart from "nobody
// is listening to the zips queue". A build that waits and never
@@ -114,6 +134,7 @@ class BuildZipDownloadJob implements ShouldQueue
$visible = app(ViewableFileScope::class)->for($requester);
$allowance = app(DownloadAllowance::class);
$availability = app(FileAvailability::class);
try {
$relativePath = 'zips/'.$zipDownload->id.'.zip';
@@ -148,7 +169,11 @@ class BuildZipDownloadJob implements ShouldQueue
// Re-checked here for the same reason visibility is: the
// archive is built some time after it was asked for, and
// the allowance may have been spent in between.
if (! $allowance->allows($file, $requester)) {
// Availability is re-checked here for a sharper reason
// than the allowance is: a file can be quarantined between
// the request and the build, and an archive is exactly how
// an infected file would leave anyway.
if (! $availability->isAvailable($file) || ! $allowance->allows($file, $requester)) {
$skipped[] = ['id' => $file->id, 'name' => $file->name];
continue;
@@ -245,9 +270,21 @@ class BuildZipDownloadJob implements ShouldQueue
@unlink($tempFile);
}
// Same division as the write failure above: the reason is the
// operator's, the sentence is the requester's. An exception
// message here has already named a disk in practice — "Disk
// [x] does not have a configured driver." — and can name a
// server path, and this column is shown to whoever asked for
// the archive, including clients.
Log::error('A zip download could not be built.', [
'zip_download_id' => $zipDownload->id,
'exception' => $e::class,
'reason' => $e->getMessage(),
]);
$zipDownload->update([
'status' => ZipDownload::STATUS_FAILED,
'error' => $e->getMessage(),
'error' => 'The zip archive could not be built.',
]);
}
}
@@ -312,22 +349,51 @@ class BuildZipDownloadJob implements ShouldQueue
throw new \RuntimeException('Could not create a temp file for '.$file->original_name);
}
// Registered before anything else can fail. tempnam() has already
// created the file, and the caller's cleanup only knows the paths
// it was told about — so every throw between here and the end of
// the copy used to leave a zip-src- file behind for good.
$tempFiles[] = $tempPath;
$stream = Storage::disk($file->disk)->readStream($file->path);
$out = fopen($tempPath, 'wb');
if ($stream === null || $out === false) {
if (is_resource($stream)) {
fclose($stream);
}
if ($out !== false) {
fclose($out);
}
throw new \RuntimeException('Could not read '.$file->original_name.' from its storage disk.');
}
stream_copy_to_stream($stream, $out);
fclose($out);
try {
// A copy that stops early is a truncated member added to the
// archive as though it were the file: the build reports ready,
// and the recipient gets something that opens and is wrong.
// fclose is checked for the same reason it is in
// LocalPartStore: it flushes, so a volume that filled on the
// last buffer fails there rather than here.
$copied = stream_copy_to_stream($stream, $out);
$flushed = fclose($out);
$out = false;
if (is_resource($stream)) {
fclose($stream);
if ($copied === false || ! $flushed) {
throw new \RuntimeException('Could not copy '.$file->original_name.' from its storage disk.');
}
} finally {
if ($out !== false) {
fclose($out);
}
if (is_resource($stream)) {
fclose($stream);
}
}
$tempFiles[] = $tempPath;
return $tempPath;
}
@@ -341,6 +407,7 @@ class BuildZipDownloadJob implements ShouldQueue
private function addFolder(ZipArchive $zip, Folder $folder, User $requester, array &$usedNames, array &$tempFiles, Builder $visible, array &$skipped, array &$added): int
{
$allowance = app(DownloadAllowance::class);
$availability = app(FileAvailability::class);
$subtreeIds = $folder->subtreeFolderIds();
/** @var Collection<int, Folder> $foldersById */
@@ -362,7 +429,7 @@ class BuildZipDownloadJob implements ShouldQueue
// inside it whose own allowance is spent — same reason the
// per-file visibility filter is re-derived rather than
// inherited from the folder.
if (! $allowance->allows($file, $requester)) {
if (! $availability->isAvailable($file) || ! $allowance->allows($file, $requester)) {
$skipped[] = ['id' => $file->id, 'name' => $file->name];
continue;
+216
View File
@@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Jobs;
use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\ScanningConfig;
use App\Modules\Files\Scanning\ScanOutcome;
use App\Modules\Files\Scanning\ScanPolicy;
use App\Modules\Files\Scanning\ScanStatus;
use App\Modules\Files\Scanning\ScanVerdict;
use App\Modules\Files\Scanning\VirusScanner;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Throwable;
/**
* Reads one file to the scanner and records what comes back.
*
* On its own queue (`scans`) with its own worker, for the reason
* BuildZipDownloadJob has one: a 5 GB file streaming to a scanner would
* otherwise sit in front of every notification email on the default
* queue.
*
* Retries are about the scanner being down, not about the file. While it
* is unreachable the job puts itself back with a growing delay, and only
* once this installation's patience runs out does the configured policy
* decide the file's fate. An installation set to "hold" never runs out:
* the file stays pending and ScanFilesCommand keeps this job coming back.
*/
class ScanFileJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Unlimited attempts, bounded by time instead — see retryUntil(). A
* fixed count would give up on a scanner that is merely being
* restarted, and the file would be decided by a timeout rather than
* by the policy.
*/
public int $tries = 0;
public function __construct(
public readonly int $fileId,
/**
* A file that has already been through here — one let through
* while the scanner was down, one that predates scanning, or one
* being checked again on purpose. It keeps its current state, and
* therefore stays downloadable, until a verdict actually arrives.
* Marking it pending first would take a library offline for the
* length of a backfill, and would announce every file a second
* time when it came back.
*/
public readonly bool $rescan = false,
) {
$this->onQueue('scans');
}
/**
* A day. Long enough that an overnight outage is survived by a
* "hold" installation, short enough that a job for a file somebody
* deleted does not live forever.
*/
public function retryUntil(): \DateTimeInterface
{
return now()->addDay();
}
public function handle(
VirusScanner $scanner,
ScanPolicy $policy,
ScanningConfig $config,
): void {
$file = File::query()->find($this->fileId);
if ($file === null) {
return;
}
// A new upload is only scanned while it is still pending: this job
// is dispatched from the upload and from the hourly sweep, and
// both can land on the same file.
if (! $this->rescan && $file->scan_status !== ScanStatus::Pending) {
return;
}
// A rescan asks again about a file people can have today — after
// new definitions, or because somebody asked. Nothing else:
//
// - a file waiting for its first verdict belongs to the job above;
// - a quarantined file leaves quarantine only by being released,
// and a rescan that came back "the scanner is down" or "too
// large" would otherwise have let it out through the policy for
// those answers;
// - a released file stays released (see QuarantineController);
// - a missing file has no bytes to read.
if ($this->rescan && ! self::rescannable($file->scan_status)) {
return;
}
if (! $config->enabled()) {
// A rescan that finds scanning switched off has learned
// nothing, and a file already checked keeps its verdict.
if (! $this->rescan) {
$policy->markNeverScanned($file);
}
return;
}
// A file identical to one already quarantined needs no second
// opinion, and asking for one would send the same malware past
// the scanner again. Checksums are already computed at upload.
$known = File::query()
->where('checksum', $file->checksum)
->where('scan_status', ScanStatus::Infected)
->whereKeyNot($file->id)
->first();
if ($known !== null) {
$policy->record($file, ScanVerdict::infected((string) $known->scan_note));
return;
}
$verdict = $this->read($file, $scanner);
// Same reasoning for a rescan the scanner could not answer: the
// file keeps the verdict it had. One let through while the scanner
// was down still says so, and the hourly sweep asks again.
if ($this->rescan && $verdict->outcome === ScanOutcome::Unavailable) {
return;
}
if ($verdict->outcome === ScanOutcome::Unavailable && $this->keepWaiting($file, $config)) {
$file->forceFill(['scan_attempts' => $file->scan_attempts + 1])->save();
// 30 seconds, then a minute, then two, up to five. Long
// enough not to hammer a scanner that is starting up; short
// enough that a brief blip does not hold an upload for the
// whole patience window.
$this->release(min(300, 30 * (2 ** min(4, $file->scan_attempts))));
return;
}
$policy->record($file, $verdict);
}
/**
* The states a rescan may act on: the ones a person can download.
* Shared with ScanFilesCommand and the settings screen's count, so
* what "New scan" says it will check is what it checks.
*
* @return list<string>
*/
public static function rescannableValues(): array
{
return [ScanStatus::Clean->value, ScanStatus::NotScanned->value];
}
private static function rescannable(ScanStatus $status): bool
{
return in_array($status->value, self::rescannableValues(), true);
}
/**
* Whether the file should wait rather than be decided now.
*
* "Hold" waits forever, by design. Otherwise the wait is measured
* from when the file was stored, not from this attempt: what the
* setting promises is that nobody's upload sits unavailable for
* longer than that, however many times the job has run.
*/
private function keepWaiting(File $file, ScanningConfig $config): bool
{
if ($config->holdsWhileUnavailable()) {
return true;
}
$storedAt = $file->created_at ?? now();
return $storedAt->copy()->addMinutes($config->unavailableWaitMinutes())->isFuture();
}
private function read(File $file, VirusScanner $scanner): ScanVerdict
{
try {
$stream = Storage::disk($file->disk)->readStream($file->path);
} catch (Throwable $e) {
$stream = null;
Log::warning("Could not open file {$file->id} for scanning: ".$e->getMessage());
}
if ($stream === null) {
// Not the scanner's fault, and not something waiting will fix
// — an orphaned row, or storage that moved. It goes through
// the same policy as a file the scanner could not open, and
// deliberately not through the scanner-unavailable path,
// which is retried hourly and would retry this forever.
return ScanVerdict::unreadable(__('The file could not be read from storage.'));
}
try {
return $scanner->scan($stream, $file->size);
} finally {
fclose($stream);
}
}
}
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Listeners;
use App\Models\User;
use App\Modules\Files\Events\FileBecameAvailable;
use App\Modules\Files\Models\FileAssignment;
use App\Modules\Files\Versions\FileVersions;
use App\Modules\Groups\Models\Group;
use App\Modules\Notifications\NotificationDigester;
use App\Modules\Notifications\Notifier;
use Illuminate\Support\Collection;
/**
* Tells the people a file was shared with, once it can actually be had.
*
* Sharing a file that is still being scanned writes the assignment and
* says nothing (FileSharing::assign). This is the other half: when the
* scan finishes, or the file is let through, or an administrator releases
* it from quarantine, whoever it was shared with hears about it then.
*
* Recipients are derived from the assignments as they stand *now* rather
* than remembered from the moment of sharing. A share taken back while
* the file was being checked should not produce an email afterwards, and
* one added in the meantime should — and deriving costs one query,
* against a table that already has to be read to answer the same question
* anywhere else.
*/
class AnnounceAvailableFile
{
public function __construct(
private readonly Notifier $notifier,
private readonly NotificationDigester $digester,
private readonly FileVersions $versions,
) {}
public function handle(FileBecameAvailable $event): void
{
$file = $event->file;
$recipients = $this->recipients($file->id);
if ($recipients->isNotEmpty()) {
$this->notifier->send('file_shared', $recipients, subject: $file, data: ['itemName' => $file->name]);
$this->digester->queue('file_shared', $recipients, $file->name, ['is_folder' => false]);
}
$previous = $file->previousVersion;
if ($previous === null) {
return;
}
// The same intersection rule the linking itself follows: only
// somebody who can see both files is told, and it is asked again
// here because while the new file was being checked the visibility
// scope hid it and the audience came out empty.
$audience = $this->versions->sharedAudience($file, $previous);
if ($audience->isNotEmpty()) {
$this->notifier->send('file_new_version', $audience, subject: $file, data: [
'itemName' => $file->name,
'previousName' => $previous->name,
]);
$this->digester->queue('file_new_version', $audience, $file->name, [
'previousName' => $previous->name,
]);
}
}
/**
* Everybody the file is assigned to, directly or through a group.
*
* @return Collection<int, User>
*/
private function recipients(int $fileId): Collection
{
return FileAssignment::query()
->where('file_id', $fileId)
->get()
->flatMap(function (FileAssignment $assignment): array {
$target = $assignment->assignable;
if ($target instanceof Group) {
return $target->members->all();
}
return $target instanceof User ? [$target] : [];
})
->unique(fn (User $user): int => $user->id)
->values();
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files;
use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\ScanStatus;
use Illuminate\Support\Facades\Storage;
/**
* Rows whose bytes are not there — the other half of the orphan problem.
*
* OrphanFileScanner finds bytes with no row. This finds rows with no
* bytes, which is the worse of the two: an orphan is disk space nobody
* claimed, while this is a file somebody was told they had. It happens
* when a volume is remounted somewhere else, when a backup is restored
* without its storage, when an external bucket is swapped, and when
* something deleted the bytes behind the application's back.
*
* Asked by listing each disk once and comparing, rather than by asking
* "does this exist?" per row: on object storage that would be one request
* per file, and a library of ten thousand files would answer with ten
* thousand HEADs every day.
*
* Only disks this installation can enumerate are checked, which is the
* same set the orphan scan walks. A row on any other disk is left alone
* rather than declared missing — never having looked is not evidence.
*/
class MissingFileScanner
{
public function __construct(
private readonly OrphanFileScanner $orphans,
) {}
/**
* The files whose bytes are gone, as ids.
*
* @return list<int>
*/
public function scan(): array
{
$missing = [];
foreach (array_keys($this->orphans->scannedDisks()) as $diskName) {
$onDisk = array_flip(Storage::disk($diskName)->allFiles());
File::query()
->where('disk', $diskName)
->select(['id', 'path'])
->chunkById(500, function ($files) use ($onDisk, &$missing): void {
foreach ($files as $file) {
if (! isset($onDisk[$file->path])) {
$missing[] = (int) $file->id;
}
}
});
}
return $missing;
}
/**
* Files this installation has marked missing whose bytes are back.
*
* A remount, a restored backup, a bucket reconnected. Recovery is not
* optional politeness: the alternative is an administrator who fixed
* their storage and still has a library that says every file is gone.
*
* @return list<int>
*/
public function recovered(): array
{
$back = [];
foreach (array_keys($this->orphans->scannedDisks()) as $diskName) {
$onDisk = array_flip(Storage::disk($diskName)->allFiles());
File::query()
->where('disk', $diskName)
->where('scan_status', ScanStatus::Missing)
->select(['id', 'path'])
->chunkById(500, function ($files) use ($onDisk, &$back): void {
foreach ($files as $file) {
if (isset($onDisk[$file->path])) {
$back[] = (int) $file->id;
}
}
});
}
return $back;
}
}
+206 -8
View File
@@ -10,8 +10,11 @@ use App\Modules\Audit\ActivityLog;
use App\Modules\Files\Access\SharingIdentity;
use App\Modules\Files\DownloadLimitScope;
use App\Modules\Files\FileDiskCleanup;
use App\Modules\Files\Scanning\NotScannedReason;
use App\Modules\Files\Scanning\ScanStatus;
use App\Modules\Files\Versions\FileVersions;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\Erasure\SelfDeletion;
use App\Support\Concerns\HasUniqueSlug;
use Database\Factories\FileFactory;
use Illuminate\Database\Eloquent\Builder;
@@ -40,6 +43,14 @@ use Illuminate\Support\Carbon;
* @property string $mime_type
* @property int $size
* @property string $checksum
* @property ScanStatus $scan_status
* @property string|null $scan_note the threat name, or a NotScannedReason
* @property Carbon|null $scanned_at
* @property string|null $scan_engine
* @property int $scan_attempts
* @property bool $scan_was_available
* @property int|null $released_by
* @property Carbon|null $released_at
* @property bool $public
* @property Carbon|null $expires_at
* @property int|null $download_limit
@@ -74,6 +85,14 @@ class File extends Model
{
return [
'public' => 'boolean',
// Where this file stands with the virus scanner. Cast to the
// enum so nothing compares raw strings — see ScanStatus and
// FileAvailability.
'scan_status' => ScanStatus::class,
'scanned_at' => 'datetime',
'released_at' => 'datetime',
'scan_attempts' => 'integer',
'scan_was_available' => 'boolean',
'commentable' => 'boolean',
'expires_at' => 'datetime',
'download_limit' => 'integer',
@@ -110,8 +129,12 @@ class File extends Model
// an account's content — deletes many rows in one transaction,
// and anything that rolls it back afterwards puts every row
// back while the bytes are already gone: a loss nothing can
// undo. Deferred, the worst case is bytes left on disk with no
// row, which OrphanFileScanner already finds and reports.
// undo. Deferred, the worst case is bytes left on disk with a
// row that is only trashed, and a scan will not offer those:
// OrphanFileScanner::knownPaths() counts a trashed row's path
// as claimed, on purpose, so nothing double-adopts a file still
// inside its erasure grace period. FileDiskCleanup's warning is
// therefore the only record that it happened.
//
// Outside a transaction the callback runs immediately, so
// deleting one file is unchanged. Nested transactions only fire
@@ -247,14 +270,120 @@ class File extends Model
/**
* A file's own expiration date — independent of any share link's.
* Null means never expires. Once past, the file is hidden from
* clients and the public site (see scopeNotExpired) but staff keep
* full access to view, download, and manage it.
* clients and the public site (see scopeNotExpired) and staff keep
* full access to view, download, and manage it — with one boundary
* this used to leave out.
*
* A client-scoped staff member's library is their own uploads ∪ what
* each assigned client may see (StaffLibraryScope::buildFiles), and
* that second half is scopeVisibleToClient, which ends in
* notExpired(). So an expired file they held only through a client
* leaves their library too, while their own expired upload stays.
* That is deliberate: c8078f65 weighed widening it and left the
* boundary where it is, because scopeVisibleToClient is the single
* source of truth for client file access, and relabelled the
* expired-files widget instead. ExpiredFileStaffAccessTest pins both
* halves so the sentence above cannot drift from the code again.
*/
public function isExpired(): bool
{
return $this->expires_at !== null && $this->expires_at->isPast();
}
/**
* Files the virus scanner has finished with, one way or another.
*
* Sits beside notExpired() in every scope that answers "what may this
* person be shown", and for the same reason: a file nobody has
* checked yet is not a file anybody may be handed. The uploader is
* the exception while it is being checked — their own upload stays on
* their screen, because a file that vanishes for ten minutes after
* you send it reads as a failed upload.
*
* Only while it is being checked. A quarantined or missing upload
* stayed listed for its uploader too, with a download button that
* answered with an error page; they are told about a blocked upload
* by notification instead, and there is nothing to offer them here.
*
* @param Builder<File> $query
*/
public function scopeAvailable(Builder $query, ?User $viewer = null): void
{
$query->where(function (Builder $inner) use ($viewer): void {
$inner->whereIn('scan_status', ScanStatus::availableValues());
if ($viewer !== null) {
$inner->orWhere(fn (Builder $own) => $own
->where('uploaded_by', $viewer->id)
->where('scan_status', ScanStatus::Pending));
}
});
}
/**
* Why a file went out unchecked. Deliberately not
* NotScannedReason::BeforeScanning: a file stored while this
* installation did not scan at all is not a scanner letting something
* past, and on an installation that has never scanned it would mean
* saying it about every file there is.
*
* @var list<string>
*/
private const LET_THROUGH_REASONS = [
NotScannedReason::ScannerUnavailable->value,
NotScannedReason::TooLarge->value,
NotScannedReason::Encrypted->value,
];
/**
* Files people can download that nothing checked: let through while
* the scanner was down, or because it could not open them.
*
* A state, not a history. The dashboard used to count "let through"
* entries in the activity log, which counted a file once per attempt,
* and went on counting files that had since been deleted, gone
* missing or been scanned clean — none of which is going out
* unscanned.
*
* @param Builder<File> $query
*/
public function scopeLetThrough(Builder $query): void
{
$query->where('scan_status', ScanStatus::NotScanned)
->whereIn('scan_note', self::LET_THROUGH_REASONS);
}
/**
* Whether this particular file is one of those — the row's own answer
* to scopeLetThrough(), for a page that already has the file.
*/
public function wasLetThrough(): bool
{
return $this->scan_status === ScanStatus::NotScanned
&& in_array((string) $this->scan_note, self::LET_THROUGH_REASONS, true);
}
/**
* Files nothing has ever looked at.
*
* Two ways to be one, and the second is the common one: a file stored
* while scanning was off carries the reason, and a file that predates
* the scanner entirely carries none at all — the migration gives the
* column its default and writes no note, and the v1 import inserts
* rows the same way. Reading only the reason missed every file on
* every real installation, which is exactly the set "Scan existing
* files" exists for.
*
* @param Builder<File> $query
*/
public function scopeNeverScanned(Builder $query): void
{
$query->where('scan_status', ScanStatus::NotScanned)
->where(fn (Builder $inner) => $inner
->whereNull('scan_note')
->orWhere('scan_note', NotScannedReason::BeforeScanning->value));
}
/**
* @param Builder<File> $query
*/
@@ -263,6 +392,36 @@ class File extends Model
$query->where(fn (Builder $q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()));
}
/**
* Files whose uploader has not deleted their own account — the first
* rule in SelfDeletion. Every surface that serves somebody other than
* staff narrows by this: the client scope, and the three public
* listing scopes. Single files ask isWithdrawn().
*
* The null branch is not tidiness. `uploaded_by NOT IN (...)` is never
* true for a NULL uploader, so a file whose uploader was erased long
* ago would vanish from every client along with the withdrawn ones.
*
* @param Builder<File> $query
*/
public function scopeNotWithdrawn(Builder $query): void
{
$query->where(fn (Builder $q) => $q
->whereNull('uploaded_by')
->orWhereNotIn('uploaded_by', app(SelfDeletion::class)->withdrawnAccounts()));
}
/**
* The single-file twin of scopeNotWithdrawn(). Asked by the routes
* that reach one file without an account behind them — share links
* and the public listing — which have no client scope to lean on.
*/
public function isWithdrawn(): bool
{
return $this->uploaded_by !== null
&& app(SelfDeletion::class)->withdrawnAccounts()->whereKey($this->uploaded_by)->exists();
}
/**
* Whether a cap has been set on how many times this may be
* downloaded. Unlike expiry, reaching it does not hide the file:
@@ -300,6 +459,41 @@ class File extends Model
return $this->public || ($this->folder?->isEffectivelyPublic() ?? false);
}
/**
* The query-side twin of isEffectivelyPublic(): narrow to files that
* are, or are not, publicly reachable.
*
* Here rather than in a controller because two surfaces now ask it --
* the staff library's visibility filter and /api/v1/files -- and a
* predicate that has to agree with isEffectivelyPublic() should not
* exist twice. The folder half resolves once into a list of ids rather
* than as a correlated subquery, because Folder::scopePubliclyVisible()
* already expresses the subtree rule and is the only place it lives.
*
* The null branch in the private half is not tidiness: `folder_id NOT
* IN (...)` is never true for a NULL folder_id, so a file at the
* library root would otherwise be neither public nor private and
* vanish from both halves of the filter.
*
* @param Builder<File> $query
*/
public function scopeEffectivelyPublic(Builder $query, bool $public): void
{
$publicFolderIds = Folder::query()->publiclyVisible()->pluck('id')->all();
if ($public) {
$query->where(fn (Builder $inner) => $inner
->where('files.public', true)
->orWhereIn('files.folder_id', $publicFolderIds));
return;
}
$query->where('files.public', false)->where(fn (Builder $inner) => $inner
->whereNull('files.folder_id')
->orWhereNotIn('files.folder_id', $publicFolderIds));
}
/**
* A client can access a file that is assigned to them directly or
* via a group, that sits in a folder shared with them (self or
@@ -336,7 +530,9 @@ class File extends Model
$outer->orWhere('uploaded_by', $client->id);
});
$query->notExpired();
// Withdrawn last, beside expiry, because it is the same kind of
// rule: not "who may see this" but "may anybody besides staff".
$query->notExpired()->notWithdrawn()->available($client);
}
/**
@@ -377,7 +573,7 @@ class File extends Model
$outer->orWhereIn('folder_id', $subtreeFolderIds);
});
$query->notExpired();
$query->notExpired()->notWithdrawn()->available();
}
/**
@@ -391,7 +587,7 @@ class File extends Model
*/
public function scopePubliclyVisibleForFolder(Builder $query, Folder $folder): void
{
$query->whereIn('folder_id', $folder->subtreeFolderIds())->notExpired();
$query->whereIn('folder_id', $folder->subtreeFolderIds())->notExpired()->notWithdrawn()->available();
}
/**
@@ -442,6 +638,8 @@ class File extends Model
->where(function (Builder $folder) use ($publicFolderSubtreeIds): void {
$folder->whereNull('folder_id')->orWhereNotIn('folder_id', $publicFolderSubtreeIds);
})
->notExpired();
->notExpired()
->notWithdrawn()
->available();
}
}
+47 -7
View File
@@ -112,6 +112,12 @@ class Folder extends Model
return $this->created_by === $user->id;
}
/** Whether this folder stands in as some client's root. */
public function isHome(): bool
{
return $this->home_for_user_id !== null;
}
/**
* Self or any ancestor is public — the inheritance every file in this
* folder's subtree relies on (File::isEffectivelyPublic()), and what
@@ -152,15 +158,26 @@ class Folder extends Model
}
/**
* Whether $user may upload a new file directly into $folder (null =
* loose at the root, always allowed).
* Whether $user may put content into $folder (null = loose at the
* root, always allowed).
*
* **Read the name as "may place into", not "may upload into".** Every
* way a file arrives in a folder has to come through here, and the
* name cost us one advisory already: the publication rule below was
* written for GHSA-237r-jx85-j3hr and wired into the upload paths
* alone, because those are what the name suggested. Moving a file in,
* bulk-moving a selection in, reparenting one through the edit form,
* and dragging a whole folder into a public parent all put content
* somewhere too, and none of them asked (GHSA-rxf8-wh8v-jm9j). They
* ask now. Anything new that writes a `folder_id` or a `parent_id`
* belongs on this list.
*
* Staff are held to the library boundary they are held to everywhere
* else: an unscoped staff member may use any folder, a client-scoped
* one only the folders StaffLibraryScope already shows them. This is
* the only place that decides it: every upload path — the web form,
* the API and the chunked flow the browser actually posts to — comes
* through here rather than checking folder_id for itself.
* one only the folders StaffLibraryScope already shows them. Callers
* that have already resolved the destination through
* StaffLibraryScope::folders() have answered that half — the two are
* the same query — and call this for the publication half.
*
* For a client this is unchanged, and is still the whole of the
* check: they own the folder, or it is a public folder that opts into
@@ -174,7 +191,30 @@ class Folder extends Model
}
if ($user->isStaff()) {
return app(StaffLibraryScope::class)->allowsFolder($user, $folder);
if (! app(StaffLibraryScope::class)->allowsFolder($user, $folder)) {
return false;
}
// Being allowed to reach the folder is not the same as being
// allowed to publish, and putting a file in a public folder
// publishes it: isEffectivelyPublic() is "my own flag, or my
// folder's". So the destination reaches the property that
// `upload_public` guards, without ever touching the switch
// (GHSA-237r-jx85-j3hr).
//
// The keys already say this. The client branch below has always
// asked for `upload_to_public_folders` here, and
// MyFilesController's picker calls that the established meaning
// of the two — it was simply never asked on a staff role, which
// left that permission doing nothing at all for staff.
//
// Effectively public, not `public`: the flag is inherited down
// a subtree, so a private folder inside a public one publishes
// just the same and a check on the folder's own flag would walk
// straight past it.
return ! $folder->isEffectivelyPublic()
|| $user->can('upload_public')
|| $user->can('upload_to_public_folders');
}
return $folder->isOwnedBy($user)
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Preview;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Models\File;
use Illuminate\Support\Facades\Cache;
/**
* One log row per viewer per file per five minutes, for both preview
* routes — FileThumbnailController::preview (signed in) and
* PublicGroupsController::preview (anonymous).
*
* Watching a video is a single deliberate act that the browser turns into
* dozens of Range requests, each arriving indistinguishable from someone
* clicking preview again. Cache::add is the whole mechanism: it writes
* only if the key is absent, so the first request through the window logs
* and the rest are silent, without a read-then-write race between two of
* them.
*
* Keyed by viewer, so one person's playback never suppresses another's
* view of the same file. An anonymous visitor has no account to key on,
* so the request IP stands in — the same substitute the API's rate
* limiter makes for an unauthenticated caller. It is a cache key with a
* five-minute life and never reaches the log, which keeps its own
* decision about recording an IP (see ActivityLogger::shouldRecordIp and
* Setting::DownloadIpLogging).
*
* Shared rather than restated, because the window is the rule: two copies
* of "five minutes" are two things to change and one to forget.
*/
class PreviewLog
{
private const WINDOW_MINUTES = 5;
public function __construct(
private readonly ActivityLogger $activity,
) {}
public function record(Action $action, File $file, ?User $viewer): void
{
$viewerKey = $viewer !== null ? (string) $viewer->id : 'ip:'.request()->ip();
if (Cache::add('file-preview-logged:'.$file->id.':'.$viewerKey, true, now()->addMinutes(self::WINDOW_MINUTES))) {
$this->activity->log($action, subject: $file);
}
}
}
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Modules\Files\Queue;
use App\Modules\Files\Models\ZipDownload;
use App\Modules\Platform\Capabilities\Capability;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use Illuminate\Support\Carbon;
/**
@@ -56,6 +58,15 @@ class StalledZipBuilds
*/
public function oldestUnstarted(): ?Carbon
{
// An installation that does not offer zips has no reason to be
// serving their queue, and one that stopped offering them may
// still hold rows queued before it did. BuildZipDownloadJob fails
// those when a worker reaches them; until one does, they are not
// a worker problem worth a banner.
if (! app(CapabilityRegistry::class)->has(Capability::ZipDownloads)) {
return null;
}
if ($this->buildInHand()) {
return null;
}
@@ -0,0 +1,333 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Scanning;
use Illuminate\Support\Carbon;
use Throwable;
/**
* Talks to ClamAV's daemon, `clamd`, over a Unix socket or TCP.
*
* The protocol is small enough to own: a command is `z<COMMAND>\0`, and
* INSTREAM is that followed by length-prefixed chunks and a zero-length
* chunk to finish. Taking a library for this would be more dependency
* than code.
*
* **Three of clamd's own settings decide whether this class can tell the
* truth**, and without them a file it could not open comes back as `OK`:
* `AlertExceedsMax`, `AlertEncrypted` and its two companions turn those
* cases into answers, which arrive here as `Heuristics.Limits.Exceeded.*`
* and `Heuristics.Encrypted.*` and are mapped below to tooLarge and
* encrypted rather than to a threat. An encrypted archive full of malware
* reported as clean is the failure this exists to prevent, so the
* shipped Docker configuration sets all of them and the documentation
* says so for manual installs.
*
* Nothing here throws for a scanner that is down, slow or misconfigured:
* the caller has a policy for that, and an exception would read as a bug
* in the job rather than as the state of somebody's server.
*/
class ClamAvScanner implements VirusScanner
{
/** 64 KiB — clamd's own read buffer size, and small enough to stream 5 GB without holding it. */
private const CHUNK = 65536;
/** Seconds to wait for an answer to VERSION. */
private const VERSION_TIMEOUT = 10;
/**
* What the daemon said it was, the first time this instance asked.
*
* Every verdict records the engine and definitions that reached it, so
* a stored "clean" can be read back against what knew it. Asking on
* every scan would double the connections; asking once per instance
* means once per queue job, and the worker is recycled hourly.
*/
private ?string $engine = null;
public function __construct(
private readonly ScanningConfig $config,
) {}
public function scan(mixed $stream, int $size): ScanVerdict
{
$max = $this->config->maxScanBytes();
// Asked before opening a socket: a file this installation has
// decided not to scan should not spend a connection, and clamd
// would refuse it anyway once it passed StreamMaxLength.
if ($max > 0 && $size > $max) {
return ScanVerdict::tooLarge($this->engine());
}
if (! $this->addressIsUsable()) {
return ScanVerdict::unavailable(__(ScannerAddress::message()));
}
$socket = $this->connect();
if ($socket === null) {
return ScanVerdict::unavailable(__('The scanner could not be reached at :address.', [
'address' => $this->config->address(),
]));
}
try {
$sent = $this->send($socket, "zINSTREAM\0");
while ($sent && ! feof($stream)) {
$chunk = fread($stream, self::CHUNK);
if ($chunk === false) {
return ScanVerdict::unavailable(__('The file could not be read for scanning.'));
}
if ($chunk === '') {
continue;
}
// Big-endian length, then the bytes.
$sent = $this->send($socket, pack('N', strlen($chunk)).$chunk);
}
if ($sent) {
$this->send($socket, pack('N', 0));
}
// Read whether or not every byte went: a write that fails
// means clamd hung up mid-stream, and it only does that after
// saying why — usually its own size limit. Treating the
// failed write as "the scanner is down" instead sent every
// file over that limit round the retry loop forever, and past
// the unscannable policy.
$reply = $this->readReply($socket);
} catch (Throwable $e) {
return ScanVerdict::unavailable($e->getMessage());
} finally {
fclose($socket);
}
if ($reply === null) {
return ScanVerdict::unavailable(__('The scanner did not answer in time.'));
}
return $this->verdictFor($reply, $this->engine());
}
public function status(): ScannerStatus
{
// Named rather than reported as "no answer". A managed address
// comes from the environment and never passed the settings
// screen's validation, so this is the only place it is checked —
// and the socket would accept a malformed one by reading the
// digits at the front of the port and ignoring the rest, which is
// how an address with a typo on the end came to look like it
// worked.
if (! $this->addressIsUsable()) {
return ScannerStatus::unreachable(__(ScannerAddress::message()));
}
// A short wait rather than the scan's: VERSION is answered at once
// by anything that is clamd, and the Test button waits on this.
$socket = $this->connect(self::VERSION_TIMEOUT);
if ($socket === null) {
return ScannerStatus::unreachable(__('No answer from :address.', ['address' => $this->config->address()]));
}
try {
$this->send($socket, "zVERSION\0");
$reply = $this->readReply($socket);
} catch (Throwable $e) {
return ScannerStatus::unreachable($e->getMessage());
} finally {
fclose($socket);
}
if ($reply === null || $reply === '') {
return ScannerStatus::unreachable(__('The scanner did not answer in time.'));
}
// "ClamAV 1.4.1/27412/Mon Sep 15 09:12:03 2026" — engine,
// signature database number, and when that database was built.
// Older builds answer with the engine alone, so every part after
// the first is optional rather than assumed.
$parts = explode('/', $reply);
// Anything listening on the port answers something. Without this a
// database or a web server "answered", and the first bytes of its
// greeting were shown as the engine's name.
if (! str_starts_with($parts[0], 'ClamAV ')) {
return ScannerStatus::unreachable(__('Something answered at :address, but it is not a ClamAV scanner.', [
'address' => $this->config->address(),
]));
}
$definitions = isset($parts[1]) && is_numeric(trim($parts[1])) ? (int) trim($parts[1]) : null;
$built = null;
if (isset($parts[2])) {
try {
$built = Carbon::parse(trim($parts[2]));
} catch (Throwable) {
$built = null;
}
}
return new ScannerStatus(true, trim($parts[0]), $definitions, $built);
}
private function verdictFor(string $reply, ?string $engine): ScanVerdict
{
// The whole reply, not its last two letters: "clean" is the one
// answer that hands a file out, so nothing else may be read as it.
if ($reply === 'stream: OK') {
return ScanVerdict::clean($engine);
}
// "stream: Win.Test.EICAR_HDB-1 FOUND"
if (str_starts_with($reply, 'stream: ') && str_ends_with($reply, ' FOUND')) {
$threat = trim(str_replace(['stream:', 'FOUND'], '', $reply));
// Not threats: clamd's way of saying "I could not look
// inside". Which one it is decides the file's fate, and both
// are the installation's policy rather than a detection.
if (str_contains($threat, 'Heuristics.Encrypted')) {
return ScanVerdict::encrypted($engine);
}
if (str_contains($threat, 'Heuristics.Limits.Exceeded')) {
return ScanVerdict::tooLarge($engine);
}
return ScanVerdict::infected($threat === '' ? 'unknown' : $threat, $engine);
}
// "INSTREAM size limit exceeded. ERROR" — the stream was longer
// than clamd's StreamMaxLength. Same meaning as the heuristic
// above, reached when this installation's own maximum is the
// larger of the two.
if (str_contains($reply, 'size limit exceeded')) {
return ScanVerdict::tooLarge($engine);
}
return ScanVerdict::unavailable($reply);
}
/**
* "ClamAV 1.5.4/28122" — engine and signature database, as recorded
* against every verdict. Null when the daemon did not say.
*/
private function engine(): ?string
{
if ($this->engine !== null) {
return $this->engine;
}
$status = $this->status();
if (! $status->reachable || $status->engine === null) {
return null;
}
return $this->engine = $status->definitionsVersion === null
? $status->engine
: $status->engine.'/'.$status->definitionsVersion;
}
/** Whether the configured address is one at all — see ScannerAddress. */
private function addressIsUsable(): bool
{
return ScannerAddress::isValid($this->config->address());
}
/** @return resource|null */
private function connect(?int $replyTimeout = null): mixed
{
$address = $this->config->address();
if ($address === '' || ! $this->addressIsUsable()) {
return null;
}
$socket = @stream_socket_client(
$address,
$code,
$message,
$this->config->connectTimeoutSeconds(),
STREAM_CLIENT_CONNECT,
);
if ($socket === false) {
return null;
}
// Without this a scanner that accepts the connection and then
// stops answering holds the worker open indefinitely.
stream_set_timeout($socket, $replyTimeout ?? $this->config->replyTimeoutSeconds());
return $socket;
}
/**
* Write all of it, or say that it could not.
*
* fwrite() may take part of a buffer and return how much, and on a
* connection the other end has closed it raises a warning — which the
* framework's error handler turns into an exception. Silenced and
* checked here instead, so a hang-up reads as a hang-up and the reply
* explaining it can still be read.
*
* @param resource $socket
*/
private function send(mixed $socket, string $bytes): bool
{
while ($bytes !== '') {
$written = @fwrite($socket, $bytes);
if ($written === false || $written === 0) {
return false;
}
$bytes = substr($bytes, $written);
}
return true;
}
/**
* clamd's replies end with a NUL in `z` mode. Returns null when the
* socket timed out rather than answered.
*
* @param resource $socket
*/
private function readReply(mixed $socket): ?string
{
$reply = '';
while (! feof($socket)) {
// Silenced for the same reason as send(): a connection clamd
// has reset raises a warning here, and the framework would
// turn that into an exception before the loop could stop.
$byte = @fread($socket, 1);
if ($byte === false || $byte === '') {
break;
}
if ($byte === "\0") {
break;
}
$reply .= $byte;
}
if (stream_get_meta_data($socket)['timed_out']) {
return null;
}
return trim($reply);
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Scanning;
use App\Modules\Files\Events\FileBecameAvailable;
use App\Modules\Files\Models\File;
use Illuminate\Support\Facades\Event;
/**
* Whether a file may be seen and served, and what happens the moment it
* may be.
*
* The one predicate every other rule asks. Three states mean yes and
* three mean no (see ScanStatus), and the reason this is a class rather
* than a comparison at each call site is that the list of "yes" states
* has already changed once — `released` was added when quarantine gained
* an override — and the day it changes again, it has to change in one
* place or a file becomes downloadable through one route and not another.
*
* "Available" is about everyone *other than* staff and the uploader. Staff
* see their library at all times, with each file's state on it; what
* availability governs is whether recipients and visitors see a file at
* all, and whether its bytes may leave the server.
*/
class FileAvailability
{
public function isAvailable(File $file): bool
{
return $file->scan_status->isAvailable();
}
/**
* Refuse to serve a file's bytes unless it is available.
*
* Called by every route that puts bytes on the wire — the download,
* the thumbnail, the preview, the share link, the public listing and
* the zip builder. Not by the listings: a staff member's library shows
* a pending file with its state on it, and the uploader sees their own.
* What this governs is the bytes.
*
* It refuses everybody, including staff and the file's own uploader.
* A file the scanner has not cleared is not one this application
* hands out, and an administrator who wants it anyway has a way to say
* so on the record: release it from quarantine.
*
* 423 rather than 403: the refusal is about the file's state and it is
* temporary in the pending case, which is exactly what "Locked" means
* and what "Forbidden" does not. ProblemDetails renders it as JSON for
* the API, which shares these controllers.
*/
public function guardDelivery(File $file): void
{
if ($this->isAvailable($file)) {
return;
}
abort(423, match ($file->scan_status) {
ScanStatus::Pending => __('This file is still being checked for viruses.'),
// Said plainly, because it is not a refusal: there is nothing
// to serve, and whoever hits this can stop looking for a
// permission that would let them through.
ScanStatus::Missing => __('This file is no longer on the server.'),
default => __('This file is not available.'),
});
}
/**
* A file has finished being checked, one way or another.
*
* Three roads lead here and they are not interchangeable: the scan
* passed, the scanner could not be reached and this installation lets
* files through, or an administrator released it from quarantine. What
* they share is the only thing this announces — the file can now be
* had by the people it was shared with, which is when everything that
* was waiting on it (a share email, a new-version notice) is allowed
* to go out.
*/
public function markAvailable(File $file): void
{
if (! $this->isAvailable($file)) {
return;
}
Event::dispatch(new FileBecameAvailable($file));
}
}

Some files were not shown because too many files have changed in this diff Show More