1 Commits

Author SHA1 Message Date
Eliana Bracciaforte 62245befc7 Give the browser icons the favicon treatment the mark deserved
The favicon.svg was a raw export of the artwork's own bounds — 234
wide by 252 tall — so every square slot a browser put it in letterboxed
it slightly. The portal's icons were padded to a square in
app-customer-portal (4e0f70d there); this is the same treatment for the
project's own edition, in the project gradient. One shape for the
project, one gradient per edition: the colour is what tells an admin's
ProjectSend tab and the Cloud portal apart.

The rasters — the .ico's 16/32/48 and the 180px touch icon — are
regenerated from that square source, so the touch icon carries the same
margin the portal's has instead of running edge to edge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 13:41:12 -03:00
281 changed files with 528 additions and 23471 deletions
-19
View File
@@ -4,11 +4,6 @@ PROJECTSEND_EDITION=community
# Emergency off switch for the CAPTCHA on public forms, for an operator who
# has a shell but no working login. Everything else about the feature is
# configured at /system/settings/captcha.
#
# Only "true" or "1" switches it off. Anything else -- including "no",
# "off", and a misspelling -- leaves the CAPTCHA on, deliberately: a flag
# that takes a protection away should not do so because a value was typed
# wrong.
# PROJECTSEND_CAPTCHA_DISABLED=true
# How downloads leave the server. Left unset (or "auto"), ProjectSend hands
@@ -20,20 +15,6 @@ 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.
+20 -185
View File
@@ -6,199 +6,34 @@ Versions follow [SemVer](https://semver.org/): the middle number moves when ther
the last one when there are only fixes, and the first one when an upgrade needs more from you than
dropping in the new files and running the migrations.
Anything under **⚠️ Important — do these yourself** is something you have to do, not something
we did. It sits at the top of a release for that reason. Older entries call the same section
**Upgrade notes**.
Anything under **Upgrade notes** is something you have to do, not something we did.
## 2.5.0 — 18 September 2026
## Unreleased
**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.
This section collects changes as they land; the release process turns it into a numbered entry
when a version is cut.
**Closed holes in who can see what**
- **A staff member limited to 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).
- A staff member limited to their own assigned clients could read the names of other clients out of
file details. Sharing means a file can reach somebody through one client while it was uploaded by
another, or while it is also shared with another. That is normal and the file is theirs to open —
but the uploader's name, the other recipient's name, and both their ID numbers were being sent
along with it, on the library list, the file's edit page, the details panel, the per-client file
list, and the matching API responses. A group holding none of their clients was named the same
way. The file list could also be filtered by uploader, which answered "does this client of yours
share files with that client of mine" without naming anybody.
**Fixed**
Those names are now left out for a limited staff member, and the uploader filter no longer answers
for a client they are not assigned to. Administrators and any unrestricted role see exactly what
they saw before.
- **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`.
**Who this affected.** Only installations using the Client Manager role, or a custom role with
"Limit to assigned clients" switched on, and only where files are shared with more than one client
or through groups. No files, downloads or credentials were reachable this way — a file belonging
to a client outside the roster was refused before, and still is.
### Issues closed since 2.4.1
- [#1310](https://github.com/projectsend/projectsend/issues/1310) — expire client accounts
- [#1658](https://github.com/projectsend/projectsend/issues/1658) — Docker services have no restart
policy
- [#1770](https://github.com/projectsend/projectsend/issues/1770) — Docker upgrade fails with
external storage configured
- [#1776](https://github.com/projectsend/projectsend/issues/1776) — logo gives error 404
- [#1777](https://github.com/projectsend/projectsend/issues/1777) — customization / branding
(start pages)
- [#1778](https://github.com/projectsend/projectsend/issues/1778) — error 500 on install
- [#1779](https://github.com/projectsend/projectsend/issues/1779) — client invitations
- [#1788](https://github.com/projectsend/projectsend/issues/1788) — a bucket folder makes every
storage page a 500
- [#1789](https://github.com/projectsend/projectsend/issues/1789) — S3 not working after an update
## 2.4.1 — 11 September 2026
### ⚠️ Important — do these yourself
Everything else in this release happens on its own. These do not: each one leaves something working
differently from how you expect until you act on it. Nothing here stops the upgrade or the
installation from starting.
- **If you set `PROJECTSEND_CAPTCHA_DISABLED`, check what you set it to.** Only `true` or `1`
switches the CAPTCHA off now. Anything else — including `no`, `off`, `yes` and a misspelling —
used to be read as "yes, disabled" and is now read as "leave it on". If you meant it off, write
`true`.
- **If a staff role uploads into public folders, give it "Upload to public folders".** That
permission was not being asked of staff, and now is. Roles holding "Upload public files" are
unaffected, and ordinary uploads need nothing new.
- **If you use Microsoft sign-in, add the `xms_edov` optional claim to your app registration.** In
the Entra portal: your app registration → Token configuration → Add optional claim → ID →
`xms_edov`. Until you do, Microsoft sign-in keeps working and keeps creating new accounts, but it
will no longer attach itself to an account that already exists.
**Added**
- **Your logo now appears on the sign-in screen.** Requested by
[@Zodiac1978](https://github.com/Zodiac1978) in
[#1777](https://github.com/projectsend/projectsend/issues/1777).
- **An installation on AWS can authenticate as its own IAM role instead of storing an access key.**
- **Clients can now see how often their own files were downloaded, and when.**
**Fixed**
- **A lookalike domain can no longer hand somebody else's account to an OIDC sign-in.** Reported by
[@choewonwoo1817](https://github.com/choewonwoo1817).
- **Changing your own email address now asks for your password.** Reported by
[@Noorkhalel](https://github.com/Noorkhalel).
- **Microsoft sign-in now checks that the person owns the address they presented.** Reported by
[@archnexus707](https://github.com/archnexus707).
- **Two people filling in the first-run setup screen at the same moment can no longer both become
administrators.** Reported by [@ry2811](https://github.com/ry2811).
- **Uploading into a public folder now needs a permission that says so.** Reported by
[@skeletonsec](https://github.com/skeletonsec).
- **Moving a file into a public folder now needs that same permission.** Reported by
[@skeletonsec](https://github.com/skeletonsec).
- **A staff member limited to some clients can no longer see or change other people's groups.**
Reported by [@Drescargot](https://github.com/Drescargot).
- **Deleting a client can no longer hand their files to a client you do not manage.** Reported by
[@skeletonsec](https://github.com/skeletonsec).
- **Erasing a staff account no longer hands their files to a client.**
- **An interrupted upload can no longer park unlimited bytes on the server.** Reported by
[@ry2811](https://github.com/ry2811).
- **The password reset screen no longer says whether an email address has an account here.**
- **An expired password reset link now says so before asking for a new password.**
- **A Docker upgrade no longer fails when external storage is already configured.**
[#1770](https://github.com/projectsend/projectsend/issues/1770).
- **A public gallery no longer renders the same thumbnail several times at once.**
- **`PROJECTSEND_CAPTCHA_DISABLED` no longer reads a "no" as a "yes".**
### Issues closed since 2.4.0
The summary above is what changed. This is the paper trail, for anyone who wants to read the
original report.
- [#1768](https://github.com/projectsend/projectsend/issues/1768) — Search in file not restricted in directory
- [#1773](https://github.com/projectsend/projectsend/issues/1773) — Feature Request : Support AWS IAM roles / default credential provider chain for S3 storage
- [#1774](https://github.com/projectsend/projectsend/issues/1774) — HTTP Error by upload on R2098
- [#1778](https://github.com/projectsend/projectsend/issues/1778) — [Documentation] Error 500 on install
## 2.4.0 — 8 September 2026
Clients can now look after the files they uploaded, and this release closes three ways somebody
could see a little more than they should.
**New**
- **Clients can edit and delete the files they uploaded**, with the name, description, expiry,
categories, download limit and public flag each behind the permission that already governs it.
A file shared *with* a client is still not theirs to touch.
- **A switch to stop this installation fetching the project news**, on Settings → General. On by
default; off means the request is never made.
**Closed holes in who can see what**
- A staff member limited to their assigned clients could read other clients' names, and their IDs,
out of file details and the uploader filter. Reported by
[@Noorkhalel](https://github.com/Noorkhalel) (GHSA-whmp-p9hv-r7j7).
- Download links to external storage now last a minute instead of an hour. Previews keep the hour.
- Eight advisories in bundled dependencies, including an XSS bypass in the markdown renderer that
builds your email templates.
**Fixed**
- A failed upload keeps its parts, so retrying it works instead of needing the whole file again.
- `projectsend:captcha-off` no longer claims success on an installation whose CAPTCHA keys are
supplied centrally, where it changed nothing.
### Upgrade notes
- **Resuming an interrupted download from external storage more than a minute after it started now
fails.** Start it again from ProjectSend. Local-disk installations and zip bundles are unaffected.
- **If your temporary directory is on a small or separate volume, allow headroom for twice your
largest allowed upload.** Only while a file is being assembled, and nothing needs configuring.
Thanks to [@Noorkhalel](https://github.com/Noorkhalel), [@denkfabrik-li](https://github.com/denkfabrik-li)
and [@mehmedturk](https://github.com/mehmedturk) for reporting and fixing.
### Issues closed since 2.3.0
The summary above is what changed. This is the paper trail, for anyone who wants to read the
original report.
- [#1765](https://github.com/projectsend/projectsend/issues/1765) — Projectsend 2.2.1 thumbnail issue after file upload
- [#1771](https://github.com/projectsend/projectsend/issues/1771) — Permissions granted to the Client role are not applied to client accounts
Reported by [@Noorkhalel](https://github.com/Noorkhalel) (GHSA-whmp-p9hv-r7j7).
## 2.3.0 — 1 September 2026
-27
View File
@@ -362,33 +362,6 @@ 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:
+2 -87
View File
@@ -324,9 +324,8 @@ like your logo reachable from the web.
## Step 6 — Point your web server at it
A complete nginx server block below; [Apache is further down](#if-you-are-using-apache). Change
`server_name`, and change `/var/www/projectsend` to wherever you unpacked the files (there are
**three** places, including one inside `/protected-files/`):
A complete nginx server block. Change `server_name`, and change `/var/www/projectsend` to wherever
you unpacked the files (there are **three** places, including one inside `/protected-files/`):
```nginx
server {
@@ -375,38 +374,6 @@ server {
}
```
### If you are using Apache
Two things matter, and both are easy to get wrong:
- **The document root is the `public/` directory**, not the directory you unpacked into. Everything
above `public/` — your `.env`, your uploaded files, the application code — has to stay out of
reach of any URL.
- **`AllowOverride All`, and `mod_rewrite` enabled** (`sudo a2enmod rewrite`). ProjectSend ships a
`public/.htaccess` that sends every address to the front controller. If Apache is told to ignore
it, every page except the home page is a 404.
```apache
<VirtualHost *:80>
ServerName files.example.com
DocumentRoot /var/www/projectsend/public
<Directory /var/www/projectsend/public>
AllowOverride All
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/projectsend-error.log
CustomLog ${APACHE_LOG_DIR}/projectsend-access.log combined
</VirtualHost>
```
Downloads work as they are: PHP sends the bytes. If that becomes a capacity problem, `mod_xsendfile`
hands the job to Apache — see [How downloads are sent](#how-downloads-are-sent).
On shared hosting you usually cannot edit any of this, and `public/.htaccess` is all you have. If
the site returns a 500 on every page, see [When something goes wrong](#when-something-goes-wrong).
Then check your PHP settings. Large uploads are sent in 20 MB pieces, so PHP never has to handle a
whole 5 GB file at once — but the pieces still need room. In your `php.ini`:
@@ -517,36 +484,6 @@ 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
@@ -644,28 +581,6 @@ names the exact command to run; do that, then reload.
Look in `storage/logs/` — open the newest file, the real error is at the bottom. Nine times out of ten it is
folder permissions (step 4) or a wrong database password (step 3).
**Every page is a 500, and `storage/logs/` is empty.**
The empty log is the answer, not a dead end: nothing reached PHP, so ProjectSend had nothing to
write. The error is your web server's, and it is in your web server's log — on Apache
`/var/log/apache2/error.log`, or wherever your host puts it. On Apache two causes account for
almost all of these, and both are about `public/.htaccess`:
- **`Options not allowed here`.** The file starts by turning off directory listings and content
negotiation, and your `AllowOverride` does not permit that. Allow it (`AllowOverride All`), or
delete the `Options` line — it is hardening, not a requirement.
- **`Request exceeded the limit of 10 internal redirects`.** Apache cannot work out which directory
the file is serving, so the rule that sends every address to `index.php` rewrites to a path that
does not exist, and tries again. Uncomment the `RewriteBase` line in `public/.htaccess` and set it
to the path ProjectSend is served from — `/` at the domain root, `/projectsend` in a subdirectory.
Reported on IONOS by [@Zodiac1978](https://github.com/Zodiac1978) in
[#1778](https://github.com/projectsend/projectsend/issues/1778).
**If you edit `public/.htaccess`, write down what you changed.** Updating replaces every file the
release ships, that one included, so a change that made your site work will be gone after the next
update and the 500 will come back. If the Apache configuration is yours to edit, put the directives
in a `<Directory>` block in the vhost instead: they do the same job there, and no update can touch
them. On shared hosting, where it is not yours, keep the note and re-apply it.
**"Please provide a valid cache path" or "failed to open stream".**
`storage/` or `bootstrap/cache/` is not writable by the web server user. Step 4.
+2 -9
View File
@@ -107,13 +107,10 @@ section on its own.
### If you installed from a release zip
```sh
composer require projectsend/v1-migration-tool --update-no-dev
composer require projectsend/v1-migration-tool
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.
@@ -359,13 +356,9 @@ Once you are satisfied:
```sh
php artisan projectsend:migrate:reset --drop # also drops the tool's own tables
composer remove projectsend/v1-migration-tool --update-no-dev
composer remove projectsend/v1-migration-tool
```
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.
@@ -4,7 +4,6 @@ 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;
@@ -31,7 +30,7 @@ class AuthenticatedSessionController extends Controller
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request, StartPages $startPages): RedirectResponse
public function store(LoginRequest $request): RedirectResponse
{
if ($request->authenticate()) {
return redirect()->route('two-factor.challenge');
@@ -39,10 +38,7 @@ class AuthenticatedSessionController extends Controller
$request->session()->regenerate();
$user = $request->user();
assert($user !== null);
return redirect()->intended($startPages->pathFor($user));
return redirect()->intended(route('dashboard', absolute: false));
}
/**
@@ -3,7 +3,6 @@
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;
@@ -16,19 +15,9 @@ class ConfirmablePasswordController extends Controller
/**
* Show the confirm password page.
*/
public function show(Request $request): Response
public function show(): Response
{
$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.
'has_local_password' => $user->auth_source === AuthSource::Local,
]);
return Inertia::render('auth/confirm-password');
}
/**
@@ -30,53 +30,9 @@ class NewPasswordController extends Controller
return Inertia::render('auth/reset-password', [
'email' => $request->email,
'token' => $request->route('token'),
'expired' => $this->linkIsSpent(
(string) $request->string('email'),
(string) $request->route('token'),
),
]);
}
/**
* Whether this link is one store() is certain to refuse.
*
* The scaffolding renders the form without looking at the token, so an
* expired link asked for a new password, asked for it a second time to
* confirm, and only then answered "this password reset token is
* invalid" — naming a word nobody outside the code knows, after the
* work rather than before it. Reset links last an hour and people open
* them late; that is ordinary, not an error to be scolded for.
*
* store() still validates and remains the rule. This is the screen
* being honest a minute earlier.
*
* **Anything that will not validate reads as expired, whether or not
* the address is one we know.** That is the whole of the rule and it
* exists for one reason: a page answering "expired" for a real address
* and drawing the form for an unknown one tells anybody who types a
* guess whether an account is here — the exact property
* /forgot-password protects by saying "a link will be sent if the
* account exists".
*
* The first version of this method described that oracle in a comment
* and then built it: unknown address returned false and drew the form,
* known address returned true and said expired. Two branches, two
* answers, and the difference *was* the account. Now both answer the
* same, so the page reveals nothing and the message is still right in
* every case somebody real will meet — a mistyped address gets "ask
* for a new link", which is what they should do anyway.
*/
private function linkIsSpent(string $email, string $token): bool
{
$broker = Password::broker();
$user = $email === '' ? null : $broker->getUser(['email' => $email]);
// One answer for "no such account", "wrong token" and "spent
// token", because telling them apart is telling somebody which
// addresses exist here.
return $user === null || ! $broker->tokenExists($user, $token);
}
/**
* Handle an incoming new password request.
*
@@ -157,21 +113,8 @@ class NewPasswordController extends Controller
return to_route('login')->with('status', __($status));
}
// One sentence for every way this can fail, and deliberately not
// Laravel's own. The scaffolding answers `passwords.user` for an
// address it cannot find and `passwords.token` for a real one whose
// token is dead — two different sentences, which is the same
// account-enumeration oracle the screen above was fixed for,
// reachable through the write instead. `passwords.throttled` is the
// third and the sharpest: the broker throttles per *user*, so an
// address nobody holds can never be throttled, and being told to
// wait is being told the account is there.
//
// Nothing is lost by collapsing them. The action is the same in
// every case — ask for a new link — and /forgot-password already
// refuses to say whether an address has an account.
throw ValidationException::withMessages([
'email' => [__('This password reset link is no longer valid. Ask for a new one and try again.')],
'email' => [__($status)],
]);
}
}
@@ -5,7 +5,6 @@ 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;
@@ -22,21 +21,9 @@ class PasswordController extends Controller
*/
public function edit(Request $request): Response
{
$user = $request->user();
assert($user !== null);
return Inertia::render('settings/password', [
'mustVerifyEmail' => $user instanceof MustVerifyEmail,
'mustVerifyEmail' => $request->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,
]);
}
@@ -45,40 +32,17 @@ class PasswordController extends Controller
*/
public function update(Request $request): RedirectResponse
{
$user = $request->user();
assert($user !== null);
// 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'],
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$attributes = ['password' => Hash::make($validated['password'])];
$user = $request->user();
assert($user !== null);
// 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();
$user->update([
'password' => Hash::make($validated['password']),
]);
// Changing a password is how someone reacts to a session they think
// is stolen, so it has to actually end that session. AuthenticateSession
@@ -10,8 +10,6 @@ use App\Modules\Clients\ClientFieldContext;
use App\Modules\Clients\ClientPortalCustomFields;
use App\Modules\Identity\Erasure\ErasureSchedule;
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;
@@ -28,7 +26,6 @@ class ProfileController extends Controller
private readonly ClientPortalCustomFields $customFields,
private readonly TimezoneRegistry $timezones,
private readonly StaffAccounts $accounts,
private readonly StartPages $startPages,
) {}
/**
@@ -47,12 +44,6 @@ 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) : [],
]);
@@ -15,9 +15,7 @@ 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;
@@ -26,10 +24,7 @@ use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Updates\LatestReleaseInfo;
use App\Modules\Platform\Updates\RunningCodeState;
use Illuminate\Foundation\Inspiring;
use App\Modules\Platform\Announcements\Events\ResolvingAnnouncement;
use App\Modules\Platform\Navigation\Events\ResolvingNavigationLinks;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
@@ -89,19 +84,6 @@ class HandleInertiaRequests extends Middleware
// ignore this and always show it.
'attribution' => app(Attribution::class)->visible(),
'capabilities' => $capabilities->enabledKeys(),
// Sidebar entries a package asked for. Shared rather than
// passed per page because the sidebar is on every page, and
// dispatched unconditionally so that with nothing listening
// the list is empty and the sidebar is exactly what it was.
// See ResolvingNavigationLinks for why core never learns what
// is in it.
'extra_nav_links' => $this->extraNavLinks($request),
// Shared rather than a dashboard prop, because it is shown in
// two places — the band on the dashboard and the icon beside
// the notification bell everywhere else — and "the same
// message" is the requirement. Two props would drift the day
// somebody edited one.
'announcement' => $this->announcement($request),
// Shared rather than passed by each page: the sign-in buttons,
// the registration form and the Connected accounts nav entry
// all need the same list, and a nav entry to a screen with
@@ -185,18 +167,6 @@ 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.
@@ -331,48 +301,4 @@ class HandleInertiaRequests extends Middleware
/** @var array<string, string> */
return app('translator')->getLoader()->load($locale, '*', '*');
}
/**
* @return list<array{title: string, url: string, external: bool, icon: string|null}>
*/
private function extraNavLinks(Request $request): array
{
$user = $request->user();
// Staff only, decided here rather than in each listener: these
// render in the administration area, and a client's portal shows
// their own files and nothing about the installation.
$event = new ResolvingNavigationLinks(isStaff: $user !== null && $user->isStaff());
if (! $event->isStaff) {
return [];
}
Event::dispatch($event);
return $event->links;
}
/**
* @return array{title: string, body: string, action_label: string|null, action_url: string|null, tone: string}|null
*/
private function announcement(Request $request): ?array
{
$user = $request->user();
if ($user === null) {
return null;
}
// Dispatched for clients too, unlike the sidebar links beside it.
// A client is somebody a shared instance may legitimately need to
// address — about their own account, not about the installation —
// and the event refuses anything not aimed at them, so widening
// this does not widen what reaches them.
$event = new ResolvingAnnouncement(isStaff: $user->isStaff());
Event::dispatch($event);
return $event->announcement;
}
}
+1 -8
View File
@@ -3,7 +3,6 @@
namespace App\Http\Requests\Auth;
use App\Models\User;
use App\Modules\Identity\AccountLookup;
use App\Modules\Identity\Ldap\LdapProvisioner;
use App\Modules\Identity\PasswordVerification;
use App\Modules\Identity\SignIn;
@@ -72,13 +71,7 @@ class LoginRequest extends FormRequest
{
$this->ensureIsNotRateLimited();
// Exact, for the reason SocialAuthenticator is: a collation that
// folds accents would otherwise let somebody typing
// admin@éxample.com be *identified* as admin@example.com. A
// password still gates this one, so it was never the takeover the
// social path was — but identifying the wrong account is the bug,
// and the credential check is a second line rather than the rule.
$user = app(AccountLookup::class)->byEmail((string) $this->string('email'));
$user = User::query()->where('email', $this->string('email'))->first();
// A directory identity with no local account yet. Returns null
// unless LDAP is on, auto-provisioning is on, and the bind
@@ -5,12 +5,9 @@ namespace App\Http\Requests\Settings;
use App\Models\User;
use App\Modules\Clients\ClientFieldContext;
use App\Modules\Clients\ClientPortalCustomFields;
use App\Modules\Identity\AuthSource;
use App\Modules\Identity\StartPages;
use App\Support\Rules;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Closure;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
@@ -34,26 +31,6 @@ class ProfileUpdateRequest extends FormRequest
Rule::unique(User::class)->ignore($this->user()?->id),
],
// Changing this address is a credential change, not a detail:
// it is where a password reset is sent, so whoever can change
// it owns the account from the next reset onwards. A stolen
// session used to be enough (GHSA-f32x-fgmp-q353) — temporary
// access became permanent ownership with one PATCH.
//
// `exclude_if` rather than a flat rule, so the rest of the
// screen keeps saving with nothing extra: a name, a timezone
// or a custom field is not a credential and must not start
// asking for a password. Only a *different* address does.
//
// The same rule destroy() one controller away has always
// asked, for the same reason: both doors lead to owning the
// account.
'current_password' => [
Rule::excludeIf(! $this->changesEmail()),
'required',
'current_password',
],
// Saved with the rest of the profile so the screen keeps one
// Save button. `timezone` is fillable, so ProfileController's
// fill() picks it up with no special handling.
@@ -66,28 +43,6 @@ class ProfileUpdateRequest extends FormRequest
];
$user = $this->user();
// Only the pages this person can open right now. `sometimes` for
// the same reason as timezone; empty clears the choice and follows
// the role again.
$rules['start_page'] = ['sometimes', 'nullable', 'string', Rule::in(
$user === null ? [] : array_column(app(StartPages::class)->personalOptions($user), 'value'),
)];
// An account whose credentials live in a directory or at an
// identity provider holds a local password nobody knows — see
// LdapProvisioner, which stores Str::password(64) exactly so it
// can never be used. Asking such a person to confirm "your current
// password" is a dead end dressed as a form error, and the address
// is not theirs to change here in any case: it is what the
// directory or the provider says it is, and a local edit would
// either be overwritten or break the link.
if ($this->changesEmail() && $user !== null && $user->auth_source !== AuthSource::Local) {
$rules['email'][] = function (string $attribute, mixed $value, Closure $fail): void {
$fail(__('Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.'));
};
}
if ($user?->isClient() === true) {
$rules = [
...$rules,
@@ -97,29 +52,4 @@ class ProfileUpdateRequest extends FormRequest
return $rules;
}
/**
* Whether this request asks for an address other than the stored one.
*
* Compared lowercased and trimmed because the `lowercase` rule runs
* beside this one rather than before it: without that, re-saving the
* profile with the address typed in a different case would be read as
* a change and demand a password for nothing.
*/
private function changesEmail(): bool
{
$user = $this->user();
if ($user === null) {
return false;
}
$submitted = $this->input('email');
if (! is_string($submitted)) {
return false;
}
return mb_strtolower(trim($submitted)) !== mb_strtolower(trim((string) $user->email));
}
}
-52
View File
@@ -29,11 +29,9 @@ 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
@@ -56,9 +54,6 @@ 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',
];
@@ -101,32 +96,6 @@ 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;
@@ -192,32 +161,11 @@ class User extends Authenticatable implements HasLocalePreference
// credentials live is a security decision, not an attribute a
// form or an API payload may set. Written with forceFill by
// the code that provisions the account.
//
// Same for 'email_verified_at' below, and it is worth saying
// what absence from $fillable does and does not buy. It stops
// a request smuggling the value in. It does not tell the code
// that meant to set it deliberately that it failed: a key in a
// create() array is dropped in silence, so every path that
// provisions an account had one and lost it — staff accounts,
// client accounts, the setup screen and projectsend:admin, all
// fixed in September 2026. Not fillable only helps when the
// writer knows it has to be deliberate.
'auth_source' => AuthSource::class,
'ldap_synced_at' => 'datetime',
'active' => 'boolean',
'account_requested' => 'boolean',
// The column is an unsignedInteger and the docblock above
// already promises int. Saying so here is what makes that true
// for a reader as well: it is passed straight into typed
// signatures (ClientAccounts::create, ClientProvisioning::
// provision), and whether a driver hands back 2048 or "2048"
// is not something those call sites should depend on.
'storage_quota_mb' => 'integer',
'erase_after' => 'datetime',
// Deliberately absent from $fillable too: when an account stops
// working is decided by staff, never by a payload the account
// itself could send (the profile form fills from its request).
'expires_at' => 'datetime',
'email_verified_at' => 'datetime',
'password' => 'hashed',
'two_factor_secret' => 'encrypted',
@@ -10,8 +10,8 @@ use Symfony\Component\HttpFoundation\Response;
/**
* The token twin of Identity's EnsureAccountIsActive: deactivating an
* 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.
* account 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->maySignIn()) {
if ($user !== null && ! $user->active) {
abort(401);
}
-45
View File
@@ -40,14 +40,6 @@ 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';
@@ -75,21 +67,6 @@ 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';
@@ -188,11 +165,6 @@ 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"',
@@ -227,14 +199,6 @@ 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"',
@@ -303,11 +267,6 @@ 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',
@@ -339,10 +298,6 @@ enum Action: string
self::CommentDeleted => 'A comment was deleted',
self::CommentApproved => 'A comment was approved',
self::FileImported => 'An orphan file was imported',
self::FileQuarantined => 'A file was quarantined by the virus scanner',
self::FileReleased => 'A quarantined file was released by an administrator',
self::FileNotScanned => 'A file was allowed through without being scanned',
self::FileMissing => 'A file in the library was found to be missing from storage',
self::OrphanFileDeleted => 'An orphan file was deleted',
self::OrphanFileAutoDeleted => 'An orphan file was automatically deleted after its retention grace period passed',
self::ExpiredFileDeleted => 'An expired file was automatically deleted after its retention grace period passed',
@@ -12,14 +12,10 @@ use App\Modules\Audit\ActivityLog;
use App\Modules\Audit\ActivityLogScope;
use App\Modules\Audit\ActivityPresenter;
use App\Modules\Audit\DashboardWidgetPreferences;
use Illuminate\Support\Facades\Event;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Delivery\FileDelivery;
use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\ScanningConfig;
use App\Modules\Files\Scanning\ScanStatus;
use App\Modules\Files\Scanning\VirusScanner;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Capabilities\Capability;
@@ -483,7 +479,7 @@ class DashboardController extends Controller
}
/**
* @return array<string, array<string, bool|int|string|null>|bool|int|string|null>
* @return array<string, array<string, bool|string|null>|bool|int|string|null>
*/
private function systemInfo(): array
{
@@ -514,74 +510,6 @@ 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(),
];
}
-139
View File
@@ -1,139 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\Notifications\ClientWelcomeNotification;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Seats\SeatAllowance;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Carbon\Carbon;
use Illuminate\Validation\ValidationException;
/**
* Creating a client account — the rules and the side effects, shared by
* every surface that makes one.
*
* The same argument StaffAccounts makes for staff. What a client account
* *is* — its type, its role, an active flag, a quota where zero means
* "inherit the site default" rather than "none" — is a set of invariants,
* and an invariant enforced in one controller and re-implemented in
* another is one that will eventually hold in only one of them. There are
* three surfaces onto this now: the staff screens, `/api/v1/clients`, and
* the platform control plane in the private package, which reaches this
* by name because it cannot import a host class.
*
* What stays with the caller is what genuinely differs: the shape of the
* request, its validation rules, its response, and anything about *who is
* asking* — a client-scoped staff member gaining the client on their own
* roster is a fact about the creator, not about the account created.
*
* **Not to be confused with ClientProvisioning**, which sits beside it and
* handles the other half: an account that comes into existence without
* anybody deciding to create it — the public registration form, and a
* first successful LDAP sign-in. The policies genuinely differ rather than
* merely duplicating. An account made here is approved and verified by
* construction, because somebody who already knows who this is asked for
* it; one made there may wait for approval, joins a configured group, and
* tells the administrators it arrived.
*/
class ClientAccounts
{
public function __construct(
private readonly ActivityLogger $activity,
private readonly SeatAllowance $seats,
private readonly Settings $settings,
) {}
/**
* @param int $storageQuotaMb 0 means no per-account quota and
* inherits the site default at
* enforcement time — see
* ClientStorageUsage::quotaMb(). It does
* not mean unlimited.
* @param Carbon|null $expiresAt when the account stops working;
* null for never. Must be in the
* future — see guardExpiry().
* @param bool $welcome whether this installation should email the
* new account. A caller that sends its own
* welcome passes false rather than having the
* customer receive two.
*/
public function create(
string $name,
string $email,
string $password,
int $storageQuotaMb = 0,
bool $welcome = true,
string $emailField = 'email',
?Carbon $expiresAt = null,
): User {
// Before anything is written, and deliberately not left to the
// caller. The platform sets this cap and the platform is also what
// calls the control plane — so enforcing it here is what stops a
// leaked control token minting accounts without limit. A guard
// that only ran on the surfaces that remembered it would not be a
// guard.
$this->seats->guardClient($emailField);
$this->guardExpiry($expiresAt, active: true);
$client = User::create([
'type' => UserType::Client,
'active' => true,
'account_requested' => false,
'role_id' => Role::query()->where('name', SystemRole::Client->value)->value('id'),
'name' => $name,
'email' => $email,
'password' => $password,
'storage_quota_mb' => $storageQuotaMb,
]);
// forceFill, and not part of the create() array above: like
// StaffAccounts, email_verified_at is deliberately absent from
// User::$fillable — where an account stands is a security decision
// rather than an attribute — so mass assignment drops it in
// silence. Every client-creation path used to pass it in that
// array and lose it. The intent is real: an account created by
// somebody who already knows who this is has no address to
// confirm and nobody to confirm it to. (Inert today, since
// MustVerifyEmail is not enabled on the model, but the column is
// what a later switch would read.)
//
// expires_at is written the same way for its own reason: see the
// note on its cast in User.
$client->forceFill(['email_verified_at' => now(), 'expires_at' => $expiresAt])->save();
$this->activity->log(Action::UserCreated, subject: $client);
if ($welcome && $this->settings->get(Setting::EmailNotificationsEnabled) === true) {
$client->notify(new ClientWelcomeNotification);
}
return $client;
}
/**
* An account cannot be both active and past its expiry date.
*
* Every surface that writes either value asks this before saving,
* because the combination is not a state anybody means: an account
* that looks switched on and refuses every sign-in, until the hourly
* sweep quietly switches it off again. Somebody reactivating an
* expired client has to give them a new date, or none.
*/
public function guardExpiry(?Carbon $expiresAt, bool $active, string $field = 'expires_at'): void
{
if ($active && $expiresAt !== null && $expiresAt->isPast()) {
throw ValidationException::withMessages([
$field => __('This date has already passed. Choose a later date, or leave it empty for an account that never expires.'),
]);
}
}
}
+1 -48
View File
@@ -12,12 +12,9 @@ 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\Notifications\Notifier;
use App\Modules\Platform\Seats\SeatAllowance;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Seats\SeatAllowance;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Support\Facades\Notification;
@@ -41,8 +38,6 @@ class ClientProvisioning
private readonly Settings $settings,
private readonly ActivityLogger $activity,
private readonly SeatAllowance $seats,
private readonly Notifier $notifier,
private readonly PermissionChecker $permissions,
) {}
/**
@@ -80,14 +75,6 @@ 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,
@@ -98,7 +85,6 @@ class ClientProvisioning
?string $ldapDn = null,
?bool $autoApprove = null,
array $context = [],
int $storageQuotaMb = 0,
): User {
$autoApprove ??= $this->autoApproves();
@@ -119,7 +105,6 @@ class ClientProvisioning
'name' => $name,
'email' => $email,
'password' => $password,
'storage_quota_mb' => $storageQuotaMb,
]);
// Not mass-assignable: where an account's credentials live is a
@@ -136,7 +121,6 @@ class ClientProvisioning
$this->joinAutoGroup($client);
$this->notifyAdministrators($client, pending: ! $autoApprove);
$this->notifyStaffInApp($client);
return $client;
}
@@ -159,37 +143,6 @@ 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) {
+5 -43
View File
@@ -28,9 +28,10 @@ class ClientStorageUsage
/**
* A client's own storage_quota_mb of 0 means "no custom quota set" —
* it inherits the installation's default instead of being unlimited,
* so a default (once set) also protects clients who never got an
* explicit quota, including self-registered ones.
* it inherits Setting::DefaultClientStorageQuotaMb instead of being
* unlimited, so a site-wide default (once set) also protects clients
* who never got an explicit quota, including self-registered ones.
* The site default itself being 0 is what actually means unlimited.
*
* @return int 0 means unlimited.
*/
@@ -38,46 +39,7 @@ class ClientStorageUsage
{
return $client->storage_quota_mb > 0
? $client->storage_quota_mb
: $this->defaultQuotaMb();
}
/**
* What a client with no quota of their own actually gets.
*
* Three sources, narrowest first, and the third is why this is a
* method rather than a `Settings::get()` at the point of use.
*
* `Setting::DefaultClientStorageQuotaMb` belongs to whoever runs the
* installation, and its default is 0 — which means unlimited. That is
* the right default for somebody setting up their own install, and the
* wrong one for an installation a platform operates on other people's
* behalf: there, an account that arrived without an explicit quota has
* no ceiling at all, which on a shared installation is one account
* away from unmetered hosting.
*
* So a platform may set a floor in the environment, exactly as it sets
* the seat caps, and for the same reason those are not settings: it is
* not a preference the installation's administrator is expressing, it
* is the shape of what was sold. It applies only where the setting says
* nothing, so an administrator who has chosen a number keeps it, and an
* install with no platform behind it is unaffected.
*
* Unset and zero are the same answer here, on purpose: a platform that
* wanted no ceiling would not set the variable.
*
* @return int 0 means unlimited.
*/
public function defaultQuotaMb(): int
{
$site = (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb);
if ($site > 0) {
return $site;
}
$floor = config('projectsend.platform.default_client_quota_mb');
return is_numeric($floor) ? max(0, (int) $floor) : 0;
: (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb);
}
/**
@@ -1,46 +0,0 @@
<?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,
]);
}
}
}
@@ -1,64 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients\Console;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Identity\UserType;
use Illuminate\Console\Command;
/**
* Switches off client accounts whose expiry date has passed.
*
* Not what stops an expired client getting in — User::maySignIn() does
* that the moment the date passes, with or without this. What this does is
* make `active` tell the truth, so everything that reads the flag rather
* than asking the account agrees: the client list and its status filter,
* the API's `active` field, and a managed plan's seat count. Hourly for the
* same reason purge-stale-uploads is: a seat held by an account that can
* no longer use it is a seat somebody else cannot have.
*/
class ExpireClientAccountsCommand extends Command
{
protected $signature = 'projectsend:expire-client-accounts';
protected $description = 'Deactivate client accounts whose expiry date has passed (runs hourly)';
public function handle(ActivityLogger $activity): int
{
$now = now();
$expired = 0;
$due = User::query()
->where('type', UserType::Client)
->where('active', true)
->whereNotNull('expires_at')
->where('expires_at', '<=', $now)
->get(['id', 'name', 'expires_at']);
foreach ($due as $client) {
// Conditional on the row still being due, not a plain save():
// an administrator who moved the date forward between the read
// above and this write has just decided the account should keep
// working, and must not be overruled by a list that is a few
// milliseconds old.
$switchedOff = User::query()
->whereKey($client->id)
->where('active', true)
->where('expires_at', '<=', $now)
->update(['active' => false]);
if ($switchedOff === 1) {
$activity->logSystem(Action::ClientExpired, ['name' => $client->name, 'id' => $client->id]);
$expired++;
}
}
$this->info("Deactivated {$expired} expired client account(s).");
return self::SUCCESS;
}
}
@@ -9,11 +9,9 @@ use App\Models\User;
use App\Modules\Api\Support\PollingQuery;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientAccounts;
use App\Modules\Clients\ClientCustomFieldType;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Platform\Localization\DateInput;
use App\Modules\Platform\Seats\SeatAllowance;
use App\Modules\Clients\Http\Resources\Api\ClientResource;
use App\Modules\Clients\Models\ClientCustomField;
@@ -24,7 +22,10 @@ use App\Modules\Files\DeletedAccountContent;
use App\Modules\Identity\AccountContentDeletion;
use App\Modules\Identity\Erasure\AvailableEmailRule;
use App\Modules\Identity\Erasure\ErasureSchedule;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Database\Eloquent\Builder;
@@ -61,9 +62,7 @@ class ClientsController extends Controller
private readonly AccountContentDeletion $accountDeletion,
private readonly StaffLibraryScope $scope,
private readonly SeatAllowance $seats,
private readonly ClientAccounts $clients,
private readonly ErasureSchedule $erasure,
private readonly DateInput $dates,
) {}
public function index(Request $request): AnonymousResourceCollection
@@ -119,6 +118,8 @@ class ClientsController extends Controller
public function store(Request $request): JsonResponse
{
$this->seats->guardClient();
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
@@ -132,38 +133,30 @@ class ClientsController extends Controller
// installation may be refused on another.
'password' => ['required', Password::defaults()],
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
// When the account stops working; omit or send null for never.
// A bare date (`2026-12-31`) means the end of that day in the
// token owner's timezone; a full timestamp is used as given.
// Must be in the future.
'expires_at' => ['nullable', 'string', 'date'],
'custom_field_values' => ['array'],
]);
$validated['custom_field_values'] = $this->validateCustomFieldValues($request);
$client = User::create([
'type' => UserType::Client,
'active' => true,
'account_requested' => false,
'role_id' => Role::query()->where('name', SystemRole::Client->value)->value('id'),
'name' => $validated['name'],
'email' => $validated['email'],
'password' => $validated['password'],
// 0 means "no custom quota" and inherits the site default at
// enforcement time — see ClientStorageUsage::quotaMb().
'storage_quota_mb' => $validated['storage_quota_mb'] ?? 0,
'email_verified_at' => now(),
]);
$this->activity->log(Action::UserCreated, subject: $client);
$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
// this surface's own business: its validation, its custom fields,
// and who the creator is.
$client = $this->clients->create(
name: $validated['name'],
email: $validated['email'],
password: $validated['password'],
// As on the staff screen, and for the same reason: the
// `integer` rule accepts a numeric string and does not convert
// it. A JSON number arrives as an int and was fine; a
// form-encoded body or a quoted JSON value is a string, and
// this file is strict_types.
storageQuotaMb: (int) ($validated['storage_quota_mb'] ?? 0),
welcome: false,
expiresAt: $this->dates->instant($validated['expires_at'] ?? null, $creator),
);
// A client-scoped creator would otherwise lose the client they just
// made. guardTarget() answers 404 for anything off their roster, so
// the record they created is not theirs to open, and
@@ -177,10 +170,6 @@ class ClientsController extends Controller
$this->saveCustomFieldValues($client, $validated['custom_field_values'] ?? []);
// Sent here rather than inside ClientAccounts so the custom fields
// are already saved when it goes: a welcome that arrives before
// the account is finished describes an account that does not quite
// exist yet.
if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) {
$client->notify(new ClientWelcomeNotification);
}
@@ -199,11 +188,6 @@ 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'],
]);
@@ -222,26 +206,6 @@ 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,7 +7,6 @@ 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;
@@ -22,7 +21,6 @@ class ClientSettingsController extends Controller
public function __construct(
private readonly Settings $settings,
private readonly ActivityLogger $activity,
private readonly ClientHomeFolders $homeFolders,
) {}
public function edit(): Response
@@ -33,14 +31,8 @@ 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(),
@@ -55,10 +47,8 @@ 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']);
@@ -66,54 +56,11 @@ class ClientSettingsController extends Controller
$this->settings->set(Setting::ClientsAutoGroup, (int) $validated['clients_auto_group']);
$this->settings->set(Setting::ClientsCanSelectGroup, $validated['clients_can_select_group']);
$this->settings->set(Setting::ClientsMembershipDenyCooldownDays, (int) $validated['clients_membership_deny_cooldown_days']);
$this->settings->set(Setting::ClientInvitationExpiryHours, (int) $validated['client_invitation_expiry_hours']);
$this->settings->set(Setting::DefaultClientStorageQuotaMb, (int) $validated['default_client_storage_quota_mb']);
$this->settings->set(Setting::ClientsCanPreviewFiles, $validated['clients_can_preview_files']);
// Saving the switch deliberately creates nothing. Existing clients
// get a folder when somebody presses the button, so that turning
// this on, looking at it, and turning it off again leaves the
// library exactly as it was.
$this->settings->set(Setting::ClientsHomeFolders, $validated['clients_home_folders']);
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'clients']);
return back();
}
/**
* Create the missing home folders, on request.
*
* Its own endpoint rather than part of saving the form, because it is a
* different kind of act: the form records a preference, this one writes
* a folder for every client on the installation. Wrapping the second
* inside the first would mean an administrator could not try the
* setting without committing to it.
*/
public function backfillHomeFolders(Request $request): RedirectResponse
{
abort_unless($this->homeFolders->enabled(), 403, 'Client folders are switched off.');
$result = $this->homeFolders->backfill();
$this->activity->log(Action::SettingsUpdated, context: [
'section' => 'clients',
'action' => 'client_home_folders_backfill',
'created' => $result['created'],
'total' => $result['total'],
]);
// Counts rather than "Done": on an installation with hundreds of
// clients the administrator wants to know how many there were and
// how many are new, and the difference between "created 200" and
// "created 0, they already had one" is the whole answer.
return back()->with('success', trans_choice(
'{0}Every client already had a folder.|[1,*]:created of :total clients got a folder. :existing already had one.',
$result['created'],
[
'created' => (string) $result['created'],
'total' => (string) $result['total'],
'existing' => (string) $result['existing'],
],
));
}
}
@@ -8,11 +8,9 @@ use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientAccounts;
use App\Modules\Clients\ClientCustomFieldType;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Platform\Localization\DateInput;
use App\Modules\Platform\Seats\SeatAllowance;
use App\Modules\Clients\Models\ClientCustomField;
use App\Modules\Clients\Models\ClientCustomFieldValue;
@@ -22,7 +20,10 @@ use App\Modules\Files\DeletedAccountContent;
use App\Modules\Identity\AccountContentDeletion;
use App\Modules\Identity\Erasure\AvailableEmailRule;
use App\Modules\Identity\Erasure\ErasureSchedule;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Support\Pagination;
@@ -50,9 +51,7 @@ class ClientsController extends Controller
private readonly AccountContentDeletion $accountDeletion,
private readonly StaffLibraryScope $scope,
private readonly SeatAllowance $seats,
private readonly ClientAccounts $clients,
private readonly ErasureSchedule $erasure,
private readonly DateInput $dates,
) {}
public function index(Request $request): Response
@@ -91,12 +90,6 @@ 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],
]);
@@ -130,57 +123,45 @@ class ClientsController extends Controller
return Inertia::render('clients/create', [
'custom_fields' => $this->customFieldDefinitions(),
// The resolved default, not the raw setting: a platform can put
// a floor under it from the environment, and both screens
// present this as what will actually happen rather than as a
// value being edited. The edit screen mirrors quotaMb()'s
// resolution client-side to draw the usage bar, and handing it
// the effective number is what keeps that mirror correct
// without it having to know floors exist.
//
// The Client settings form deliberately still reads the raw
// setting (ClientSettingsController): that field is edited and
// saved back, so prefilling it with a floor would write the
// platform's number into the setting as the administrator's own
// choice, where it would outlive the floor.
'default_storage_quota_mb' => $this->storageUsage->defaultQuotaMb(),
'default_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
]);
}
public function store(Request $request): RedirectResponse
{
// A client created here is approved by construction, so it counts
// immediately — unlike a self-registration awaiting a decision.
$this->seats->guardClient();
$validated = $request->validate(array_merge([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
'password' => ['required', 'confirmed', Password::defaults()],
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
'expires_at' => ['nullable', 'string', 'date'],
], $this->customFieldRules()));
$client = User::create([
'type' => UserType::Client,
'active' => true,
'account_requested' => false,
'role_id' => Role::query()->where('name', SystemRole::Client->value)->value('id'),
'name' => $validated['name'],
'email' => $validated['email'],
'password' => $validated['password'],
// 0 (including an omitted field) means "no custom quota" —
// it inherits Setting::DefaultClientStorageQuotaMb at
// enforcement time (see ClientStorageUsage::quotaMb()), not
// baked in here, so a later change to the site default
// keeps applying to this client automatically.
'storage_quota_mb' => $validated['storage_quota_mb'] ?? 0,
'email_verified_at' => now(),
]);
$this->activity->log(Action::UserCreated, subject: $client);
$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
// approved by construction, so it counts against the cap
// immediately — unlike a self-registration awaiting a decision.
// The welcome waits until the custom fields are saved below.
$client = $this->clients->create(
name: $validated['name'],
email: $validated['email'],
password: $validated['password'],
// Cast, because `integer` validates without converting:
// $request->validate() hands back the raw input, so a form
// field arrives as the string "2048" and this file is
// strict_types. Filling the quota in was a 500; leaving it
// blank went through null ?? 0 as an int, which is why it
// survived to the fleet.
storageQuotaMb: (int) ($validated['storage_quota_mb'] ?? 0),
welcome: false,
expiresAt: $this->dates->instant($validated['expires_at'] ?? null, $creator),
);
// A client-scoped creator would otherwise lose the client they just
// made. guardTarget() answers 404 for anything off their roster, so
// the record they created is not theirs to open, and
@@ -244,13 +225,8 @@ 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(),
'default_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
'storage_used_mb' => (int) ceil($this->storageUsage->usedBytes($client) / 1024 / 1024),
'custom_fields' => $this->customFieldDefinitions(),
'custom_field_values' => ClientCustomFieldValue::query()
@@ -274,27 +250,8 @@ 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'] !== '';
@@ -309,8 +266,6 @@ 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
@@ -1,213 +0,0 @@
<?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.'));
}
}
@@ -1,249 +0,0 @@
<?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,10 +79,6 @@ 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
@@ -1,156 +0,0 @@
<?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');
}
}
@@ -1,50 +0,0 @@
<?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,9 +61,7 @@ class FileCommentsController extends Controller
$viewer,
CommentVisibility::from($validated['visibility']),
$validated['body'],
// 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),
$this->replyTarget($viewer, $file, $validated['reply_to'] ?? null),
);
return response()->json($this->payload($viewer, $file), 201);
@@ -126,10 +126,6 @@ class PublicFileCommentsController extends Controller
{
abort_unless($this->settings->get(Setting::PublicListingSlug) === $publicSlug, 404);
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 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);
}
}
@@ -1,90 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Access;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Files\Models\File;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
/**
* How often a client's own file has been taken, and when it last was.
*
* The question somebody asks about a file they sent: did it arrive? On a
* hosted free account the link is the whole of the sharing, so "3
* downloads, last one on Tuesday" is the only evidence there is that it
* worked.
*
* **Own files only, and that is a privacy rule rather than a scoping
* convenience.** A download entry says somebody fetched the file, and on
* a file shared with several clients, telling one of them the count tells
* them about the others' activity. Nobody is entitled to that except the
* person who put the file there. So a file shared *with* this client
* carries no numbers at all — not zero, which would be a claim, but
* nothing.
*
* Counts come from the activity log through File::downloads(), the same
* source every other download count in the interface uses. There is
* deliberately no counter column — see DownloadAllowance for the whole
* argument, which applies unchanged here.
*
* One query for a page, whatever it holds.
*/
class OwnFileDownloads
{
/**
* @param Collection<int, File> $files
* @return array<int, array{count: int, last_at: string|null}> keyed by
* file id, only for files this client uploaded
*/
public function forMany(Collection $files, User $client): array
{
$own = $files
->filter(fn (File $file): bool => $file->uploaded_by === $client->id)
->pluck('id')
->map(fn ($id): int => (int) $id)
->all();
if ($own === []) {
return [];
}
// Every own file gets an entry, including the ones with nothing to
// report: the difference between "nobody has downloaded this" and
// "this is not yours to know" is exactly what the caller renders,
// and a missing key would collapse the two.
$stats = [];
foreach ($own as $id) {
$stats[$id] = ['count' => 0, 'last_at' => null];
}
$rows = ActivityLog::query()
->selectRaw('subject_id, count(*) as downloads, max(created_at) as last_at')
->where('subject_type', (new File)->getMorphClass())
->whereIn('subject_id', $own)
->whereIn('action', [
Action::FileDownloaded->value,
Action::ShareLinkDownloaded->value,
Action::PublicFileDownloaded->value,
])
->groupBy('subject_id')
->get();
foreach ($rows as $row) {
$id = (int) $row->getAttribute('subject_id');
$lastAt = $row->getAttribute('last_at');
$stats[$id] = [
'count' => (int) $row->getAttribute('downloads'),
'last_at' => $lastAt === null ? null : Carbon::parse((string) $lastAt)->toIso8601String(),
];
}
return $stats;
}
}
+7 -90
View File
@@ -159,52 +159,15 @@ class StaffLibraryScope
return null;
}
return array_values($this->groups($user)->pluck('id')->map(fn ($id): int => (int) $id)->all());
}
$clientIds = $this->assignableClientIds($user) ?? [];
/**
* Every group this staff member may be told about, as a query.
*
* The listing half of assignableGroupIds(), and the same rule: a
* group counts as theirs because one of their clients is in it. The
* two were not the same code, and the listing simply had none — so
* `/groups` and `/api/v1/groups` showed a scoped staff member every
* group on the installation, name, description and member count,
* including groups whose every member was somebody else's client
* (GHSA-r3hg-3fxw-rcmr).
*
* Deliberately the *sharing* rule rather than the change rule below.
* A scoped staff member may already share a file with a mixed group,
* so its existence is not news to them; what they may not do is
* rename, publish or delete it.
*
* @return Builder<Group>
*/
public function groups(User $user): Builder
{
$query = Group::query();
$clientIds = $this->assignableClientIds($user);
if ($clientIds === null) {
return $query;
if ($clientIds === []) {
return [];
}
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];
return array_values(Group::query()
->whereHas('members', fn (Builder $members) => $members->whereIn('users.id', $clientIds))
->pluck('id')->map(fn ($id): int => (int) $id)->all());
}
public function canAssignClient(User $user, User $client): bool
@@ -286,53 +249,7 @@ class StaffLibraryScope
*/
public function allowsGroupChange(User $user, Group $group): bool
{
return $this->groupIsNotWhollySomebodyElses($user, $group)
&& $this->groupReachesNoFurther($user, $group);
}
/**
* Whether this group is somebody else's entirely — every member
* outside the staff member's roster, and none of theirs in it.
*
* The half allowsGroupChange() was missing. Reach answers "what would
* this group hand somebody", which is the right question for putting a
* client *into* it; it says nothing about who is already there. So a
* group with nothing shared with it yet passed the reach check
* vacuously, and a scoped staff member could rename it, delete it, or
* publish it — a group made entirely of clients they had never been
* assigned (GHSA-r3hg-3fxw-rcmr).
*
* **Not "every member is mine", which is the obvious reading and is
* wrong.** A mixed group has to stay changeable: GHSA-whmp-p9hv-r7j7
* settled that a scoped staff member opens such a group's edit screen
* and is shown only their own clients in it, rather than being refused
* the screen. Requiring every member to be theirs turns that narrowing
* back into a 404 and undoes the earlier fix. What is left over — a
* mixed group whose shared content reaches past their library — is
* refused by groupReachesNoFurther() beside this, which is the check
* that has always covered it.
*
* **And deliberately not folded into groupReachesNoFurther() either.**
* That predicate is shared with allowsGroupMembership(), where a group
* nobody has joined must stay usable so its creator can put the first
* member in — the case that method's own docblock calls out.
*
* An empty group is nobody else's, so whoever just made it can still
* name it.
*/
private function groupIsNotWhollySomebodyElses(User $user, Group $group): bool
{
$clientIds = $this->assignableClientIds($user);
if ($clientIds === null) {
return true;
}
if (! $group->members()->exists()) {
return true;
}
return $group->members()->whereIn('users.id', $clientIds)->exists();
return $this->groupReachesNoFurther($user, $group);
}
/**
@@ -1,100 +0,0 @@
<?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;
}
}
@@ -1,115 +0,0 @@
<?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();
}
}
+4 -26
View File
@@ -61,7 +61,7 @@ class FileDelivery
/**
* The method in force, and whether it was detected or stated.
*
* @return array{method: DeliveryMethod, detected: bool, observed: bool}
* @return array{method: DeliveryMethod, detected: bool}
*/
public function resolve(): array
{
@@ -69,25 +69,10 @@ class FileDelivery
$explicit = is_string($configured) ? DeliveryMethod::tryFrom($configured) : null;
if ($explicit !== null) {
return ['method' => $explicit, 'detected' => false, 'observed' => true];
return ['method' => $explicit, 'detected' => false];
}
// Whether there was anything to detect *from*. detect() reads
// SERVER_SOFTWARE, which only exists inside a request — so a
// console process has nothing to look at and falls to the `php`
// default. That default is right for the console (no web server is
// handling this, so nothing could hand a file off), and wrong as a
// statement about the installation, which is how somebody reading
// it from `artisan tinker` will take it.
//
// Reported rather than papered over: a reader who runs
// `describe()` from a shell on a perfectly good nginx box was
// being told `php`, with `detected: true` vouching for it. That
// cost somebody an afternoon before it was recognised as an
// artefact of asking outside a request.
$observed = is_string($this->request->server('SERVER_SOFTWARE'));
return ['method' => $this->detect(), 'detected' => true, 'observed' => $observed];
return ['method' => $this->detect(), 'detected' => true];
}
public function method(): DeliveryMethod
@@ -103,12 +88,7 @@ class FileDelivery
* the installation from outside, and neither should change meaning if
* the enum ever grows a JsonSerializable of its own.
*
* `observed` is false only outside an HTTP request, where nothing can
* be detected and `method` is a default rather than a finding. Both
* screens that read this run in a request, so they always see true;
* it exists for whoever asks from a console.
*
* @return array{method: string, detected: bool, observed: bool}
* @return array{method: string, detected: bool}
*/
public function describe(): array
{
@@ -120,8 +100,6 @@ class FileDelivery
// to the reader: a detected `php` is an installation that
// could be faster, a stated one is somebody's decision.
'detected' => $resolved['detected'],
// And whether the detection had anything to work with.
'observed' => $resolved['observed'],
];
}
@@ -31,65 +31,32 @@ use Symfony\Component\HttpFoundation\Response;
* path, BinaryFileResponse when PHP is streaming — each dropping the
* Content-Length passed here in favour of the range actually served.
*
* The two paths are not equally revocable, which is why the lifetimes
* below differ. Every local delivery method authorises one response and
* no more — nginx's X-Accel-Redirect, Apache's X-Sendfile, or PHP
* streaming the bytes itself: these bytes, now, to this request, and
* nothing that outlives it. A presigned URL is a bearer
* credential — whoever holds it can fetch the file without passing the
* caller's checks again, and it outlives them: a download cap that is
* spent in the meantime, an expires_at that falls in between, an
* assignment that is withdrawn. Nothing here can revoke one, so the only
* dial is how long it lasts.
*
* A download needs to survive being followed, which is a redirect and a
* request: a minute is generous. A preview is held by the player for as
* long as somebody watches, and each seek outside the buffer is a fresh
* Range request against the same URL, so it keeps the hour. That is the
* trade, stated rather than left in a single number.
*
* Callers of inline() must have established that the mime type is
* inline-safe first; PreviewKind is the allowlist, and the reason there
* is one.
*/
class StoredFileResponse
{
/**
* Long enough for a browser, a download manager or a queued transfer
* to follow the redirect and start the request. An object store
* checks the signature when the request arrives, not while it runs,
* so a transfer that begins inside this window finishes however long
* it takes.
*/
private const DOWNLOAD_LINK_SECONDS = 60;
/**
* A preview is watched, not fetched: the player holds this URL and
* issues a Range request every time somebody seeks past the buffer,
* so it has to outlive the viewing rather than the redirect.
*/
private const PREVIEW_LINK_SECONDS = 3600;
public function __construct(private readonly FileDelivery $delivery) {}
/** Shown in place — a preview. */
public function inline(File $file): Response|RedirectResponse
{
return $this->make($file, ContentDisposition::inline($file->original_name), self::PREVIEW_LINK_SECONDS);
return $this->make($file, ContentDisposition::inline($file->original_name));
}
/** Handed over — a download. */
public function attachment(File $file): Response|RedirectResponse
{
return $this->make($file, ContentDisposition::attachment($file->original_name), self::DOWNLOAD_LINK_SECONDS);
return $this->make($file, ContentDisposition::attachment($file->original_name));
}
private function make(File $file, string $disposition, int $linkSeconds): Response|RedirectResponse
private function make(File $file, string $disposition): Response|RedirectResponse
{
if ($file->disk !== 'files') {
$url = Storage::disk($file->disk)->temporaryUrl(
$file->path,
now()->addSeconds($linkSeconds),
now()->addHour(),
['ResponseContentDisposition' => $disposition],
);
+31 -10
View File
@@ -6,25 +6,35 @@ namespace App\Modules\Files\Editing;
use App\Models\User;
use App\Modules\Files\Models\File;
use App\Modules\Platform\Localization\DateInput;
use App\Modules\Platform\Localization\LocalDay;
use App\Modules\Platform\Localization\TimezoneRegistry;
use Carbon\Carbon;
/**
* Reading and writing a file's expiry in the zone of whoever is looking.
*
* The 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 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.
*
* Was three private copies — the staff editor, the API, and the client
* The two halves have to agree, which is the whole reason they sit
* together: a form is rendered with asShown() and posts the same string
* back untouched with every other edit, so a caller compares against
* asShown() to tell "the editor changed the date" from "the editor renamed
* the file and the date came along for the ride". Re-deriving on every
* save instead moves the expiry by the difference between two people's
* zones each time somebody edits anything.
*
* Was three private copies — the staff editor, the API, and now the client
* portal — of which the API's was the only one that could read a
* timestamp.
*/
class FileExpiry
{
public function __construct(
private readonly DateInput $dates,
private readonly TimezoneRegistry $timezones,
) {}
/**
@@ -33,14 +43,25 @@ class FileExpiry
*/
public function asShown(File $file, ?User $viewer): ?string
{
return $this->dates->asShown($file->expires_at, $viewer);
return $file->expires_at?->copy()->setTimezone($this->timezones->resolve($viewer))->toDateString();
}
/**
* The instant a submitted value actually names. See DateInput::instant().
* The instant a submitted value actually names.
*
* A bare `YYYY-MM-DD` is a calendar day and means the end of it where
* the setter is — what every date input posts. Anything carrying a
* time is an instant somebody named on purpose and is stored as it
* arrives: the API can express a moment, and a date input cannot.
*/
public function instant(?string $value, ?User $setter): ?Carbon
{
return $this->dates->instant($value, $setter);
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);
}
}
@@ -1,25 +0,0 @@
<?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,
) {}
}
@@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Events;
use App\Models\User;
use App\Modules\Files\Models\File;
/**
* A file's bytes are on a disk and its row exists.
*
* Dispatched from StoreUploadedFile, which every upload path goes through
* — the chunked flow that staff and clients share, and the synchronous
* POST beside it — so a listener sees every upload once and does not have
* to know which route produced it.
*
* A notification rather than a filter: nothing here is mutable and no
* listener can change what was stored. Anything that needs to influence
* the upload has to do so before the bytes land, which is what
* ResolvingUploadDisk is for.
*
* Fired after the row is created and before the caller has linked a
* version or answered the request, so a listener sees a complete File and
* can safely read it back.
*/
class FileWasStored
{
public function __construct(
public readonly File $file,
public readonly User $uploader,
) {}
}
@@ -1,40 +0,0 @@
<?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,23 +5,13 @@ 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;
@@ -45,18 +35,6 @@ 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
@@ -64,8 +42,6 @@ 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.
@@ -121,47 +97,8 @@ 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,
@@ -170,43 +107,4 @@ 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,17 +38,6 @@ 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');
}
@@ -59,16 +48,6 @@ 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');
}
@@ -1,210 +0,0 @@
<?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;
}
}
@@ -18,7 +18,6 @@ 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;
@@ -103,15 +102,7 @@ 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)
@@ -148,58 +139,10 @@ 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
@@ -211,10 +154,6 @@ 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'));
}
@@ -367,17 +306,14 @@ class FilesController extends Controller
'slug' => Rules::slug('files', $file->id),
'categories' => ['sometimes', 'array'],
'categories.*' => ['integer', 'exists:categories,id'],
'expires_at' => ['sometimes', 'nullable', 'string', 'date'],
'expires_at' => ['sometimes', 'nullable', 'date'],
'download_limit' => ['sometimes', 'nullable', 'integer', 'min:1'],
'download_limit_scope' => ['sometimes', Rule::enum(DownloadLimitScope::class)],
]);
// Reparenting through update() must respect the same two rules as
// Reparenting through update() must respect the same library scope as
// the web move()/bulkUpdate() paths: the destination folder must be
// one this user can see, and one they may put content into. A public
// destination publishes what lands in it, so the second question is
// the one `upload_public` exists to ask and store() above already
// asks (GHSA-rxf8-wh8v-jm9j). Only enforced when folder_id actually
// one this user can see. Only enforced when folder_id actually
// changes, so re-saving a file that already sits in an out-of-scope
// folder (reachable via a direct client share) still works. The
// integer rule admits numeric strings, so cast before the strict
@@ -386,9 +322,7 @@ class FilesController extends Controller
$validated['folder_id'] = (int) $validated['folder_id'];
if ($validated['folder_id'] !== $file->folder_id) {
$destination = $this->scope->folders($user)->whereKey($validated['folder_id'])->firstOrFail();
abort_unless(Folder::uploadableBy($user, $destination), 403);
$this->scope->folders($user)->findOrFail($validated['folder_id']);
}
}
@@ -10,7 +10,6 @@ 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;
@@ -27,7 +26,6 @@ use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Support\Rules;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -58,7 +56,6 @@ class ChunkedUploadsController extends Controller
private readonly Notifier $notifier,
private readonly PermissionChecker $permissions,
private readonly ActivityLogger $activity,
private readonly ClientHomeFolders $homeFolders,
private readonly FileVersions $versions,
) {}
@@ -92,51 +89,14 @@ class ChunkedUploadsController extends Controller
assert($user !== null);
$folder = isset($validated['folder_id']) ? Folder::query()->whereKey($validated['folder_id'])->first() : null;
// A client uploading without naming a folder lands in their own,
// where this installation gives them one. That is what makes the
// home a root rather than just another folder: nothing in the
// portal has to be told about it for their files to end up there.
//
// Only when no folder was named. A client who picked a destination
// picked it, and uploadableBy() below is still what decides whether
// they may -- this chooses a default, it never grants anything.
if ($folder === null) {
$folder = $this->homeFolders->for($user);
}
abort_unless(Folder::uploadableBy($user, $folder), 403);
// One session per file, and a person uploads a handful at a time.
// A cap is here because nothing else counts sessions: for anyone
// without a quota to spend — staff, and clients on an installation
// that sets no quotas — the number of sessions is the only thing
// standing between a declared size and any multiple of it.
$openSessions = UploadSession::query()->where('user_id', $user->id)->count();
$maxOpen = max(1, (int) config('projectsend.uploads.max_open_sessions'));
if ($openSessions >= $maxOpen) {
throw ValidationException::withMessages([
'filename' => __('Too many uploads are already in progress. Finish or cancel one and try again.'),
]);
}
// The declared size here is client-supplied and unverified until
// complete()'s real assembled byte count — re-checked there too.
if ($user->isClient()) {
$quotaBytes = $this->storageUsage->quotaBytes($user);
// Sessions already open count too, at the size they declared.
// A quota measured against stored files alone is spent twice
// over by opening the sessions one after another: each one is
// told there is room, because the ones before it had not
// finished and so had not become files. putPart() holds each
// session to its declaration, so reserving the declarations
// here is what puts bytes waiting on the temporary volume
// under the same ceiling as bytes that landed.
$pendingBytes = (int) UploadSession::query()->where('user_id', $user->id)->sum('size');
if ($quotaBytes > 0 && $this->storageUsage->usedBytes($user) + $pendingBytes + (int) $validated['size'] > $quotaBytes) {
if ($quotaBytes > 0 && $this->storageUsage->usedBytes($user) + (int) $validated['size'] > $quotaBytes) {
throw ValidationException::withMessages([
'size' => __('This upload would exceed your storage quota of :quota MB.', [
// The resolved quota, not the column: a client who
@@ -227,7 +187,13 @@ class ChunkedUploadsController extends Controller
// ownership of the session is still enforced below.
$this->authorizeSession($request, $session);
// signPart() bounds the part number; bound the part body too.
// signPart() bounds the part number; bound the part body too, or a
// session can absorb unlimited bytes. The quota is only enforceable
// at complete(), against the assembled size — until then nothing
// stops a client declaring a 1-byte upload and streaming gigabytes
// of parts, which never becomes a File row and so never counts
// against anything. Stale sessions are purged daily, so without a
// cap here the exposure is a day's worth of disk.
abort_unless($part >= 1 && $part <= 10000, 422);
$maxPartBytes = max(1, (int) config('projectsend.upload_part_size_mb')) * 1024 * 1024;
@@ -239,48 +205,16 @@ class ChunkedUploadsController extends Controller
abort(413);
}
// Bounding one request bounds one request, and nothing else. Ten
// thousand part numbers at twice a 20 MB part is about 400 GB per
// session, sessions were not counted against anything, and none of
// it becomes a File row — so a client with a 1 MB quota could
// declare a one-byte upload and fill the temporary volume, then do
// it again. The session needs a ceiling of its own, and the room
// for a part has to be claimed before the part is read: a body's
// length is not known until it has arrived, and by then it is on
// the disk this is protecting.
//
// The ceiling is the size the session declared, which store() has
// already weighed against the file-size limit and the quota. So
// what a part gets is whatever the session has left, and the write
// is then capped at exactly that — an over-long body is cut off
// mid-stream as it always was, just against a smaller number.
$reserve = $this->reservePartRoom($session, $part, $limit);
if ($reserve < 1) {
// 413 rather than 422: this is about the size of what is being
// sent, and a client's resume logic already understands it. The
// session survives — the parts it holds are untouched, and it
// can still be completed or aborted.
abort(413);
}
$stream = $request->getContent(true);
try {
$etag = $this->parts->storePart($session, $part, $stream, $reserve);
$etag = $this->parts->storePart($session, $part, $stream, $limit);
} catch (PartTooLargeException) {
abort(413);
} finally {
if (is_resource($stream)) {
fclose($stream);
}
// In the finally, because every way out of here needs it: the
// refused part was deleted and weighs nothing, a client that
// hung up left a short one, and a clean write leaves exactly
// what it reserved. Without this a client's own retries would
// slowly exhaust a session that has plenty of room.
$session->settleStaged($reserve, $this->parts->partSize($session, $part));
}
return response('', 200, [
@@ -297,52 +231,6 @@ class ChunkedUploadsController extends Controller
]);
}
/**
* Claim room for one part, returning how many bytes were claimed — 0
* when the session has none left.
*
* Read-then-claim, under a lock held for the two statements and not
* for the transfer. The protocol sends parts in parallel and how many
* is the client's choice, so without it every part in flight reads the
* same "room left" and they all claim it; and making the claim alone
* atomic is no better, because then the honest parallel upload is the
* one that gets refused. The lock is the same per-session shape
* complete() already uses, and it is released before a byte is read.
*/
private function reservePartRoom(UploadSession $session, int $part, int $limit): int
{
$lock = Cache::lock('upload-part:'.$session->id, 30);
try {
$lock->block(15);
} catch (LockTimeoutException) {
// Nothing is wrong with the upload — the queue for this one
// session just did not clear. 503 with Retry-After is what the
// client's own backoff is for.
abort(503, headers: ['Retry-After' => '5']);
}
try {
$existing = $this->parts->partSize($session, $part);
$session->refresh();
// Re-sending a part replaces it rather than adding to it, so
// what it already holds is room this request may spend again.
// That is an ordinary resume.
$room = max(0, $session->size - ($session->staged_bytes - $existing));
$reserve = min($limit, $room);
if ($reserve > 0 && ! $session->reserveStaged($reserve, $existing)) {
return 0;
}
return $reserve;
} finally {
$lock->release();
}
}
/**
* List the parts already received, so an interrupted upload can resume
* rather than start again.
@@ -10,7 +10,6 @@ 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;
@@ -30,18 +29,12 @@ 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,7 +10,6 @@ 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;
@@ -79,18 +78,12 @@ 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
@@ -126,8 +119,6 @@ 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.
@@ -177,18 +177,6 @@ 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
@@ -279,7 +267,7 @@ class FilesController extends Controller
'slug' => Rules::slug('files', $file->id),
'categories' => ['array'],
'categories.*' => ['integer', 'exists:categories,id'],
'expires_at' => ['nullable', 'string', 'date'],
'expires_at' => ['nullable', 'date'],
'download_limit' => ['nullable', 'integer', 'min:1'],
'download_limit_scope' => ['nullable', Rule::enum(DownloadLimitScope::class)],
]);
@@ -298,11 +286,7 @@ class FilesController extends Controller
// an out-of-scope folder (reachable via a direct client share) still
// works.
if ($folderId !== null && $folderId !== $file->folder_id) {
$destination = $this->scope->folders($user)->whereKey($folderId)->firstOrFail();
// And one they may publish into, if it is public. Reparenting
// through the edit form is the same privileged write as move().
abort_unless(Folder::uploadableBy($user, $destination), 403);
$this->scope->folders($user)->findOrFail($folderId);
}
// Normalised into the shape ApplyFileEdits reads, then handed
@@ -360,18 +344,9 @@ class FilesController extends Controller
$folderId = $validated['folder_id'] ?? null;
$user = $request->user();
// The target folder must be one the mover can actually see, and one
// they are allowed to put content into. Those are two questions:
// a file in a public folder is published by being there, so the
// destination reaches the property `upload_public` guards without
// anybody touching the switch. Asking only the first let an editor
// who is deliberately not allowed to publish do it by dragging
// (GHSA-rxf8-wh8v-jm9j — the move half of GHSA-237r-jx85-j3hr,
// whose fix was wired into the upload paths and no further).
if ($folderId !== null && $user !== null && $folderId !== $file->folder_id) {
$destination = $this->scope->folders($user)->whereKey($folderId)->firstOrFail();
abort_unless(Folder::uploadableBy($user, $destination), 403);
// The target folder must be one the mover can actually see.
if ($folderId !== null && $user !== null) {
$this->scope->folders($user)->findOrFail($folderId);
}
$file->update(['folder_id' => $folderId]);
@@ -405,7 +380,7 @@ class FilesController extends Controller
'description' => ['nullable', 'string', 'max:2000'],
'expiration_action' => ['required', Rule::in(['no_change', 'set', 'clear'])],
'expires_at' => ['nullable', 'string', 'date', 'required_if:expiration_action,set'],
'expires_at' => ['nullable', 'date', 'required_if:expiration_action,set'],
// `sometimes` rather than `required` like the fields above:
// a browser still running the previous build would start
@@ -428,19 +403,13 @@ class FilesController extends Controller
&& ($validated['remove_category_ids'] ?? []) === [];
abort_if($touchesNothing, 422, __('Change at least one field before applying a bulk edit.'));
// The target folder must be one this user can actually see, and one
// they may put content into — the same two questions move() asks of
// a single file's target. Checked once, on the destination, rather
// than per file: the destination is one folder for the whole batch,
// and if putting content there publishes it then no file in the
// batch may go.
// The target folder must be one this user can actually see — same
// rule move() already applies to a single file's target.
$targetFolderId = null;
if ($validated['folder_action'] === 'move') {
$targetFolderId = $validated['folder_id'] ?? null;
if ($targetFolderId !== null) {
$destination = $this->scope->folders($user)->whereKey($targetFolderId)->firstOrFail();
abort_unless(Folder::uploadableBy($user, $destination), 403);
$this->scope->folders($user)->findOrFail($targetFolderId);
}
}
@@ -18,13 +18,9 @@ use App\Modules\Files\Folders\BreadcrumbBuilder;
use App\Modules\Files\Folders\FolderService;
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;
@@ -88,15 +84,6 @@ 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
@@ -105,25 +92,12 @@ 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 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;
// 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;
$folderQuery = $this->scope->folders($user)->withCount(['children', 'files']);
// `downloads` unconditionally — the library has always shown a
@@ -131,18 +105,7 @@ 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'])
// 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,
]),
->withCount(['assignments', 'downloads']),
$user,
);
@@ -157,29 +120,6 @@ 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
@@ -222,11 +162,6 @@ 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']),
]));
@@ -241,15 +176,6 @@ 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),
@@ -259,11 +185,6 @@ class FoldersController extends Controller
'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(),
@@ -273,18 +194,6 @@ 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'),
@@ -292,39 +201,6 @@ 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>
*/
@@ -377,9 +253,6 @@ 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.
@@ -529,20 +402,7 @@ class FoldersController extends Controller
'parent_id' => Rules::folderId(),
]);
$user = $request->user();
$newParent = $this->resolveParent($user, $validated['parent_id'] ?? null);
// A folder carries its contents with it, and a folder inside a
// public one is public — isEffectivelyPublic() reads the whole
// ancestry. So dropping a private folder into a public parent
// publishes every file in its subtree at once, which is the same
// act the upload path refuses without `upload_public`. The flag on
// this screen is already guarded (update() above leaves public
// state alone without the permission); the placement was not
// (GHSA-rxf8-wh8v-jm9j).
if ($user !== null) {
abort_unless(Folder::uploadableBy($user, $newParent), 403);
}
$newParent = $this->resolveParent($request->user(), $validated['parent_id'] ?? null);
$this->folders->move($folder, $newParent);
@@ -12,18 +12,13 @@ use App\Modules\Comments\Access\VisibleCommentScope;
use App\Modules\Comments\CommentingRules;
use App\Modules\Comments\CommentScope;
use App\Modules\Files\Access\DownloadAllowance;
use App\Modules\Files\Access\OwnFileDownloads;
use App\Modules\Files\DownloadLimitScope;
use App\Modules\Files\Editing\ApplyFileEdits;
use App\Modules\Files\Editing\FileExpiry;
use App\Modules\Files\Events\ResolvingUploadNotice;
use App\Modules\Files\Folders\BreadcrumbBuilder;
use App\Modules\Files\Folders\ClientHomeFolders;
use App\Modules\Files\Models\Category;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Models\ShareLink;
use App\Modules\Files\Sharing\ClientShareLinks;
use App\Modules\Files\Uploads\UploadExtensionPolicy;
use App\Modules\Files\Versions\FileVersionLinks;
use App\Modules\Files\Versions\FileVersions;
@@ -73,14 +68,11 @@ class MyFilesController extends Controller
private readonly PublicThemeRegistry $themes,
private readonly CapabilityRegistry $capabilities,
private readonly BreadcrumbBuilder $breadcrumbs,
private readonly ClientHomeFolders $homeFolders,
private readonly CommentingRules $commenting,
private readonly VisibleCommentScope $comments,
private readonly DownloadAllowance $allowance,
private readonly FileVersions $versions,
private readonly FileVersionLinks $versionLinks,
private readonly ClientShareLinks $shareLinks,
private readonly OwnFileDownloads $ownDownloads,
private readonly ApplyFileEdits $fileEdits,
private readonly FileExpiry $expiry,
private readonly ActivityLogger $activity,
@@ -110,17 +102,6 @@ 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
@@ -161,24 +142,7 @@ class MyFilesController extends Controller
if ($current === null) {
$folders = Folder::query()
->whereIn('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);
}
});
})
->where(fn ($q) => $q->whereNull('parent_id')->orWhereNotIn('parent_id', $visibleIds))
->orderBy('name');
} else {
$folders = Folder::query()
@@ -193,12 +157,7 @@ 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)
// 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)));
$filesQuery->where(fn (Builder $q) => $q->whereNull('folder_id')->orWhereNotIn('folder_id', $visibleIds));
} else {
$filesQuery->where('folder_id', $current->id);
}
@@ -265,21 +224,10 @@ class MyFilesController extends Controller
// docs/theming-files-checklist.md).
$versions = $this->versionLinks->forMany($fileRows, $client);
$unreadComments = $this->comments->unreadCountsFor($client, array_values(array_map(intval(...), $fileRows->pluck('id')->all())));
// One query for the page. Already narrowed to links this client
// minted on files this client uploaded — see ClientShareLinks for
// why both halves are required.
$shareUrls = $this->shareLinks->forMany($fileRows, $client);
// Also one query for the page, and also own files only — see
// OwnFileDownloads for why telling a recipient the count would be
// telling them about the other recipients.
$downloads = $this->ownDownloads->forMany($fileRows, $client);
return Inertia::render("portal/themes/{$this->themeKey()}/my-files", [
'folder' => $current === null ? null : ['id' => $current->id, 'name' => $current->name],
// 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),
'breadcrumb' => $flat ? [] : $this->breadcrumbs->visible($current, $visibleIds),
'folders' => $folderRows->map(fn (Folder $folder): array => [
'id' => $folder->id,
'name' => $folder->name,
@@ -300,10 +248,6 @@ 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
@@ -323,19 +267,6 @@ class MyFilesController extends Controller
// counterpart they were not given is null, not hidden by
// the theme. A theme must never filter this itself.
'version' => $versions[$file->id] ?? ['previous' => null, 'next' => null],
// The public URL for a file of their own, where one
// exists. Null on a file somebody shared with them, and
// null on their own file that has no link — a client has
// no way to mint one, so this is populated only where the
// installation did it for them. Never derived from
// is_mine: a theme renders what is here and nothing else.
'share_url' => $shareUrls[$file->id] ?? null,
// How often this went out and when it last did — the
// answer to "did it arrive?", which on a link-only
// account is the only evidence there is. Null on a file
// somebody shared with this client: not zero, which would
// be a claim about other people's activity, but nothing.
'downloads' => $downloads[$file->id] ?? null,
'categories' => $file->categories->map(fn (Category $category): array => [
'id' => $category->id, 'name' => $category->name, 'color' => $category->color,
])->values()->all(),
@@ -380,13 +311,7 @@ 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'),
@@ -447,19 +372,6 @@ class MyFilesController extends Controller
],
'can_delete' => Gate::forUser($client)->allows('delete', $file),
'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'),
@@ -530,7 +442,7 @@ class MyFilesController extends Controller
'commentable' => ['sometimes', 'boolean'],
'categories' => ['array'],
'categories.*' => ['integer', 'exists:categories,id'],
'expires_at' => ['nullable', 'string', 'date'],
'expires_at' => ['nullable', 'date'],
'download_limit' => ['nullable', 'integer', 'min:1'],
'download_limit_scope' => ['nullable', Rule::enum(DownloadLimitScope::class)],
]);
@@ -641,23 +553,4 @@ 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,7 +7,6 @@ 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;
@@ -31,7 +30,6 @@ 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
@@ -54,13 +52,6 @@ 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,9 +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\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;
@@ -47,14 +45,6 @@ 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
@@ -85,51 +75,10 @@ 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,9 +11,6 @@ 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;
@@ -32,8 +29,6 @@ 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
@@ -52,16 +47,6 @@ 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
@@ -86,11 +71,6 @@ 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(),
]);
}
@@ -103,12 +83,6 @@ class PublicShareController extends Controller
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);
}
// Before the link's counter moves, not after: a download refused
// by the file's own limit must not spend one of the link's.
if (! $this->allowance->allows($file, null)) {
@@ -1,141 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\FileAvailability;
use App\Modules\Files\Scanning\NotScannedReason;
use App\Modules\Files\Scanning\ScanStatus;
use App\Support\Pagination;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
/**
* The files the virus scanner refused, and the one decision a person can
* make about them.
*
* Nothing is deleted here automatically and nothing expires out of this
* list: a quarantined file waits for somebody. Deleting one is the
* ordinary file deletion, with its ordinary permission — this screen only
* adds the other answer, which is that the scanner was wrong.
*
* Releasing is gated by a permission of its own that only the
* administrator role holds by default, and by password confirmation on
* top of it, because it is the one action in the application that
* deliberately hands out a file something reported as malicious.
*/
class QuarantineController extends Controller
{
public function __construct(
private readonly ActivityLogger $activity,
private readonly FileAvailability $availability,
private readonly StaffLibraryScope $scope,
) {}
public function index(Request $request): Response
{
$user = $request->user();
assert($user !== null);
$files = $this->quarantined($user)
->with('uploader')
->orderByDesc('scanned_at')
->paginate(25)
->withQueryString();
$files->through(fn (File $file): array => [
'id' => $file->id,
'name' => $file->name,
'original_name' => $file->original_name,
'size' => $file->size,
'uploader' => $file->uploader?->name,
// The threat name, or — for a file nothing could open — what
// stopped it being read.
'threat' => $file->scan_status === ScanStatus::UnscannableBlocked
? __(NotScannedReason::tryFrom((string) $file->scan_note)?->label() ?? 'Could not be scanned')
: $file->scan_note,
'status' => $file->scan_status->value,
'scanned_at' => $file->scanned_at?->toIso8601String(),
// True only for a file that went out unscanned while the
// scanner was unreachable and was caught later — which is the
// one case where somebody may already have a copy.
'was_available' => $file->scan_was_available,
'downloads_count' => $file->downloads()->count(),
]);
return Inertia::render('files/quarantine', [
'files' => $files->items(),
'pagination' => Pagination::meta($files),
]);
}
/**
* Overrule the scanner for one file.
*
* The reason is required and is recorded against the person who gave
* it. A release is not undone by a later scan: the file stays
* released until somebody deletes it, which is the point — an
* administrator who has decided a detection is wrong should not have
* to decide it again every hour.
*/
public function release(Request $request, File $file): RedirectResponse
{
$actor = $request->user();
assert($actor !== null);
abort_unless($this->quarantined($actor)->whereKey($file->id)->exists(), 404);
$validated = $request->validate([
'reason' => ['required', 'string', 'max:500'],
]);
$file->forceFill([
'scan_status' => ScanStatus::Released,
'released_by' => $actor->id,
'released_at' => now(),
])->save();
$this->activity->log(Action::FileReleased, subject: $file, context: [
'reason' => $validated['reason'],
'threat' => $file->scan_note,
]);
// Everything that was waiting on this file — a share email, a new
// version notice — goes out now, exactly as it would have if the
// scan had passed.
$this->availability->markAvailable($file);
return back()->with('success', __('The file has been released.'));
}
/**
* The quarantined files this person may see and release.
*
* A client-scoped staff member gets their own clients' uploads and
* their own, the same boundary as the rest of the library. The
* permission alone let one read every quarantined file on the
* installation, and release a file belonging to a client they could
* not otherwise open.
*
* @return Builder<File>
*/
private function quarantined(User $user): Builder
{
$query = File::query()
->whereIn('scan_status', [ScanStatus::Infected->value, ScanStatus::UnscannableBlocked->value]);
$uploaders = $this->scope->uploaderIds($user);
return $uploaders === null ? $query : $query->whereIn('uploaded_by', $uploaders);
}
}
@@ -9,7 +9,6 @@ use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\ShareLink;
use App\Modules\Files\Sharing\CreateShareLink;
use App\Modules\Platform\Localization\LocalDay;
use App\Modules\Platform\Localization\TimezoneRegistry;
use Illuminate\Http\RedirectResponse;
@@ -31,29 +30,19 @@ class ShareLinksController extends Controller
public function __construct(
private readonly ActivityLogger $activity,
private readonly TimezoneRegistry $timezones,
private readonly CreateShareLink $links,
) {}
public function store(Request $request, File $file): RedirectResponse
{
Gate::authorize('update', $file);
$user = $request->user();
assert($user !== null);
// Making a link is publishing, so it asks the publishing key. Staff
// are not asked for it, as they never have been: `update` on the
// file is their boundary and this would be a new refusal on every
// installation that upgraded.
abort_unless($user->isStaff() || $user->can('upload_public'), 403);
$validated = $request->validate([
// Deliberately not `after:now`: that rule reads the bare
// YYYY-MM-DD the picker posts as midnight UTC, so a creator
// far enough east would be told today's date is in the past
// while it is plainly still today where they are. The check
// moves below, onto the instant the date actually resolves to.
'expires_at' => ['nullable', 'string', 'date'],
'expires_at' => ['nullable', '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
@@ -91,27 +80,16 @@ class ShareLinksController extends Controller
]);
}
// The permission gates stay here, where the request is: whether
// this person may set an expiry or a cap is a fact about them,
// not about link creation, and the action has no viewer to ask.
$this->links->for(
file: $file,
creator: $user,
expiresAt: $user->can('set_file_expiration_date') ? $expiresAt : null,
// Cast, and null kept as null rather than falling through a
// bare (int) that would turn "no cap" into a cap of zero. The
// `integer` rule validates a numeric string without converting
// it, and this file is strict_types, so an uncast "5" is a
// TypeError against `?int $maxDownloads`. Nothing sends one
// today only because files/edit.tsx calls Number() first --
// which is a fact about a frontend file, not a guarantee this
// signature has. It cost a 500 on the client form, where the
// same field was typed as a string.
maxDownloads: $user->can('limit_downloads') && ($validated['max_downloads'] ?? null) !== null
? (int) $validated['max_downloads']
: null,
token: $validated['token'] ?? null,
);
ShareLink::query()->create([
'shareable_type' => $file->getMorphClass(),
'shareable_id' => $file->id,
'token' => $validated['token'] ?? Str::random(32),
'created_by' => $user->id,
'expires_at' => $user->can('set_file_expiration_date') ? $expiresAt : null,
'max_downloads' => $user->can('limit_downloads') ? $validated['max_downloads'] ?? null : null,
]);
$this->activity->log(Action::ShareLinkCreated, subject: $file);
return back()->with('success', __('Public link created.'));
}
@@ -121,9 +99,6 @@ 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();
@@ -1,428 +0,0 @@
<?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,8 +13,6 @@ 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;
@@ -47,7 +45,6 @@ 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,
) {}
@@ -105,11 +102,7 @@ class ZipDownloadsController extends Controller
// as many times as they were meant to is the whole point of not
// hiding exhausted files.
$selected = $files->count();
// 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));
$files = $files->filter(fn (File $file): bool => $this->allowance->allows($file, $user));
abort_if(
$files->isEmpty() && $folders->isEmpty() && $selected > 0,
@@ -187,21 +180,6 @@ 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,18 +78,6 @@ 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
@@ -8,7 +8,6 @@ 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\Settings\Setting;
@@ -115,7 +114,6 @@ class BuildZipDownloadJob implements ShouldQueue
$visible = app(ViewableFileScope::class)->for($requester);
$allowance = app(DownloadAllowance::class);
$availability = app(FileAvailability::class);
try {
$relativePath = 'zips/'.$zipDownload->id.'.zip';
@@ -150,11 +148,7 @@ 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.
// 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)) {
if (! $allowance->allows($file, $requester)) {
$skipped[] = ['id' => $file->id, 'name' => $file->name];
continue;
@@ -388,7 +382,6 @@ 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 */
@@ -410,7 +403,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 (! $availability->isAvailable($file) || ! $allowance->allows($file, $requester)) {
if (! $allowance->allows($file, $requester)) {
$skipped[] = ['id' => $file->id, 'name' => $file->name];
continue;
-216
View File
@@ -1,216 +0,0 @@
<?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);
}
}
}
@@ -1,96 +0,0 @@
<?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
@@ -1,94 +0,0 @@
<?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;
}
}
+4 -152
View File
@@ -10,8 +10,6 @@ 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\Support\Concerns\HasUniqueSlug;
@@ -42,14 +40,6 @@ 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
@@ -84,14 +74,6 @@ 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',
@@ -289,100 +271,6 @@ 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
*/
@@ -428,41 +316,6 @@ 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
@@ -499,7 +352,7 @@ class File extends Model
$outer->orWhere('uploaded_by', $client->id);
});
$query->notExpired()->available($client);
$query->notExpired();
}
/**
@@ -540,7 +393,7 @@ class File extends Model
$outer->orWhereIn('folder_id', $subtreeFolderIds);
});
$query->notExpired()->available();
$query->notExpired();
}
/**
@@ -554,7 +407,7 @@ class File extends Model
*/
public function scopePubliclyVisibleForFolder(Builder $query, Folder $folder): void
{
$query->whereIn('folder_id', $folder->subtreeFolderIds())->notExpired()->available();
$query->whereIn('folder_id', $folder->subtreeFolderIds())->notExpired();
}
/**
@@ -605,7 +458,6 @@ class File extends Model
->where(function (Builder $folder) use ($publicFolderSubtreeIds): void {
$folder->whereNull('folder_id')->orWhereNotIn('folder_id', $publicFolderSubtreeIds);
})
->notExpired()
->available();
->notExpired();
}
}
+7 -47
View File
@@ -112,12 +112,6 @@ 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
@@ -158,26 +152,15 @@ class Folder extends Model
}
/**
* Whether $user may put content into $folder (null = loose at the
* root, always allowed).
*
* **Read the name as "may place into", not "may upload into".** Every
* way a file arrives in a folder has to come through here, and the
* name cost us one advisory already: the publication rule below was
* written for GHSA-237r-jx85-j3hr and wired into the upload paths
* alone, because those are what the name suggested. Moving a file in,
* bulk-moving a selection in, reparenting one through the edit form,
* and dragging a whole folder into a public parent all put content
* somewhere too, and none of them asked (GHSA-rxf8-wh8v-jm9j). They
* ask now. Anything new that writes a `folder_id` or a `parent_id`
* belongs on this list.
* Whether $user may upload a new file directly into $folder (null =
* loose at the root, always allowed).
*
* Staff are held to the library boundary they are held to everywhere
* else: an unscoped staff member may use any folder, a client-scoped
* one only the folders StaffLibraryScope already shows them. Callers
* that have already resolved the destination through
* StaffLibraryScope::folders() have answered that half — the two are
* the same query — and call this for the publication half.
* one only the folders StaffLibraryScope already shows them. This is
* the only place that decides it: every upload path — the web form,
* the API and the chunked flow the browser actually posts to — comes
* through here rather than checking folder_id for itself.
*
* For a client this is unchanged, and is still the whole of the
* check: they own the folder, or it is a public folder that opts into
@@ -191,30 +174,7 @@ class Folder extends Model
}
if ($user->isStaff()) {
if (! app(StaffLibraryScope::class)->allowsFolder($user, $folder)) {
return false;
}
// Being allowed to reach the folder is not the same as being
// allowed to publish, and putting a file in a public folder
// publishes it: isEffectivelyPublic() is "my own flag, or my
// folder's". So the destination reaches the property that
// `upload_public` guards, without ever touching the switch
// (GHSA-237r-jx85-j3hr).
//
// The keys already say this. The client branch below has always
// asked for `upload_to_public_folders` here, and
// MyFilesController's picker calls that the established meaning
// of the two — it was simply never asked on a staff role, which
// left that permission doing nothing at all for staff.
//
// Effectively public, not `public`: the flag is inherited down
// a subtree, so a private folder inside a public one publishes
// just the same and a check on the folder's own flag would walk
// straight past it.
return ! $folder->isEffectivelyPublic()
|| $user->can('upload_public')
|| $user->can('upload_to_public_folders');
return app(StaffLibraryScope::class)->allowsFolder($user, $folder);
}
return $folder->isOwnedBy($user)
@@ -1,333 +0,0 @@
<?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);
}
}
@@ -1,88 +0,0 @@
<?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));
}
}
@@ -1,38 +0,0 @@
<?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',
};
}
}
@@ -1,82 +0,0 @@
<?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();
}
}
@@ -1,17 +0,0 @@
<?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
@@ -1,201 +0,0 @@
<?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
@@ -1,92 +0,0 @@
<?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',
};
}
}
@@ -1,57 +0,0 @@
<?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);
}
}
@@ -1,56 +0,0 @@
<?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.';
}
}
@@ -1,47 +0,0 @@
<?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());
}
}
@@ -1,132 +0,0 @@
<?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) : '';
}
}
@@ -1,35 +0,0 @@
<?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;
}
@@ -1,78 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Sharing;
use App\Models\User;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\ShareLink;
use Illuminate\Support\Collection;
/**
* The public URLs a client may be shown for their own files.
*
* A client's portal lists two kinds of file side by side: what they
* uploaded, and what somebody shared with them. Both can carry share
* links, and only one kind of link is theirs to see — a link a staff
* member minted for a file they were given is that staff member's
* decision about who may reach it, and handing the recipient the URL
* would turn "you may download this" into "you may pass this on to
* anyone".
*
* So the rule is narrow and stated once: **a link this client created, on
* a file this client uploaded.** Both halves, not either. Neither is
* redundant — a client-created link on a file they no longer own would
* outlive a reassignment, and a staff link on their own upload is still
* not theirs to hand out.
*
* Inactive links are left out rather than shown greyed: the only thing a
* client can do with this is copy it, and a URL that answers "this link
* has expired" is worse than no URL at all.
*
* Resolved for a whole page at a time. One query for the listing, not one
* per row.
*/
class ClientShareLinks
{
/**
* @param Collection<int, File> $files
* @return array<int, string> file id => URL, for the files that have one
*/
public function forMany(Collection $files, User $client): array
{
$own = $files
->filter(fn (File $file): bool => $file->uploaded_by === $client->id)
->pluck('id')
->map(fn ($id): int => (int) $id)
->all();
if ($own === []) {
return [];
}
$links = ShareLink::query()
->where('shareable_type', (new File)->getMorphClass())
->whereIn('shareable_id', $own)
->where('created_by', $client->id)
// Oldest first, so a file that somehow carries two is
// described by the one the client has already been given
// rather than by whichever the database returned today.
->orderBy('id')
->get();
$urls = [];
foreach ($links as $link) {
$fileId = (int) $link->shareable_id;
if (isset($urls[$fileId]) || ! $link->isActive()) {
continue;
}
$urls[$fileId] = route('share.show', $link->token);
}
return $urls;
}
}
@@ -1,62 +0,0 @@
<?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\File;
use App\Modules\Files\Models\ShareLink;
use Carbon\CarbonInterface;
use Illuminate\Support\Str;
/**
* Minting a public link for a file, in one place.
*
* Extracted from ShareLinksController rather than invented: the
* controller is an HTTP handler behind `staff` middleware, and a link now
* needs creating from outside a request as well. Two copies of "make a
* token, write the row, log it" would drift, and the half most likely to
* drift is the token.
*
* **The token is the whole authorization.** There is nothing behind
* /s/{token} — no session, no second factor — so its only defence is
* being unguessable. Str::random(32) is about 190 bits, which is more
* than a UUID's 122; anything minted here gets that and never a chosen
* value. A caller that wants a chosen token is a person typing one into a
* form, and that path stays in the controller where its minimum length
* can be argued about in a validation rule.
*
* Expiry and download caps are the caller's to decide and are passed in
* already resolved, because "the end of the 12th" depends on whose zone
* you are in and this class has no viewer.
*/
class CreateShareLink
{
public function __construct(
private readonly ActivityLogger $activity,
) {}
public function for(
File $file,
User $creator,
?CarbonInterface $expiresAt = null,
?int $maxDownloads = null,
?string $token = null,
): ShareLink {
$link = ShareLink::query()->create([
'shareable_type' => $file->getMorphClass(),
'shareable_id' => $file->id,
'token' => $token ?? Str::random(32),
'created_by' => $creator->id,
'expires_at' => $expiresAt,
'max_downloads' => $maxDownloads,
]);
$this->activity->log(Action::ShareLinkCreated, subject: $file);
return $link;
}
}
-14
View File
@@ -8,7 +8,6 @@ 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;
@@ -36,7 +35,6 @@ class FileSharing
private readonly ActivityLogger $activity,
private readonly NotificationDigester $digester,
private readonly Notifier $notifier,
private readonly FileAvailability $availability,
) {}
/**
@@ -53,18 +51,6 @@ 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]);
@@ -6,8 +6,6 @@ namespace App\Modules\Files\Thumbnails;
use App\Modules\Files\Thumbnails\Events\RenderingImage;
use claviska\SimpleImage;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
use RuntimeException;
@@ -104,111 +102,12 @@ class ThumbnailGenerator
};
}
/**
* How long a render may hold the lock before another request is
* entitled to assume it died. Generous: a 40-megapixel decode is a
* second or two, and an external source is copied local first.
*/
private const LOCK_SECONDS = 120;
/**
* How long to wait for the request that got there first.
*
* Waiting costs an idle worker — about 35 MB. Rendering costs that
* plus four bytes per source pixel, up to 160 MB at the megapixel
* ceiling. Waiting is the cheap option by an order of magnitude,
* which is the whole reason this exists.
*
* Configurable because the right number depends on how long a decode
* takes here, and that is a property of the machine rather than of
* the application: a small VPS reading a large source off a slow disk
* wants longer than this, and nothing in the code can know that.
*/
private const DEFAULT_LOCK_WAIT_SECONDS = 15;
/**
* Render one image, once, however many requests ask at the same time.
*
* **Why the lock.** Renditions are generated on demand and cached by
* existence, and nothing between the callers stopped two requests
* rendering the same image at once. The atomic rename below settles
* which file survives — it never stopped both from decoding. So N
* concurrent requests for one cold rendition were N full-size decodes,
* each holding four bytes per source pixel.
*
* That is not an attack. A public listing emits a thumbnail URL per
* file, a browser opens six or more connections at once, and the first
* visit to a gallery of ordinary camera images is six simultaneous
* decodes on a container sized for one. It kills the container, and
* because a killed render writes nothing, the cache never warms: the
* page dies again on the next visit. `PublicGroupsController` reaches
* here with no account at all.
*
* **Why waiting rather than refusing.** The request that waits holds
* an idle worker. The request that renders holds a worker plus the
* whole source bitmap. Six waiters cost what one renderer costs, so
* blocking is the cheap answer even when it looks like the slow one.
*
* **Why the re-check after acquiring.** The winner has finished by the
* time a waiter gets in, so the file it was waiting for is already
* there. Re-reading is what turns a wait into a cache hit rather than
* a second render of the same image.
*/
public function generate(
string $sourcePath,
string $destinationPath,
string $mimeType,
ImageAudience $audience,
ImageRendition $rendition,
): void {
// Keyed on the destination, which already encodes the file, the
// audience and the rendition — two requests collide here exactly
// when they would have written the same path.
$lock = Cache::lock('rendition:'.sha1($destinationPath), self::LOCK_SECONDS);
try {
$lock->block($this->lockWaitSeconds());
} catch (LockTimeoutException) {
// Deliberately not rendering anyway. Falling through on
// timeout would reinstate exactly the pile-on this exists to
// stop, at the moment the system is already struggling — one
// failed thumbnail is a better outcome than a container that
// dies and takes the warm cache with it.
throw new RuntimeException('Timed out waiting for another request to render this image.');
}
try {
// Somebody else rendered it while we waited. An empty file is
// not a rendition — same rule the callers apply, and the same
// reason: nothing invalidates one once it is cached.
if (is_file($destinationPath) && filesize($destinationPath) > 0) {
return;
}
$this->render($sourcePath, $destinationPath, $mimeType, $audience, $rendition);
} finally {
$lock->release();
}
}
/**
* Clamped to at least a second: a zero would make every concurrent
* request fail instead of waiting, which is the opposite of the point
* and exactly what a stray empty environment variable produces.
*/
private function lockWaitSeconds(): int
{
$configured = config('projectsend.rendition_lock_wait_seconds');
return max(1, is_numeric($configured) ? (int) $configured : self::DEFAULT_LOCK_WAIT_SECONDS);
}
private function render(
string $sourcePath,
string $destinationPath,
string $mimeType,
ImageAudience $audience,
ImageRendition $rendition,
): void {
$dimensions = @getimagesize($sourcePath);
+75 -160
View File
@@ -27,12 +27,6 @@ use Throwable;
*/
class LocalPartStore
{
/**
* Said twice, because a full temp volume can announce itself in the
* middle of the copy or only when the last buffer is flushed.
*/
private const WRITE_FAILED = 'Could not assemble the upload: writing to the temporary directory failed.';
/**
* The route name is a parameter because the same flow is mounted twice:
* once on the session-authenticated web routes for the browser, once on
@@ -118,25 +112,6 @@ class LocalPartStore
return md5_file($path) ?: '';
}
/**
* What one part number currently weighs on disk, 0 if it has never
* arrived. Read before and after a part is received, so the session's
* reservation can be settled against what is really there rather than
* against what the request claimed it would send.
*/
public function partSize(UploadSession $session, int $partNumber): int
{
$path = $this->partPath($session, $partNumber);
if (! is_file($path)) {
return 0;
}
clearstatcache(true, $path);
return (int) (filesize($path) ?: 0);
}
/**
* @return list<array{PartNumber: int, Size: int, ETag: string}>
*/
@@ -165,21 +140,9 @@ class LocalPartStore
}
/**
* Stream-append parts in order onto the files disk, hashing as we go.
*
* The parts stay on disk until the assembled bytes are safely on the
* target disk. ChunkedUploadsController's completion lock promises that
* "a later retry still works", and everything that can fail after the
* concatenation — reopening the copy, a disk refusing the write, the
* File row itself — happens while the client has nothing but this
* session to retry with. Unlinking each part as it was consumed left
* listParts() empty, so every later complete() answered "Upload is
* incomplete: missing parts" for good.
*
* The cost is temp space: peak usage is the whole file twice over
* (every part, plus the assembled copy) rather than the file plus one
* part. Both are freed by the abort() below the moment the write lands,
* and by the failure path the moment it does not.
* Stream-append parts in order onto the files disk, hashing as we
* go. Peak temp usage ≈ file size + one part (parts are unlinked
* as they are consumed).
*
* @return array{path: string, disk: string, size: int, checksum: string}
*/
@@ -195,93 +158,6 @@ class LocalPartStore
}
$assembledPath = $this->directory($session).'/assembled';
try {
[$size, $checksum] = $this->concatenate($session, $parts, $assembledPath);
$readStream = fopen($assembledPath, 'rb');
if ($readStream === false) {
throw new RuntimeException('Could not reopen assembled file.');
}
$diskEvent = new ResolvingUploadDisk($session->user);
Event::dispatch($diskEvent);
$disk = $diskEvent->disk;
$written = Storage::disk($disk)->writeStream($targetPath, $readStream);
if (is_resource($readStream)) {
fclose($readStream);
}
// The disks are configured with 'throw' => false, so a refused
// write is a `false` return rather than an exception — and the
// caller goes on to record a File row for bytes that were never
// stored. Losing an upload silently is worse than failing it, and
// this is the only place that can tell the difference: a real
// instance of it was a GCS bucket rejecting the adapter's ACL,
// which looked exactly like a successful upload.
if ($written === false) {
// The reason is lost by the time it gets here — 'throw' => false
// means Flysystem swallowed the exception rather than passing it
// on — so log what was attempted. Which bucket it was is the
// difference between reading this as "my credentials expired"
// and "I typed the wrong bucket name", and only the log can say
// it: the message below is shown to whoever was uploading, which
// includes clients, and a bucket name is not theirs to see.
Log::error('Upload could not be written to storage.', [
'disk' => $disk,
'bucket' => config('filesystems.disks.'.$disk.'.bucket'),
'driver' => config('filesystems.disks.'.$disk.'.driver'),
'path' => $targetPath,
]);
throw new RuntimeException(
'Could not write the assembled upload to the "'.$disk.'" disk. '
.'Check the storage backend is reachable and its credentials are still valid.'
);
}
} catch (Throwable $failure) {
// The half-written copy belongs to this attempt and the next one
// makes its own; the parts belong to the client, and they are
// what a retry needs. Deleting the copy here is also the only
// thing that removes it at all on this path — it used to sit in
// the session directory until the sweeper came round.
FileSystem::delete($assembledPath);
throw $failure;
}
$this->abort($session);
return [
'path' => $targetPath,
'disk' => $disk,
'size' => $size,
'checksum' => $checksum,
];
}
/**
* Concatenate the parts into $assembledPath, returning the byte count
* and the sha256 of what was written.
*
* Every read and every write is checked. They were not, and while a
* failing fwrite on a full volume is loud in practice — Laravel's
* error handler turns the warning into an ErrorException — loud there
* means a 500 carrying a PHP message, where the disk-refused-the-write
* case a few lines above becomes a sentence the person uploading can
* act on. A short write arriving without a warning would be worse
* still: $size and the hash describe the buffer that was read, so an
* unchecked one yields a truncated file with a checksum matching bytes
* that were never stored.
*
* @param list<array{PartNumber: int, Size: int, ETag: string}> $parts
* @return array{0: int, 1: string}
*/
private function concatenate(UploadSession $session, array $parts, string $assembledPath): array
{
$out = fopen($assembledPath, 'wb');
if ($out === false) {
@@ -291,46 +167,85 @@ class LocalPartStore
$hash = hash_init('sha256');
$size = 0;
try {
foreach ($parts as $part) {
$in = fopen($this->partPath($session, $part['PartNumber']), 'rb');
foreach ($parts as $part) {
$partPath = $this->partPath($session, $part['PartNumber']);
$in = fopen($partPath, 'rb');
if ($in === false) {
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
}
try {
while (! feof($in)) {
$buffer = fread($in, 1024 * 1024);
if ($buffer === false) {
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
}
if ($buffer !== '' && @fwrite($out, $buffer) !== strlen($buffer)) {
throw new RuntimeException(self::WRITE_FAILED);
}
hash_update($hash, $buffer);
$size += strlen($buffer);
}
} finally {
fclose($in);
}
if ($in === false) {
fclose($out);
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
}
} catch (Throwable $failure) {
fclose($out);
throw $failure;
while (! feof($in)) {
$buffer = fread($in, 1024 * 1024);
if ($buffer === false) {
break;
}
fwrite($out, $buffer);
hash_update($hash, $buffer);
$size += strlen($buffer);
}
fclose($in);
unlink($partPath);
}
// fclose flushes, so a volume that filled up on the last buffer
// fails here rather than in the loop.
if (! fclose($out)) {
throw new RuntimeException(self::WRITE_FAILED);
fclose($out);
$readStream = fopen($assembledPath, 'rb');
if ($readStream === false) {
throw new RuntimeException('Could not reopen assembled file.');
}
return [$size, hash_final($hash)];
$diskEvent = new ResolvingUploadDisk($session->user);
Event::dispatch($diskEvent);
$disk = $diskEvent->disk;
$written = Storage::disk($disk)->writeStream($targetPath, $readStream);
if (is_resource($readStream)) {
fclose($readStream);
}
// The disks are configured with 'throw' => false, so a refused
// write is a `false` return rather than an exception — and the
// caller goes on to record a File row for bytes that were never
// stored. Losing an upload silently is worse than failing it, and
// this is the only place that can tell the difference: a real
// instance of it was a GCS bucket rejecting the adapter's ACL,
// which looked exactly like a successful upload.
if ($written === false) {
// The reason is lost by the time it gets here — 'throw' => false
// means Flysystem swallowed the exception rather than passing it
// on — so log what was attempted. Which bucket it was is the
// difference between reading this as "my credentials expired"
// and "I typed the wrong bucket name", and only the log can say
// it: the message below is shown to whoever was uploading, which
// includes clients, and a bucket name is not theirs to see.
Log::error('Upload could not be written to storage.', [
'disk' => $disk,
'bucket' => config('filesystems.disks.'.$disk.'.bucket'),
'driver' => config('filesystems.disks.'.$disk.'.driver'),
'path' => $targetPath,
]);
throw new RuntimeException(
'Could not write the assembled upload to the "'.$disk.'" disk. '
.'Check the storage backend is reachable and its credentials are still valid.'
);
}
$this->abort($session);
return [
'path' => $targetPath,
'disk' => $disk,
'size' => $size,
'checksum' => hash_final($hash),
];
}
public function abort(UploadSession $session): void
@@ -5,14 +5,9 @@ declare(strict_types=1);
namespace App\Modules\Files\Uploads;
use App\Models\User;
use App\Modules\Files\Events\FileWasStored;
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
@@ -23,7 +18,6 @@ class StoreUploadedFile
{
public function __construct(
private readonly ActivityLogger $activity,
private readonly ScanningConfig $scanning,
) {}
public function create(
@@ -41,8 +35,6 @@ class StoreUploadedFile
): File {
$originalName = self::sanitizeFilename($originalName);
$scanning = $this->scanning->enabled();
$file = File::query()->create([
'uploaded_by' => $uploader->id,
'folder_id' => $folderId,
@@ -56,24 +48,10 @@ 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);
// Every upload path converges here — the chunked flow staff and
// clients share, and the synchronous POST beside it — so a
// listener sees each upload once without knowing which route
// produced it. Dispatched after the row exists, so what it
// receives is a complete File.
Event::dispatch(new FileWasStored($file, $uploader));
return $file;
}
@@ -9,7 +9,6 @@ use App\Modules\Files\Models\Folder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\DB;
/**
* A resumable chunked upload in progress. Parts live on disk under the
@@ -21,7 +20,6 @@ use Illuminate\Support\Facades\DB;
* @property int|null $previous_file_id
* @property string $original_name
* @property int $size
* @property int $staged_bytes
* @property string|null $mime_type
* @property string|null $description
* @property string $status
@@ -55,71 +53,4 @@ class UploadSession extends Model
{
return $this->user_id === $user->id;
}
/**
* Claim room on the temporary volume for a part that is about to
* arrive, returning false if the session has no room left.
*
* The claim is made before the bytes are read, and it is one
* statement, because neither weaker version holds. Checking the part
* directory and then writing leaves a gap that every other part
* currently in flight fits through — and the protocol sends parts in
* parallel, so the number of them is the caller's choice, not ours.
* Charging the real size afterwards is the same gap by another name.
*
* $replacing is what the part number already holds, since re-sending a
* part overwrites it rather than adding to it. That is an ordinary
* resume, not an attack.
*
* The ceiling is the size the session declared. A client cannot stage
* more than it said it was sending, which is the invariant the whole
* fix rests on: store() has already measured that declaration against
* the file-size limit and the storage quota, so bounding staged bytes
* by it puts temporary bytes under the same limits as stored ones.
*/
public function reserveStaged(int $bytes, int $replacing = 0): bool
{
$delta = $bytes - $replacing;
$query = static::query()->whereKey($this->getKey());
// Both bounds are rearranged so that the column is never part of a
// subtraction. `staged_bytes + :delta BETWEEN 0 AND size` reads
// naturally and is wrong: staged_bytes is BIGINT UNSIGNED, and on
// MySQL a negative delta makes that expression underflow and raise
// SQLSTATE 22003 — in the comparison, before any row is chosen, so
// the bound meant to prevent it is the thing that trips over it.
// SQLite has no unsigned integers, so the suite cannot see this at
// all; UploadSessionStagedBytesMysqlTest is what covers it.
if ($delta >= 0) {
if ($delta > $this->size) {
return false;
}
$query->where('staged_bytes', '<=', $this->size - $delta);
} else {
// Never give back more than is held.
$query->where('staged_bytes', '>=', -$delta);
}
return $query->update(['staged_bytes' => DB::raw(sprintf('staged_bytes + (%d)', $delta))]) === 1;
}
/**
* Replace a reservation with what the part actually weighs.
*
* Always called, whatever happened to the part: a body shorter than
* its Content-Length, a client that hung up mid-transfer, a part
* refused for being too long and deleted. Whatever is on disk now is
* the truth, and the difference goes back to the session — otherwise
* a client's own retries would slowly exhaust their room.
*/
public function settleStaged(int $reserved, int $actual): void
{
if ($reserved === $actual) {
return;
}
$this->reserveStaged($actual, $reserved);
}
}
@@ -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', 'scan_status']);
->get(['id', 'name', 'slug', 'previous_file_id', 'public', 'expires_at', 'folder_id']);
/** @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', 'scan_status']);
->get(['id', 'name', 'slug', 'previous_file_id', 'public', 'expires_at', 'folder_id']);
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, unexpired and available — the same predicate
// public and unexpired — 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() && $candidate->scan_status->isAvailable()) {
if ($candidate->isEffectivelyPublic() && ! $candidate->isExpired()) {
$ids[] = $candidate->id;
}
}
+2 -5
View File
@@ -151,14 +151,11 @@ 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 — 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.
* authoritative visibility scope.
*
* @return Collection<int, User>
*/
public function sharedAudience(File $file, File $previous): Collection
private function sharedAudience(File $file, File $previous): Collection
{
$candidateIds = FileAssignment::query()
->where('file_id', $previous->sharingOwnerId())
@@ -41,13 +41,7 @@ class GroupsController extends Controller
'visibility' => ['nullable', Rule::in(['public', 'private'])],
]);
$viewer = $request->user();
assert($viewer !== null);
// The API twin of the web listing's narrowing, and it has to be
// here rather than only there: the same disclosure through a token
// is the same disclosure (GHSA-r3hg-3fxw-rcmr).
$query = $this->scope->groups($viewer)->withCount('members');
$query = Group::query()->withCount('members');
if (($filters['search'] ?? null) !== null) {
$search = $filters['search'];
@@ -40,14 +40,7 @@ class GroupsController extends Controller
'visibility' => $validated['visibility'] ?? null,
];
$viewer = $request->user();
assert($viewer !== null);
// Scoped, not Group::query(): a client-scoped staff member is told
// about a group because one of their clients is in it. Without
// this the listing showed every group on the installation, to a
// viewer who could reach nothing of theirs (GHSA-r3hg-3fxw-rcmr).
$groups = $this->scope->groups($viewer)
$groups = Group::query()
->withCount('members')
->when($filters['search'], fn (Builder $query, string $search) => $query->where(fn (Builder $q) => $q
->where('name', 'like', "%{$search}%")
@@ -13,8 +13,6 @@ 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;
@@ -83,7 +81,6 @@ 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,
@@ -195,7 +192,6 @@ class PublicGroupsController extends Controller
$this->guardSlug($publicSlug);
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 404);
abort_unless($this->availability->isAvailable($file), 404);
$file->loadMissing('categories');
@@ -229,9 +225,6 @@ 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.
@@ -250,7 +243,6 @@ class PublicGroupsController extends Controller
$this->guardSlug($publicSlug);
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 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
@@ -308,7 +300,6 @@ class PublicGroupsController extends Controller
$this->guardSlug($publicSlug);
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 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);
@@ -355,7 +346,6 @@ class PublicGroupsController extends Controller
$this->guardSlug($publicSlug);
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 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
@@ -10,7 +10,6 @@ use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\DeletedAccountContent;
use App\Modules\Identity\Models\Role;
use Closure;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
@@ -62,8 +61,12 @@ class AccountContentDeletion
*/
public function candidates(?User $viewer, ?int $excludeId = null): array
{
return $this->reachableTargets($viewer)
return User::query()
->when($excludeId, fn (Builder $query, int $id) => $query->whereKeyNot($id))
->when($viewer, fn (Builder $query, User $for) => $query->where(fn (Builder $reachable) => $reachable
->where('type', UserType::Staff)
->orWhereIn('id', $this->scope->clients($for)->select('users.id'))))
->where('active', true)
->with('role')
->orderBy('name')
->get()
@@ -96,62 +99,17 @@ class AccountContentDeletion
return [];
}
$viewer = $request->user();
return $request->validate([
'content_action' => ['required', Rule::in(['cascade_delete', 'reassign'])],
'reassign_to_id' => [
'required_if:content_action,reassign',
'integer',
Rule::exists('users', 'id')->where('active', true),
Rule::notIn([$target->id]),
// The same question the picker asks, asked again of what
// came back from it. It used to be "exists, and is active",
// which is not the boundary the picker documents two
// methods up: a client-scoped staff member was shown their
// own roster and could name anybody, so deleting a roster
// client could hand that client's files and folders to a
// client on somebody else's roster — who then reads, edits
// and deletes them under the own-upload rules
// (GHSA-w29w-pj29-x7ww).
//
// One predicate for both, rather than a matching pair: a
// picker that promises a boundary the write does not keep
// is exactly what this was.
function (string $attribute, mixed $value, Closure $fail) use ($viewer): void {
if (! $this->reachableTargets($viewer)->whereKey($value)->exists()) {
// Deliberately the message an id that does not
// exist at all would get. "Not yours" and "not
// there" have to read the same, or refusing is how
// a scoped staff member enumerates the accounts
// outside their roster.
$fail('validation.exists')->translate();
}
},
],
]);
}
/**
* Every active account $viewer may hand content to: staff, who are
* narrowed nowhere in the application, plus the clients
* StaffLibraryScope shows them. An unscoped viewer gets everybody,
* because clients() returns everybody for them.
*
* $viewer is null only where the question is about the installation
* rather than about a screen — the erasure default in privacy
* settings, which is stored once for everybody.
*
* @return Builder<User>
*/
private function reachableTargets(?User $viewer): Builder
{
return User::query()
->when($viewer, fn (Builder $query, User $for) => $query->where(fn (Builder $reachable) => $reachable
->where('type', UserType::Staff)
->orWhereIn('id', $this->scope->clients($for)->select('users.id'))))
->where('active', true);
}
/**
* @param array{content_action?: string, reassign_to_id?: int} $validated
*/
@@ -260,10 +260,6 @@ 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) {
-81
View File
@@ -1,81 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Identity;
use App\Models\User;
/**
* Finding the account that holds an address, exactly.
*
* `where('email', $address)` is not an exact match. It is whatever the
* database's collation says equality means, and the documented one here —
* `utf8mb4_unicode_ci`, in INSTALL.md and in config/database.php — folds
* accents:
*
* administrator@example.com = administrator@éxample.com -> 1
*
* Those are two different domains. `éxample.com` is `xn--xample-9ua.com`,
* a name somebody else can own and prove they own. So an attacker could
* register the second at an OIDC provider, verify it honestly, sign in,
* and be handed the first account — no password, no interaction from its
* owner, and an administrator session if that account was one
* (GHSA-wgxf-v8cr-37mj).
*
* ### Loose is right when refusing and wrong when selecting
*
* The same looseness protects elsewhere and is deliberately left alone.
* `AvailableEmailRule` and `ClientProvisioning::emailIsAvailable()` ask
* "is this address free?", and a collation that answers "no" to a
* near-miss refuses *more* registrations, which is the safe direction.
* This class is for the other question — "which account is this?" — where
* matching more than you meant hands somebody an account.
*
* ### Why the filtering is in PHP
*
* A `COLLATE utf8mb4_bin` in the query would work on MySQL and break
* everywhere else, and the test suite runs on SQLite, which is byte-exact
* and would never have shown the bug in the first place. Comparing here
* gives one answer on every driver, and it is the answer that does not
* depend on how somebody created their database.
*
* Case is still folded, because that is a real requirement rather than an
* accident: addresses are stored lowercased and a provider may send any
* case. `mb_strtolower` folds case without folding accents, which is
* exactly the line to draw.
*/
class AccountLookup
{
/**
* The account whose address is exactly this one, or null.
*
* @param bool $withTrashed include soft-deleted accounts — a
* deleted account still holds its address
* until erasure
*/
public function byEmail(string $email, bool $withTrashed = false): ?User
{
$query = $withTrashed ? User::withTrashed() : User::query();
// The database narrows, this decides. A collation that matches too
// much returns extra rows here and they are dropped; one that
// matches too little was never going to return the right row at
// all, which is a different bug and not one anybody has.
return $query->where('email', $email)->get()
->first(fn (User $user): bool => $this->isSameAddress($user->email, $email));
}
/**
* Whether two strings name the same mailbox: case-insensitively, and
* byte-exact about everything else.
*/
public function isSameAddress(?string $stored, ?string $given): bool
{
if ($stored === null || $given === null) {
return false;
}
return mb_strtolower(trim($stored), 'UTF-8') === mb_strtolower(trim($given), 'UTF-8');
}
}
@@ -8,7 +8,6 @@ use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Identity\Erasure\AvailableEmailRule;
use App\Modules\Identity\FirstAdministrator;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\UserType;
@@ -31,14 +30,7 @@ class CreateAdminCommand extends Command
public function handle(): int
{
$ifNone = (bool) $this->option('if-none');
// Asked early so an unattended boot does not prompt for a name and
// a password it is about to throw away. It is asked again below,
// under a lock, because this read on its own has the same hole the
// setup screen had: two containers coming up against one database
// both see no staff and both create an administrator.
if ($ifNone && $this->staffExists()) {
if ($this->option('if-none') && User::query()->where('type', UserType::Staff)->exists()) {
$this->info('A staff user already exists; nothing to do.');
return self::SUCCESS;
@@ -65,36 +57,15 @@ class CreateAdminCommand extends Command
return self::FAILURE;
}
$create = function () use ($name, $email, $password): User {
$user = User::create([
'type' => UserType::Staff,
'active' => true,
'role_id' => Role::query()->where('name', SystemRole::SystemAdministrator->value)->value('id'),
'name' => $name,
'email' => $email,
'password' => $password,
]);
// forceFill, for the reason SetupController gives beside it:
// email_verified_at is not in User::$fillable, so passing it
// into create() lost it without a word. Whoever provisioned
// this container supplied the address themselves.
$user->forceFill(['email_verified_at' => now()])->save();
return $user;
};
// Without --if-none an operator is asking for an administrator
// outright, whoever else exists, so there is nothing to claim.
$user = $ifNone
? FirstAdministrator::claim(fn (): bool => ! $this->staffExists(), $create)
: $create();
if ($user === null) {
$this->info('A staff user already exists; nothing to do.');
return self::SUCCESS;
}
$user = User::create([
'type' => UserType::Staff,
'active' => true,
'role_id' => Role::query()->where('name', SystemRole::SystemAdministrator->value)->value('id'),
'name' => $name,
'email' => $email,
'password' => $password,
'email_verified_at' => now(),
]);
app(ActivityLogger::class)->log(Action::UserCreated, null, $user);
@@ -113,12 +84,4 @@ class CreateAdminCommand extends Command
return self::SUCCESS;
}
/**
* @phpstan-impure another process can create one between two calls
*/
private function staffExists(): bool
{
return User::query()->where('type', UserType::Staff)->exists();
}
}
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Modules\Identity\Console;
use App\Models\User;
use App\Modules\Identity\AccountLookup;
use App\Modules\Identity\Erasure\AccountEraser;
use Illuminate\Console\Command;
@@ -26,10 +25,7 @@ class EraseAccountCommand extends Command
{
$email = (string) $this->argument('email');
// Exact: this deletes somebody permanently, and a collation that
// folds accents could hand it a different account than the one an
// operator typed. See AccountLookup.
$user = app(AccountLookup::class)->byEmail($email, withTrashed: true);
$user = User::withTrashed()->where('email', $email)->first();
if ($user === null) {
$this->error("No account found for {$email}.");
@@ -9,7 +9,6 @@ use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\DeletedAccountContent;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Support\Facades\DB;
@@ -103,24 +102,6 @@ class AccountEraser
$this->activity->logSystem(Action::AccountContentCascadeDeleted, ['name' => $user->name, ...$result]);
}
/**
* The account configured to inherit erased content, or null when
* there is nobody valid to hand it to — in which case handleContent()
* cascades, because orphaning is never the answer.
*
* **A staff member's content may only go to staff.** The target is one
* installation-wide id used for every erasure, and the picker offers
* clients on purpose: erasing a client and handing their files to
* another client is what the setting is for. Applied to a *staff*
* account the same id means something else entirely — a staff library
* is usually the whole installation's, and a client named there would
* inherit all of it, in one unattended scheduled job.
*
* The check cannot live in the settings validation, which is where it
* would otherwise belong: that runs when the target is chosen, and
* whose account will be erased later is not knowable then. So it is
* asked here, where both halves are in hand.
*/
private function fallbackFor(User $user): ?User
{
$id = (int) $this->settings->get(Setting::AccountErasureReassignTo);
@@ -132,7 +113,6 @@ class AccountEraser
return User::query()
->where('active', true)
->whereKeyNot($user->id)
->when($user->isStaff(), fn ($query) => $query->where('type', UserType::Staff))
->find($id);
}
}
@@ -1,65 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Identity;
use App\Models\User;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\EnsureSystemRoles;
use App\Modules\Identity\Permissions\SystemRole;
use Illuminate\Support\Facades\DB;
/**
* Creating the very first administrator is a claim, not a check followed
* by an insert.
*
* "Has this installation been set up" is answered by asking whether any
* staff row exists, and an empty result has nothing in it to lock. So two
* unauthenticated setup requests arriving together both read "no staff",
* both spend a quarter of a second hashing a password, and both insert a
* System Administrator. The operator's own setup succeeds and looks
* entirely normal, which is the point: a stranger walks away with a
* second, permanent administrator account and nothing says so.
* (GHSA-w3w9-prpw-qx77, reported by @ry2811.)
*
* What gets locked is the System Administrator role row. It is the thing
* being claimed; it is written by the roles migration and rewritten on
* every boot, so unlike the staff rows it is always there to be locked.
* The second caller waits on it, and by the time it has the lock the
* first caller's user row is committed and visible — so its own re-check,
* asked inside the claim this time, sees an installation that is already
* set up and creates nothing.
*
* Locking the staff query itself would not do. There are no matching rows
* on a fresh install, and a lock over nothing serialises nothing.
*/
final class FirstAdministrator
{
/**
* Create the initial administrator, or nothing if somebody else got
* there first.
*
* @param callable(): bool $stillNeeded asked again with the claim held
* @param callable(): User $create runs only if it is still needed
* @return User|null null when the claim was lost
*/
public static function claim(callable $stillNeeded, callable $create): ?User
{
// The lock needs a row to bite on. This is idempotent and already
// runs on every boot; asking again costs one query on the one
// request in the life of an installation that comes through here,
// and means a database somehow missing its roles gets them back
// rather than quietly racing.
(new EnsureSystemRoles)->ensure();
return DB::transaction(function () use ($stillNeeded, $create): ?User {
Role::query()
->where('name', SystemRole::SystemAdministrator->value)
->lockForUpdate()
->value('id');
return $stillNeeded() ? $create() : null;
});
}
}
@@ -13,9 +13,6 @@ 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;
@@ -34,7 +31,6 @@ 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
@@ -79,9 +75,6 @@ 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),
]);
}
@@ -92,11 +85,9 @@ 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);
@@ -104,7 +95,6 @@ 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'] ?? []);
@@ -125,35 +115,17 @@ 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) {
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.'));
throw ValidationException::withMessages([
'permissions' => __('The administrator role always has every permission and cannot be edited.'),
]);
}
$validated = $request->validate([
@@ -161,12 +133,8 @@ 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) {
@@ -190,10 +158,6 @@ 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)),
@@ -294,36 +258,6 @@ 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,7 +8,6 @@ use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Identity\FirstAdministrator;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\UserType;
@@ -55,46 +54,17 @@ class SetupController extends Controller
'password' => ['required', 'confirmed', Password::defaults()],
]);
// The check above is not enough on its own: it is a plain read, and
// between it and the insert a second setup request can do the same
// read and insert an administrator of its own. Everything this
// request writes therefore happens inside the claim, so a request
// that loses the race writes nothing at all — not the site name
// either. See FirstAdministrator.
$admin = FirstAdministrator::claim(
fn (): bool => ! $this->setupIsComplete(),
function () use ($validated): User {
$this->settings->set(Setting::SiteName, $validated['site_name']);
$this->settings->set(Setting::SiteName, $validated['site_name']);
$admin = User::create([
'type' => UserType::Staff,
'active' => true,
'role_id' => Role::query()->where('name', SystemRole::SystemAdministrator->value)->value('id'),
'name' => $validated['name'],
'email' => $validated['email'],
'password' => $validated['password'],
]);
// forceFill, not part of the create() array:
// email_verified_at is deliberately absent from
// User::$fillable, so mass assignment dropped it in silence
// and this account was never marked verified. The first
// administrator typed their own address into the form in
// front of them; there is nobody to confirm it to. (Inert
// today, since MustVerifyEmail is not enabled on the model,
// but the column is what a later switch would read.)
$admin->forceFill(['email_verified_at' => now()])->save();
return $admin;
},
);
// Somebody else finished setup while this request was in flight.
// Theirs is the administrator that exists; this one is sent to the
// login screen like any other visitor to an installed site.
if ($admin === null) {
return redirect()->route('home');
}
$admin = User::create([
'type' => UserType::Staff,
'active' => true,
'role_id' => Role::query()->where('name', SystemRole::SystemAdministrator->value)->value('id'),
'name' => $validated['name'],
'email' => $validated['email'],
'password' => $validated['password'],
'email_verified_at' => now(),
]);
// v1 logged installation as action 0; setup is a recorded action.
$this->activity->log(Action::SetupCompleted, $admin);
@@ -134,9 +104,6 @@ class SetupController extends Controller
* The middleware and this must agree — one of them saying "not set
* up" while the other says "set up" is either a redirect loop or an
* open form.
*
* @phpstan-impure asking twice can honestly give two answers, which is
* the entire reason store() asks a second time under a lock
*/
private function setupIsComplete(): bool
{
@@ -8,7 +8,6 @@ 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;
@@ -41,7 +40,6 @@ class SocialLoginController extends Controller
private readonly SocialAuthenticator $authenticator,
private readonly SignIn $signIn,
private readonly ActivityLogger $activity,
private readonly StartPages $startPages,
) {}
/**
@@ -130,7 +128,7 @@ class SocialLoginController extends Controller
$request->session()->regenerate();
return redirect()->intended($this->startPages->pathFor($resolution->user));
return redirect()->intended(route('dashboard', absolute: false));
}
private function begin(Request $request, string $provider, string $intent): Response

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