100 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
269 changed files with 21581 additions and 478 deletions
+14
View File
@@ -20,6 +20,20 @@ PROJECTSEND_EDITION=community
# 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
+131
View File
@@ -10,6 +10,137 @@ Anything under **⚠️ Important — do these yourself** is something you have
we did. It sits at the top of a release for that reason. Older entries call the same section
**Upgrade notes**.
## 2.6.0 — 25 September 2026
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
+28 -1
View File
@@ -218,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
@@ -362,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:
+30
View File
@@ -517,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
+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.
+32 -20
View File
@@ -54,34 +54,29 @@ out in [LICENSING.md](LICENSING.md).
- 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.
@@ -103,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\Http\Response as HttpResponse;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
@@ -15,9 +17,25 @@ class ConfirmablePasswordController extends Controller
/**
* Show the confirm password page.
*/
public function show(): Response
public function show(Request $request): Response
{
return Inertia::render('auth/confirm-password');
$user = $request->user();
assert($user !== null);
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,
]);
}
/**
@@ -30,8 +48,12 @@ class ConfirmablePasswordController extends Controller
* 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
public function store(Request $request, PasswordVerification $passwords): RedirectResponse|HttpResponse
{
$user = $request->user();
assert($user !== null);
@@ -44,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));
}
}
@@ -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;
@@ -183,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.
+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);
}
}
@@ -6,6 +6,7 @@ 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;
@@ -66,6 +67,13 @@ 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
+42
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;
@@ -175,7 +206,18 @@ class User extends Authenticatable implements HasLocalePreference
'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',
@@ -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);
}
+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',
@@ -17,6 +17,9 @@ 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;
@@ -27,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;
@@ -53,6 +57,7 @@ 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,
@@ -95,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).
@@ -480,12 +485,10 @@ class DashboardController extends Controller
}
/**
* @return array<string, array<string, bool|string|null>|bool|int|string|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.
@@ -494,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,
@@ -511,6 +514,74 @@ class DashboardController extends Controller
// 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(),
];
}
+29 -1
View File
@@ -14,6 +14,8 @@ 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
@@ -56,6 +58,9 @@ class ClientAccounts
* 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
@@ -68,6 +73,7 @@ class ClientAccounts
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
@@ -76,6 +82,7 @@ class ClientAccounts
// 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,
@@ -98,7 +105,10 @@ class ClientAccounts
// 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.)
$client->forceFill(['email_verified_at' => now()])->save();
//
// 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);
@@ -108,4 +118,22 @@ class ClientAccounts
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.'),
]);
}
}
}
+48 -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,
) {}
/**
@@ -75,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,
@@ -85,6 +98,7 @@ class ClientProvisioning
?string $ldapDn = null,
?bool $autoApprove = null,
array $context = [],
int $storageQuotaMb = 0,
): User {
$autoApprove ??= $this->autoApproves();
@@ -105,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
@@ -121,6 +136,7 @@ class ClientProvisioning
$this->joinAutoGroup($client);
$this->notifyAdministrators($client, pending: ! $autoApprove);
$this->notifyStaffInApp($client);
return $client;
}
@@ -143,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) {
@@ -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;
}
}
@@ -13,6 +13,7 @@ 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;
@@ -62,6 +63,7 @@ class ClientsController extends Controller
private readonly SeatAllowance $seats,
private readonly ClientAccounts $clients,
private readonly ErasureSchedule $erasure,
private readonly DateInput $dates,
) {}
public function index(Request $request): AnonymousResourceCollection
@@ -130,11 +132,19 @@ 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);
$creator = $request->user();
assert($creator !== null);
// 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
@@ -144,13 +154,16 @@ class ClientsController extends Controller
name: $validated['name'],
email: $validated['email'],
password: $validated['password'],
storageQuotaMb: $validated['storage_quota_mb'] ?? 0,
// 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),
);
$creator = $request->user();
assert($creator !== null);
// 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
@@ -186,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'],
]);
@@ -204,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.
@@ -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'],
],
));
}
}
@@ -12,6 +12,7 @@ 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;
@@ -51,6 +52,7 @@ class ClientsController extends Controller
private readonly SeatAllowance $seats,
private readonly ClientAccounts $clients,
private readonly ErasureSchedule $erasure,
private readonly DateInput $dates,
) {}
public function index(Request $request): Response
@@ -89,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],
]);
@@ -146,8 +154,12 @@ class ClientsController extends Controller
'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()));
$creator = $request->user();
assert($creator !== null);
// 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
@@ -158,13 +170,17 @@ class ClientsController extends Controller
name: $validated['name'],
email: $validated['email'],
password: $validated['password'],
storageQuotaMb: $validated['storage_quota_mb'] ?? 0,
// 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),
);
$creator = $request->user();
assert($creator !== null);
// 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
@@ -228,6 +244,10 @@ 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(),
],
// Resolved, not raw — see create() above.
'default_storage_quota_mb' => $this->storageUsage->defaultQuotaMb(),
@@ -254,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'] !== '';
@@ -270,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);
}
}
@@ -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);
}
}
@@ -192,6 +192,21 @@ class StaffLibraryScope
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
{
$ids = $this->assignableClientIds($user);
@@ -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();
}
}
@@ -87,7 +87,7 @@ class StoredFileResponse
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()->addSeconds($linkSeconds),
['ResponseContentDisposition' => $disposition],
@@ -98,4 +98,30 @@ class StoredFileResponse
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;
}
}
+10 -31
View File
@@ -6,35 +6,25 @@ namespace App\Modules\Files\Editing;
use App\Models\User;
use App\Modules\Files\Models\File;
use App\Modules\Platform\Localization\LocalDay;
use App\Modules\Platform\Localization\TimezoneRegistry;
use App\Modules\Platform\Localization\DateInput;
use Carbon\Carbon;
/**
* Reading and writing a file's expiry in the zone of whoever is looking.
*
* The stored value is an instant. What a person sets is a calendar day,
* and "the 12th" means the end of the 12th where *they* live — otherwise a
* file asked to expire on the 12th dies partway through the 11th for
* anyone west of Greenwich, and gives anyone east of it most of a day
* nobody promised.
* The 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.
*
* The two halves have to agree, which is the whole reason they sit
* together: a form is rendered with asShown() and posts the same string
* back untouched with every other edit, so a caller compares against
* asShown() to tell "the editor changed the date" from "the editor renamed
* the file and the date came along for the ride". Re-deriving on every
* save instead moves the expiry by the difference between two people's
* zones each time somebody edits anything.
*
* Was three private copies — the staff editor, the API, and now the client
* 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 TimezoneRegistry $timezones,
private readonly DateInput $dates,
) {}
/**
@@ -43,25 +33,14 @@ class FileExpiry
*/
public function asShown(File $file, ?User $viewer): ?string
{
return $file->expires_at?->copy()->setTimezone($this->timezones->resolve($viewer))->toDateString();
return $this->dates->asShown($file->expires_at, $viewer);
}
/**
* The instant a submitted value actually names.
*
* A bare `YYYY-MM-DD` is a calendar day and means the end of it where
* the setter is — what every date input posts. Anything carrying a
* time is an instant somebody named on purpose and is stored as it
* arrives: the API can express a moment, and a date input cannot.
* The instant a submitted value actually names. See DateInput::instant().
*/
public function instant(?string $value, ?User $setter): ?Carbon
{
if ($value === null) {
return null;
}
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1
? LocalDay::end($value, $this->timezones->resolve($setter))
: Carbon::parse($value);
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,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;
}
}
+102
View File
@@ -5,13 +5,23 @@ 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;
@@ -35,6 +45,18 @@ class FilesServiceProvider extends ServiceProvider
// 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
@@ -42,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.
@@ -97,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,
@@ -107,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();
}
}
@@ -18,6 +18,7 @@ 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;
@@ -102,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)
@@ -139,10 +148,58 @@ 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). Dropping them
// is the client branch's rule, applied inside the visibility scopes
@@ -154,6 +211,10 @@ class FilesController extends Controller
$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'));
}
@@ -306,7 +367,7 @@ 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)],
]);
@@ -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;
@@ -57,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,
) {}
@@ -90,6 +92,19 @@ 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.
@@ -220,7 +235,10 @@ 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);
}
@@ -239,7 +257,12 @@ class ChunkedUploadsController extends Controller
// 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 = $this->reservePartRoom($session, $part, $limit);
// 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
@@ -249,9 +272,10 @@ class ChunkedUploadsController extends Controller
abort(413);
}
$stream = $request->getContent(true);
$stream = null;
try {
$stream = $request->getContent(true);
$etag = $this->parts->storePart($session, $part, $stream, $reserve);
} catch (PartTooLargeException) {
abort(413);
@@ -10,6 +10,7 @@ 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\Support\Facades\Gate;
@@ -29,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
@@ -10,6 +10,7 @@ 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;
@@ -78,12 +79,18 @@ class FileThumbnailController extends Controller
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
@@ -119,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.
@@ -62,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),
@@ -177,6 +192,18 @@ class FilesController extends Controller
// 12th reopens showing the 11th.
'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
@@ -267,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)],
]);
@@ -393,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
@@ -543,4 +570,41 @@ class FilesController extends Controller
return redirect()->route('files.index')->with('success', __('File deleted.'));
}
/**
* Delete several files at once, from the staff selection bar (#1800).
*
* 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.
*/
public function bulkDestroy(Request $request): RedirectResponse
{
$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();
}
@@ -16,11 +16,16 @@ 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;
@@ -61,6 +66,7 @@ class FoldersController extends Controller
private readonly VisibleCommentScope $comments,
private readonly FileVersionLinks $versionLinks,
private readonly DownloadAllowance $allowance,
private readonly UndeletableFiles $undeletable,
) {}
/**
@@ -84,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
@@ -92,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
@@ -105,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,
);
@@ -120,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
@@ -162,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']),
]));
@@ -176,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(),
@@ -194,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'),
@@ -201,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>
*/
@@ -253,6 +379,9 @@ class FoldersController extends Controller
] : 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.
@@ -299,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),
@@ -323,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 —
@@ -431,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(
@@ -461,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
@@ -16,10 +16,13 @@ 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;
@@ -70,6 +73,7 @@ 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,
@@ -106,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
@@ -146,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()
@@ -161,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);
}
@@ -239,7 +276,10 @@ class MyFilesController extends Controller
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,
@@ -260,6 +300,10 @@ 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
@@ -336,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'),
@@ -396,7 +446,24 @@ class MyFilesController extends Controller
'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'),
@@ -467,7 +534,7 @@ class MyFilesController extends Controller
'commentable' => ['sometimes', 'boolean'],
'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)],
]);
@@ -481,6 +548,15 @@ class MyFilesController extends Controller
$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
@@ -578,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,6 +11,9 @@ 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 Inertia\Inertia;
@@ -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);
}
}
@@ -38,13 +38,22 @@ class ShareLinksController extends Controller
{
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
@@ -89,7 +98,18 @@ class ShareLinksController extends Controller
file: $file,
creator: $user,
expiresAt: $user->can('set_file_expiration_date') ? $expiresAt : null,
maxDownloads: $user->can('limit_downloads') ? $validated['max_downloads'] ?? null : 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,
);
@@ -101,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*';
}
}
@@ -13,6 +13,8 @@ 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;
@@ -45,6 +47,7 @@ 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,
) {}
@@ -102,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,
@@ -180,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) {
@@ -78,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
@@ -117,9 +129,11 @@ class FileResource extends JsonResource
'name' => $this->nextVersion->name,
]),
// GET /folders/{id} has the rest, its place in the tree included.
'folder' => $this->whenLoaded('folder', fn (): ?array => $this->folder === null ? null : [
'id' => $this->folder->id,
'name' => $this->folder->name,
'parent_id' => $this->folder->parent_id,
]),
// Name only. The uploader is a user record; their email address
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Http\Resources\Api;
use App\Modules\Files\Access\ClientIdentityScope;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Models\FolderAssignment;
use App\Modules\Groups\Models\Group;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Folder
*
* Every field is listed explicitly, never $folder->toArray(), for the same
* reason as FileResource: the next migration must not publish itself.
*
* `ancestors` and `path` come from FolderTrails, loaded by the controller
* for a whole page at once, and are trimmed to the folders the caller may
* see. The assignment list is narrowed per entry by ClientIdentityScope,
* exactly as FileResource narrows a file's.
*/
class FolderResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$viewer = $request->user();
$identity = app(ClientIdentityScope::class);
$groupMorph = (new Group)->getMorphClass();
$ancestors = $this->ancestors();
return [
'id' => $this->id,
'name' => $this->name,
'parent_id' => $this->parent_id,
// The folders above this one, root first, as far up as the
// caller may see. Empty for a folder at the top of the library.
'ancestors' => $ancestors,
// The same trail as one string, this folder included:
// "Clients / Acme / 2026". For display; match on ids, since a
// folder name may itself contain " / ".
'path' => implode(' / ', [...array_column($ancestors, 'name'), $this->name]),
// Read-only here. Making a folder public publishes everything
// inside it, and is done on the web.
'public' => (bool) $this->public,
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
'assignments' => $this->whenLoaded('assignments', fn (): array => $this->assignments
->filter(fn (FolderAssignment $assignment): bool => $assignment->assignable_type === $groupMorph
? $identity->permitsGroupId($viewer, (int) $assignment->assignable_id)
: $identity->permitsClientId($viewer, (int) $assignment->assignable_id))
->map(fn (FolderAssignment $assignment): array => [
'type' => $assignment->assignable_type === $groupMorph ? 'group' : 'client',
'id' => $assignment->assignable_id,
'name' => $assignment->assignable?->getAttribute('name'),
])
->values()
->all()),
];
}
/**
* @return list<array{id: int, name: string}>
*/
private function ancestors(): array
{
if (! $this->resource->relationLoaded('trail')) {
return [];
}
/** @var list<array{id: int, name: string}> $trail */
$trail = $this->resource->getRelation('trail')->all();
return $trail;
}
}
+28 -2
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;
@@ -382,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 */
@@ -403,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;
}
}
+186 -4
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',
@@ -271,6 +290,100 @@ class File extends Model
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
*/
@@ -279,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:
@@ -316,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
@@ -352,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);
}
/**
@@ -393,7 +573,7 @@ class File extends Model
$outer->orWhereIn('folder_id', $subtreeFolderIds);
});
$query->notExpired();
$query->notExpired()->notWithdrawn()->available();
}
/**
@@ -407,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();
}
/**
@@ -458,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();
}
}
+6
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
@@ -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));
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Scanning;
/**
* Why a file carries ScanStatus::NotScanned — stored in `scan_note`.
*
* Four different things to say to a person, and two of them are the
* installation's own doing rather than the file's, so a single "not
* scanned" badge with no reason would be unactionable.
*/
enum NotScannedReason: string
{
/** Bigger than the largest file this installation scans. */
case TooLarge = 'too_large';
/** An encrypted archive or document the scanner cannot open. */
case Encrypted = 'encrypted';
/** The scanner could not be reached in time, and the policy lets files through. */
case ScannerUnavailable = 'scanner_unavailable';
/** Uploaded before scanning was switched on, or while it is off. */
case BeforeScanning = 'before_scanning';
public function label(): string
{
return match ($this) {
self::TooLarge => 'Too large to scan',
self::Encrypted => 'Encrypted, so it could not be scanned',
self::ScannerUnavailable => 'The scanner could not be reached',
self::BeforeScanning => 'Uploaded before virus scanning was switched on',
};
}
}
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Scanning;
use App\Models\User;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Models\File;
use App\Modules\Identity\Permissions\Permission;
use App\Modules\Identity\Permissions\PermissionChecker;
use App\Modules\Identity\UserType;
use App\Modules\Notifications\Notifier;
/**
* Who hears about a quarantined file.
*
* Two audiences, deliberately not three. Staff who can do something about
* it are told, because a file sitting in quarantine that nobody looks at
* is the same as a file silently lost. The person who uploaded it is
* told, because on an honest account this is how they find out their own
* machine has something on it — and because otherwise their file simply
* never arrives and they have no idea why.
*
* The people the file was shared with are **not** told. They never
* received it, and a message about a virus in a file they never saw
* would alarm without informing.
*
* Recipients are resolved here rather than inside Notifier, which
* authorizes nothing by design — see its security contract.
*/
class QuarantineNotifier
{
public function __construct(
private readonly Notifier $notifier,
private readonly PermissionChecker $permissions,
private readonly StaffLibraryScope $scope,
) {}
public function quarantined(File $file, string $threat): void
{
$uploader = $file->uploader;
$staff = $this->staff($file);
$this->notifier->send('file_quarantined', $staff, subject: $file, data: [
'itemName' => $file->name,
'uploaderName' => $uploader->name ?? __('a deleted account'),
'threat' => $threat,
]);
// The uploader hears it once. Without this check a staff member
// who uploaded an infected file would get both messages, which
// read as two different files.
if ($uploader !== null && ! $staff->contains(fn (User $member): bool => $member->is($uploader))) {
$this->notifier->send('upload_blocked', [$uploader], subject: $file, data: [
'itemName' => $file->name,
'threat' => $threat,
]);
}
}
/**
* Staff who can release this file — the permission, and a client
* scope that reaches its uploader (see QuarantineController).
*
* @return \Illuminate\Support\Collection<int, User>
*/
private function staff(File $file): \Illuminate\Support\Collection
{
return User::query()
->where('type', UserType::Staff)
->where('active', true)
->get()
->filter(fn (User $staff): bool => $this->permissions->allows($staff, Permission::ReleaseQuarantinedFiles))
->filter(function (User $staff) use ($file): bool {
$uploaders = $this->scope->uploaderIds($staff);
return $uploaders === null || in_array($file->uploaded_by, $uploaders, true);
})
->values();
}
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Scanning;
enum ScanOutcome
{
case Clean;
case Infected;
case TooLarge;
case Encrypted;
/** The file's own bytes could not be read. Nothing to do with the scanner. */
case Unreadable;
case Unavailable;
}
+201
View File
@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Scanning;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Models\File;
use App\Modules\Files\Thumbnails\ThumbnailGenerator;
use Illuminate\Support\Facades\Storage;
/**
* What a verdict means for a file, on this installation.
*
* The scanner answers a question of fact — clean, infected, could not
* open it, did not answer. Three of those four are only half an answer:
* whether a file nobody could check may be handed to a client is a
* decision about somebody's business, not about the file, so it is a
* setting and it is applied here. Keeping that split is why ClamAvScanner
* knows nothing about settings and this class knows nothing about
* sockets.
*
* Every write to a file's scan columns goes through this class. They are
* not fillable and nothing else sets them.
*/
class ScanPolicy
{
public function __construct(
private readonly ScanningConfig $config,
private readonly FileAvailability $availability,
private readonly ActivityLogger $activity,
private readonly QuarantineNotifier $notifier,
) {}
/**
* Record a verdict, and return the state the file ended up in.
*
* Returns null when the verdict was "the scanner did not answer" and
* this installation waits: nothing is written, the file stays
* pending, and the caller retries.
*/
public function record(File $file, ScanVerdict $verdict): ?ScanStatus
{
return match ($verdict->outcome) {
ScanOutcome::Clean => $this->settle($file, ScanStatus::Clean, null, $verdict->engine),
ScanOutcome::Infected => $this->quarantine($file, $verdict->detail ?? 'unknown', $verdict->engine),
ScanOutcome::TooLarge => $this->unscannable($file, NotScannedReason::TooLarge, $verdict->engine),
ScanOutcome::Encrypted => $this->unscannable($file, NotScannedReason::Encrypted, $verdict->engine),
ScanOutcome::Unreadable => $this->missing($file),
ScanOutcome::Unavailable => $this->unavailable($file, $verdict->detail),
};
}
/**
* The file existed before there was a scanner, or scanning is off.
* Not a verdict, so it is never logged: nothing happened to this
* file, it simply was never looked at.
*/
public function markNeverScanned(File $file): void
{
$file->forceFill([
'scan_status' => ScanStatus::NotScanned,
'scan_note' => NotScannedReason::BeforeScanning->value,
])->save();
}
/**
* A threat was found. The bytes stay — a scanner can be wrong, and an
* administrator may release it — but nothing may reach them, and the
* thumbnails already rendered from this file have to go: they are
* derived from the same bytes and are served by their own routes.
*/
private function quarantine(File $file, string $threat, ?string $engine): ScanStatus
{
$wasAvailable = $this->availability->isAvailable($file);
$file->forceFill(['scan_was_available' => $wasAvailable])->save();
$this->settle($file, ScanStatus::Infected, $threat, $engine);
$this->purgeRenditions($file);
$this->activity->logSystem(Action::FileQuarantined, [
'id' => $file->id,
'name' => $file->name,
'threat' => $threat,
// Said out loud because it changes what an administrator has
// to do: a file that was downloadable while it waited for a
// scanner may already be on somebody's machine, and its
// download history is the only way to know.
'was_available' => $wasAvailable,
]);
$this->notifier->quarantined($file, $threat);
return ScanStatus::Infected;
}
/**
* The row is here and the bytes are not.
*
* Not a scanning verdict at all, and deliberately not run through the
* unscannable policy: "allow files nobody could scan" is a decision
* about risk, and there is no risk in a file that cannot be served.
* What there is, is a problem somebody has to look at — see
* MissingFileScanner and the Files → Missing screen.
*/
private function missing(File $file): ScanStatus
{
$this->settle($file, ScanStatus::Missing, null, null);
return ScanStatus::Missing;
}
/** The scanner could not open the file: too large, or encrypted. */
private function unscannable(File $file, NotScannedReason $reason, ?string $engine): ScanStatus
{
if ($this->config->blocksUnscannable()) {
$this->settle($file, ScanStatus::UnscannableBlocked, $reason->value, $engine);
$this->purgeRenditions($file);
$this->activity->logSystem(Action::FileQuarantined, [
'id' => $file->id,
'name' => $file->name,
'threat' => $reason->label(),
'was_available' => false,
]);
$this->notifier->quarantined($file, $reason->label());
return ScanStatus::UnscannableBlocked;
}
return $this->letThrough($file, $reason, $engine);
}
/** The scanner never answered. Either wait for it, or let the file go. */
private function unavailable(File $file, ?string $reason): ?ScanStatus
{
if ($this->config->holdsWhileUnavailable()) {
return null;
}
return $this->letThrough($file, NotScannedReason::ScannerUnavailable, null);
}
/**
* Allowed through without being checked.
*
* Always logged, even though it is the configured behaviour: this is
* the state where the installation looks protected and is not, and
* the log is what makes "we were unprotected between these two dates"
* answerable afterwards.
*/
private function letThrough(File $file, NotScannedReason $reason, ?string $engine): ScanStatus
{
$this->settle($file, ScanStatus::NotScanned, $reason->value, $engine);
$this->activity->logSystem(Action::FileNotScanned, [
'id' => $file->id,
'name' => $file->name,
'reason' => $reason->value,
]);
return ScanStatus::NotScanned;
}
private function settle(File $file, ScanStatus $status, ?string $note, ?string $engine): ScanStatus
{
// Asked before the write, because what the announcement means is
// "this can now be had" and a file that could already be had has
// nothing to announce. Without this, re-scanning a file that went
// out unscanned would tell its recipients a second time.
$wasAvailable = $this->availability->isAvailable($file);
$file->forceFill([
'scan_status' => $status,
'scan_note' => $note,
'scanned_at' => now(),
'scan_engine' => $engine,
])->save();
if (! $wasAvailable) {
$this->availability->markAvailable($file);
}
return $status;
}
/**
* Thumbnails and previews are cached copies of the same bytes, served
* by routes of their own, so a quarantined file with a rendition
* already on disk would still be showing part of itself.
*/
private function purgeRenditions(File $file): void
{
foreach (ThumbnailGenerator::pathsFor($file->id, $file->mime_type) as $path) {
Storage::disk('files')->delete($path);
}
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Scanning;
/**
* Where a file stands with the virus scanner.
*
* Availability is not a case here on purpose: three of these mean the
* file may be served and three mean it may not, and asking
* FileAvailability rather than comparing cases is what keeps that rule in
* one place. See docs/feature-virus-scanning.md.
*/
enum ScanStatus: string
{
/** Waiting to be scanned, or being scanned right now. */
case Pending = 'pending';
/** Scanned, nothing found. */
case Clean = 'clean';
/** A threat was found. Quarantined; `scan_note` is the threat name. */
case Infected = 'infected';
/** Was infected, and an administrator decided to allow it anyway. */
case Released = 'released';
/** Not checked, and allowed through. `scan_note` is a NotScannedReason. */
case NotScanned = 'not_scanned';
/** Could not be checked, and this installation blocks those. Quarantined. */
case UnscannableBlocked = 'unscannable_blocked';
/**
* The row is here and the bytes are not.
*
* Its own state rather than a kind of "not scanned", because what it
* means for the file is different: nothing can be served, so nothing
* is offered. A client listing it and getting an error on the
* download is worse than not seeing it, and staff need to see it
* precisely because somebody has to decide what to do about it.
*/
case Missing = 'missing';
/**
* Whether a file in this state may be seen and downloaded by people
* other than staff and its uploader.
*/
public function isAvailable(): bool
{
return match ($this) {
self::Clean, self::Released, self::NotScanned => true,
self::Pending, self::Infected, self::UnscannableBlocked, self::Missing => false,
};
}
/**
* The states a query may hand to somebody other than staff.
*
* @return list<string>
*/
public static function availableValues(): array
{
return array_values(array_map(
fn (self $status): string => $status->value,
array_filter(self::cases(), fn (self $status): bool => $status->isAvailable()),
));
}
/** Whether this state is waiting on an administrator's decision. */
public function isQuarantined(): bool
{
return $this === self::Infected || $this === self::UnscannableBlocked;
}
/**
* English, and the translation key — what staff see on the file.
*/
public function label(): string
{
return match ($this) {
self::Pending => 'Checking for viruses',
self::Clean => 'Checked',
self::Infected => 'Quarantined',
self::Released => 'Released by an administrator',
self::NotScanned => 'Not scanned',
self::UnscannableBlocked => 'Blocked: could not be scanned',
self::Missing => 'Missing from storage',
};
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Scanning;
/**
* What a scanner answered about one file.
*
* Five outcomes rather than a boolean, because four of them are not
* "clean or not": a file the scanner refused to open, one too big for it,
* and a scanner that never answered are three different facts, and this
* installation's settings decide what each one means for the file. That
* decision lives in ScanPolicy, not here.
*/
final class ScanVerdict
{
private function __construct(
public readonly ScanOutcome $outcome,
/** The threat name, the reason a scan was refused, or null. */
public readonly ?string $detail = null,
/** Engine and definitions, as the scanner reported them. */
public readonly ?string $engine = null,
) {}
public static function clean(?string $engine = null): self
{
return new self(ScanOutcome::Clean, null, $engine);
}
public static function infected(string $threat, ?string $engine = null): self
{
return new self(ScanOutcome::Infected, $threat, $engine);
}
public static function tooLarge(?string $engine = null): self
{
return new self(ScanOutcome::TooLarge, null, $engine);
}
public static function encrypted(?string $engine = null): self
{
return new self(ScanOutcome::Encrypted, null, $engine);
}
/** The file could not be read, so nothing was scanned. */
public static function unreadable(string $reason): self
{
return new self(ScanOutcome::Unreadable, $reason);
}
/** The scanner could not be reached, or did not answer in time. */
public static function unavailable(string $reason): self
{
return new self(ScanOutcome::Unavailable, $reason);
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Scanning;
/**
* Whether a scanner address is one, and not merely one PHP will accept.
*
* `stream_socket_client()` reads a port the way `atoi` does: it takes the
* digits at the front and ignores whatever follows. So
* `tcp://clamav:3310djlkasjdlk` connects happily to port 3310, and an
* address with a typo on the end is saved, tested, and reported as
* working — until the day something parses it differently. Meanwhile
* `tcp://clamav:33101` goes somewhere else entirely and fails, so the
* feedback an operator gets is inconsistent with the mistake they made.
*
* This refuses both, and says so while the field is still on screen.
*/
final class ScannerAddress
{
/**
* A TCP address: host, then a port of one to five digits and nothing
* after it. The host is a hostname, an IPv4 address, or an IPv6
* address in brackets — the three forms PHP itself accepts.
*/
private const TCP = '#^tcp://(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9._-]+):([0-9]{1,5})$#';
/** A Unix socket: an absolute path, and nothing clever. */
private const UNIX = '#^unix://(/[^\x00]+)$#';
public static function isValid(string $address): bool
{
$address = trim($address);
if (preg_match(self::UNIX, $address) === 1) {
return true;
}
if (preg_match(self::TCP, $address, $matches) !== 1) {
return false;
}
$port = (int) $matches[1];
return $port >= 1 && $port <= 65535;
}
/**
* English, and the translation key: what to type instead.
*/
public static function message(): string
{
return 'Enter the scanner as tcp://host:3310 or unix:///path/to/clamd.sock.';
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Scanning;
use Illuminate\Support\Carbon;
/**
* What the scanner said about itself — for the Test button, the dashboard
* warning and `projectsend:status`.
*/
final class ScannerStatus
{
public function __construct(
public readonly bool $reachable,
/** e.g. "ClamAV 1.4.1", or null when unreachable. */
public readonly ?string $engine = null,
/** The signature database number, when the scanner reports one. */
public readonly ?int $definitionsVersion = null,
public readonly ?Carbon $definitionsDate = null,
/** Why it could not be reached, for a person to act on. */
public readonly ?string $error = null,
) {}
public static function unreachable(string $error): self
{
return new self(false, error: $error);
}
/**
* How old the definitions are, in hours. Null when the scanner does
* not say — absent and zero are different answers, and a caller
* warning on "older than three days" must not treat "did not say" as
* "brand new".
*/
public function definitionsAgeHours(): ?int
{
if ($this->definitionsDate === null) {
return null;
}
// diffInHours() answers with a float; whole hours is what the
// warning threshold and the status document both speak in.
return (int) $this->definitionsDate->diffInHours(now());
}
}
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Scanning;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
/**
* What this installation's scanning setup actually is, once the managed
* configuration and the settings screen have both had their say.
*
* The rule is the one Captcha::resolve() already follows: an address
* named in the environment wins, and where it wins the screen stops
* offering the choice. That is how a hosted fleet points every site at one
* scanning service without a per-site setting to get wrong, and it is
* deliberately not an edition check — a self-hosted operator who prefers
* to configure this in the environment gets the same behaviour. Edition
* differences flow through the capability registry; this is not one.
*
* The two policies stay editable either way. What to do with a file that
* cannot be scanned, and what to do while the scanner is down, are
* decisions about somebody's own files.
*/
class ScanningConfig
{
public function __construct(
private readonly Settings $settings,
) {}
/**
* Whether new uploads are scanned at all.
*
* Forced on under a managed configuration: a platform that supplies
* the scanner is not offering the tenant a switch for it.
*/
public function enabled(): bool
{
return $this->isManaged() || $this->settings->get(Setting::VirusScanningEnabled) === true;
}
/**
* An address to use instead of the stored one, for this request only.
*
* The Test button exists to answer "is *this* address right?", and
* the address in question is the one being typed — testing what is
* saved would make the button useless exactly when it is needed, on
* the first attempt, before anything is saved. Set by
* VirusScanningSettingsController::test() and never persisted.
*/
private ?string $preview = null;
public function preview(string $address): void
{
$this->preview = trim($address);
}
public function isManaged(): bool
{
return $this->managedAddress() !== '';
}
public function address(): string
{
// Ahead of the managed address too: an operator on a managed
// installation has no field to type in, so nothing sets this
// there — and where something does, it was asked for.
if ($this->preview !== null && $this->preview !== '') {
return $this->preview;
}
if ($this->isManaged()) {
return $this->managedAddress();
}
$stored = $this->settings->get(Setting::VirusScannerAddress);
return is_string($stored) ? trim($stored) : '';
}
/**
* The largest file this installation sends to the scanner, in bytes.
* Zero means no limit of our own — clamd's StreamMaxLength still
* applies, and answers with tooLarge when it is reached.
*/
public function maxScanBytes(): int
{
return max(0, (int) $this->settings->get(Setting::VirusScanMaxSizeMb)) * 1024 * 1024;
}
/** What happens to a file the scanner could not open. */
public function blocksUnscannable(): bool
{
return $this->settings->get(Setting::VirusUnscannablePolicy) === 'block';
}
/** Whether uploads wait for a scanner that is not answering. */
public function holdsWhileUnavailable(): bool
{
return $this->settings->get(Setting::VirusScannerDownPolicy) === 'hold';
}
/** How long a file waits for an unreachable scanner before the policy applies. */
public function unavailableWaitMinutes(): int
{
return max(1, (int) $this->settings->get(Setting::VirusScannerWaitMinutes));
}
/** How many already-stored files an hour-long backfill may scan per minute. */
public function existingScanRatePerMinute(): int
{
return max(1, (int) $this->settings->get(Setting::VirusScanExistingRatePerMinute));
}
public function connectTimeoutSeconds(): int
{
return max(1, (int) config('projectsend.scanning.connect_timeout', 5));
}
public function replyTimeoutSeconds(): int
{
return max(1, (int) config('projectsend.scanning.reply_timeout', 600));
}
private function managedAddress(): string
{
$address = config('projectsend.scanning.address', '');
return is_string($address) ? trim($address) : '';
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Scanning;
/**
* The seam between this application and whatever actually reads the
* bytes.
*
* One implementation ships (ClamAvScanner) and one more lives in the test
* suite. It exists as an interface because a commercial engine is a
* plausible later addition and because every test that is *about* policy
* — what happens to a file the scanner could not open — should be able to
* state the verdict rather than produce a file that provokes it.
*/
interface VirusScanner
{
/**
* Read a file and say what it is.
*
* Implementations never throw for a scanner that is down or slow:
* that is ScanVerdict::unavailable(), because the caller has a policy
* for it and an exception would look like a bug in the job.
*
* @param resource $stream the file's bytes, at position 0
* @param int $size the file's size in bytes
*/
public function scan(mixed $stream, int $size): ScanVerdict;
/**
* Whether the scanner answers, and what it is running.
*/
public function status(): ScannerStatus;
}
+14
View File
@@ -8,6 +8,7 @@ use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\FileAvailability;
use App\Modules\Files\Models\FileAssignment;
use App\Modules\Groups\Models\Group;
use App\Modules\Notifications\NotificationDigester;
@@ -35,6 +36,7 @@ class FileSharing
private readonly ActivityLogger $activity,
private readonly NotificationDigester $digester,
private readonly Notifier $notifier,
private readonly FileAvailability $availability,
) {}
/**
@@ -51,6 +53,18 @@ class FileSharing
$this->activity->log(Action::FileAssigned, subject: $file, context: ['target' => $targetName]);
// Sharing itself is never held up — the assignment above is
// written, and the file is theirs the moment it can be had. What
// waits is the telling: a file still being checked for viruses
// cannot be downloaded, so an email now would send somebody to a
// page that refuses them, and a file about to be quarantined would
// have been announced to everyone before anybody knew. The
// announcement goes out from AnnounceAvailableFile instead, on the
// event that says the file can be handed over.
if (! $this->availability->isAvailable($file)) {
return;
}
$recipients = $this->recipients($assignable);
$this->notifier->send('file_shared', $recipients, subject: $file, data: ['itemName' => $file->name]);
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Sharing;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Models\FolderAssignment;
use App\Modules\Groups\Models\Group;
use App\Modules\Notifications\NotificationDigester;
use App\Modules\Notifications\Notifier;
/**
* What actually happens when a folder is shared with a client or a group —
* the assignment row, the activity entry, the in-app notification and the
* debounced digest email, in that order. The folder twin of FileSharing.
*
* Extracted for the same reason FileSharing was: the web controller and the
* API controller must not be able to answer the question differently. The
* AI connector in the hosted edition repeated these four steps too, because
* there was nothing here to call.
*
* Unlike a file, a folder has no scan to wait for: the files inside it are
* held back individually until they can be had, and sharing the folder
* does not change that. So the telling is never deferred here.
*
* Authorization is the caller's job — both callers reach this after
* Gate::authorize('update', $folder), and the target has already been
* resolved and scope-checked by ResolvesShareTargets.
*/
class FolderSharing
{
public function __construct(
private readonly ActivityLogger $activity,
private readonly NotificationDigester $digester,
private readonly Notifier $notifier,
) {}
/**
* Idempotent for the row: sharing the same folder with the same target
* twice leaves one assignment, which matters for an API caller retrying
* a request.
*/
public function assign(Folder $folder, User|Group $assignable, string $targetName): void
{
FolderAssignment::query()->firstOrCreate([
'folder_id' => $folder->id,
'assignable_type' => $assignable->getMorphClass(),
'assignable_id' => $assignable->getKey(),
]);
$this->activity->log(Action::FolderShared, subject: $folder, context: ['target' => $targetName]);
$recipients = $this->recipients($assignable);
$this->notifier->send('file_shared', $recipients, subject: $folder, data: ['itemName' => $folder->name]);
// The master switch and each recipient's own preference are the
// digester's job now — every caller was repeating them.
$this->digester->queue('file_shared', $recipients, $folder->name, ['is_folder' => true]);
}
/**
* @return bool whether an assignment was actually removed
*/
public function unassign(Folder $folder, User|Group $assignable, string $targetName): bool
{
$deleted = FolderAssignment::query()
->where('folder_id', $folder->id)
->where('assignable_type', $assignable->getMorphClass())
->where('assignable_id', $assignable->getKey())
->delete();
if ($deleted > 0) {
$this->activity->log(Action::FolderUnshared, subject: $folder, context: ['target' => $targetName]);
}
return $deleted > 0;
}
/**
* Notifier performs no authorization of its own — see its SECURITY
* CONTRACT docblock — so the recipient list is resolved here, from the
* assignment itself.
*
* @return iterable<User>
*/
private function recipients(User|Group $assignable): iterable
{
return $assignable instanceof Group ? $assignable->members : [$assignable];
}
}
@@ -353,6 +353,11 @@ class LocalPartStore
* deletes the whole tree, for everybody. Unset, which is every
* installation, the path is what it has always been.
*/
public function temporaryDirectory(): string
{
return $this->root();
}
private function root(): string
{
$configured = config('projectsend.uploads.parts_path');
@@ -10,6 +10,9 @@ use Illuminate\Support\Facades\Event;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\NotScannedReason;
use App\Modules\Files\Scanning\ScanStatus;
use App\Modules\Files\Scanning\ScanningConfig;
/**
* The single place a stored payload becomes a File record — shared by
@@ -20,6 +23,7 @@ class StoreUploadedFile
{
public function __construct(
private readonly ActivityLogger $activity,
private readonly ScanningConfig $scanning,
) {}
public function create(
@@ -37,6 +41,8 @@ class StoreUploadedFile
): File {
$originalName = self::sanitizeFilename($originalName);
$scanning = $this->scanning->enabled();
$file = File::query()->create([
'uploaded_by' => $uploader->id,
'folder_id' => $folderId,
@@ -50,6 +56,13 @@ class StoreUploadedFile
'mime_type' => $mimeType,
'size' => $size,
'checksum' => $checksum,
// Decided in the same insert as the row rather than a moment
// later: a file is unavailable from the instant it exists, or
// there is a window in which it is neither scanned nor
// withheld. Every upload path arrives here, so this is the
// only place that has to be right.
'scan_status' => $scanning ? ScanStatus::Pending : ScanStatus::NotScanned,
'scan_note' => $scanning ? null : NotScannedReason::BeforeScanning->value,
]);
$this->activity->log($action, $uploader, $file);
@@ -88,7 +88,7 @@ class FileVersionLinks
// than failing anywhere near here.
$successors = File::query()
->whereIn('previous_file_id', array_keys($rows))
->get(['id', 'name', 'slug', 'previous_file_id', 'public', 'expires_at', 'folder_id']);
->get(['id', 'name', 'slug', 'previous_file_id', 'public', 'expires_at', 'folder_id', 'scan_status']);
/** @var array<int, File> $candidates */
$candidates = [];
@@ -99,7 +99,7 @@ class FileVersionLinks
if ($previousIds !== []) {
$previous = File::query()
->whereIn('id', array_keys($previousIds))
->get(['id', 'name', 'slug', 'previous_file_id', 'public', 'expires_at', 'folder_id']);
->get(['id', 'name', 'slug', 'previous_file_id', 'public', 'expires_at', 'folder_id', 'scan_status']);
foreach ($previous as $file) {
$candidates[$file->id] = $file;
@@ -145,13 +145,13 @@ class FileVersionLinks
}
// A guest "sees both files" exactly when both are effectively
// public and unexpired — the same predicate
// public, unexpired and available — the same predicate
// PublicGroupsController::showFile 404s on, so the badge can never
// point at a page that would refuse to load.
if ($viewer === null) {
$ids = [];
foreach ($candidates as $candidate) {
if ($candidate->isEffectivelyPublic() && ! $candidate->isExpired()) {
if ($candidate->isEffectivelyPublic() && ! $candidate->isExpired() && $candidate->scan_status->isAvailable()) {
$ids[] = $candidate->id;
}
}
+5 -2
View File
@@ -151,11 +151,14 @@ class FileVersions
*
* Candidates come from the previous file's own audience rather than a
* broad user query, then each is re-checked against both files with the
* authoritative visibility scope.
* authoritative visibility scope — which is also why this is public:
* while the new file is being scanned that scope hides it, so the
* audience is empty and nothing is sent. AnnounceAvailableFile asks
* again once the file can actually be had.
*
* @return Collection<int, User>
*/
private function sharedAudience(File $file, File $previous): Collection
public function sharedAudience(File $file, File $previous): Collection
{
$candidateIds = FileAssignment::query()
->where('file_id', $previous->sharingOwnerId())
@@ -13,6 +13,8 @@ use App\Modules\Files\Delivery\FileDelivery;
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\Models\Folder;
use App\Modules\Files\Preview\PreviewKind;
use App\Modules\Files\Preview\PreviewLog;
@@ -81,6 +83,7 @@ class PublicGroupsController extends Controller
private readonly ActivityLogger $activity,
private readonly PreviewLog $previews,
private readonly DownloadAllowance $allowance,
private readonly FileAvailability $availability,
private readonly ThumbnailGenerator $thumbnails,
private readonly PublicThemeRegistry $themes,
private readonly CapabilityRegistry $capabilities,
@@ -191,7 +194,8 @@ class PublicGroupsController extends Controller
{
$this->guardSlug($publicSlug);
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 404);
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired() && ! $file->isWithdrawn(), 404);
abort_unless($this->availability->isAvailable($file), 404);
$file->loadMissing('categories');
@@ -225,6 +229,9 @@ class PublicGroupsController extends Controller
// is allowed.
'preview_url' => $this->previewUrlFor($file, $publicSlug),
'download_url' => route('public.download', [$publicSlug, $file->slug]),
// See PublicShareController::show — the same sentence, to the
// same person, on the other public surface.
'unscanned' => app(ScanningConfig::class)->enabled() && $file->wasLetThrough(),
// Same decided shape the listings send, so a theme's single
// file page disables its button for the same reason a row
// does — see DownloadAllowance::summaryFor.
@@ -242,7 +249,8 @@ class PublicGroupsController extends Controller
{
$this->guardSlug($publicSlug);
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 404);
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired() && ! $file->isWithdrawn(), 404);
abort_unless($this->availability->isAvailable($file), 404);
abort_unless(ThumbnailGenerator::supports($file->mime_type), 404);
// Always the external variant — nobody reaching a public listing is
@@ -299,7 +307,8 @@ class PublicGroupsController extends Controller
{
$this->guardSlug($publicSlug);
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 404);
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired() && ! $file->isWithdrawn(), 404);
abort_unless($this->availability->isAvailable($file), 404);
abort_unless($this->settings->get(Setting::PublicListingPreviewEnabled) === true, 404);
abort_if(PreviewKind::forMime($file->mime_type) === null, 404);
@@ -345,7 +354,8 @@ class PublicGroupsController extends Controller
{
$this->guardSlug($publicSlug);
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 404);
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired() && ! $file->isWithdrawn(), 404);
abort_unless($this->availability->isAvailable($file), 404);
// 403 rather than 404, unlike the checks above it: the file is
// genuinely here and genuinely public, it has simply been taken
@@ -260,6 +260,10 @@ class AccountConversion
// `ldap_dn` is deliberately kept: it is the record of where
// the account came from, and a demotion makes it live again.
'auth_source' => AuthSource::Local,
// Only client accounts carry an expiry, and no staff screen
// shows one. Kept, it would switch a staff member off on a
// date nobody who manages staff can see or change.
'expires_at' => null,
]);
if ($newPassword !== null) {
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Modules\Identity\Erasure\Events;
/**
* "When somebody deletes their own account, do their files go at once?"
* Asked by SelfDeletion, with the installation's own setting already in
* $filesImmediately.
*
* A hosted platform answers it for some installations. On the free
* shared instance the files are only ever the customer's own, and the
* staff who would keep seeing them through the grace period are us, so
* cloud-modules makes this true there. The settings screen shows the
* choice as made by the platform rather than offering a switch that
* would do nothing.
*
* Listened to by *string* class name from a package, same as every other
* hook here — see docs/extension-points-architecture.md.
*/
final class ResolvingSelfDeletion
{
/**
* Whether a listener made the choice rather than the setting.
*/
public bool $managed = false;
public function __construct(
public bool $filesImmediately,
) {}
/**
* One direction only, the way ResolvingAttribution moves: a listener
* can make deletion sooner, never later. A package that could turn
* "delete my files now" into "keep them for a month" would be
* overruling a promise the person was shown when they confirmed.
*/
public function deleteFilesImmediately(): void
{
$this->filesImmediately = true;
$this->managed = true;
}
}
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Modules\Identity\Erasure;
use App\Models\User;
use App\Modules\Identity\Erasure\Events\ResolvingSelfDeletion;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Event;
/**
* What deleting your own account does to your files.
*
* Two rules, and they share one question — whose deletion counts, set by
* Setting::AccountSelfDeleteScope:
*
* 1. **The files stop being served at once.** From the moment the account
* is soft-deleted, nobody but staff gets them: not the clients and
* groups they were shared with, not a share link, not the public
* listing. Somebody who asked to leave should not stay published for
* the length of a grace period. Staff keep seeing them, because the
* grace period exists so that a mistake can still be undone. Nothing
* is deleted by this rule, and share links are kept, so an account
* that is restored is served again exactly as before.
*
* 2. **Optionally, the files are deleted at once** rather than when the
* account is erased (Setting::AccountSelfDeleteFiles, which a platform
* can overrule through ResolvingSelfDeletion).
*
* In practice rule 1 only ever meets a *self*-deleted account. An
* administrator deleting an account that owns anything must choose there
* and then to delete or reassign it (AccountContentDeletion), so no file
* is left pointing at an account an administrator removed.
*/
class SelfDeletion
{
public function __construct(
private readonly Settings $settings,
) {}
/**
* Whether the two rules apply to this account's own deletion.
*/
public function appliesTo(User $user): bool
{
return $this->clientsOnly() ? $user->isClient() : true;
}
public function deletesFilesImmediately(): bool
{
return $this->resolve()->filesImmediately;
}
/**
* Whether the platform made the choice, so the settings screen shows
* it rather than a switch that would change nothing.
*/
public function isManaged(): bool
{
return $this->resolve()->managed;
}
/**
* The ids of every deleted account whose files are withdrawn — the
* subquery File::scopeNotWithdrawn() and File::isWithdrawn() ask.
*
* @return Builder<User>
*/
public function withdrawnAccounts(): Builder
{
return User::onlyTrashed()
->select('id')
->when($this->clientsOnly(), fn (Builder $query) => $query->where('type', UserType::Client));
}
private function clientsOnly(): bool
{
return $this->settings->get(Setting::AccountSelfDeleteScope) === 'clients';
}
private function resolve(): ResolvingSelfDeletion
{
$event = new ResolvingSelfDeletion(
$this->settings->get(Setting::AccountSelfDeleteFiles) === 'immediately',
);
Event::dispatch($event);
return $event;
}
}
@@ -13,6 +13,9 @@ use App\Modules\Identity\Permissions\Permission;
use App\Modules\Identity\Permissions\PermissionCategory;
use App\Modules\Identity\Permissions\PermissionChecker;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\StartPage;
use App\Modules\Identity\StartPages;
use App\Modules\Identity\UserType;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -31,6 +34,7 @@ class RolesController extends Controller
public function __construct(
private readonly ActivityLogger $activity,
private readonly PermissionChecker $permissions,
private readonly StartPages $startPages,
) {}
public function index(Request $request): Response
@@ -75,6 +79,9 @@ class RolesController extends Controller
{
return Inertia::render('roles/create', [
'catalog' => $this->catalog(),
// A role made here is always a staff role: the Client role is
// built in, and there is no second one.
'start_page_options' => $this->startPages->roleOptions(UserType::Staff),
]);
}
@@ -85,9 +92,11 @@ class RolesController extends Controller
'client_scoped' => ['boolean'],
'permissions' => ['array'],
'permissions.*' => [Rule::enum(Permission::class)],
'start_page' => $this->startPageRules(UserType::Staff),
]);
$this->guardGrantablePermissions($request, $validated['permissions'] ?? []);
$this->guardStartPage($validated['start_page'] ?? null, UserType::Staff, $validated['permissions'] ?? []);
$clientScoped = $request->boolean('client_scoped');
$this->guardScopeRemoval($request, removesScope: ! $clientScoped);
@@ -95,6 +104,7 @@ class RolesController extends Controller
$role = Role::query()->create([
'name' => $validated['name'],
'client_scoped' => $clientScoped,
'start_page' => $validated['start_page'] ?? null,
]);
$this->syncPermissions($role, $validated['permissions'] ?? []);
@@ -115,17 +125,35 @@ class RolesController extends Controller
'client_scoped' => $role->client_scoped,
'users_count' => $role->users()->count(),
'permissions' => $role->permissions()->pluck('permission')->all(),
'start_page' => $role->start_page,
],
'catalog' => $this->catalog(),
'start_page_options' => $this->startPages->roleOptions(StartPages::typeOf($role)),
]);
}
public function update(Request $request, Role $role): RedirectResponse
{
$type = StartPages::typeOf($role);
// The one thing about the administrator role that is not
// authority: where its members land. Everything else stays locked,
// and a request carrying anything more is refused rather than
// quietly half-applied.
if ($role->is_administrator) {
throw ValidationException::withMessages([
'permissions' => __('The administrator role always has every permission and cannot be edited.'),
]);
if ($request->hasAny(['name', 'client_scoped', 'permissions'])) {
throw ValidationException::withMessages([
'permissions' => __('The administrator role always has every permission and cannot be edited.'),
]);
}
$validated = $request->validate(['start_page' => $this->startPageRules($type)]);
$role->update(['start_page' => $validated['start_page'] ?? null]);
$this->activity->log(Action::RoleUpdated, subject: $role);
return back()->with('success', __('Role updated.'));
}
$validated = $request->validate([
@@ -133,8 +161,12 @@ class RolesController extends Controller
'client_scoped' => ['boolean'],
'permissions' => ['array'],
'permissions.*' => [Rule::enum(Permission::class)],
'start_page' => $this->startPageRules($type),
]);
$this->guardStartPage($validated['start_page'] ?? null, $type, $validated['permissions'] ?? []);
$role->start_page = $validated['start_page'] ?? null;
// Built-in roles have fixed names and a fixed scope flag; only their
// permission set is editable. Custom roles can change name + scope.
if (! $role->is_system) {
@@ -158,6 +190,10 @@ class RolesController extends Controller
$this->syncPermissions($role, $newPermissions);
// Built-in roles skip the update() above, so the start page is
// saved here for every role alike.
$role->save();
$this->activity->log(Action::RoleUpdated, subject: $role, context: [
'permissions_added' => array_values(array_diff($newPermissions, $oldPermissions)),
'permissions_removed' => array_values(array_diff($oldPermissions, $newPermissions)),
@@ -258,6 +294,36 @@ class RolesController extends Controller
]);
}
/**
* @return list<mixed>
*/
private function startPageRules(UserType $type): array
{
return ['nullable', 'string', Rule::in(array_map(fn (StartPage $page): string => $page->value, StartPage::optionsFor($type)))];
}
/**
* A role cannot send its members to a page its own permissions keep
* them out of. Checked against the permissions saved in the same
* request, so granting "Manage clients" and choosing Clients as the
* start page is one save, not two. StartPages would fall back to the
* dashboard anyway; this says so at the moment it can be fixed.
*
* @param list<string> $permissions
*/
private function guardStartPage(?string $value, UserType $type, array $permissions): void
{
$required = $value === null ? null : StartPage::tryFrom($value)?->requiredPermission($type);
if ($required !== null && ! in_array($required->value, $permissions, true)) {
throw ValidationException::withMessages([
'start_page' => __('This role cannot open that page. Give it the ":permission" permission, or choose another start page.', [
'permission' => __($required->label()),
]),
]);
}
}
/**
* @param list<string> $permissions
*/
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Identity\SignIn;
use App\Modules\Identity\StartPages;
use App\Modules\Identity\Social\SocialAuthenticator;
use App\Modules\Identity\Social\SocialGateway;
use App\Modules\Identity\Social\SocialIdentity;
@@ -40,6 +41,7 @@ class SocialLoginController extends Controller
private readonly SocialAuthenticator $authenticator,
private readonly SignIn $signIn,
private readonly ActivityLogger $activity,
private readonly StartPages $startPages,
) {}
/**
@@ -128,7 +130,7 @@ class SocialLoginController extends Controller
$request->session()->regenerate();
return redirect()->intended(route('dashboard', absolute: false));
return redirect()->intended($this->startPages->pathFor($resolution->user));
}
private function begin(Request $request, string $provider, string $intent): Response
@@ -7,6 +7,7 @@ namespace App\Modules\Identity\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Identity\SignIn;
use App\Modules\Identity\StartPages;
use App\Modules\Identity\TwoFactor\TwoFactorService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -25,6 +26,7 @@ class TwoFactorChallengeController extends Controller
{
public function __construct(
private readonly TwoFactorService $twoFactor,
private readonly StartPages $startPages,
) {}
public function create(Request $request): Response|RedirectResponse
@@ -77,7 +79,7 @@ class TwoFactorChallengeController extends Controller
$request->session()->forget(SignIn::TWO_FACTOR_ID);
$request->session()->regenerate();
return redirect()->intended(route('dashboard', absolute: false));
return redirect()->intended($this->startPages->pathFor($user));
}
private function pendingUser(Request $request): ?User
@@ -90,6 +92,6 @@ class TwoFactorChallengeController extends Controller
$user = User::query()->find($id);
return $user instanceof User && $user->active && $user->hasTwoFactorEnabled() ? $user : null;
return $user instanceof User && $user->maySignIn() && $user->hasTwoFactorEnabled() ? $user : null;
}
}
@@ -50,7 +50,14 @@ class EnforceTwoFactor
// GET left the form rendering and its submission redirected away,
// so the password was never confirmed and the loop stayed shut
// one step further along than before.
if ($request->routeIs('two-factor.*', 'password.confirm*', 'logout', 'locale.update')) {
// password.edit/update is on it for the same shape of reason, one
// step further out: an account provisioned by a provider has no
// password to confirm with, so the confirm screen sends it to set
// one — and without this, that screen was redirected back here
// too. The loop then had no exit at all, which is how an
// installation that made two-factor compulsory locked out
// everybody who signs in with Microsoft.
if ($request->routeIs('two-factor.*', 'password.confirm*', 'password.edit', 'password.update', 'logout', 'locale.update')) {
return $next($request);
}
@@ -11,8 +11,8 @@ use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
/**
* A deactivated account loses access immediately, not at next login:
* any open session is terminated on the following request.
* A deactivated or expired account loses access immediately, not at next
* login: any open session is terminated on the following request.
*/
class EnsureAccountIsActive
{
@@ -20,14 +20,16 @@ class EnsureAccountIsActive
{
$user = $request->user();
if ($user !== null && ! $user->active) {
if ($user !== null && ! $user->maySignIn()) {
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return WriteSafeRedirect::apply($request, redirect()->route('login')->withErrors([
'email' => __('Your account has been deactivated.'),
'email' => $user->hasExpired()
? __('Your account has expired.')
: __('Your account has been deactivated.'),
]));
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Modules\Identity\Http\Middleware;
use App\Modules\Identity\AuthSource;
use Closure;
use Illuminate\Auth\Middleware\RequirePassword;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* `password.confirm`, answered in place for the app's own screens.
*
* The framework's version redirects to the confirm-password screen and
* relies on the "intended" URL to come back. For a GET that works. For
* the writes this guards it cannot: Redirector::guest() only remembers
* the exact URL of a GET, so a POST comes back to the page it was sent
* from, freshly rendered, and whatever was typed into the form -- a
* token's name and scopes, a release reason -- is gone, along with the
* action itself.
*
* So an Inertia request gets a 423 instead, which the browser turns into
* a password dialog over the page it is on (password-confirmation-dialog.tsx).
* Nothing navigates, the form keeps its state, and once the password is
* proved the same request is sent again. The check itself is the
* framework's, unchanged: this only decides what the refusal looks like.
* Anything else -- a plain form post, a JSON client -- is answered
* exactly as before.
*/
class RequirePasswordConfirmation extends RequirePassword
{
/**
* Marks the 423 as this refusal and not any other, so the browser does
* not open a password dialog in answer to something else.
*/
public const HEADER = 'X-Password-Confirmation';
public function handle($request, Closure $next, $redirectToRoute = null, $passwordTimeoutSeconds = null)
{
// Middleware parameters arrive as strings ("password.confirm:,300").
$timeout = $passwordTimeoutSeconds === null || $passwordTimeoutSeconds === '' ? null : (int) $passwordTimeoutSeconds;
if ($request->header('X-Inertia') && $this->shouldConfirmPassword($request, $timeout)) {
return $this->inertiaRefusal($request);
}
return parent::handle($request, $next, $redirectToRoute, $passwordTimeoutSeconds);
}
private function inertiaRefusal(Request $request): Response
{
$user = $request->user();
return $this->responseFactory->json([
'message' => 'Password confirmation required.',
// The same question the confirm-password screen asks, and see
// there for why it is Social and not "anything but Local": an
// account provisioned by a provider has no password to type,
// and the dialog has to offer it a way to set one instead.
'has_password' => $user !== null && $user->auth_source !== AuthSource::Social,
], 423, [self::HEADER => 'required']);
}
}
+1
View File
@@ -15,6 +15,7 @@ use RuntimeException;
* @property bool $is_system
* @property bool $is_administrator
* @property bool $client_scoped
* @property string|null $start_page a StartPage value; see StartPages
* @property-read int $users_count
* @property-read int $permissions_count
*/
@@ -35,6 +35,13 @@ enum Permission: string
// rather than a key nobody can reach.
case ModerateComments = 'moderate_comments';
// Overrule the virus scanner: let a quarantined file out. Its own key
// rather than riding on delete_files, because deciding that a threat
// report is wrong is a different judgement from deciding a file is no
// longer needed — and only the administrator role holds it by
// default. See docs/feature-virus-scanning.md.
case ReleaseQuarantinedFiles = 'release_quarantined_files';
// Categories
case CreateCategories = 'create_categories';
case EditCategories = 'edit_categories';
@@ -95,6 +102,7 @@ enum Permission: string
self::ImportOrphans => 'Import orphan files',
self::LimitDownloads => 'Limit download counts',
self::ModerateComments => 'Moderate comments',
self::ReleaseQuarantinedFiles => 'Release quarantined files',
self::CreateCategories => 'Create categories',
self::EditCategories => 'Edit categories',
self::DeleteCategories => 'Delete categories',
@@ -186,6 +194,7 @@ enum Permission: string
self::ImportOrphans,
self::LimitDownloads,
self::ModerateComments => PermissionCategory::Files,
self::ReleaseQuarantinedFiles => PermissionCategory::Files,
self::CreateCategories,
self::EditCategories,

Some files were not shown because too many files have changed in this diff Show More