mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-03 21:03:17 +00:00
Compare commits
163 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fcfbb5c41 | |||
| e9b71993f5 | |||
| 33bc90c9ef | |||
| 70dc725858 | |||
| a5b6538b31 | |||
| 48a1c9f227 | |||
| 185c46fff1 | |||
| a8adf6f614 | |||
| f9e08412f2 | |||
| 9c26d46374 | |||
| 60c82afe5a | |||
| f24a8587b9 | |||
| a640bf81ed | |||
| a9b17ddc1e | |||
| 24a94d3beb | |||
| 27f994263f | |||
| 8180a66243 | |||
| 1d483f6a81 | |||
| bd26740390 | |||
| 37c9cb839f | |||
| 1b3f014f5f | |||
| c795c58963 | |||
| acab833b72 | |||
| a150dc4955 | |||
| bccf3d1f29 | |||
| 4524b75c9d | |||
| ca85c8a7e3 | |||
| 42721b1bab | |||
| ac5803b773 | |||
| 86edbc640d | |||
| c9a4b5520d | |||
| 0a7330d5cd | |||
| 3a3fd5358d | |||
| a45eae315c | |||
| 5902e7ab32 | |||
| d3f213da16 | |||
| 60171799e7 | |||
| 849ec5f3e8 | |||
| 4905be8e32 | |||
| cf1cd3ab9a | |||
| 51035994ae | |||
| ff9ad10742 | |||
| 305c79bc96 | |||
| 521927a3bb | |||
| 8fdc8b0102 | |||
| 7ce1e3487f | |||
| 419ecea3b0 | |||
| 7762755a2d | |||
| 62c2ddfdd3 | |||
| 99c8c469c5 | |||
| 963e36397d | |||
| ba99674cc7 | |||
| 783536be40 | |||
| 3b5352d886 | |||
| 6e5edfa7ad | |||
| c15c9c48f8 | |||
| 5e6b792104 | |||
| 616a355d54 | |||
| 044afe5fcb | |||
| a255a883a8 | |||
| 5f7e3089eb | |||
| 7119435c3c | |||
| 25b92c086b | |||
| afb4c2c6d4 | |||
| 0b36cf2c38 | |||
| d445b01dd4 | |||
| f937b4398d | |||
| dc0937fda1 | |||
| c2039d9608 | |||
| da79969435 | |||
| 85b1650ef0 | |||
| f2a7bbb182 | |||
| 73d5a5f8e4 | |||
| c0494c6b4b | |||
| 5493955bea | |||
| 0ae3f3d0f0 | |||
| d11bda094b | |||
| b6b777e42f | |||
| 7944eccaf1 | |||
| e9496dc357 | |||
| bab90c0ad8 | |||
| 5f414c7a4a | |||
| 5966d22f50 | |||
| 9b99972a1a | |||
| 2768c87b27 | |||
| b0a95f953d | |||
| 3917cb2af3 | |||
| 495f3ae471 | |||
| c21658f6f7 | |||
| 7c7ba7cd53 | |||
| f06a3c7ab3 | |||
| 123ae68972 | |||
| 1577862099 | |||
| fb3fd1766c | |||
| 073bf8b853 | |||
| 38187dcf1a | |||
| a502a26075 | |||
| 74d1de2d6e | |||
| cd2ce960d3 | |||
| 856c13b09c | |||
| b8050b36ca | |||
| da5aadd1f3 | |||
| 386cb32ebb | |||
| 38400956bc | |||
| 8aef6e5b5a | |||
| 8372f42525 | |||
| 50f8b578df | |||
| 6ad26bb61e | |||
| 83a8fe2288 | |||
| 62c763d04e | |||
| 0671848bfa | |||
| 469297893b | |||
| eaba7ff633 | |||
| 6339ae1514 | |||
| 7c4b582d25 | |||
| b96d060ad8 | |||
| 6d7d80f62f | |||
| bc559ade3f | |||
| df44c46a12 | |||
| a1f59e133b | |||
| 0a3410140d | |||
| 80cf99d80e | |||
| cbc6760a93 | |||
| d8ca41ae0c | |||
| 1149df277b | |||
| ab5fa2da8b | |||
| 3244be6bac | |||
| 5fb17388cd | |||
| 2be423d685 | |||
| 6560346280 | |||
| e187513cdd | |||
| 896675d631 | |||
| 92bb807849 | |||
| 0a28e239d6 | |||
| 3d923188d9 | |||
| b128b114b5 | |||
| 757fba19ca | |||
| 763e7b0e2e | |||
| a5496d24cd | |||
| fba5f30436 | |||
| 0f66f9030c | |||
| 7c16733c16 | |||
| d7d7acce85 | |||
| 334b11d562 | |||
| da1f432d87 | |||
| 82dd475f8f | |||
| b758fca19c | |||
| 02946abf85 | |||
| b7ac44e77b | |||
| 50a6a19455 | |||
| 8de28059db | |||
| ea214fc27e | |||
| 922be7226c | |||
| 1e30e83f11 | |||
| d32788e4a1 | |||
| c3503a0651 | |||
| 51477cbd02 | |||
| 7c9847981a | |||
| 7da4635f13 | |||
| ddf09677f0 | |||
| 9b2aea4812 | |||
| 5a9133bb07 | |||
| f2b705beee |
@@ -4,6 +4,11 @@ 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
|
||||
@@ -15,6 +20,20 @@ PROJECTSEND_EDITION=community
|
||||
# stream deliberately. The dashboard's System panel shows which is in use.
|
||||
# PROJECTSEND_FILE_DELIVERY=auto
|
||||
|
||||
# Optional: the virus scanner every upload is checked against, as
|
||||
# tcp://host:3310 or unix:///path/to/clamd.sock. Naming it here makes
|
||||
# scanning managed: it is used, it cannot be switched off from the settings
|
||||
# screen, and the address does not appear there. Leave it unset to
|
||||
# configure scanning in Settings instead, which is the ordinary way.
|
||||
# PROJECTSEND_SCANNER_ADDRESS=tcp://clamav:3310
|
||||
|
||||
# Optional: the scanner a fresh installation starts out pointed at, written
|
||||
# into the settings on first boot and ignored on every later one. Unlike the
|
||||
# variable above it leaves both the address and the switch on the settings
|
||||
# screen, which is what a self-hosted install wants: configured out of the
|
||||
# box, and still yours.
|
||||
# PROJECTSEND_SCANNER_DEFAULT_ADDRESS=tcp://clamav:3310
|
||||
|
||||
# Optional: uid/gid the app/web containers' internal user runs as, so the
|
||||
# bind-mounted repo needs no permission fixes. Defaults to 1000; override
|
||||
# if your host user's `id -u`/`id -g` differ.
|
||||
|
||||
@@ -41,11 +41,18 @@ jobs:
|
||||
# `issue.pull_request` is present only when the comment is on a pull
|
||||
# request; comments on ordinary issues have nothing for this action to
|
||||
# check.
|
||||
#
|
||||
# Loose on purpose, never `==`. This only decides whether a runner
|
||||
# starts; whether a comment is a signature is decided by the action,
|
||||
# strictly, against `custom-pr-sign-comment` below. An exact match here
|
||||
# threw away a real signature that arrived with trailing line breaks
|
||||
# ("...sign the CLA\r\n\r\n"), which the action -- it trims first --
|
||||
# would have accepted. `contains` and `startsWith` ignore case.
|
||||
if: >-
|
||||
github.event_name == 'pull_request_target'
|
||||
|| (github.event.issue.pull_request
|
||||
&& (github.event.comment.body == 'recheck'
|
||||
|| github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA'))
|
||||
&& (startsWith(github.event.comment.body, 'recheck')
|
||||
|| contains(github.event.comment.body, 'I have read the CLA Document and I hereby sign the CLA')))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: CLA check
|
||||
@@ -74,6 +81,12 @@ jobs:
|
||||
Please read the **[CLA]($pathToCLADocument)**, then post exactly this as a comment
|
||||
on this pull request:
|
||||
|
||||
# Not decoration, although it repeats the action's default phrase.
|
||||
# Set, it makes the action compare the whole comment, trimmed and
|
||||
# lowercased, against it. Unset, the action searches the comment
|
||||
# for the phrase instead, and "I LIE, I have read the CLA Document
|
||||
# and I hereby sign the CLA. I do not sign it" on one line would
|
||||
# be recorded as a signature.
|
||||
custom-pr-sign-comment: 'I have read the CLA Document and I hereby sign the CLA'
|
||||
custom-allsigned-prcomment: 'CLA signed — thanks. A maintainer will review this shortly.'
|
||||
lock-pullrequest-aftermerge: false
|
||||
|
||||
@@ -39,6 +39,7 @@ yarn-error.log
|
||||
/database/seeders/DevDataSeeder.php
|
||||
/docs/*.md
|
||||
!/docs/api-guide.md
|
||||
!/docs/api-modules.md
|
||||
!/docs/email-oauth.md
|
||||
!/docs/api-zapier.md
|
||||
|
||||
|
||||
+244
-4
@@ -6,12 +6,252 @@ Versions follow [SemVer](https://semver.org/): the middle number moves when ther
|
||||
the last one when there are only fixes, and the first one when an upgrade needs more from you than
|
||||
dropping in the new files and running the migrations.
|
||||
|
||||
Anything under **Upgrade notes** is something you have to do, not something we did.
|
||||
Anything under **⚠️ Important — do these yourself** is something you have to do, not something
|
||||
we did. It sits at the top of a release for that reason. Older entries call the same section
|
||||
**Upgrade notes**.
|
||||
|
||||
## Unreleased
|
||||
## 2.6.0 — 25 September 2026
|
||||
|
||||
This section collects changes as they land; the release process turns it into a numbered entry
|
||||
when a version is cut.
|
||||
Mostly fixes: files and folders are easier to tidy, the sign-in pages carry your brand better, and
|
||||
someone who deletes their own account stops being published straight away.
|
||||
|
||||
**Added**
|
||||
|
||||
- **Delete several files at once** from the selection bar on the Files page, with one confirmation.
|
||||
- **Upload inside a folder puts the files in that folder**, and brings you back to it afterwards.
|
||||
- **Show your site name under the logo** on the sign-in and download pages, from Branding → Logo.
|
||||
- **Choose what happens to someone's files when they delete their own account**: removed right away,
|
||||
or at the end of the grace period — and whether that applies to everyone or only to clients.
|
||||
Found under Settings → Privacy.
|
||||
|
||||
**Changed**
|
||||
|
||||
- **A deleted account's files stop being shared at once.** From the moment someone deletes their own
|
||||
account, their files are visible only to staff — not to the clients and groups they were shared
|
||||
with, not through share links, not on the public pages — until the account is erased. Restoring
|
||||
the account brings them back.
|
||||
- **Your logo is shown larger on the sign-in and download pages**, so a square logo is clearly
|
||||
visible. The Branding screen now says what size to use.
|
||||
|
||||
**Fixed**
|
||||
|
||||
- Saving a settings form could show an error dialog containing `{"count":0}` instead of saving.
|
||||
- A file upload running several parts at once could be refused near the end with "too large".
|
||||
- The System card reported the server's free disk space as file storage on installations that keep
|
||||
files in S3; it now shows the two separately.
|
||||
- Confirming your password no longer throws away the form you were filling in.
|
||||
- LDAP accounts can now get past the password confirmation, which kept them from turning on
|
||||
two-factor authentication.
|
||||
- With per-client folders on, a client choosing "No folder" moved the file out of their own folder.
|
||||
- PHP 8.5 no longer prints deprecation warnings from the database configuration.
|
||||
- The Docker quick start now saves `compose.yaml`, so the `docker compose` commands in the other
|
||||
guides work as written. If you saved `compose.example.yaml`, rename it to `compose.yaml`.
|
||||
- The Docker and migration guides now cover installing Docker, and migrating from a Legacy install
|
||||
on the same machine.
|
||||
|
||||
Thanks to [@JensS](https://github.com/JensS), binghuo, [@lukatong](https://github.com/lukatong),
|
||||
[@jjoelc](https://github.com/jjoelc), [@jiits](https://github.com/jiits),
|
||||
[@0xVavaldi](https://github.com/0xVavaldi) and [@lolgufdHD](https://github.com/lolgufdHD) for
|
||||
reporting and fixing.
|
||||
|
||||
### Issues closed since 2.5.0
|
||||
|
||||
- [#1635](https://github.com/projectsend/projectsend/issues/1635) — Docs: consider moving the Docker quick start above screenshots
|
||||
- [#1795](https://github.com/projectsend/projectsend/issues/1795) — Slightly confused regarding the reviews
|
||||
- [#1796](https://github.com/projectsend/projectsend/issues/1796) — PHP 8.5.10: PDO::MYSQL_ATTR_SSL_CA Deprecated Warning
|
||||
- [#1799](https://github.com/projectsend/projectsend/issues/1799) — Inertia error from notifications/unread-count because of JSON response
|
||||
- [#1800](https://github.com/projectsend/projectsend/issues/1800) — Add Bulk-Edit for Moving and Deleting files
|
||||
- [#1801](https://github.com/projectsend/projectsend/issues/1801) — Add Upload into Folder
|
||||
|
||||
## 2.5.0 — 18 September 2026
|
||||
|
||||
**Added**
|
||||
|
||||
- **Uploaded files can be checked for viruses before anyone can download them.**
|
||||
- **Files the virus scanner refuses go to a Quarantine screen, where you can read the verdict and
|
||||
release a file if the report is wrong.**
|
||||
- **You choose what happens to a file the scanner cannot open, and to uploads arriving while the
|
||||
scanner is unreachable.**
|
||||
- **A Test button says which scanner answered and how old its virus definitions are, and fails a
|
||||
scanner that reports password-protected archives as clean.**
|
||||
- **The dashboard says when virus scanning has stopped protecting anything.**
|
||||
- **Files uploaded before scanning was switched on can be worked through in the background, at a
|
||||
pace you set.**
|
||||
- **Invite a client to set their own password instead of handing them one.** Contributed by
|
||||
[@mash2k3](https://github.com/mash2k3).
|
||||
- **Staff who administer clients are notified in the bell when a client account appears.**
|
||||
- **Client accounts can expire on a date.** Requested by
|
||||
[@Drardollan](https://github.com/Drardollan) in
|
||||
[#1310](https://github.com/projectsend/projectsend/issues/1310).
|
||||
- **Each role, and each person, can choose where they land after signing in.** Requested by
|
||||
[@Zodiac1978](https://github.com/Zodiac1978) in
|
||||
[#1777](https://github.com/projectsend/projectsend/issues/1777).
|
||||
- **Each client can be given a folder of their own, which becomes their root.**
|
||||
- **The file library filters by uploader, by the uploader's role, by public or private, by whether
|
||||
a file was ever downloaded, and by current or outdated version.**
|
||||
- **The same five filters are available through the API.**
|
||||
- **Files whose bytes have gone missing from storage are found daily and listed on their own
|
||||
screen.**
|
||||
- **Clients see on each file the date it stops being available.**
|
||||
- **Clients can make a public link for a file they uploaded**, where their role may publish files —
|
||||
the switch that marks a file public now comes with the link it promises, and they can copy or
|
||||
revoke it. Reported by Ricardo Cazati.
|
||||
- **A public page says when the file on it was never checked for viruses**, which happens where an
|
||||
installation scans and chooses to let through what the scanner could not read.
|
||||
|
||||
**Closed holes in who can see what**
|
||||
|
||||
- **A staff member limited to certain clients can no longer reach past that limit through an
|
||||
invitation.** The invitation form listed every group on the installation, and an invited client
|
||||
could be pre-assigned into a group outside that staff member's own clients. Reported by
|
||||
[@hackchang](https://github.com/hackchang).
|
||||
|
||||
**Fixed**
|
||||
|
||||
- **Setting a folder inside the bucket no longer breaks every page that touches storage.** Reported
|
||||
by [@veenone](https://github.com/veenone) in
|
||||
[#1788](https://github.com/projectsend/projectsend/issues/1788).
|
||||
- **The Microsoft sign-in settings now say why new accounts still wait for approval.** They wait
|
||||
until the `xms_edov` optional claim is added to the app registration, because until then Microsoft
|
||||
does not confirm that the person owns the address — which is a rule ProjectSend already had and
|
||||
nothing on screen said. Reported by Ricardo Cazati.
|
||||
- **Someone who signs in with Microsoft, Google or another provider can now set a password.** The
|
||||
screen asked for the current one, which such an account never had — so it could not get a
|
||||
password, and therefore could not turn on two-factor authentication. Where two-factor is
|
||||
compulsory, that locked those accounts out of everything. Reported by Ricardo Cazati.
|
||||
- **Creating a client with a storage quota typed in no longer answers with an error page.**
|
||||
- **A file expiry date sent to the API as a number now answers 422 instead of 500.**
|
||||
- **A quarantined file, or one missing from storage, is no longer listed in the library with a
|
||||
download that cannot work.**
|
||||
- A dependency advisory in the bundled `js-yaml`.
|
||||
|
||||
### Issues closed since 2.4.1
|
||||
|
||||
- [#1310](https://github.com/projectsend/projectsend/issues/1310) — expire client accounts
|
||||
- [#1658](https://github.com/projectsend/projectsend/issues/1658) — Docker services have no restart
|
||||
policy
|
||||
- [#1770](https://github.com/projectsend/projectsend/issues/1770) — Docker upgrade fails with
|
||||
external storage configured
|
||||
- [#1776](https://github.com/projectsend/projectsend/issues/1776) — logo gives error 404
|
||||
- [#1777](https://github.com/projectsend/projectsend/issues/1777) — customization / branding
|
||||
(start pages)
|
||||
- [#1778](https://github.com/projectsend/projectsend/issues/1778) — error 500 on install
|
||||
- [#1779](https://github.com/projectsend/projectsend/issues/1779) — client invitations
|
||||
- [#1788](https://github.com/projectsend/projectsend/issues/1788) — a bucket folder makes every
|
||||
storage page a 500
|
||||
- [#1789](https://github.com/projectsend/projectsend/issues/1789) — S3 not working after an update
|
||||
|
||||
## 2.4.1 — 11 September 2026
|
||||
|
||||
### ⚠️ Important — do these yourself
|
||||
|
||||
Everything else in this release happens on its own. These do not: each one leaves something working
|
||||
differently from how you expect until you act on it. Nothing here stops the upgrade or the
|
||||
installation from starting.
|
||||
|
||||
- **If you set `PROJECTSEND_CAPTCHA_DISABLED`, check what you set it to.** Only `true` or `1`
|
||||
switches the CAPTCHA off now. Anything else — including `no`, `off`, `yes` and a misspelling —
|
||||
used to be read as "yes, disabled" and is now read as "leave it on". If you meant it off, write
|
||||
`true`.
|
||||
- **If a staff role uploads into public folders, give it "Upload to public folders".** That
|
||||
permission was not being asked of staff, and now is. Roles holding "Upload public files" are
|
||||
unaffected, and ordinary uploads need nothing new.
|
||||
- **If you use Microsoft sign-in, add the `xms_edov` optional claim to your app registration.** In
|
||||
the Entra portal: your app registration → Token configuration → Add optional claim → ID →
|
||||
`xms_edov`. Until you do, Microsoft sign-in keeps working and keeps creating new accounts, but it
|
||||
will no longer attach itself to an account that already exists.
|
||||
|
||||
**Added**
|
||||
|
||||
- **Your logo now appears on the sign-in screen.** Requested by
|
||||
[@Zodiac1978](https://github.com/Zodiac1978) in
|
||||
[#1777](https://github.com/projectsend/projectsend/issues/1777).
|
||||
- **An installation on AWS can authenticate as its own IAM role instead of storing an access key.**
|
||||
- **Clients can now see how often their own files were downloaded, and when.**
|
||||
|
||||
**Fixed**
|
||||
|
||||
- **A lookalike domain can no longer hand somebody else's account to an OIDC sign-in.** Reported by
|
||||
[@choewonwoo1817](https://github.com/choewonwoo1817).
|
||||
- **Changing your own email address now asks for your password.** Reported by
|
||||
[@Noorkhalel](https://github.com/Noorkhalel).
|
||||
- **Microsoft sign-in now checks that the person owns the address they presented.** Reported by
|
||||
[@archnexus707](https://github.com/archnexus707).
|
||||
- **Two people filling in the first-run setup screen at the same moment can no longer both become
|
||||
administrators.** Reported by [@ry2811](https://github.com/ry2811).
|
||||
- **Uploading into a public folder now needs a permission that says so.** Reported by
|
||||
[@skeletonsec](https://github.com/skeletonsec).
|
||||
- **Moving a file into a public folder now needs that same permission.** Reported by
|
||||
[@skeletonsec](https://github.com/skeletonsec).
|
||||
- **A staff member limited to some clients can no longer see or change other people's groups.**
|
||||
Reported by [@Drescargot](https://github.com/Drescargot).
|
||||
- **Deleting a client can no longer hand their files to a client you do not manage.** Reported by
|
||||
[@skeletonsec](https://github.com/skeletonsec).
|
||||
- **Erasing a staff account no longer hands their files to a client.**
|
||||
- **An interrupted upload can no longer park unlimited bytes on the server.** Reported by
|
||||
[@ry2811](https://github.com/ry2811).
|
||||
- **The password reset screen no longer says whether an email address has an account here.**
|
||||
- **An expired password reset link now says so before asking for a new password.**
|
||||
- **A Docker upgrade no longer fails when external storage is already configured.**
|
||||
[#1770](https://github.com/projectsend/projectsend/issues/1770).
|
||||
- **A public gallery no longer renders the same thumbnail several times at once.**
|
||||
- **`PROJECTSEND_CAPTCHA_DISABLED` no longer reads a "no" as a "yes".**
|
||||
|
||||
### Issues closed since 2.4.0
|
||||
|
||||
The summary above is what changed. This is the paper trail, for anyone who wants to read the
|
||||
original report.
|
||||
|
||||
- [#1768](https://github.com/projectsend/projectsend/issues/1768) — Search in file not restricted in directory
|
||||
- [#1773](https://github.com/projectsend/projectsend/issues/1773) — Feature Request : Support AWS IAM roles / default credential provider chain for S3 storage
|
||||
- [#1774](https://github.com/projectsend/projectsend/issues/1774) — HTTP Error by upload on R2098
|
||||
- [#1778](https://github.com/projectsend/projectsend/issues/1778) — [Documentation] Error 500 on install
|
||||
|
||||
## 2.4.0 — 8 September 2026
|
||||
|
||||
Clients can now look after the files they uploaded, and this release closes three ways somebody
|
||||
could see a little more than they should.
|
||||
|
||||
**New**
|
||||
|
||||
- **Clients can edit and delete the files they uploaded**, with the name, description, expiry,
|
||||
categories, download limit and public flag each behind the permission that already governs it.
|
||||
A file shared *with* a client is still not theirs to touch.
|
||||
- **A switch to stop this installation fetching the project news**, on Settings → General. On by
|
||||
default; off means the request is never made.
|
||||
|
||||
**Closed holes in who can see what**
|
||||
|
||||
- A staff member limited to their assigned clients could read other clients' names, and their IDs,
|
||||
out of file details and the uploader filter. Reported by
|
||||
[@Noorkhalel](https://github.com/Noorkhalel) (GHSA-whmp-p9hv-r7j7).
|
||||
- Download links to external storage now last a minute instead of an hour. Previews keep the hour.
|
||||
- Eight advisories in bundled dependencies, including an XSS bypass in the markdown renderer that
|
||||
builds your email templates.
|
||||
|
||||
**Fixed**
|
||||
|
||||
- A failed upload keeps its parts, so retrying it works instead of needing the whole file again.
|
||||
- `projectsend:captcha-off` no longer claims success on an installation whose CAPTCHA keys are
|
||||
supplied centrally, where it changed nothing.
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
- **Resuming an interrupted download from external storage more than a minute after it started now
|
||||
fails.** Start it again from ProjectSend. Local-disk installations and zip bundles are unaffected.
|
||||
- **If your temporary directory is on a small or separate volume, allow headroom for twice your
|
||||
largest allowed upload.** Only while a file is being assembled, and nothing needs configuring.
|
||||
|
||||
Thanks to [@Noorkhalel](https://github.com/Noorkhalel), [@denkfabrik-li](https://github.com/denkfabrik-li)
|
||||
and [@mehmedturk](https://github.com/mehmedturk) for reporting and fixing.
|
||||
|
||||
### Issues closed since 2.3.0
|
||||
|
||||
The summary above is what changed. This is the paper trail, for anyone who wants to read the
|
||||
original report.
|
||||
|
||||
- [#1765](https://github.com/projectsend/projectsend/issues/1765) — Projectsend 2.2.1 thumbnail issue after file upload
|
||||
- [#1771](https://github.com/projectsend/projectsend/issues/1771) — Permissions granted to the Client role are not applied to client accounts
|
||||
|
||||
## 2.3.0 — 1 September 2026
|
||||
|
||||
|
||||
@@ -218,7 +218,7 @@ its own directory the first time it starts.
|
||||
|
||||
### 2. Point the compose file at them
|
||||
|
||||
`compose.example.yaml` is yours — you downloaded and edited it — so change the volumes in place
|
||||
Your `compose.yaml` is yours — you downloaded and edited it — so change the volumes in place
|
||||
rather than layering an override on top:
|
||||
|
||||
```yaml
|
||||
@@ -362,6 +362,33 @@ restored is a hypothesis, not a backup.
|
||||
|
||||
---
|
||||
|
||||
## Virus scanning
|
||||
|
||||
Uploads can be checked before anybody can download them. The scanner is an extra container, off
|
||||
unless you ask for it:
|
||||
|
||||
```sh
|
||||
docker compose --profile scanner up -d
|
||||
```
|
||||
|
||||
Then go to **System → Settings → Virus scanning**, switch it on, and use `tcp://clamav:3310` as the
|
||||
address. On a brand-new installation you can skip that step: uncomment
|
||||
`PROJECTSEND_SCANNER_DEFAULT_ADDRESS` in the compose file before the first start and the site comes
|
||||
up already pointed at the scanner. It is a starting value, not a lock — the address and the switch
|
||||
stay on that screen. **Test scanner** sends EICAR, a harmless file made only for testing that every antivirus
|
||||
recognises, and tells you whether it was actually detected. It also sends a password-protected zip, and fails if the scanner calls it clean.
|
||||
|
||||
The compose file gives the scanner the settings ProjectSend needs, in the `configs` section at the
|
||||
bottom. Keep them if you change that file. On its own defaults ClamAV reports an archive it cannot
|
||||
open as clean, so a password-protected zip would get through unchecked.
|
||||
|
||||
Two things to know before you turn it on. It needs about **1–1.5 GB of memory**, because the virus
|
||||
definitions are held in memory. And the first start downloads those definitions, which takes a few
|
||||
minutes — until it finishes, the scanner does not answer, and the Test button says so.
|
||||
|
||||
The scanner is reachable only from the application's own network. That is deliberate: ClamAV has no
|
||||
password of any kind, so anything that can reach it can use it.
|
||||
|
||||
## Upgrading
|
||||
|
||||
With the data outside the containers, an upgrade touches only the containers:
|
||||
|
||||
+87
-2
@@ -324,8 +324,9 @@ like your logo reachable from the web.
|
||||
|
||||
## Step 6 — Point your web server at it
|
||||
|
||||
A complete nginx server block. Change `server_name`, and change `/var/www/projectsend` to wherever
|
||||
you unpacked the files (there are **three** places, including one inside `/protected-files/`):
|
||||
A complete nginx server block below; [Apache is further down](#if-you-are-using-apache). Change
|
||||
`server_name`, and change `/var/www/projectsend` to wherever you unpacked the files (there are
|
||||
**three** places, including one inside `/protected-files/`):
|
||||
|
||||
```nginx
|
||||
server {
|
||||
@@ -374,6 +375,38 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
### If you are using Apache
|
||||
|
||||
Two things matter, and both are easy to get wrong:
|
||||
|
||||
- **The document root is the `public/` directory**, not the directory you unpacked into. Everything
|
||||
above `public/` — your `.env`, your uploaded files, the application code — has to stay out of
|
||||
reach of any URL.
|
||||
- **`AllowOverride All`, and `mod_rewrite` enabled** (`sudo a2enmod rewrite`). ProjectSend ships a
|
||||
`public/.htaccess` that sends every address to the front controller. If Apache is told to ignore
|
||||
it, every page except the home page is a 404.
|
||||
|
||||
```apache
|
||||
<VirtualHost *:80>
|
||||
ServerName files.example.com
|
||||
DocumentRoot /var/www/projectsend/public
|
||||
|
||||
<Directory /var/www/projectsend/public>
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
ErrorLog ${APACHE_LOG_DIR}/projectsend-error.log
|
||||
CustomLog ${APACHE_LOG_DIR}/projectsend-access.log combined
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
Downloads work as they are: PHP sends the bytes. If that becomes a capacity problem, `mod_xsendfile`
|
||||
hands the job to Apache — see [How downloads are sent](#how-downloads-are-sent).
|
||||
|
||||
On shared hosting you usually cannot edit any of this, and `public/.htaccess` is all you have. If
|
||||
the site returns a 500 on every page, see [When something goes wrong](#when-something-goes-wrong).
|
||||
|
||||
Then check your PHP settings. Large uploads are sent in 20 MB pieces, so PHP never has to handle a
|
||||
whole 5 GB file at once — but the pieces still need room. In your `php.ini`:
|
||||
|
||||
@@ -484,6 +517,36 @@ the place to configure it — the settings screen also has a "send test email" b
|
||||
save you a lot of guessing. The `MAIL_*` values in `.env` are only used until you fill that screen
|
||||
in.
|
||||
|
||||
### Virus scanning
|
||||
|
||||
ProjectSend can check every upload before anyone can download it. It needs ClamAV, which you install
|
||||
from your distribution's packages:
|
||||
|
||||
```sh
|
||||
sudo apt install clamav-daemon # Debian/Ubuntu
|
||||
sudo dnf install clamav-server # Fedora/RHEL
|
||||
```
|
||||
|
||||
Then set these in `/etc/clamav/clamd.conf` (the paths differ per distribution) and restart the
|
||||
daemon:
|
||||
|
||||
```
|
||||
StreamMaxLength 512M
|
||||
AlertExceedsMax yes
|
||||
AlertEncrypted yes
|
||||
AlertEncryptedArchive yes
|
||||
AlertEncryptedDoc yes
|
||||
```
|
||||
|
||||
Those `Alert` lines matter more than they look. Without them ClamAV answers "clean" for a file it
|
||||
could not actually open — an encrypted zip, or one past a size limit — and ProjectSend would record
|
||||
a scan that never happened.
|
||||
|
||||
Log in, go to **System → Settings → Virus scanning**, switch it on, and give it the socket, usually
|
||||
`unix:///var/run/clamav/clamd.ctl`. Press **Test scanner**: it sends EICAR, a harmless file made only for testing,
|
||||
and tells you whether the scanner actually detected it. Scanning happens in the background, so the
|
||||
queue worker below must be running.
|
||||
|
||||
### Redis
|
||||
|
||||
If you have Redis available, it is faster than the database for sessions, cache and queues. Install
|
||||
@@ -581,6 +644,28 @@ names the exact command to run; do that, then reload.
|
||||
Look in `storage/logs/` — open the newest file, the real error is at the bottom. Nine times out of ten it is
|
||||
folder permissions (step 4) or a wrong database password (step 3).
|
||||
|
||||
**Every page is a 500, and `storage/logs/` is empty.**
|
||||
The empty log is the answer, not a dead end: nothing reached PHP, so ProjectSend had nothing to
|
||||
write. The error is your web server's, and it is in your web server's log — on Apache
|
||||
`/var/log/apache2/error.log`, or wherever your host puts it. On Apache two causes account for
|
||||
almost all of these, and both are about `public/.htaccess`:
|
||||
|
||||
- **`Options not allowed here`.** The file starts by turning off directory listings and content
|
||||
negotiation, and your `AllowOverride` does not permit that. Allow it (`AllowOverride All`), or
|
||||
delete the `Options` line — it is hardening, not a requirement.
|
||||
- **`Request exceeded the limit of 10 internal redirects`.** Apache cannot work out which directory
|
||||
the file is serving, so the rule that sends every address to `index.php` rewrites to a path that
|
||||
does not exist, and tries again. Uncomment the `RewriteBase` line in `public/.htaccess` and set it
|
||||
to the path ProjectSend is served from — `/` at the domain root, `/projectsend` in a subdirectory.
|
||||
Reported on IONOS by [@Zodiac1978](https://github.com/Zodiac1978) in
|
||||
[#1778](https://github.com/projectsend/projectsend/issues/1778).
|
||||
|
||||
**If you edit `public/.htaccess`, write down what you changed.** Updating replaces every file the
|
||||
release ships, that one included, so a change that made your site work will be gone after the next
|
||||
update and the 500 will come back. If the Apache configuration is yours to edit, put the directives
|
||||
in a `<Directory>` block in the vhost instead: they do the same job there, and no update can touch
|
||||
them. On shared hosting, where it is not yours, keep the note and re-apply it.
|
||||
|
||||
**"Please provide a valid cache path" or "failed to open stream".**
|
||||
`storage/` or `bootstrap/cache/` is not writable by the web server user. Step 4.
|
||||
|
||||
|
||||
+67
-4
@@ -107,10 +107,13 @@ section on its own.
|
||||
### If you installed from a release zip
|
||||
|
||||
```sh
|
||||
composer require projectsend/v1-migration-tool
|
||||
composer require projectsend/v1-migration-tool --update-no-dev
|
||||
php artisan migrate # creates the tool's two tables
|
||||
```
|
||||
|
||||
`--update-no-dev` keeps Composer from also installing the tools ProjectSend is developed and tested
|
||||
with. A release zip leaves them out, and a server has no use for them.
|
||||
|
||||
That is the whole installation — there is no `npm run build` to run. The zip ships its assets
|
||||
already compiled and deliberately without the toolchain that compiled them, so there is no
|
||||
`package.json` to build from.
|
||||
@@ -177,6 +180,7 @@ are listed at each step.
|
||||
|---|---|
|
||||
| Legacy and ProjectSend are on the **same machine** | [**Direct**](#step-3a--direct-same-machine) |
|
||||
| Legacy is on **another server**, or on hosting you cannot reach from the new box | [**Bundle**](#step-3b--bundle-different-machines) |
|
||||
| Legacy runs on this machine's own web server, and ProjectSend runs **in Docker** | **Bundle** — see [below](#legacy-on-this-machine-projectsend-in-docker) |
|
||||
|
||||
Direct is faster and simpler. It copies your files by default, and it can also *hardlink* them
|
||||
instead when you ask it to — on a single filesystem that writes no bytes at all, so 400 GB migrates
|
||||
@@ -205,8 +209,63 @@ file bytes:
|
||||
| `move` | Takes the bytes out of Legacy. Fast and frees disk — and **cannot be undone** |
|
||||
| `defer` | Writes no bytes at all. For importing the database now and moving half a terabyte overnight |
|
||||
|
||||
If ProjectSend runs in Docker, the Legacy directory has to be visible **inside the app container**
|
||||
— bind-mount it there, and use the container's path, not the host's.
|
||||
If ProjectSend runs in Docker, Direct needs two things the container does not have by default: the
|
||||
Legacy directory mounted inside it, and a way to reach Legacy's database. That database is usually
|
||||
at `localhost` in Legacy's config, and inside a container `localhost` is the container itself.
|
||||
Hardlinks also cannot cross into a mount, so `--files=hardlink` quietly becomes a copy. When
|
||||
Legacy runs on the same machine's own web server, the bundle route below is simpler and needs
|
||||
none of that.
|
||||
|
||||
### Legacy on this machine, ProjectSend in Docker
|
||||
|
||||
The usual shape of an upgrade: Legacy was copied into a LAMP server, and the new install follows
|
||||
[Getting started](README.md#getting-started). Use a bundle. The exporter runs with the PHP your
|
||||
Legacy site already uses, on the host, where `localhost` really is Legacy's database. Nothing has
|
||||
to reach across into the container except one directory at the end.
|
||||
|
||||
Every command below runs from the directory that holds your `compose.yaml`, after the tool is
|
||||
installed as described in [Step 1](#if-you-are-running-the-official-docker-image).
|
||||
|
||||
**1. Take the exporter out of the container:**
|
||||
|
||||
```sh
|
||||
docker compose cp \
|
||||
app:/var/www/html/vendor/projectsend/v1-migration-tool/bin/projectsend-v1-export.php .
|
||||
```
|
||||
|
||||
**2. Export, on the host.** Point `--install` at the directory Legacy runs from, the one that holds
|
||||
`includes/sys.config.php`. `--files=copy` puts the files in the bundle too, so this needs free
|
||||
disk space about the size of Legacy's `upload/files` directory:
|
||||
|
||||
```sh
|
||||
php projectsend-v1-export.php --install=/var/www/projectsend-legacy --preflight
|
||||
php projectsend-v1-export.php --install=/var/www/projectsend-legacy --out=/srv/ps-export --files=copy
|
||||
```
|
||||
|
||||
If `php` says it cannot connect to the database, run it as a user who can read Legacy's config and
|
||||
use the same `php` your web server uses.
|
||||
|
||||
**3. Put the bundle inside the container**, and give it to the user the application runs as:
|
||||
|
||||
```sh
|
||||
docker compose cp /srv/ps-export app:/tmp/ps-export
|
||||
docker compose exec app chown -R www-data:www-data /tmp/ps-export
|
||||
```
|
||||
|
||||
For a very large install, mount it instead of copying it: add `- /srv/ps-export:/tmp/ps-export:ro`
|
||||
under the app service's `volumes:` in `compose.yaml`, and run `docker compose up -d`. Take the line
|
||||
out again when you are done.
|
||||
|
||||
**4. Carry on from [Step 4](#step-4--read-the-preflight)** with the bundle's path inside the
|
||||
container:
|
||||
|
||||
```sh
|
||||
docker compose exec -u www-data app php artisan projectsend:migrate:preflight --bundle=/tmp/ps-export
|
||||
docker compose exec -u www-data app php artisan projectsend:migrate:import --bundle=/tmp/ps-export
|
||||
```
|
||||
|
||||
When the import is verified, delete `/srv/ps-export` on the host and the copy in the container
|
||||
(`docker compose exec app rm -rf /tmp/ps-export`). Your Legacy install is never written to.
|
||||
|
||||
---
|
||||
|
||||
@@ -356,9 +415,13 @@ Once you are satisfied:
|
||||
|
||||
```sh
|
||||
php artisan projectsend:migrate:reset --drop # also drops the tool's own tables
|
||||
composer remove projectsend/v1-migration-tool
|
||||
composer remove projectsend/v1-migration-tool --update-no-dev
|
||||
```
|
||||
|
||||
Removing a package makes Composer update the rest, so it needs `--update-no-dev` for the same reason
|
||||
as installing did. Leave it off only on a git checkout you develop on. On the Docker image, run it
|
||||
the way Step 1 ran `require`: `php /tmp/composer.phar remove …` as `www-data`.
|
||||
|
||||
`--drop` throws away the Legacy → ProjectSend id map. **Keep it** if you may ever want to redirect
|
||||
old `download.php?id=…` links, because it is the only thing that can resolve them. Removing the
|
||||
package without `--drop` leaves the two tables behind harmlessly.
|
||||
|
||||
@@ -23,6 +23,11 @@ page to download it.
|
||||
No public link passed around by email, no third-party service holding your clients' documents, no
|
||||
per-seat pricing. It runs on your server, and the files stay there.
|
||||
|
||||
Prefer not to run the server yourself? [ProjectSend Cloud](https://projectsend.cloud) is the
|
||||
official hosted version of ProjectSend, run by the same team — every subscription funds this free
|
||||
software. The line between the free core and Cloud, and the commitments that go with it, are set
|
||||
out in [LICENSING.md](LICENSING.md).
|
||||
|
||||
## What it does
|
||||
|
||||
**For the people you send to**
|
||||
@@ -49,34 +54,29 @@ per-seat pricing. It runs on your server, and the files stay there.
|
||||
- Privacy controls, including GDPR-grade account erasure with a grace period
|
||||
- Local disk, S3-compatible storage, or Google Cloud Storage
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/screenshots/dashboard.png" alt="The dashboard: counters for files, clients and groups, the clients using the most storage against their quotas, a month of uploads and downloads as a line chart, and recent activity" width="900">
|
||||
</p>
|
||||
<p align="center"><em>The dashboard — what is in the installation, and what has been happening in it.</em></p>
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/screenshots/files.png" alt="The file library, showing folders and files with thumbnails, sharing status and download counts" width="900">
|
||||
</p>
|
||||
<p align="center"><em>Your library — folders, categories, and who each file is shared with.</em></p>
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/screenshots/portal.png" alt="A client's own page, listing the files shared with them with download buttons" width="900">
|
||||
</p>
|
||||
<p align="center"><em>What your client sees — only their files, nothing else.</em></p>
|
||||
|
||||
## Getting started
|
||||
|
||||
**With Docker** — the quickest path, and the one we recommend. Nothing to build: the published
|
||||
image ships with its dependencies and its frontend already compiled.
|
||||
|
||||
You need Docker Engine with the Compose plugin. If the machine does not have it yet,
|
||||
[install it](https://docs.docker.com/engine/install/) and then
|
||||
[let your own user run it](https://docs.docker.com/engine/install/linux-postinstall/): add yourself
|
||||
to the `docker` group, then log out and back in. Without that step every command below fails with
|
||||
"permission denied", and putting `sudo` in front of it is not the fix.
|
||||
|
||||
Then, in an empty directory of your choosing — `/srv/projectsend` is a good one — run:
|
||||
|
||||
```sh
|
||||
curl -O https://raw.githubusercontent.com/projectsend/projectsend/main/docker/production/compose.example.yaml
|
||||
# edit the passwords and APP_URL in it, then:
|
||||
docker compose -f compose.example.yaml up -d
|
||||
curl -o compose.yaml https://raw.githubusercontent.com/projectsend/projectsend/main/docker/production/compose.example.yaml
|
||||
# edit the passwords and APP_URL in compose.yaml, then:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Every `docker compose` command in these guides runs from that same directory, and finds the file
|
||||
because it is called `compose.yaml`. If you saved it under its original name, `compose.example.yaml`,
|
||||
rename it: `mv compose.example.yaml compose.yaml`.
|
||||
|
||||
Open `APP_URL` and the first thing you see is a setup screen that creates your administrator
|
||||
account — or uncomment `ADMIN_EMAIL` and `ADMIN_PASSWORD` in the file first, with a password of
|
||||
your own, and it is created for you.
|
||||
@@ -98,6 +98,23 @@ installation: the dependencies and the compiled frontend are deliberately not in
|
||||
needs Composer and npm before it runs. **[CONTRIBUTING.md](CONTRIBUTING.md)** has the sequence, and
|
||||
it is short.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/screenshots/dashboard.png" alt="The dashboard: counters for files, clients and groups, the clients using the most storage against their quotas, a month of uploads and downloads as a line chart, and recent activity" width="900">
|
||||
</p>
|
||||
<p align="center"><em>The dashboard — what is in the installation, and what has been happening in it.</em></p>
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/screenshots/files.png" alt="The file library, showing folders and files with thumbnails, sharing status and download counts" width="900">
|
||||
</p>
|
||||
<p align="center"><em>Your library — folders, categories, and who each file is shared with.</em></p>
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/screenshots/portal.png" alt="A client's own page, listing the files shared with them with download buttons" width="900">
|
||||
</p>
|
||||
<p align="center"><em>What your client sees — only their files, nothing else.</em></p>
|
||||
|
||||
## Coming from ProjectSend Legacy?
|
||||
|
||||
The previous generation of ProjectSend lives on at
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use App\Modules\Identity\StartPages;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -30,7 +31,7 @@ class AuthenticatedSessionController extends Controller
|
||||
/**
|
||||
* Handle an incoming authentication request.
|
||||
*/
|
||||
public function store(LoginRequest $request): RedirectResponse
|
||||
public function store(LoginRequest $request, StartPages $startPages): RedirectResponse
|
||||
{
|
||||
if ($request->authenticate()) {
|
||||
return redirect()->route('two-factor.challenge');
|
||||
@@ -38,7 +39,10 @@ class AuthenticatedSessionController extends Controller
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
$user = $request->user();
|
||||
assert($user !== null);
|
||||
|
||||
return redirect()->intended($startPages->pathFor($user));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Identity\AuthSource;
|
||||
use App\Modules\Identity\PasswordVerification;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response as HttpResponse;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
@@ -15,9 +17,25 @@ class ConfirmablePasswordController extends Controller
|
||||
/**
|
||||
* Show the confirm password page.
|
||||
*/
|
||||
public function show(): Response
|
||||
public function show(Request $request): Response
|
||||
{
|
||||
return Inertia::render('auth/confirm-password');
|
||||
$user = $request->user();
|
||||
assert($user !== null);
|
||||
|
||||
return Inertia::render('auth/confirm-password', [
|
||||
// An account provisioned by a provider has no password to
|
||||
// confirm with — its stored hash is a generated string nobody
|
||||
// has seen. The screen offers to set one instead of asking for
|
||||
// it, which is the only way past this for those accounts, and
|
||||
// this screen stands in front of two-factor enrolment.
|
||||
//
|
||||
// Social, not "anything but Local": a directory account has a
|
||||
// password -- the directory's -- and store() accepts it. Asking
|
||||
// whether the account was Local told those accounts to set one
|
||||
// here instead, which /settings/password refuses them, and left
|
||||
// them no way past this screen at all.
|
||||
'has_password' => $user->auth_source !== AuthSource::Social,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,8 +48,12 @@ class ConfirmablePasswordController extends Controller
|
||||
* their local hash is a Str::password(64) nobody has ever seen -- and
|
||||
* this screen stands in front of enrolling in two-factor, so those
|
||||
* accounts could not enrol at all.
|
||||
*
|
||||
* Asked for JSON, it answers with a bare 204: that is the password
|
||||
* dialog (RequirePasswordConfirmation), which stays on the page and
|
||||
* sends the refused request again itself, so there is nowhere to go.
|
||||
*/
|
||||
public function store(Request $request, PasswordVerification $passwords): RedirectResponse
|
||||
public function store(Request $request, PasswordVerification $passwords): RedirectResponse|HttpResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
assert($user !== null);
|
||||
@@ -44,6 +66,10 @@ class ConfirmablePasswordController extends Controller
|
||||
|
||||
$request->session()->put('auth.password_confirmed_at', time());
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,9 +30,53 @@ class NewPasswordController extends Controller
|
||||
return Inertia::render('auth/reset-password', [
|
||||
'email' => $request->email,
|
||||
'token' => $request->route('token'),
|
||||
'expired' => $this->linkIsSpent(
|
||||
(string) $request->string('email'),
|
||||
(string) $request->route('token'),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this link is one store() is certain to refuse.
|
||||
*
|
||||
* The scaffolding renders the form without looking at the token, so an
|
||||
* expired link asked for a new password, asked for it a second time to
|
||||
* confirm, and only then answered "this password reset token is
|
||||
* invalid" — naming a word nobody outside the code knows, after the
|
||||
* work rather than before it. Reset links last an hour and people open
|
||||
* them late; that is ordinary, not an error to be scolded for.
|
||||
*
|
||||
* store() still validates and remains the rule. This is the screen
|
||||
* being honest a minute earlier.
|
||||
*
|
||||
* **Anything that will not validate reads as expired, whether or not
|
||||
* the address is one we know.** That is the whole of the rule and it
|
||||
* exists for one reason: a page answering "expired" for a real address
|
||||
* and drawing the form for an unknown one tells anybody who types a
|
||||
* guess whether an account is here — the exact property
|
||||
* /forgot-password protects by saying "a link will be sent if the
|
||||
* account exists".
|
||||
*
|
||||
* The first version of this method described that oracle in a comment
|
||||
* and then built it: unknown address returned false and drew the form,
|
||||
* known address returned true and said expired. Two branches, two
|
||||
* answers, and the difference *was* the account. Now both answer the
|
||||
* same, so the page reveals nothing and the message is still right in
|
||||
* every case somebody real will meet — a mistyped address gets "ask
|
||||
* for a new link", which is what they should do anyway.
|
||||
*/
|
||||
private function linkIsSpent(string $email, string $token): bool
|
||||
{
|
||||
$broker = Password::broker();
|
||||
$user = $email === '' ? null : $broker->getUser(['email' => $email]);
|
||||
|
||||
// One answer for "no such account", "wrong token" and "spent
|
||||
// token", because telling them apart is telling somebody which
|
||||
// addresses exist here.
|
||||
return $user === null || ! $broker->tokenExists($user, $token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming new password request.
|
||||
*
|
||||
@@ -113,8 +157,21 @@ class NewPasswordController extends Controller
|
||||
return to_route('login')->with('status', __($status));
|
||||
}
|
||||
|
||||
// One sentence for every way this can fail, and deliberately not
|
||||
// Laravel's own. The scaffolding answers `passwords.user` for an
|
||||
// address it cannot find and `passwords.token` for a real one whose
|
||||
// token is dead — two different sentences, which is the same
|
||||
// account-enumeration oracle the screen above was fixed for,
|
||||
// reachable through the write instead. `passwords.throttled` is the
|
||||
// third and the sharpest: the broker throttles per *user*, so an
|
||||
// address nobody holds can never be throttled, and being told to
|
||||
// wait is being told the account is there.
|
||||
//
|
||||
// Nothing is lost by collapsing them. The action is the same in
|
||||
// every case — ask for a new link — and /forgot-password already
|
||||
// refuses to say whether an address has an account.
|
||||
throw ValidationException::withMessages([
|
||||
'email' => [__($status)],
|
||||
'email' => [__('This password reset link is no longer valid. Ask for a new one and try again.')],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Settings;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Identity\AuthSource;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -21,9 +22,21 @@ class PasswordController extends Controller
|
||||
*/
|
||||
public function edit(Request $request): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
assert($user !== null);
|
||||
|
||||
return Inertia::render('settings/password', [
|
||||
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
|
||||
'mustVerifyEmail' => $user instanceof MustVerifyEmail,
|
||||
'status' => $request->session()->get('status'),
|
||||
// Whether there is a password here at all. An account
|
||||
// provisioned by a provider has a generated one nobody was
|
||||
// ever told, so asking for "your current password" asks for
|
||||
// something that does not exist — and until this, that was
|
||||
// the only door to a password, which is the only way to reach
|
||||
// two-factor enrolment. See update().
|
||||
'has_local_password' => $user->auth_source === AuthSource::Local,
|
||||
// A directory's password is not this installation's to change.
|
||||
'managed_elsewhere' => $user->auth_source === AuthSource::Ldap,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -32,18 +45,41 @@ class PasswordController extends Controller
|
||||
*/
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'password' => ['required', Password::defaults(), 'confirmed'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
assert($user !== null);
|
||||
|
||||
$user->update([
|
||||
'password' => Hash::make($validated['password']),
|
||||
// An LDAP account's password lives in the directory. Changing the
|
||||
// hash here would change nothing anybody signs in with, so the
|
||||
// honest answer is to refuse rather than to appear to work.
|
||||
abort_if($user->auth_source === AuthSource::Ldap, 403);
|
||||
|
||||
$setsFirstPassword = $user->auth_source === AuthSource::Social;
|
||||
|
||||
$validated = $request->validate([
|
||||
// Not asked of an account that has never had one: it signs in
|
||||
// through a provider, and its stored hash is a generated
|
||||
// string nobody has seen. Asking anyway left those accounts
|
||||
// with no way to set a password — and so no way to enrol in
|
||||
// two-factor, which an installation can make compulsory.
|
||||
'current_password' => $setsFirstPassword ? ['nullable'] : ['required', 'current_password'],
|
||||
'password' => ['required', Password::defaults(), 'confirmed'],
|
||||
]);
|
||||
|
||||
$attributes = ['password' => Hash::make($validated['password'])];
|
||||
|
||||
// The same line NewPasswordController writes when a provider
|
||||
// account resets its password, for the same reason: the hash is
|
||||
// now what signs this account in, and `has_local_password` is read
|
||||
// off this column all over the settings screens.
|
||||
if ($setsFirstPassword) {
|
||||
$attributes['auth_source'] = AuthSource::Local;
|
||||
}
|
||||
|
||||
// forceFill, not update(): `auth_source` is guarded, so a mass
|
||||
// assignment drops it silently — which left the account still
|
||||
// reading as passwordless after it had a password.
|
||||
$user->forceFill($attributes)->save();
|
||||
|
||||
// Changing a password is how someone reacts to a session they think
|
||||
// is stolen, so it has to actually end that session. AuthenticateSession
|
||||
// (registered on the web group) compares each request's stored
|
||||
|
||||
@@ -8,8 +8,12 @@ use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Clients\ClientFieldContext;
|
||||
use App\Modules\Clients\ClientPortalCustomFields;
|
||||
use App\Modules\Files\DeletedAccountContent;
|
||||
use App\Modules\Identity\Erasure\ErasureSchedule;
|
||||
use App\Modules\Identity\Erasure\SelfDeletion;
|
||||
use App\Modules\Identity\StaffAccounts;
|
||||
use App\Modules\Identity\StartPage;
|
||||
use App\Modules\Identity\StartPages;
|
||||
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
@@ -17,6 +21,7 @@ use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@@ -26,6 +31,7 @@ class ProfileController extends Controller
|
||||
private readonly ClientPortalCustomFields $customFields,
|
||||
private readonly TimezoneRegistry $timezones,
|
||||
private readonly StaffAccounts $accounts,
|
||||
private readonly StartPages $startPages,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -44,6 +50,12 @@ class ProfileController extends Controller
|
||||
// browser was detected as, not something they ever chose.
|
||||
'timezone' => $this->timezones->resolve($user),
|
||||
'timezones' => $this->timezones->options(),
|
||||
// Stored, not resolved: an empty choice means "follow my role",
|
||||
// and the form has to be able to say that rather than show the
|
||||
// role's page as if the person had picked it.
|
||||
'start_page' => $user->start_page,
|
||||
'start_page_options' => $this->startPages->personalOptions($user),
|
||||
'role_start_page' => (string) __(($this->startPages->roleDefault($user) ?? StartPage::Dashboard)->label($user->type)),
|
||||
'custom_fields' => $user->isClient() ? $this->customFields->rows(ClientFieldContext::AccountEdit, $user) : [],
|
||||
'custom_field_values' => $user->isClient() ? $this->customFields->values(ClientFieldContext::AccountEdit, $user) : [],
|
||||
]);
|
||||
@@ -58,10 +70,20 @@ class ProfileController extends Controller
|
||||
* you scroll past on the way to saving your email address. The delete
|
||||
* itself still goes to destroy() below.
|
||||
*/
|
||||
public function deleteAccount(): Response
|
||||
public function deleteAccount(Request $request): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
$selfDeletion = app(SelfDeletion::class);
|
||||
$applies = $user !== null && $selfDeletion->appliesTo($user);
|
||||
|
||||
return Inertia::render('settings/delete-account', [
|
||||
'erasureGraceDays' => (int) app(Settings::class)->get(Setting::AccountErasureGraceDays),
|
||||
// What happens to their files, said before they confirm. Both
|
||||
// follow the account's own type (SelfDeletion::appliesTo), so a
|
||||
// staff member on a "clients only" installation is told
|
||||
// neither, because neither happens to them.
|
||||
'filesWithdrawn' => $applies,
|
||||
'filesDeletedImmediately' => $applies && $selfDeletion->deletesFilesImmediately(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -123,10 +145,27 @@ class ProfileController extends Controller
|
||||
|
||||
// Self-deletion: soft delete now, permanent GDPR erasure after
|
||||
// the disclosed grace period (Setting::AccountErasureGraceDays).
|
||||
app(ErasureSchedule::class)->apply($user);
|
||||
$user->delete();
|
||||
//
|
||||
// One transaction with the files, for the reason
|
||||
// ClientsController::destroy gives: a deletion whose second half
|
||||
// failed must not leave the account gone and the files it
|
||||
// promised to delete still there.
|
||||
DB::transaction(function () use ($user): void {
|
||||
app(ErasureSchedule::class)->apply($user);
|
||||
$user->delete();
|
||||
|
||||
app(ActivityLogger::class)->log(Action::UserDeleted, $user, context: ['name' => $user->name]);
|
||||
app(ActivityLogger::class)->log(Action::UserDeleted, $user, context: ['name' => $user->name]);
|
||||
|
||||
// Only what they own, by the rule an administrator's delete
|
||||
// uses: their uploads, and their folders only if nothing else
|
||||
// is left inside them. See SelfDeletion.
|
||||
$selfDeletion = app(SelfDeletion::class);
|
||||
|
||||
if ($selfDeletion->appliesTo($user) && $selfDeletion->deletesFilesImmediately()) {
|
||||
$result = app(DeletedAccountContent::class)->cascadeDelete($user);
|
||||
app(ActivityLogger::class)->log(Action::AccountContentCascadeDeleted, context: ['name' => $user->name, ...$result]);
|
||||
}
|
||||
});
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
@@ -15,7 +15,9 @@ use App\Modules\Platform\Attribution\Attribution;
|
||||
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||
use App\Modules\Platform\Captcha\Captcha;
|
||||
use App\Modules\Platform\Installation\Installation;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Queue\StalledZipBuilds;
|
||||
use App\Modules\Files\Scanning\ScanStatus;
|
||||
use App\Modules\Platform\Localization\LocaleRegistry;
|
||||
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||
use App\Modules\Platform\OfficialLinks;
|
||||
@@ -24,7 +26,10 @@ use App\Modules\Platform\Settings\Settings;
|
||||
use App\Modules\Platform\Updates\LatestReleaseInfo;
|
||||
use App\Modules\Platform\Updates\RunningCodeState;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use App\Modules\Platform\Announcements\Events\ResolvingAnnouncement;
|
||||
use App\Modules\Platform\Navigation\Events\ResolvingNavigationLinks;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Inertia\Middleware;
|
||||
|
||||
class HandleInertiaRequests extends Middleware
|
||||
@@ -84,6 +89,19 @@ class HandleInertiaRequests extends Middleware
|
||||
// ignore this and always show it.
|
||||
'attribution' => app(Attribution::class)->visible(),
|
||||
'capabilities' => $capabilities->enabledKeys(),
|
||||
// Sidebar entries a package asked for. Shared rather than
|
||||
// passed per page because the sidebar is on every page, and
|
||||
// dispatched unconditionally so that with nothing listening
|
||||
// the list is empty and the sidebar is exactly what it was.
|
||||
// See ResolvingNavigationLinks for why core never learns what
|
||||
// is in it.
|
||||
'extra_nav_links' => $this->extraNavLinks($request),
|
||||
// Shared rather than a dashboard prop, because it is shown in
|
||||
// two places — the band on the dashboard and the icon beside
|
||||
// the notification bell everywhere else — and "the same
|
||||
// message" is the requirement. Two props would drift the day
|
||||
// somebody edited one.
|
||||
'announcement' => $this->announcement($request),
|
||||
// Shared rather than passed by each page: the sign-in buttons,
|
||||
// the registration form and the Connected accounts nav entry
|
||||
// all need the same list, and a nav entry to a screen with
|
||||
@@ -167,6 +185,18 @@ class HandleInertiaRequests extends Middleware
|
||||
$counts['comments'] = app(VisibleCommentScope::class)->pendingTotal($user);
|
||||
}
|
||||
|
||||
if ($checker->allows($user, Permission::ReleaseQuarantinedFiles)) {
|
||||
// Deliberately not library-scoped, unlike the comments count
|
||||
// above: a quarantined file is not a file anybody is working
|
||||
// with, it is one somebody has to decide about, and the
|
||||
// permission is already narrow enough that whoever holds it
|
||||
// is meant to see all of them.
|
||||
$counts['quarantine'] = File::query()->whereIn('scan_status', [
|
||||
ScanStatus::Infected->value,
|
||||
ScanStatus::UnscannableBlocked->value,
|
||||
])->count();
|
||||
}
|
||||
|
||||
// Unlike the counts above, every authenticated user (staff or
|
||||
// client) has their own personal notifications — no permission
|
||||
// gate here.
|
||||
@@ -301,4 +331,48 @@ class HandleInertiaRequests extends Middleware
|
||||
/** @var array<string, string> */
|
||||
return app('translator')->getLoader()->load($locale, '*', '*');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{title: string, url: string, external: bool, icon: string|null}>
|
||||
*/
|
||||
private function extraNavLinks(Request $request): array
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Staff only, decided here rather than in each listener: these
|
||||
// render in the administration area, and a client's portal shows
|
||||
// their own files and nothing about the installation.
|
||||
$event = new ResolvingNavigationLinks(isStaff: $user !== null && $user->isStaff());
|
||||
|
||||
if (! $event->isStaff) {
|
||||
return [];
|
||||
}
|
||||
|
||||
Event::dispatch($event);
|
||||
|
||||
return $event->links;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{title: string, body: string, action_label: string|null, action_url: string|null, tone: string}|null
|
||||
*/
|
||||
private function announcement(Request $request): ?array
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Dispatched for clients too, unlike the sidebar links beside it.
|
||||
// A client is somebody a shared instance may legitimately need to
|
||||
// address — about their own account, not about the installation —
|
||||
// and the event refuses anything not aimed at them, so widening
|
||||
// this does not widen what reaches them.
|
||||
$event = new ResolvingAnnouncement(isStaff: $user->isStaff());
|
||||
|
||||
Event::dispatch($event);
|
||||
|
||||
return $event->announcement;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Session\Middleware\StartSession as FrameworkStartSession;
|
||||
use Illuminate\Contracts\Session\Session;
|
||||
|
||||
/**
|
||||
* The framework's session middleware, except that a request asking for
|
||||
* JSON is never remembered as "the previous page".
|
||||
*
|
||||
* `back()` prefers the Referer header and falls back to the URL the
|
||||
* session recorded last. Laravel records every GET not marked as Ajax,
|
||||
* and a plain fetch() is not marked. So the notification bell's poll for
|
||||
* its unread count became the previous page. Wherever the Referer did not
|
||||
* arrive, because a proxy or a browser stripped it, saving any settings
|
||||
* form redirected to /notifications/unread-count, and Inertia showed the
|
||||
* raw {"count":0} in an error dialog (#1799).
|
||||
*
|
||||
* The rule is about the request, not about that one route: nothing that
|
||||
* asked for JSON is a page anybody goes back to. Inertia visits ask for
|
||||
* HTML, so they are recorded exactly as before.
|
||||
*/
|
||||
class StartSession extends FrameworkStartSession
|
||||
{
|
||||
protected function storeCurrentUrl(Request $request, $session): void
|
||||
{
|
||||
if ($request->wantsJson()) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var Session $session */
|
||||
parent::storeCurrentUrl($request, $session);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
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;
|
||||
@@ -71,7 +72,13 @@ class LoginRequest extends FormRequest
|
||||
{
|
||||
$this->ensureIsNotRateLimited();
|
||||
|
||||
$user = User::query()->where('email', $this->string('email'))->first();
|
||||
// Exact, for the reason SocialAuthenticator is: a collation that
|
||||
// folds accents would otherwise let somebody typing
|
||||
// admin@éxample.com be *identified* as admin@example.com. A
|
||||
// password still gates this one, so it was never the takeover the
|
||||
// social path was — but identifying the wrong account is the bug,
|
||||
// and the credential check is a second line rather than the rule.
|
||||
$user = app(AccountLookup::class)->byEmail((string) $this->string('email'));
|
||||
|
||||
// A directory identity with no local account yet. Returns null
|
||||
// unless LDAP is on, auto-provisioning is on, and the bind
|
||||
|
||||
@@ -5,9 +5,12 @@ namespace App\Http\Requests\Settings;
|
||||
use App\Models\User;
|
||||
use App\Modules\Clients\ClientFieldContext;
|
||||
use App\Modules\Clients\ClientPortalCustomFields;
|
||||
use App\Modules\Identity\AuthSource;
|
||||
use App\Modules\Identity\StartPages;
|
||||
use App\Support\Rules;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Closure;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ProfileUpdateRequest extends FormRequest
|
||||
@@ -31,6 +34,26 @@ class ProfileUpdateRequest extends FormRequest
|
||||
Rule::unique(User::class)->ignore($this->user()?->id),
|
||||
],
|
||||
|
||||
// Changing this address is a credential change, not a detail:
|
||||
// it is where a password reset is sent, so whoever can change
|
||||
// it owns the account from the next reset onwards. A stolen
|
||||
// session used to be enough (GHSA-f32x-fgmp-q353) — temporary
|
||||
// access became permanent ownership with one PATCH.
|
||||
//
|
||||
// `exclude_if` rather than a flat rule, so the rest of the
|
||||
// screen keeps saving with nothing extra: a name, a timezone
|
||||
// or a custom field is not a credential and must not start
|
||||
// asking for a password. Only a *different* address does.
|
||||
//
|
||||
// The same rule destroy() one controller away has always
|
||||
// asked, for the same reason: both doors lead to owning the
|
||||
// account.
|
||||
'current_password' => [
|
||||
Rule::excludeIf(! $this->changesEmail()),
|
||||
'required',
|
||||
'current_password',
|
||||
],
|
||||
|
||||
// Saved with the rest of the profile so the screen keeps one
|
||||
// Save button. `timezone` is fillable, so ProfileController's
|
||||
// fill() picks it up with no special handling.
|
||||
@@ -43,6 +66,28 @@ class ProfileUpdateRequest extends FormRequest
|
||||
];
|
||||
|
||||
$user = $this->user();
|
||||
|
||||
// Only the pages this person can open right now. `sometimes` for
|
||||
// the same reason as timezone; empty clears the choice and follows
|
||||
// the role again.
|
||||
$rules['start_page'] = ['sometimes', 'nullable', 'string', Rule::in(
|
||||
$user === null ? [] : array_column(app(StartPages::class)->personalOptions($user), 'value'),
|
||||
)];
|
||||
|
||||
// An account whose credentials live in a directory or at an
|
||||
// identity provider holds a local password nobody knows — see
|
||||
// LdapProvisioner, which stores Str::password(64) exactly so it
|
||||
// can never be used. Asking such a person to confirm "your current
|
||||
// password" is a dead end dressed as a form error, and the address
|
||||
// is not theirs to change here in any case: it is what the
|
||||
// directory or the provider says it is, and a local edit would
|
||||
// either be overwritten or break the link.
|
||||
if ($this->changesEmail() && $user !== null && $user->auth_source !== AuthSource::Local) {
|
||||
$rules['email'][] = function (string $attribute, mixed $value, Closure $fail): void {
|
||||
$fail(__('Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.'));
|
||||
};
|
||||
}
|
||||
|
||||
if ($user?->isClient() === true) {
|
||||
$rules = [
|
||||
...$rules,
|
||||
@@ -52,4 +97,29 @@ class ProfileUpdateRequest extends FormRequest
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this request asks for an address other than the stored one.
|
||||
*
|
||||
* Compared lowercased and trimmed because the `lowercase` rule runs
|
||||
* beside this one rather than before it: without that, re-saving the
|
||||
* profile with the address typed in a different case would be read as
|
||||
* a change and demand a password for nothing.
|
||||
*/
|
||||
private function changesEmail(): bool
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
if ($user === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$submitted = $this->input('email');
|
||||
|
||||
if (! is_string($submitted)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return mb_strtolower(trim($submitted)) !== mb_strtolower(trim((string) $user->email));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,9 +29,11 @@ use Laravel\Sanctum\HasApiTokens;
|
||||
* @property bool $account_requested
|
||||
* @property string|null $locale
|
||||
* @property string|null $timezone
|
||||
* @property string|null $start_page a StartPage value; see StartPages
|
||||
* @property int|null $dashboard_columns
|
||||
* @property int $storage_quota_mb
|
||||
* @property Carbon|null $erase_after
|
||||
* @property \Carbon\Carbon|null $expires_at
|
||||
* @property-read Role|null $role
|
||||
*/
|
||||
class User extends Authenticatable implements HasLocalePreference
|
||||
@@ -54,6 +56,9 @@ class User extends Authenticatable implements HasLocalePreference
|
||||
'password',
|
||||
'locale',
|
||||
'timezone',
|
||||
// A personal preference, like timezone: the profile form fills it
|
||||
// from its own validated request. See StartPages.
|
||||
'start_page',
|
||||
'dashboard_columns',
|
||||
'storage_quota_mb',
|
||||
];
|
||||
@@ -96,6 +101,32 @@ class User extends Authenticatable implements HasLocalePreference
|
||||
return $this->isStaff() && $this->role?->client_scoped === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this account's expiry date has passed. Only client accounts
|
||||
* are given one (see the client screens and /api/v1/clients).
|
||||
*/
|
||||
public function hasExpired(): bool
|
||||
{
|
||||
return $this->expires_at !== null && $this->expires_at->isPast();
|
||||
}
|
||||
|
||||
/**
|
||||
* The one question every door into the application asks of an account
|
||||
* that has already proved who it is: sign-in, every web request, every
|
||||
* API request, and the second-factor challenge.
|
||||
*
|
||||
* Expiry is checked here as well as by the hourly sweep that switches
|
||||
* `active` off, and neither is enough alone. The sweep is what keeps
|
||||
* everything else that reads `active` — lists, filters, seat counts —
|
||||
* in step. But a sweep runs on a schedule, and a scheduler that is not
|
||||
* running would leave an expired account working forever. So access
|
||||
* is refused the moment the date passes, whatever the flag says.
|
||||
*/
|
||||
public function maySignIn(): bool
|
||||
{
|
||||
return $this->active && ! $this->hasExpired();
|
||||
}
|
||||
|
||||
public function hasTwoFactorEnabled(): bool
|
||||
{
|
||||
return $this->two_factor_confirmed_at !== null;
|
||||
@@ -161,11 +192,32 @@ class User extends Authenticatable implements HasLocalePreference
|
||||
// credentials live is a security decision, not an attribute a
|
||||
// form or an API payload may set. Written with forceFill by
|
||||
// the code that provisions the account.
|
||||
//
|
||||
// Same for 'email_verified_at' below, and it is worth saying
|
||||
// what absence from $fillable does and does not buy. It stops
|
||||
// a request smuggling the value in. It does not tell the code
|
||||
// that meant to set it deliberately that it failed: a key in a
|
||||
// create() array is dropped in silence, so every path that
|
||||
// provisions an account had one and lost it — staff accounts,
|
||||
// client accounts, the setup screen and projectsend:admin, all
|
||||
// fixed in September 2026. Not fillable only helps when the
|
||||
// writer knows it has to be deliberate.
|
||||
'auth_source' => AuthSource::class,
|
||||
'ldap_synced_at' => 'datetime',
|
||||
'active' => 'boolean',
|
||||
'account_requested' => 'boolean',
|
||||
// The column is an unsignedInteger and the docblock above
|
||||
// already promises int. Saying so here is what makes that true
|
||||
// for a reader as well: it is passed straight into typed
|
||||
// signatures (ClientAccounts::create, ClientProvisioning::
|
||||
// provision), and whether a driver hands back 2048 or "2048"
|
||||
// is not something those call sites should depend on.
|
||||
'storage_quota_mb' => 'integer',
|
||||
'erase_after' => 'datetime',
|
||||
// Deliberately absent from $fillable too: when an account stops
|
||||
// working is decided by staff, never by a payload the account
|
||||
// itself could send (the profile form fills from its request).
|
||||
'expires_at' => 'datetime',
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'two_factor_secret' => 'encrypted',
|
||||
|
||||
@@ -10,8 +10,8 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* The token twin of Identity's EnsureAccountIsActive: deactivating an
|
||||
* account revokes its API access on the very next request, without anyone
|
||||
* having to hunt down the tokens it minted.
|
||||
* account, or its expiry date passing, revokes its API access on the very
|
||||
* next request, without anyone having to hunt down the tokens it minted.
|
||||
*
|
||||
* Deleted accounts need no equivalent — users are soft-deleted and the
|
||||
* default query scope means Sanctum simply fails to resolve the tokenable,
|
||||
@@ -26,7 +26,7 @@ class EnsureApiAccountIsActive
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user !== null && ! $user->active) {
|
||||
if ($user !== null && ! $user->maySignIn()) {
|
||||
abort(401);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,14 @@ enum Action: string
|
||||
case SocialAccountUnlinked = 'social.account_unlinked';
|
||||
case ClientApproved = 'client.approved';
|
||||
case ClientDenied = 'client.denied';
|
||||
case ClientInvited = 'client.invited';
|
||||
case ClientInvitationRedeemed = 'client.invitation_redeemed';
|
||||
case ClientInvitationRevoked = 'client.invitation_revoked';
|
||||
case ClientInvitationResent = 'client.invitation_resent';
|
||||
// Logged by the hourly sweep, so it has no actor: nobody switched the
|
||||
// account off, its date passed. Distinct from UserDeactivated for the
|
||||
// same reason TwoFactorReset is distinct from TwoFactorDisabled.
|
||||
case ClientExpired = 'client.expired';
|
||||
// Files
|
||||
case FileUploaded = 'file.uploaded';
|
||||
case FileUpdated = 'file.updated';
|
||||
@@ -67,6 +75,21 @@ enum Action: string
|
||||
case FolderMadePrivate = 'folder.made_private';
|
||||
case UploadAborted = 'upload.aborted';
|
||||
case FileImported = 'file.imported';
|
||||
|
||||
// Virus scanning. A clean result is not logged: it is the ordinary
|
||||
// outcome of every upload, and a row per upload would bury the ones
|
||||
// that matter. Only the three that need answering are.
|
||||
case FileQuarantined = 'file.quarantined';
|
||||
case FileReleased = 'file.released';
|
||||
// Allowed through without being checked — because the scanner could
|
||||
// not be reached, or the file was too large or encrypted and this
|
||||
// installation allows those. The context says which.
|
||||
case FileNotScanned = 'file.not_scanned';
|
||||
|
||||
// The row is here and the bytes are not. Written by the daily check,
|
||||
// so it has no actor: nobody did this, or nobody who was using the
|
||||
// application did.
|
||||
case FileMissing = 'file.missing';
|
||||
case OrphanFileDeleted = 'orphan_file.deleted';
|
||||
case OrphanFileAutoDeleted = 'orphan_file.auto_deleted';
|
||||
case ExpiredFileDeleted = 'file.expired_deleted';
|
||||
@@ -165,6 +188,11 @@ enum Action: string
|
||||
self::ClientSelfRegistered => 'Registered a new client account',
|
||||
self::ClientApproved => 'Approved the account request of ":subject"',
|
||||
self::ClientDenied => 'Denied the account request of ":name"',
|
||||
self::ClientInvited => 'Invited :email to register a client account',
|
||||
self::ClientInvitationRedeemed => 'Registered a client account from an invitation',
|
||||
self::ClientInvitationRevoked => 'Revoked the invitation sent to :email',
|
||||
self::ClientInvitationResent => 'A new invitation link was requested for :email',
|
||||
self::ClientExpired => 'The client account ":name" expired and was deactivated',
|
||||
self::FileUploaded => 'Uploaded the file ":subject"',
|
||||
self::FileUpdated => 'Updated the file ":subject"',
|
||||
self::FileDeleted => 'Deleted the file ":name"',
|
||||
@@ -199,6 +227,14 @@ enum Action: string
|
||||
self::CommentDeleted => 'Deleted a comment on the file ":subject"',
|
||||
self::CommentApproved => 'Approved a comment on the file ":subject"',
|
||||
self::FileImported => 'Imported the orphan file ":subject"',
|
||||
// :name rather than :subject, unlike the file actions above
|
||||
// it: these two are written by the scan job, which has no
|
||||
// actor and attaches no subject, so the name has to travel in
|
||||
// the context or the line reads 'The file "" was quarantined'.
|
||||
self::FileQuarantined => 'The file ":name" was quarantined: :threat',
|
||||
self::FileReleased => 'Released the quarantined file ":subject" (:reason)',
|
||||
self::FileNotScanned => 'The file ":name" was not scanned for viruses: :reason',
|
||||
self::FileMissing => 'The file ":name" is no longer on the server',
|
||||
self::OrphanFileDeleted => 'Deleted the orphan file ":name"',
|
||||
self::OrphanFileAutoDeleted => 'Deleted the orphan file ":name"',
|
||||
self::ExpiredFileDeleted => 'Deleted the expired file ":name"',
|
||||
@@ -267,6 +303,11 @@ enum Action: string
|
||||
self::ClientSelfRegistered => 'A client registered an account',
|
||||
self::ClientApproved => 'A client account request was approved',
|
||||
self::ClientDenied => 'A client account request was denied',
|
||||
self::ClientInvited => 'A client was invited to register an account',
|
||||
self::ClientInvitationRedeemed => 'A client registered an account from an invitation',
|
||||
self::ClientInvitationRevoked => 'An invitation was revoked before it was used',
|
||||
self::ClientInvitationResent => 'An invited person asked for a replacement link',
|
||||
self::ClientExpired => 'A client account reached its expiry date and was deactivated',
|
||||
self::FileUploaded => 'A file was uploaded',
|
||||
self::FileUpdated => 'A file was updated',
|
||||
self::FileDeleted => 'A file was deleted',
|
||||
@@ -298,6 +339,10 @@ enum Action: string
|
||||
self::CommentDeleted => 'A comment was deleted',
|
||||
self::CommentApproved => 'A comment was approved',
|
||||
self::FileImported => 'An orphan file was imported',
|
||||
self::FileQuarantined => 'A file was quarantined by the virus scanner',
|
||||
self::FileReleased => 'A quarantined file was released by an administrator',
|
||||
self::FileNotScanned => 'A file was allowed through without being scanned',
|
||||
self::FileMissing => 'A file in the library was found to be missing from storage',
|
||||
self::OrphanFileDeleted => 'An orphan file was deleted',
|
||||
self::OrphanFileAutoDeleted => 'An orphan file was automatically deleted after its retention grace period passed',
|
||||
self::ExpiredFileDeleted => 'An expired file was automatically deleted after its retention grace period passed',
|
||||
|
||||
@@ -12,10 +12,14 @@ use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Audit\ActivityLogScope;
|
||||
use App\Modules\Audit\ActivityPresenter;
|
||||
use App\Modules\Audit\DashboardWidgetPreferences;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use App\Modules\Clients\ClientStorageUsage;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Delivery\FileDelivery;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Scanning\ScanningConfig;
|
||||
use App\Modules\Files\Scanning\ScanStatus;
|
||||
use App\Modules\Files\Scanning\VirusScanner;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use App\Modules\Identity\UserType;
|
||||
use App\Modules\Platform\Capabilities\Capability;
|
||||
@@ -26,6 +30,7 @@ use App\Modules\Platform\News\NewsItems;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use App\Modules\Platform\Storage\StorageDurability;
|
||||
use App\Modules\Platform\Storage\StorageCapacity;
|
||||
use App\Modules\Platform\System\SystemEnvironment;
|
||||
use App\Modules\Platform\Updates\LatestReleaseInfo;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -52,6 +57,7 @@ class DashboardController extends Controller
|
||||
private readonly Settings $settings,
|
||||
private readonly ApiUsage $apiUsage,
|
||||
private readonly StorageDurability $storageDurability,
|
||||
private readonly StorageCapacity $storageCapacity,
|
||||
private readonly FileDelivery $fileDelivery,
|
||||
private readonly Installation $installation,
|
||||
private readonly TimezoneRegistry $timezones,
|
||||
@@ -94,7 +100,7 @@ class DashboardController extends Controller
|
||||
: null,
|
||||
'largest_files' => $canStatistics && $prefs->isEnabled($user, 'largest_files') ? $this->largestFiles($user) : null,
|
||||
'recent' => $canActionsLog && $prefs->isEnabled($user, 'recent') ? $this->recentActivity($user) : null,
|
||||
'system' => $canSystem && $prefs->isEnabled($user, 'system') ? $this->systemInfo() : null,
|
||||
'system' => $canSystem && $prefs->isEnabled($user, 'system') ? $this->systemInfo($user) : null,
|
||||
// Both editions — informational content, not an update action,
|
||||
// so no Capability check alongside the permission (unlike
|
||||
// 'system' above).
|
||||
@@ -479,12 +485,10 @@ class DashboardController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, bool|string|null>|bool|int|string|null>
|
||||
* @return array<string, array<string, bool|int|string|null>|bool|int|string|null>
|
||||
*/
|
||||
private function systemInfo(): array
|
||||
private function systemInfo(User $viewer): array
|
||||
{
|
||||
$freeBytes = @disk_free_space(storage_path('app/files'));
|
||||
|
||||
// Cached by CheckForUpdatesCommand (daily) — never a live HTTP
|
||||
// call from the request path. null means either no successful
|
||||
// check yet, or the current version is already the latest.
|
||||
@@ -493,7 +497,7 @@ class DashboardController extends Controller
|
||||
return [
|
||||
...$this->environment->toArray(),
|
||||
'storage_used_bytes' => (int) File::query()->sum('size'),
|
||||
'storage_free_bytes' => $freeBytes === false ? -1 : (int) $freeBytes,
|
||||
...$this->storageCapacity->inspect($viewer),
|
||||
'update_available' => $release !== null,
|
||||
'latest_version' => $release['version'] ?? null,
|
||||
'release_url' => $release['url'] ?? null,
|
||||
@@ -510,6 +514,74 @@ class DashboardController extends Controller
|
||||
// able to confirm at a glance, not only worth warning about
|
||||
// when it is false — the same reasoning as storage_durability.
|
||||
'file_delivery' => $this->fileDelivery->describe(),
|
||||
// Always stated, like delivery and storage above it: "my
|
||||
// uploads are checked by ClamAV" is worth confirming at a
|
||||
// glance, not only worth mentioning when it is false. Null
|
||||
// only where this installation does not connect its own
|
||||
// scanner at all.
|
||||
'scanning' => $this->scanningState(),
|
||||
// Rows this installation lists and cannot produce. Zero is the
|
||||
// ordinary answer and says nothing on screen; anything else is
|
||||
// somebody's files gone, which is worth interrupting for.
|
||||
'missing_files' => File::query()->where('scan_status', ScanStatus::Missing)->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Where this installation stands with virus scanning.
|
||||
*
|
||||
* Reported whether or not anything is wrong: the System card states
|
||||
* how downloads leave and where files are stored for the same reason,
|
||||
* and "nothing is checking my uploads" is exactly the fact an
|
||||
* administrator will not go looking for.
|
||||
*
|
||||
* Null only when this installation does not connect its own scanner —
|
||||
* on a hosted one that is the platform's infrastructure, and a tenant
|
||||
* reading about it could neither confirm nor fix it. See
|
||||
* Capability::VirusScanningConnect.
|
||||
*
|
||||
* @return array{configured: bool, reachable: bool, engine: string|null, definitions_age_hours: int|null, let_through_24h: int, pending: int}|null
|
||||
*/
|
||||
private function scanningState(): ?array
|
||||
{
|
||||
if (! $this->capabilities->has(Capability::VirusScanningConnect)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$config = app(ScanningConfig::class);
|
||||
|
||||
if (! $config->enabled()) {
|
||||
return [
|
||||
'configured' => false,
|
||||
'reachable' => false,
|
||||
'engine' => null,
|
||||
'definitions_age_hours' => null,
|
||||
'let_through_24h' => 0,
|
||||
'pending' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$scanner = app(VirusScanner::class)->status();
|
||||
|
||||
return [
|
||||
'configured' => true,
|
||||
'reachable' => $scanner->reachable,
|
||||
'engine' => $scanner->engine,
|
||||
'definitions_age_hours' => $scanner->definitionsAgeHours(),
|
||||
// Files let through unchecked in the last day that can still
|
||||
// be downloaded. Zero is the only number that means
|
||||
// "protected"; anything else is a scanner that was down, or
|
||||
// files nobody could open. See File::scopeLetThrough().
|
||||
'let_through_24h' => File::query()
|
||||
->letThrough()
|
||||
->where('scanned_at', '>=', now()->subDay())
|
||||
->count(),
|
||||
// Waiting more than an hour: on an installation set to hold,
|
||||
// this is what an outage looks like.
|
||||
'pending' => File::query()
|
||||
->where('scan_status', ScanStatus::Pending)
|
||||
->where('created_at', '<=', now()->subHour())
|
||||
->count(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Clients;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Clients\Notifications\ClientWelcomeNotification;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use App\Modules\Identity\Permissions\SystemRole;
|
||||
use App\Modules\Identity\UserType;
|
||||
use App\Modules\Platform\Seats\SeatAllowance;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* Creating a client account — the rules and the side effects, shared by
|
||||
* every surface that makes one.
|
||||
*
|
||||
* The same argument StaffAccounts makes for staff. What a client account
|
||||
* *is* — its type, its role, an active flag, a quota where zero means
|
||||
* "inherit the site default" rather than "none" — is a set of invariants,
|
||||
* and an invariant enforced in one controller and re-implemented in
|
||||
* another is one that will eventually hold in only one of them. There are
|
||||
* three surfaces onto this now: the staff screens, `/api/v1/clients`, and
|
||||
* the platform control plane in the private package, which reaches this
|
||||
* by name because it cannot import a host class.
|
||||
*
|
||||
* What stays with the caller is what genuinely differs: the shape of the
|
||||
* request, its validation rules, its response, and anything about *who is
|
||||
* asking* — a client-scoped staff member gaining the client on their own
|
||||
* roster is a fact about the creator, not about the account created.
|
||||
*
|
||||
* **Not to be confused with ClientProvisioning**, which sits beside it and
|
||||
* handles the other half: an account that comes into existence without
|
||||
* anybody deciding to create it — the public registration form, and a
|
||||
* first successful LDAP sign-in. The policies genuinely differ rather than
|
||||
* merely duplicating. An account made here is approved and verified by
|
||||
* construction, because somebody who already knows who this is asked for
|
||||
* it; one made there may wait for approval, joins a configured group, and
|
||||
* tells the administrators it arrived.
|
||||
*/
|
||||
class ClientAccounts
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly SeatAllowance $seats,
|
||||
private readonly Settings $settings,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param int $storageQuotaMb 0 means no per-account quota and
|
||||
* inherits the site default at
|
||||
* enforcement time — see
|
||||
* ClientStorageUsage::quotaMb(). It does
|
||||
* not mean unlimited.
|
||||
* @param Carbon|null $expiresAt when the account stops working;
|
||||
* null for never. Must be in the
|
||||
* future — see guardExpiry().
|
||||
* @param bool $welcome whether this installation should email the
|
||||
* new account. A caller that sends its own
|
||||
* welcome passes false rather than having the
|
||||
* customer receive two.
|
||||
*/
|
||||
public function create(
|
||||
string $name,
|
||||
string $email,
|
||||
string $password,
|
||||
int $storageQuotaMb = 0,
|
||||
bool $welcome = true,
|
||||
string $emailField = 'email',
|
||||
?Carbon $expiresAt = null,
|
||||
): User {
|
||||
// Before anything is written, and deliberately not left to the
|
||||
// caller. The platform sets this cap and the platform is also what
|
||||
// calls the control plane — so enforcing it here is what stops a
|
||||
// leaked control token minting accounts without limit. A guard
|
||||
// that only ran on the surfaces that remembered it would not be a
|
||||
// guard.
|
||||
$this->seats->guardClient($emailField);
|
||||
$this->guardExpiry($expiresAt, active: true);
|
||||
|
||||
$client = User::create([
|
||||
'type' => UserType::Client,
|
||||
'active' => true,
|
||||
'account_requested' => false,
|
||||
'role_id' => Role::query()->where('name', SystemRole::Client->value)->value('id'),
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'password' => $password,
|
||||
'storage_quota_mb' => $storageQuotaMb,
|
||||
]);
|
||||
|
||||
// forceFill, and not part of the create() array above: like
|
||||
// StaffAccounts, email_verified_at is deliberately absent from
|
||||
// User::$fillable — where an account stands is a security decision
|
||||
// rather than an attribute — so mass assignment drops it in
|
||||
// silence. Every client-creation path used to pass it in that
|
||||
// array and lose it. The intent is real: an account created by
|
||||
// somebody who already knows who this is has no address to
|
||||
// confirm and nobody to confirm it to. (Inert today, since
|
||||
// MustVerifyEmail is not enabled on the model, but the column is
|
||||
// what a later switch would read.)
|
||||
//
|
||||
// expires_at is written the same way for its own reason: see the
|
||||
// note on its cast in User.
|
||||
$client->forceFill(['email_verified_at' => now(), 'expires_at' => $expiresAt])->save();
|
||||
|
||||
$this->activity->log(Action::UserCreated, subject: $client);
|
||||
|
||||
if ($welcome && $this->settings->get(Setting::EmailNotificationsEnabled) === true) {
|
||||
$client->notify(new ClientWelcomeNotification);
|
||||
}
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
/**
|
||||
* An account cannot be both active and past its expiry date.
|
||||
*
|
||||
* Every surface that writes either value asks this before saving,
|
||||
* because the combination is not a state anybody means: an account
|
||||
* that looks switched on and refuses every sign-in, until the hourly
|
||||
* sweep quietly switches it off again. Somebody reactivating an
|
||||
* expired client has to give them a new date, or none.
|
||||
*/
|
||||
public function guardExpiry(?Carbon $expiresAt, bool $active, string $field = 'expires_at'): void
|
||||
{
|
||||
if ($active && $expiresAt !== null && $expiresAt->isPast()) {
|
||||
throw ValidationException::withMessages([
|
||||
$field => __('This date has already passed. Choose a later date, or leave it empty for an account that never expires.'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,12 @@ use App\Modules\Groups\Models\Group;
|
||||
use App\Modules\Identity\AuthSource;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use App\Modules\Identity\Permissions\SystemRole;
|
||||
use App\Modules\Identity\Permissions\Permission;
|
||||
use App\Modules\Identity\Permissions\PermissionChecker;
|
||||
use App\Modules\Identity\UserType;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Notifications\Notifier;
|
||||
use App\Modules\Platform\Seats\SeatAllowance;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
@@ -38,6 +41,8 @@ class ClientProvisioning
|
||||
private readonly Settings $settings,
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly SeatAllowance $seats,
|
||||
private readonly Notifier $notifier,
|
||||
private readonly PermissionChecker $permissions,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -75,6 +80,14 @@ class ClientProvisioning
|
||||
* @param array<string, mixed> $context Placeholders for the action's
|
||||
* log template, e.g. which
|
||||
* provider an account came from.
|
||||
* @param int $storageQuotaMb 0 means no per-account quota and
|
||||
* inherits the site default at
|
||||
* enforcement time — see
|
||||
* ClientStorageUsage::quotaMb(). Same
|
||||
* meaning as ClientAccounts::create()'s
|
||||
* parameter of the same name; a caller
|
||||
* with no quota to offer (the public
|
||||
* registration form, LDAP) leaves it at 0.
|
||||
*/
|
||||
public function provision(
|
||||
string $name,
|
||||
@@ -85,6 +98,7 @@ class ClientProvisioning
|
||||
?string $ldapDn = null,
|
||||
?bool $autoApprove = null,
|
||||
array $context = [],
|
||||
int $storageQuotaMb = 0,
|
||||
): User {
|
||||
$autoApprove ??= $this->autoApproves();
|
||||
|
||||
@@ -105,6 +119,7 @@ class ClientProvisioning
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'password' => $password,
|
||||
'storage_quota_mb' => $storageQuotaMb,
|
||||
]);
|
||||
|
||||
// Not mass-assignable: where an account's credentials live is a
|
||||
@@ -121,6 +136,7 @@ class ClientProvisioning
|
||||
|
||||
$this->joinAutoGroup($client);
|
||||
$this->notifyAdministrators($client, pending: ! $autoApprove);
|
||||
$this->notifyStaffInApp($client);
|
||||
|
||||
return $client;
|
||||
}
|
||||
@@ -143,6 +159,37 @@ class ClientProvisioning
|
||||
$group?->members()->syncWithoutDetaching([$client->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The bell, for staff who administer clients.
|
||||
*
|
||||
* Separate from notifyAdministrators() above, and not a replacement for
|
||||
* it: that one emails a list of raw addresses an operator typed into a
|
||||
* setting, which need not correspond to any account in this
|
||||
* installation. This one reaches the people actually signed in, which
|
||||
* is the only place an account arriving unannounced was ever going to
|
||||
* be noticed.
|
||||
*
|
||||
* Recipients are resolved here rather than inside Notifier, which
|
||||
* authorizes nothing by design — see its security contract. Two rules,
|
||||
* and the second is the one worth stating: a client-scoped staff member
|
||||
* is not told. Their whole view is the clients assigned to them, and a
|
||||
* brand-new account is assigned to nobody, so the notification would
|
||||
* link them to a screen they are refused.
|
||||
*/
|
||||
private function notifyStaffInApp(User $client): void
|
||||
{
|
||||
$recipients = User::query()
|
||||
->where('type', UserType::Staff)
|
||||
->get()
|
||||
->filter(fn (User $staff): bool => ! $staff->isClientScoped()
|
||||
&& $this->permissions->allows($staff, Permission::ManageClients));
|
||||
|
||||
$this->notifier->send('client_registered', $recipients, subject: $client, data: [
|
||||
'clientName' => $client->name,
|
||||
'clientEmail' => $client->email,
|
||||
]);
|
||||
}
|
||||
|
||||
private function notifyAdministrators(User $client, bool $pending): void
|
||||
{
|
||||
if ($this->settings->get(Setting::EmailNotificationsEnabled) !== true) {
|
||||
|
||||
@@ -28,10 +28,9 @@ class ClientStorageUsage
|
||||
|
||||
/**
|
||||
* A client's own storage_quota_mb of 0 means "no custom quota set" —
|
||||
* it inherits Setting::DefaultClientStorageQuotaMb instead of being
|
||||
* unlimited, so a site-wide default (once set) also protects clients
|
||||
* who never got an explicit quota, including self-registered ones.
|
||||
* The site default itself being 0 is what actually means unlimited.
|
||||
* it inherits the installation's default instead of being unlimited,
|
||||
* so a default (once set) also protects clients who never got an
|
||||
* explicit quota, including self-registered ones.
|
||||
*
|
||||
* @return int 0 means unlimited.
|
||||
*/
|
||||
@@ -39,7 +38,46 @@ class ClientStorageUsage
|
||||
{
|
||||
return $client->storage_quota_mb > 0
|
||||
? $client->storage_quota_mb
|
||||
: (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb);
|
||||
: $this->defaultQuotaMb();
|
||||
}
|
||||
|
||||
/**
|
||||
* What a client with no quota of their own actually gets.
|
||||
*
|
||||
* Three sources, narrowest first, and the third is why this is a
|
||||
* method rather than a `Settings::get()` at the point of use.
|
||||
*
|
||||
* `Setting::DefaultClientStorageQuotaMb` belongs to whoever runs the
|
||||
* installation, and its default is 0 — which means unlimited. That is
|
||||
* the right default for somebody setting up their own install, and the
|
||||
* wrong one for an installation a platform operates on other people's
|
||||
* behalf: there, an account that arrived without an explicit quota has
|
||||
* no ceiling at all, which on a shared installation is one account
|
||||
* away from unmetered hosting.
|
||||
*
|
||||
* So a platform may set a floor in the environment, exactly as it sets
|
||||
* the seat caps, and for the same reason those are not settings: it is
|
||||
* not a preference the installation's administrator is expressing, it
|
||||
* is the shape of what was sold. It applies only where the setting says
|
||||
* nothing, so an administrator who has chosen a number keeps it, and an
|
||||
* install with no platform behind it is unaffected.
|
||||
*
|
||||
* Unset and zero are the same answer here, on purpose: a platform that
|
||||
* wanted no ceiling would not set the variable.
|
||||
*
|
||||
* @return int 0 means unlimited.
|
||||
*/
|
||||
public function defaultQuotaMb(): int
|
||||
{
|
||||
$site = (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb);
|
||||
|
||||
if ($site > 0) {
|
||||
return $site;
|
||||
}
|
||||
|
||||
$floor = config('projectsend.platform.default_client_quota_mb');
|
||||
|
||||
return is_numeric($floor) ? max(0, (int) $floor) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Clients;
|
||||
|
||||
use App\Modules\Notifications\NotificationTypeDefinition;
|
||||
use App\Modules\Notifications\NotificationTypeRegistry;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class ClientsServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
// In-app only, the same reasoning client_uploaded gives: email for
|
||||
// this event is already sent separately, to whatever raw addresses
|
||||
// Setting::AdminNotificationEmails lists, via
|
||||
// AdminClientRegisteredNotification. Routing it through Notifier's
|
||||
// mail dispatch as well would risk double-emailing any staff member
|
||||
// who also appears in that list.
|
||||
//
|
||||
// One type for both doors, deliberately. A client arriving through
|
||||
// the public form and one arriving through an invitation are the
|
||||
// same event to the person being told — an account now exists that
|
||||
// did not — and a second type would buy nothing: preferences here
|
||||
// govern email only (see NotificationPreferences), so it could not
|
||||
// be switched off separately, and which door it came through is one
|
||||
// click away in the activity log and on the invitations screen.
|
||||
$this->app->make(NotificationTypeRegistry::class)->register(new NotificationTypeDefinition(
|
||||
key: 'client_registered',
|
||||
label: 'A new client registered an account',
|
||||
template: ':clientName (:clientEmail) registered a client account',
|
||||
// The list, filtered to this address — not clients.edit, which
|
||||
// is gated by edit_clients while the recipients below are chosen
|
||||
// by manage_clients. A notification that refuses the person it
|
||||
// was sent to is worse than one that lands a click short.
|
||||
url: fn (array $data): string => route('clients.index', ['search' => $data['clientEmail']]),
|
||||
));
|
||||
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->commands([
|
||||
Console\ExpireClientAccountsCommand::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Clients\Console;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Identity\UserType;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Switches off client accounts whose expiry date has passed.
|
||||
*
|
||||
* Not what stops an expired client getting in — User::maySignIn() does
|
||||
* that the moment the date passes, with or without this. What this does is
|
||||
* make `active` tell the truth, so everything that reads the flag rather
|
||||
* than asking the account agrees: the client list and its status filter,
|
||||
* the API's `active` field, and a managed plan's seat count. Hourly for the
|
||||
* same reason purge-stale-uploads is: a seat held by an account that can
|
||||
* no longer use it is a seat somebody else cannot have.
|
||||
*/
|
||||
class ExpireClientAccountsCommand extends Command
|
||||
{
|
||||
protected $signature = 'projectsend:expire-client-accounts';
|
||||
|
||||
protected $description = 'Deactivate client accounts whose expiry date has passed (runs hourly)';
|
||||
|
||||
public function handle(ActivityLogger $activity): int
|
||||
{
|
||||
$now = now();
|
||||
$expired = 0;
|
||||
|
||||
$due = User::query()
|
||||
->where('type', UserType::Client)
|
||||
->where('active', true)
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<=', $now)
|
||||
->get(['id', 'name', 'expires_at']);
|
||||
|
||||
foreach ($due as $client) {
|
||||
// Conditional on the row still being due, not a plain save():
|
||||
// an administrator who moved the date forward between the read
|
||||
// above and this write has just decided the account should keep
|
||||
// working, and must not be overruled by a list that is a few
|
||||
// milliseconds old.
|
||||
$switchedOff = User::query()
|
||||
->whereKey($client->id)
|
||||
->where('active', true)
|
||||
->where('expires_at', '<=', $now)
|
||||
->update(['active' => false]);
|
||||
|
||||
if ($switchedOff === 1) {
|
||||
$activity->logSystem(Action::ClientExpired, ['name' => $client->name, 'id' => $client->id]);
|
||||
$expired++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Deactivated {$expired} expired client account(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,11 @@ use App\Models\User;
|
||||
use App\Modules\Api\Support\PollingQuery;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Clients\ClientAccounts;
|
||||
use App\Modules\Clients\ClientCustomFieldType;
|
||||
use App\Modules\Clients\ClientStorageUsage;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Platform\Localization\DateInput;
|
||||
use App\Modules\Platform\Seats\SeatAllowance;
|
||||
use App\Modules\Clients\Http\Resources\Api\ClientResource;
|
||||
use App\Modules\Clients\Models\ClientCustomField;
|
||||
@@ -22,10 +24,7 @@ use App\Modules\Files\DeletedAccountContent;
|
||||
use App\Modules\Identity\AccountContentDeletion;
|
||||
use App\Modules\Identity\Erasure\AvailableEmailRule;
|
||||
use App\Modules\Identity\Erasure\ErasureSchedule;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use App\Modules\Identity\Permissions\SystemRole;
|
||||
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
|
||||
use App\Modules\Identity\UserType;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -62,7 +61,9 @@ class ClientsController extends Controller
|
||||
private readonly AccountContentDeletion $accountDeletion,
|
||||
private readonly StaffLibraryScope $scope,
|
||||
private readonly SeatAllowance $seats,
|
||||
private readonly ClientAccounts $clients,
|
||||
private readonly ErasureSchedule $erasure,
|
||||
private readonly DateInput $dates,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
@@ -118,8 +119,6 @@ class ClientsController extends Controller
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->seats->guardClient();
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
|
||||
@@ -133,30 +132,38 @@ 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
|
||||
@@ -170,6 +177,10 @@ 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);
|
||||
}
|
||||
@@ -188,6 +199,11 @@ class ClientsController extends Controller
|
||||
'active' => ['sometimes', 'boolean'],
|
||||
'password' => ['sometimes', 'nullable', Password::defaults()],
|
||||
'storage_quota_mb' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
// Send null to remove the expiry. Read the same way as on
|
||||
// create. An account cannot be active with a date that has
|
||||
// passed, so reactivating an expired client needs a new date
|
||||
// (or null) in the same request.
|
||||
'expires_at' => ['sometimes', 'nullable', 'string', 'date'],
|
||||
'custom_field_values' => ['sometimes', 'array'],
|
||||
]);
|
||||
|
||||
@@ -206,6 +222,26 @@ class ClientsController extends Controller
|
||||
$client->storage_quota_mb = $validated['storage_quota_mb'] ?? 0;
|
||||
}
|
||||
|
||||
// Asked only when this request touches one of the two values, so a
|
||||
// PATCH renaming a client whose date passed an hour ago is not
|
||||
// refused over a field it never sent. Resolved with boolean() for
|
||||
// the reason given in the web controller.
|
||||
if (array_key_exists('expires_at', $validated) || array_key_exists('active', $validated)) {
|
||||
$editor = $request->user();
|
||||
assert($editor !== null);
|
||||
|
||||
$expiresAt = array_key_exists('expires_at', $validated)
|
||||
? $this->dates->instant($validated['expires_at'], $editor)
|
||||
: $client->expires_at;
|
||||
|
||||
$this->clients->guardExpiry(
|
||||
$expiresAt,
|
||||
active: array_key_exists('active', $validated) ? $request->boolean('active') : $client->active,
|
||||
);
|
||||
|
||||
$client->expires_at = $expiresAt;
|
||||
}
|
||||
|
||||
// Approval, and so the moment the seat is spent — same rule the
|
||||
// web edit screen and approve() answer to. Inside the branch, so a
|
||||
// capped installation can still edit a client it already holds.
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Modules\Clients\Http\Controllers;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Folders\ClientHomeFolders;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
@@ -21,6 +22,7 @@ class ClientSettingsController extends Controller
|
||||
public function __construct(
|
||||
private readonly Settings $settings,
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly ClientHomeFolders $homeFolders,
|
||||
) {}
|
||||
|
||||
public function edit(): Response
|
||||
@@ -31,8 +33,14 @@ class ClientSettingsController extends Controller
|
||||
'clients_auto_group' => $this->settings->get(Setting::ClientsAutoGroup),
|
||||
'clients_can_select_group' => $this->settings->get(Setting::ClientsCanSelectGroup),
|
||||
'clients_membership_deny_cooldown_days' => $this->settings->get(Setting::ClientsMembershipDenyCooldownDays),
|
||||
'client_invitation_expiry_hours' => $this->settings->get(Setting::ClientInvitationExpiryHours),
|
||||
'default_client_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
|
||||
'clients_can_preview_files' => $this->settings->get(Setting::ClientsCanPreviewFiles),
|
||||
'clients_home_folders' => $this->settings->get(Setting::ClientsHomeFolders),
|
||||
// What the button beside the switch would actually do, so it can
|
||||
// say "3 clients have no folder yet" instead of asking somebody
|
||||
// to press it and find out.
|
||||
'clients_without_home' => $this->homeFolders->pendingCount(),
|
||||
'groups' => Group::query()->orderBy('name')->get()
|
||||
->map(fn (Group $group): array => ['id' => $group->id, 'name' => $group->name])
|
||||
->all(),
|
||||
@@ -47,8 +55,10 @@ class ClientSettingsController extends Controller
|
||||
'clients_auto_group' => ['required', 'integer', Rule::in([0, ...Group::query()->pluck('id')->all()])],
|
||||
'clients_can_select_group' => ['required', Rule::in(['none', 'public'])],
|
||||
'clients_membership_deny_cooldown_days' => ['required', 'integer', 'min:0', 'max:365'],
|
||||
'client_invitation_expiry_hours' => ['required', 'integer', 'min:1', 'max:720'],
|
||||
'default_client_storage_quota_mb' => ['required', 'integer', 'min:0'],
|
||||
'clients_can_preview_files' => ['required', 'boolean'],
|
||||
'clients_home_folders' => ['required', 'boolean'],
|
||||
]);
|
||||
|
||||
$this->settings->set(Setting::ClientsCanRegister, $validated['clients_can_register']);
|
||||
@@ -56,11 +66,54 @@ class ClientSettingsController extends Controller
|
||||
$this->settings->set(Setting::ClientsAutoGroup, (int) $validated['clients_auto_group']);
|
||||
$this->settings->set(Setting::ClientsCanSelectGroup, $validated['clients_can_select_group']);
|
||||
$this->settings->set(Setting::ClientsMembershipDenyCooldownDays, (int) $validated['clients_membership_deny_cooldown_days']);
|
||||
$this->settings->set(Setting::ClientInvitationExpiryHours, (int) $validated['client_invitation_expiry_hours']);
|
||||
$this->settings->set(Setting::DefaultClientStorageQuotaMb, (int) $validated['default_client_storage_quota_mb']);
|
||||
$this->settings->set(Setting::ClientsCanPreviewFiles, $validated['clients_can_preview_files']);
|
||||
// Saving the switch deliberately creates nothing. Existing clients
|
||||
// get a folder when somebody presses the button, so that turning
|
||||
// this on, looking at it, and turning it off again leaves the
|
||||
// library exactly as it was.
|
||||
$this->settings->set(Setting::ClientsHomeFolders, $validated['clients_home_folders']);
|
||||
|
||||
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'clients']);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the missing home folders, on request.
|
||||
*
|
||||
* Its own endpoint rather than part of saving the form, because it is a
|
||||
* different kind of act: the form records a preference, this one writes
|
||||
* a folder for every client on the installation. Wrapping the second
|
||||
* inside the first would mean an administrator could not try the
|
||||
* setting without committing to it.
|
||||
*/
|
||||
public function backfillHomeFolders(Request $request): RedirectResponse
|
||||
{
|
||||
abort_unless($this->homeFolders->enabled(), 403, 'Client folders are switched off.');
|
||||
|
||||
$result = $this->homeFolders->backfill();
|
||||
|
||||
$this->activity->log(Action::SettingsUpdated, context: [
|
||||
'section' => 'clients',
|
||||
'action' => 'client_home_folders_backfill',
|
||||
'created' => $result['created'],
|
||||
'total' => $result['total'],
|
||||
]);
|
||||
|
||||
// Counts rather than "Done": on an installation with hundreds of
|
||||
// clients the administrator wants to know how many there were and
|
||||
// how many are new, and the difference between "created 200" and
|
||||
// "created 0, they already had one" is the whole answer.
|
||||
return back()->with('success', trans_choice(
|
||||
'{0}Every client already had a folder.|[1,*]:created of :total clients got a folder. :existing already had one.',
|
||||
$result['created'],
|
||||
[
|
||||
'created' => (string) $result['created'],
|
||||
'total' => (string) $result['total'],
|
||||
'existing' => (string) $result['existing'],
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Clients\ClientAccounts;
|
||||
use App\Modules\Clients\ClientCustomFieldType;
|
||||
use App\Modules\Clients\ClientStorageUsage;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Platform\Localization\DateInput;
|
||||
use App\Modules\Platform\Seats\SeatAllowance;
|
||||
use App\Modules\Clients\Models\ClientCustomField;
|
||||
use App\Modules\Clients\Models\ClientCustomFieldValue;
|
||||
@@ -20,10 +22,7 @@ use App\Modules\Files\DeletedAccountContent;
|
||||
use App\Modules\Identity\AccountContentDeletion;
|
||||
use App\Modules\Identity\Erasure\AvailableEmailRule;
|
||||
use App\Modules\Identity\Erasure\ErasureSchedule;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use App\Modules\Identity\Permissions\SystemRole;
|
||||
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
|
||||
use App\Modules\Identity\UserType;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use App\Support\Pagination;
|
||||
@@ -51,7 +50,9 @@ class ClientsController extends Controller
|
||||
private readonly AccountContentDeletion $accountDeletion,
|
||||
private readonly StaffLibraryScope $scope,
|
||||
private readonly SeatAllowance $seats,
|
||||
private readonly ClientAccounts $clients,
|
||||
private readonly ErasureSchedule $erasure,
|
||||
private readonly DateInput $dates,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
@@ -90,6 +91,12 @@ class ClientsController extends Controller
|
||||
'email' => $client->email,
|
||||
'active' => $client->active,
|
||||
'account_requested' => $client->account_requested,
|
||||
// A calendar day in the viewer's zone, as the edit form shows
|
||||
// it, plus whether it has passed: the sweep that switches an
|
||||
// expired account off runs hourly, and the list should not
|
||||
// call an account that already refuses sign-ins "Active".
|
||||
'expires_on' => $this->dates->asShown($client->expires_at, $viewer),
|
||||
'expired' => $client->hasExpired(),
|
||||
'created_at' => $client->created_at?->toIso8601String(),
|
||||
'content' => $content[$client->id] ?? ['files' => 0, 'folders' => 0],
|
||||
]);
|
||||
@@ -123,45 +130,57 @@ class ClientsController extends Controller
|
||||
|
||||
return Inertia::render('clients/create', [
|
||||
'custom_fields' => $this->customFieldDefinitions(),
|
||||
'default_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
|
||||
// The resolved default, not the raw setting: a platform can put
|
||||
// a floor under it from the environment, and both screens
|
||||
// present this as what will actually happen rather than as a
|
||||
// value being edited. The edit screen mirrors quotaMb()'s
|
||||
// resolution client-side to draw the usage bar, and handing it
|
||||
// the effective number is what keeps that mirror correct
|
||||
// without it having to know floors exist.
|
||||
//
|
||||
// The Client settings form deliberately still reads the raw
|
||||
// setting (ClientSettingsController): that field is edited and
|
||||
// saved back, so prefilling it with a floor would write the
|
||||
// platform's number into the setting as the administrator's own
|
||||
// choice, where it would outlive the floor.
|
||||
'default_storage_quota_mb' => $this->storageUsage->defaultQuotaMb(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
// A client created here is approved by construction, so it counts
|
||||
// immediately — unlike a self-registration awaiting a decision.
|
||||
$this->seats->guardClient();
|
||||
|
||||
$validated = $request->validate(array_merge([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
|
||||
'expires_at' => ['nullable', 'string', 'date'],
|
||||
], $this->customFieldRules()));
|
||||
|
||||
$client = User::create([
|
||||
'type' => UserType::Client,
|
||||
'active' => true,
|
||||
'account_requested' => false,
|
||||
'role_id' => Role::query()->where('name', SystemRole::Client->value)->value('id'),
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'password' => $validated['password'],
|
||||
// 0 (including an omitted field) means "no custom quota" —
|
||||
// it inherits Setting::DefaultClientStorageQuotaMb at
|
||||
// enforcement time (see ClientStorageUsage::quotaMb()), not
|
||||
// baked in here, so a later change to the site default
|
||||
// keeps applying to this client automatically.
|
||||
'storage_quota_mb' => $validated['storage_quota_mb'] ?? 0,
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$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
|
||||
@@ -225,8 +244,13 @@ class ClientsController extends Controller
|
||||
'account_requested' => $client->account_requested,
|
||||
'storage_quota_mb' => $client->storage_quota_mb,
|
||||
'two_factor_enabled' => $client->hasTwoFactorEnabled(),
|
||||
// update() compares the posted value against this same
|
||||
// string — see there.
|
||||
'expires_at' => $this->dates->asShown($client->expires_at, $request->user()),
|
||||
'expired' => $client->hasExpired(),
|
||||
],
|
||||
'default_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
|
||||
// Resolved, not raw — see create() above.
|
||||
'default_storage_quota_mb' => $this->storageUsage->defaultQuotaMb(),
|
||||
'storage_used_mb' => (int) ceil($this->storageUsage->usedBytes($client) / 1024 / 1024),
|
||||
'custom_fields' => $this->customFieldDefinitions(),
|
||||
'custom_field_values' => ClientCustomFieldValue::query()
|
||||
@@ -250,8 +274,27 @@ class ClientsController extends Controller
|
||||
'active' => ['required', 'boolean'],
|
||||
'password' => ['nullable', 'confirmed', Password::defaults()],
|
||||
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
|
||||
'expires_at' => ['nullable', 'string', 'date'],
|
||||
], $this->customFieldRules()));
|
||||
|
||||
$editor = $request->user();
|
||||
assert($editor !== null);
|
||||
|
||||
// The form was rendered with the stored instant read back as a day
|
||||
// in the editor's zone, and posts that string again with every
|
||||
// other edit. Only a different string is a new date; re-deriving
|
||||
// an unchanged one would move the expiry by the difference between
|
||||
// two editors' zones each time either of them renamed the client.
|
||||
// Same rule as a file's expiry (FilesController::update).
|
||||
$postedExpiry = $validated['expires_at'] ?? null;
|
||||
$expiresAt = $postedExpiry !== $this->dates->asShown($client->expires_at, $editor)
|
||||
? $this->dates->instant($postedExpiry, $editor)
|
||||
: $client->expires_at;
|
||||
|
||||
// Request::boolean(), not the validated value: `boolean` accepts
|
||||
// "1" and "0" without converting them.
|
||||
$this->clients->guardExpiry($expiresAt, active: $request->boolean('active'));
|
||||
|
||||
$wasActive = $client->active;
|
||||
$passwordChanged = is_string($validated['password'] ?? null) && $validated['password'] !== '';
|
||||
|
||||
@@ -266,6 +309,8 @@ class ClientsController extends Controller
|
||||
'storage_quota_mb' => $validated['storage_quota_mb'] ?? 0,
|
||||
]);
|
||||
|
||||
$client->expires_at = $expiresAt;
|
||||
|
||||
// Activating a pending account through the edit screen counts as
|
||||
// approval and clears the request flag — which is the moment a
|
||||
// seat is spent, so the cap is asked here for the same reason
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Clients\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Clients\ClientStorageUsage;
|
||||
use App\Modules\Clients\Models\Invitation;
|
||||
use App\Modules\Clients\Notifications\ClientInvitationNotification;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use App\Modules\Identity\Erasure\AvailableEmailRule;
|
||||
use App\Modules\Platform\Seats\SeatAllowance;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use App\Support\Pagination;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
/**
|
||||
* Staff sending a client an invitation to register, ahead of the public
|
||||
* form — the "New client" button's sibling for an installation that
|
||||
* would rather have somebody set their own password than hand them one.
|
||||
*/
|
||||
class InvitationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly StaffLibraryScope $scope,
|
||||
private readonly Settings $settings,
|
||||
private readonly ClientStorageUsage $storageUsage,
|
||||
private readonly SeatAllowance $seats,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Every state the status filter accepts. What each one means lives in
|
||||
* applyStateFilter() alone: two of them narrow the same stored status
|
||||
* by the clock, and a second copy of that rule is how the filter and
|
||||
* the badge start disagreeing about a row whose expiry just passed.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const FILTERABLE_STATES = [
|
||||
'pending',
|
||||
'expired',
|
||||
Invitation::STATUS_REDEEMED,
|
||||
Invitation::STATUS_REVOKED,
|
||||
Invitation::STATUS_SUPERSEDED,
|
||||
];
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'status' => ['nullable', 'string', Rule::in(self::FILTERABLE_STATES)],
|
||||
]);
|
||||
|
||||
$status = $validated['status'] ?? null;
|
||||
|
||||
// Every invitation ever sent, not only the live ones. The list is a
|
||||
// history: what was sent, what became of it, and who is still
|
||||
// waiting. A screen that showed only what is outstanding cannot
|
||||
// answer "did we ever invite this person", which is the question
|
||||
// somebody actually arrives with.
|
||||
$invitations = Invitation::query()
|
||||
->when($status !== null, fn (Builder $query) => $this->applyStateFilter($query, (string) $status))
|
||||
->with(['group:id,name', 'invitedBy:id,name'])
|
||||
// Newest first, the order a history is read in. What is urgent
|
||||
// rather than recent is reachable through the status filter,
|
||||
// and the Expires column says the rest.
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id')
|
||||
->paginate(25)
|
||||
->withQueryString()
|
||||
->through(fn (Invitation $invitation): array => [
|
||||
'id' => $invitation->id,
|
||||
'name' => $invitation->name,
|
||||
'email' => $invitation->email,
|
||||
'group' => $invitation->group?->name,
|
||||
'invited_by' => $invitation->invitedBy?->name,
|
||||
'created_at' => $invitation->created_at?->toIso8601String(),
|
||||
'expires_at' => $invitation->expires_at->toIso8601String(),
|
||||
// What the screen labels the row, and what the filter above
|
||||
// selects on — one definition, so the badge and the filter
|
||||
// cannot disagree about a row whose expiry just passed.
|
||||
'state' => $invitation->state(),
|
||||
]);
|
||||
|
||||
return Inertia::render('clients/invitations', [
|
||||
'invitations' => $invitations->items(),
|
||||
'pagination' => Pagination::meta($invitations),
|
||||
'filters' => ['status' => $status],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Invitation> $query
|
||||
* @return Builder<Invitation>
|
||||
*/
|
||||
private function applyStateFilter(Builder $query, string $state): Builder
|
||||
{
|
||||
return match ($state) {
|
||||
'pending' => $query->pending()->where('expires_at', '>=', now()),
|
||||
'expired' => $query->pending()->where('expires_at', '<', now()),
|
||||
default => $query->where('status', $state),
|
||||
};
|
||||
}
|
||||
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
$viewer = $request->user();
|
||||
assert($viewer instanceof User);
|
||||
|
||||
return Inertia::render('clients/invite', [
|
||||
// Scoped, exactly as GroupsController::index() is: a
|
||||
// client-scoped staff member is told about a group because one
|
||||
// of their clients is in it. Unscoped, this form listed every
|
||||
// group on the installation to a viewer who can reach none of
|
||||
// them — the hole GHSA-r3hg-3fxw-rcmr closed everywhere else,
|
||||
// left open here because invitations were written after it.
|
||||
'groups' => $this->scope->groups($viewer)->orderBy('name')->get(['id', 'name']),
|
||||
// Resolved, not raw — see ClientsController::create()'s note on
|
||||
// the same prop: this is what will actually happen, and the
|
||||
// form's own field mirrors this resolution to draw its hint.
|
||||
'default_storage_quota_mb' => $this->storageUsage->defaultQuotaMb(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$viewer = $request->user();
|
||||
assert($viewer instanceof User);
|
||||
|
||||
$validated = $request->validate([
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
// Against the groups this person may actually put somebody in,
|
||||
// not against every group there is: the list above is only what
|
||||
// the form drew, and a request does not have to come from it.
|
||||
'group_id' => ['required', 'integer', Rule::in([0, ...$this->scope->groups($viewer)->pluck('id')->all()])],
|
||||
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
|
||||
]);
|
||||
|
||||
// Asked here as well as at redemption. An outstanding invitation
|
||||
// is not a client and is not counted as one — the same rule a
|
||||
// pending account request follows — so this refuses sending a link
|
||||
// a full installation could not honour, rather than reserving
|
||||
// anything. The redemption door still guards, because the seat can
|
||||
// be taken by somebody else in the days between.
|
||||
$this->seats->guardClient();
|
||||
|
||||
$group = $validated['group_id'] > 0
|
||||
? Group::query()->whereKey($validated['group_id'])->first()
|
||||
: null;
|
||||
|
||||
$invitation = Invitation::issue(
|
||||
email: $validated['email'],
|
||||
name: $validated['name'] ?? null,
|
||||
group: $group,
|
||||
invitedBy: $request->user(),
|
||||
expiresAt: now()->addHours((int) $this->settings->get(Setting::ClientInvitationExpiryHours)),
|
||||
// The `integer` rule above validates the shape but does not
|
||||
// cast it — this arrives as a numeric string from the request,
|
||||
// same as group_id, and issue() takes a real int.
|
||||
storageQuotaMb: (int) ($validated['storage_quota_mb'] ?? 0),
|
||||
);
|
||||
|
||||
Notification::route('mail', $invitation->email)->notify(
|
||||
new ClientInvitationNotification($invitation->name ?? $invitation->email, $invitation->token),
|
||||
);
|
||||
|
||||
$this->activity->log(Action::ClientInvited, context: ['email' => $invitation->email]);
|
||||
|
||||
return redirect()->route('invitations.index')->with('success', __('Invitation sent.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels an invitation nobody has used yet.
|
||||
*
|
||||
* Until this existed, letting one expire was the only way to take it
|
||||
* back — and the expired page's own "send me a new one" button undid
|
||||
* that, silently, for anybody still holding the link. Revoking is the
|
||||
* decision that button cannot reverse: STATUS_REVOKED is outside
|
||||
* pending(), which is the scope both the redemption and the resend
|
||||
* doors look through.
|
||||
*
|
||||
* The row is kept rather than deleted, for the reason
|
||||
* Invitation::STATUS_SUPERSEDED is kept: the activity log names who
|
||||
* invited this address and when, and that trail should still lead
|
||||
* somewhere.
|
||||
*/
|
||||
public function destroy(Invitation $invitation): RedirectResponse
|
||||
{
|
||||
// Already spent, already superseded, already revoked: there is
|
||||
// nothing left to cancel, and saying so is better than reporting a
|
||||
// success that changed nothing.
|
||||
abort_unless($invitation->status === Invitation::STATUS_PENDING, 404);
|
||||
|
||||
$invitation->forceFill(['status' => Invitation::STATUS_REVOKED])->save();
|
||||
|
||||
$this->activity->log(Action::ClientInvitationRevoked, context: ['email' => $invitation->email]);
|
||||
|
||||
return back()->with('success', __('Invitation revoked.'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Clients\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Clients\ClientProvisioning;
|
||||
use App\Modules\Clients\Models\Invitation;
|
||||
use App\Modules\Clients\Notifications\ClientInvitationNotification;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
/**
|
||||
* A client redeeming the link an invitation emailed them — the invited
|
||||
* counterpart to RegistrationController's public form. Reaching the form
|
||||
* at all is the whole difference: it is gated by a specific address
|
||||
* having a live token rather than by Setting::ClientsCanRegister, and the
|
||||
* account that comes out of it is provisioned exactly the way any other
|
||||
* self-registration is (ClientProvisioning), so an installation with
|
||||
* auto-approve off still puts one in the same queue as everybody else.
|
||||
*/
|
||||
class InvitationRedemptionController extends Controller
|
||||
{
|
||||
/**
|
||||
* How many times an invitation may be renewed by the person holding
|
||||
* it, before a staff member has to send a new one.
|
||||
*
|
||||
* Without a limit, expiry stops meaning anything: whoever holds a dead
|
||||
* link can re-arm it, so the window an operator configured is only as
|
||||
* short as the longest anybody bothers to wait. Three is enough for
|
||||
* somebody who genuinely keeps missing it, and short enough that a link
|
||||
* sitting somewhere it should not be — a forwarded thread, a shared
|
||||
* inbox, a mailbox that changed hands — eventually stops answering on
|
||||
* its own, as the expiry setting says it will.
|
||||
*
|
||||
* Revoking is the immediate version of the same decision, and does not
|
||||
* wait for this: see InvitationController::destroy().
|
||||
*/
|
||||
private const SELF_RESEND_LIMIT = 3;
|
||||
|
||||
public function __construct(
|
||||
private readonly ClientProvisioning $provisioning,
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly Settings $settings,
|
||||
private readonly StaffLibraryScope $scope,
|
||||
) {}
|
||||
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
$token = (string) $request->route('token');
|
||||
$invitation = $this->findUsable($token);
|
||||
|
||||
return Inertia::render('auth/invite', [
|
||||
'token' => $token,
|
||||
'email' => $invitation->email ?? '',
|
||||
'name' => $invitation->name ?? '',
|
||||
'status' => $request->session()->get('status'),
|
||||
// Same shape as NewPasswordController::create()'s $expired: one
|
||||
// answer for "no such token" and "spent or expired token",
|
||||
// because telling them apart would tell a guesser which
|
||||
// addresses this installation has invited.
|
||||
'expired' => $invitation === null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'token' => ['required', 'string'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
]);
|
||||
|
||||
$invitation = $this->findUsable($validated['token']);
|
||||
|
||||
if ($invitation === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'token' => [__('This invitation is no longer valid. Ask whoever invited you to send a new one.')],
|
||||
]);
|
||||
}
|
||||
|
||||
// An invitation is live for days, and the address it names can be
|
||||
// taken in the meantime — staff got impatient and made the account
|
||||
// by hand, or the person registered through the public form. The
|
||||
// unique index on users.email spans trashed rows, so provision()
|
||||
// would raise a QueryException here rather than refusing: a 500 on
|
||||
// the screen of somebody who has just typed a password. Every other
|
||||
// caller with no form to validate asks this first, for this reason
|
||||
// — see ClientProvisioning::addressIsFree().
|
||||
if (! $this->provisioning->addressIsFree($invitation->email)) {
|
||||
throw ValidationException::withMessages([
|
||||
// Says what happened, because the person holding this link
|
||||
// already knows the address is theirs — it is the one the
|
||||
// invitation was sent to. There is nothing here to disclose
|
||||
// that the invitation itself did not.
|
||||
'token' => [__('An account already exists for this email address. Try signing in instead, or reset your password.')],
|
||||
]);
|
||||
}
|
||||
|
||||
$client = $this->provisioning->provision(
|
||||
name: $validated['name'],
|
||||
email: $invitation->email,
|
||||
password: $validated['password'],
|
||||
action: Action::ClientInvitationRedeemed,
|
||||
// Always, regardless of Setting::ClientsAutoApprove: an
|
||||
// invitation names a specific address a staff member already
|
||||
// decided to let in, which is the trust an approval queue
|
||||
// exists to establish for the address it never named.
|
||||
autoApprove: true,
|
||||
storageQuotaMb: $invitation->storage_quota_mb,
|
||||
);
|
||||
|
||||
if ($invitation->group !== null && $this->mayJoin($invitation, $client)) {
|
||||
$invitation->group->members()->syncWithoutDetaching([$client->id]);
|
||||
}
|
||||
|
||||
$invitation->forceFill(['status' => Invitation::STATUS_REDEEMED])->save();
|
||||
|
||||
return redirect()->route('login')->with(
|
||||
'status',
|
||||
$client->account_requested
|
||||
? __('Your account has been created. You will be able to log in once it is approved.')
|
||||
: __('Your account has been created. You can log in now.'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resends a fresh link to the same address without anybody deciding
|
||||
* to — the invited person asked for it, not an administrator. A spent
|
||||
* or genuinely unknown token answers the same as an expired one: this
|
||||
* is the one door on the flow an anonymous visitor can knock on
|
||||
* repeatedly, so it must not become a way to learn which addresses
|
||||
* were ever invited.
|
||||
*
|
||||
* The new link goes to the address on the invitation, never to whoever
|
||||
* asked, so holding a leaked URL gets nobody a working one. What this
|
||||
* does spend is the operator's expiry window, which is why
|
||||
* SELF_RESEND_LIMIT caps how often it can be spent, and why staff can
|
||||
* end it outright by revoking.
|
||||
*/
|
||||
public function resend(Request $request): RedirectResponse
|
||||
{
|
||||
$token = (string) $request->route('token');
|
||||
$invitation = $this->findUsable($token, includingExpired: true);
|
||||
|
||||
// Spent, unknown, revoked, or renewed as often as it may be: all
|
||||
// four answer the same sentence below, and none of them sends
|
||||
// anything. Only the last of the four is a link whose holder did
|
||||
// nothing wrong, and telling them apart here would tell a guesser
|
||||
// which addresses this installation has invited.
|
||||
if ($invitation !== null && $invitation->resends < self::SELF_RESEND_LIMIT) {
|
||||
$fresh = Invitation::issue(
|
||||
email: $invitation->email,
|
||||
name: $invitation->name,
|
||||
group: $invitation->group,
|
||||
invitedBy: $invitation->invitedBy,
|
||||
expiresAt: now()->addHours((int) $this->settings->get(Setting::ClientInvitationExpiryHours)),
|
||||
storageQuotaMb: $invitation->storage_quota_mb,
|
||||
resends: $invitation->resends + 1,
|
||||
);
|
||||
|
||||
Notification::route('mail', $fresh->email)->notify(
|
||||
new ClientInvitationNotification($fresh->name ?? $fresh->email, $fresh->token),
|
||||
);
|
||||
|
||||
// Nobody is signed in, so this records no actor — which is the
|
||||
// point of logging it. Sending and redeeming were already in
|
||||
// the trail; renewing was the one step that moved an invitation
|
||||
// along with no staff member behind it and left no trace.
|
||||
// Bounded by the limit above, so an anonymous door cannot flood
|
||||
// the log.
|
||||
$this->activity->log(Action::ClientInvitationResent, context: ['email' => $fresh->email]);
|
||||
}
|
||||
|
||||
return back()->with('status', __('If that invitation can still be resent, a new one is on its way.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The invitation $token names, if it is still one store() would
|
||||
* accept — pending and not expired, unless $includingExpired asks for
|
||||
* the resend door's wider question instead.
|
||||
*/
|
||||
/**
|
||||
* Whether the membership this invitation carries is one its sender may
|
||||
* grant — the same question GroupMembersController::store() asks before
|
||||
* adding anybody to a group.
|
||||
*
|
||||
* Asked here as well as when the invitation was written, and this is
|
||||
* the half that matters: an invitation is a grant that lands days
|
||||
* later, when the person who sent it is not present to be checked, and
|
||||
* one written before this check existed can still be outstanding. A
|
||||
* refused membership is dropped rather than failing the redemption —
|
||||
* the account is what the person holding the link came for, and it is
|
||||
* theirs either way.
|
||||
*
|
||||
* An invitation whose sender is gone (the account was deleted and the
|
||||
* column nulls out) keeps its group: there is no longer a reach to
|
||||
* exceed, and dropping it would quietly undo what an administrator
|
||||
* arranged.
|
||||
*/
|
||||
private function mayJoin(Invitation $invitation, User $client): bool
|
||||
{
|
||||
$inviter = $invitation->invitedBy;
|
||||
$group = $invitation->group;
|
||||
|
||||
if ($inviter === null || $group === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->scope->allowsGroupMembership($inviter, $group, $client)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Log::warning('An invitation named a group its sender may not add anybody to; the account was created without it.', [
|
||||
'invitation' => $invitation->id,
|
||||
'group' => $group->id,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function findUsable(string $token, bool $includingExpired = false): ?Invitation
|
||||
{
|
||||
$invitation = Invitation::query()->pending()->where('token', $token)->first();
|
||||
|
||||
if ($invitation === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $includingExpired && $invitation->isExpired()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $invitation;
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,10 @@ class ClientResource extends JsonResource
|
||||
// caller needs to see before removing it. The secret and the
|
||||
// recovery codes stay where they are.
|
||||
'two_factor_enabled' => $this->hasTwoFactorEnabled(),
|
||||
// Null when the account never expires. Once this passes the
|
||||
// client can no longer sign in, and `active` turns false within
|
||||
// the hour.
|
||||
'expires_at' => $this->expires_at?->toIso8601String(),
|
||||
'created_at' => $this->created_at?->toIso8601String(),
|
||||
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Clients\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* A staff-sent invitation for a specific address to register a client
|
||||
* account, ahead of the public registration form. Redeeming one is
|
||||
* handled by ClientProvisioning, the same as any other self-provisioned
|
||||
* account — an invitation only settles who is allowed to reach the form
|
||||
* and with which address, not the account's own policy.
|
||||
*
|
||||
* **The token is the whole authorization**, the same as a file's share
|
||||
* link: the redemption route has nothing else to look it up by, so it is
|
||||
* stored the way CreateShareLink stores one — Str::random(40), plain,
|
||||
* queried directly — rather than hashed the way a password is.
|
||||
*
|
||||
* @property int $id
|
||||
* @property string|null $name
|
||||
* @property string $email
|
||||
* @property string $token
|
||||
* @property string $status
|
||||
* @property int $resends
|
||||
* @property int $storage_quota_mb
|
||||
* @property int|null $group_id
|
||||
* @property int|null $invited_by_id
|
||||
* @property Carbon $expires_at
|
||||
*/
|
||||
class Invitation extends Model
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_REDEEMED = 'redeemed';
|
||||
|
||||
// Retired by a fresh invitation to the same address, issued either
|
||||
// because staff sent another one or because the invited person asked
|
||||
// for a new link — see issue(). Never redeemable, but kept rather than
|
||||
// deleted so the activity log's trail of who invited this address,
|
||||
// and when, stays intact.
|
||||
public const STATUS_SUPERSEDED = 'superseded';
|
||||
|
||||
// Cancelled by staff before anybody used it — the wrong address, or a
|
||||
// decision taken back. Distinct from superseded because it is the only
|
||||
// one of the two that was somebody's intention: a revoked invitation is
|
||||
// never re-issued, where a superseded one was retired precisely so a
|
||||
// fresh link could take its place. Both are outside pending(), so the
|
||||
// redemption and resend doors refuse either without asking which.
|
||||
public const STATUS_REVOKED = 'revoked';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'expires_at' => 'datetime',
|
||||
// Read straight into provision()'s `int $storageQuotaMb` when
|
||||
// the invitation is redeemed -- see the same cast on User.
|
||||
'storage_quota_mb' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh invitation for $email, retiring any other still-pending one
|
||||
* for the same address first — one live token per address at a time,
|
||||
* whether this is staff sending a second invite or the invited person
|
||||
* asking for a new link after the first expired.
|
||||
*
|
||||
* @param int $resends How many self-resends this link already stands
|
||||
* on. Staff leave it at zero; the resend door
|
||||
* passes the previous invitation's count plus
|
||||
* one, which is what makes the limit apply to
|
||||
* the chain rather than to a single row.
|
||||
*/
|
||||
public static function issue(string $email, ?string $name, ?Group $group, ?User $invitedBy, Carbon $expiresAt, int $storageQuotaMb = 0, int $resends = 0): self
|
||||
{
|
||||
self::query()->pending()->where('email', $email)->update(['status' => self::STATUS_SUPERSEDED]);
|
||||
|
||||
return self::query()->create([
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'token' => Str::random(40),
|
||||
'status' => self::STATUS_PENDING,
|
||||
// Zero from staff, and deliberately: sending an invitation is
|
||||
// somebody deciding to, which starts the allowance again. Only
|
||||
// a self-resend carries the previous count forward.
|
||||
'resends' => $resends,
|
||||
'storage_quota_mb' => $storageQuotaMb,
|
||||
'group_id' => $group?->id,
|
||||
'invited_by_id' => $invitedBy?->id,
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expires_at->isPast();
|
||||
}
|
||||
|
||||
/**
|
||||
* What a person reading a list of invitations should be told this one
|
||||
* is — which is not quite `status`.
|
||||
*
|
||||
* "Expired" is not a stored status and deliberately is not one: nothing
|
||||
* writes it, a row becomes expired by the clock passing rather than by
|
||||
* anybody acting, and a stored value would need a scheduled task to
|
||||
* stay true. But it is the distinction somebody scanning the list cares
|
||||
* about most, so it is derived here, once, rather than in the screen
|
||||
* and again in the filter — the two would eventually disagree about the
|
||||
* edge.
|
||||
*
|
||||
* @return 'pending'|'expired'|'redeemed'|'revoked'|'superseded'
|
||||
*/
|
||||
public function state(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
self::STATUS_PENDING => $this->isExpired() ? 'expired' : 'pending',
|
||||
self::STATUS_REDEEMED => 'redeemed',
|
||||
self::STATUS_REVOKED => 'revoked',
|
||||
default => 'superseded',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Invitation> $query
|
||||
* @return Builder<Invitation>
|
||||
*/
|
||||
public function scopePending(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_PENDING);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Group, $this>
|
||||
*/
|
||||
public function group(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Group::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function invitedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'invited_by_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Clients\Notifications;
|
||||
|
||||
use App\Modules\Platform\Notifications\Concerns\RendersOverridableMail;
|
||||
use App\Modules\Platform\Notifications\EmailTemplateSlot;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
/**
|
||||
* Sent on-demand (Notification::route('mail', ...)), never via
|
||||
* $client->notify() — there is no account yet to notify, only an address
|
||||
* somebody typed into the invite form.
|
||||
*/
|
||||
class ClientInvitationNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable, RendersOverridableMail;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $name,
|
||||
private readonly string $token,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$url = route('invitations.show', $this->token);
|
||||
|
||||
if (($override = $this->overrideOrNull(EmailTemplateSlot::ClientInvited)) !== null) {
|
||||
return $this->mailFromOverride($override, [':name' => $this->name])->action(__('Register'), $url);
|
||||
}
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(__("You've been invited to register"))
|
||||
->greeting(__('Hello :name,', ['name' => $this->name]))
|
||||
->line(__("You've been invited to register a client account. The link below will let you set your own password."))
|
||||
->action(__('Register'), $url);
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,9 @@ class FileCommentsController extends Controller
|
||||
$viewer,
|
||||
CommentVisibility::from($validated['visibility']),
|
||||
$validated['body'],
|
||||
$this->replyTarget($viewer, $file, $validated['reply_to'] ?? null),
|
||||
// Cast for the reason ShareLinksController gives: `integer`
|
||||
// does not convert, and replyTarget() takes a strict ?int.
|
||||
$this->replyTarget($viewer, $file, isset($validated['reply_to']) ? (int) $validated['reply_to'] : null),
|
||||
);
|
||||
|
||||
return response()->json($this->payload($viewer, $file), 201);
|
||||
|
||||
@@ -125,7 +125,11 @@ class PublicFileCommentsController extends Controller
|
||||
private function guard(string $publicSlug, File $file): void
|
||||
{
|
||||
abort_unless($this->settings->get(Setting::PublicListingSlug) === $publicSlug, 404);
|
||||
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 404);
|
||||
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired() && ! $file->isWithdrawn(), 404);
|
||||
// Same answer as the file's own public page, which 404s a file
|
||||
// that is not available: otherwise a pending or quarantined file
|
||||
// could be discussed, and found to exist, by anybody.
|
||||
abort_unless($file->scan_status->isAvailable(), 404);
|
||||
abort_unless($this->rules->enabled(), 404);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Access;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
|
||||
/**
|
||||
* Whether a viewer may be told who a client is.
|
||||
*
|
||||
* A different question from whether they may read a file, and the gap
|
||||
* between the two is the whole reason this exists. A stranger client's
|
||||
* upload can sit legitimately inside a client-scoped staff member's
|
||||
* library — shared with a group one of their own clients belongs to, or
|
||||
* assigned to one of their clients alongside somebody else's. The file is
|
||||
* theirs to read. The other client's name is not theirs to see.
|
||||
*
|
||||
* Commit 12a8ebe3 said exactly that while fixing one dashboard widget, and
|
||||
* then the rule stayed in that widget. Every other place that serialises a
|
||||
* file went on publishing the uploader and each recipient by name, so a
|
||||
* manager assigned to one client could read the names and ids of clients
|
||||
* on nobody's roster but their own out of ordinary file metadata. That is
|
||||
* what this class ends: one statement of the rule, asked by every surface
|
||||
* that names a client.
|
||||
*
|
||||
* Two things it deliberately is not:
|
||||
*
|
||||
* - It is not a download check. The file boundary is StaffLibraryScope's
|
||||
* and FilePolicy's, and it is already correct — a file belonging only
|
||||
* to a client off the roster is a 403 today. This narrows what a
|
||||
* permitted response is allowed to say, nothing more.
|
||||
* - It is not applied to staff. A colleague's name is not a client
|
||||
* identity, and hiding it would hide who uploaded most of the library
|
||||
* from the people who work in it.
|
||||
*
|
||||
* Unscoped staff are unaffected: they may identify everyone, which is what
|
||||
* `null` means everywhere StaffLibraryScope answers this shape of question.
|
||||
*/
|
||||
class ClientIdentityScope
|
||||
{
|
||||
/**
|
||||
* Memoised per viewer, since the listings ask once per row and each
|
||||
* miss is a roster query. Registered as `scoped`, so this lasts a
|
||||
* request and is dropped between queue jobs — the same lifetime, and
|
||||
* for the same reason, as StaffLibraryScope's own memo.
|
||||
*
|
||||
* @var array<int, list<int>|null>
|
||||
*/
|
||||
private array $clientIds = [];
|
||||
|
||||
/** @var array<int, list<int>|null> */
|
||||
private array $groupIds = [];
|
||||
|
||||
public function __construct(private readonly StaffLibraryScope $scope) {}
|
||||
|
||||
/**
|
||||
* Whether $viewer may be told that $subject exists, and what they are
|
||||
* called.
|
||||
*
|
||||
* A null subject is permitted: there is no identity to leak, and every
|
||||
* caller here is reading an optional relation.
|
||||
*/
|
||||
public function permits(?User $viewer, ?User $subject): bool
|
||||
{
|
||||
if ($subject === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $subject->isClient()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($viewer === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($viewer->is($subject)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$ids = $this->identifiableClientIds($viewer);
|
||||
|
||||
return $ids === null || in_array($subject->id, $ids, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same question about a client known only by id — used where a
|
||||
* caller has a foreign key rather than a loaded model.
|
||||
*
|
||||
* An id that belongs to nobody, or to a staff member, is permitted:
|
||||
* there is no client identity behind it to protect.
|
||||
*/
|
||||
public function permitsClientId(?User $viewer, ?int $id): bool
|
||||
{
|
||||
if ($id === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->permits($viewer, User::query()->find($id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether $viewer may be told a group exists.
|
||||
*
|
||||
* A group is a list of clients wearing one name, so naming one to
|
||||
* somebody who may reach none of its members says the same thing
|
||||
* naming a client would. The set is StaffLibraryScope's
|
||||
* assignableGroupIds — every group holding at least one of the
|
||||
* viewer's own clients.
|
||||
*/
|
||||
public function permitsGroupId(?User $viewer, ?int $id): bool
|
||||
{
|
||||
if ($id === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($viewer === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ids = $this->identifiableGroupIds($viewer);
|
||||
|
||||
return $ids === null || in_array($id, $ids, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* A client's name, or null when this viewer may not be told it.
|
||||
*
|
||||
* Null rather than a placeholder on purpose: every consumer of these
|
||||
* fields already renders "no uploader recorded" for a null, because a
|
||||
* deleted account leaves one behind. Inventing a "Hidden" string would
|
||||
* be a new thing for sixteen locales to translate and would itself
|
||||
* announce that there is somebody there to hide.
|
||||
*/
|
||||
public function nameOf(?User $viewer, ?User $subject): ?string
|
||||
{
|
||||
return $this->permits($viewer, $subject) ? $subject?->name : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the entries this viewer may not be told about from a list of
|
||||
* id/name pairs describing clients.
|
||||
*
|
||||
* @param list<array{id: int, name: string}> $pairs
|
||||
* @return list<array{id: int, name: string}>
|
||||
*/
|
||||
public function filterClientPairs(?User $viewer, array $pairs): array
|
||||
{
|
||||
if ($this->identifiableClientIds($viewer) === null) {
|
||||
return $pairs;
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$pairs,
|
||||
fn (array $pair): bool => $this->permitsClientId($viewer, $pair['id']),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{id: int, name: string}> $pairs
|
||||
* @return list<array{id: int, name: string}>
|
||||
*/
|
||||
public function filterGroupPairs(?User $viewer, array $pairs): array
|
||||
{
|
||||
if ($this->identifiableGroupIds($viewer) === null) {
|
||||
return $pairs;
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$pairs,
|
||||
fn (array $pair): bool => $this->permitsGroupId($viewer, $pair['id']),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Both halves of a `shares` payload at once, since the two lists are
|
||||
* always filtered together.
|
||||
*
|
||||
* @param array{clients: list<array{id: int, name: string}>, groups: list<array{id: int, name: string}>} $shares
|
||||
* @return array{clients: list<array{id: int, name: string}>, groups: list<array{id: int, name: string}>}
|
||||
*/
|
||||
public function filterShares(?User $viewer, array $shares): array
|
||||
{
|
||||
return [
|
||||
'clients' => $this->filterClientPairs($viewer, $shares['clients']),
|
||||
'groups' => $this->filterGroupPairs($viewer, $shares['groups']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this viewer is narrowed at all. Callers use it to skip
|
||||
* per-row work for the common unscoped case.
|
||||
*/
|
||||
public function isNarrowed(?User $viewer): bool
|
||||
{
|
||||
return $viewer === null || $this->identifiableClientIds($viewer) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>|null
|
||||
*/
|
||||
private function identifiableClientIds(?User $viewer): ?array
|
||||
{
|
||||
if ($viewer === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Deliberately the same set as "who may I share with". A client on
|
||||
// the roster is one this viewer already works with by name; a
|
||||
// client off it is one they have no business knowing exists.
|
||||
return $this->clientIds[$viewer->id] ??= $this->scope->assignableClientIds($viewer);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>|null
|
||||
*/
|
||||
private function identifiableGroupIds(?User $viewer): ?array
|
||||
{
|
||||
if ($viewer === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->groupIds[$viewer->id] ??= $this->scope->assignableGroupIds($viewer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Access;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Files\Models\File;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* How often a client's own file has been taken, and when it last was.
|
||||
*
|
||||
* The question somebody asks about a file they sent: did it arrive? On a
|
||||
* hosted free account the link is the whole of the sharing, so "3
|
||||
* downloads, last one on Tuesday" is the only evidence there is that it
|
||||
* worked.
|
||||
*
|
||||
* **Own files only, and that is a privacy rule rather than a scoping
|
||||
* convenience.** A download entry says somebody fetched the file, and on
|
||||
* a file shared with several clients, telling one of them the count tells
|
||||
* them about the others' activity. Nobody is entitled to that except the
|
||||
* person who put the file there. So a file shared *with* this client
|
||||
* carries no numbers at all — not zero, which would be a claim, but
|
||||
* nothing.
|
||||
*
|
||||
* Counts come from the activity log through File::downloads(), the same
|
||||
* source every other download count in the interface uses. There is
|
||||
* deliberately no counter column — see DownloadAllowance for the whole
|
||||
* argument, which applies unchanged here.
|
||||
*
|
||||
* One query for a page, whatever it holds.
|
||||
*/
|
||||
class OwnFileDownloads
|
||||
{
|
||||
/**
|
||||
* @param Collection<int, File> $files
|
||||
* @return array<int, array{count: int, last_at: string|null}> keyed by
|
||||
* file id, only for files this client uploaded
|
||||
*/
|
||||
public function forMany(Collection $files, User $client): array
|
||||
{
|
||||
$own = $files
|
||||
->filter(fn (File $file): bool => $file->uploaded_by === $client->id)
|
||||
->pluck('id')
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->all();
|
||||
|
||||
if ($own === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Every own file gets an entry, including the ones with nothing to
|
||||
// report: the difference between "nobody has downloaded this" and
|
||||
// "this is not yours to know" is exactly what the caller renders,
|
||||
// and a missing key would collapse the two.
|
||||
$stats = [];
|
||||
|
||||
foreach ($own as $id) {
|
||||
$stats[$id] = ['count' => 0, 'last_at' => null];
|
||||
}
|
||||
|
||||
$rows = ActivityLog::query()
|
||||
->selectRaw('subject_id, count(*) as downloads, max(created_at) as last_at')
|
||||
->where('subject_type', (new File)->getMorphClass())
|
||||
->whereIn('subject_id', $own)
|
||||
->whereIn('action', [
|
||||
Action::FileDownloaded->value,
|
||||
Action::ShareLinkDownloaded->value,
|
||||
Action::PublicFileDownloaded->value,
|
||||
])
|
||||
->groupBy('subject_id')
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$id = (int) $row->getAttribute('subject_id');
|
||||
$lastAt = $row->getAttribute('last_at');
|
||||
|
||||
$stats[$id] = [
|
||||
'count' => (int) $row->getAttribute('downloads'),
|
||||
'last_at' => $lastAt === null ? null : Carbon::parse((string) $lastAt)->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
}
|
||||
@@ -28,13 +28,25 @@ use Illuminate\Support\Collection;
|
||||
*/
|
||||
class ShareTargets
|
||||
{
|
||||
public function __construct(private readonly StaffLibraryScope $scope) {}
|
||||
public function __construct(
|
||||
private readonly StaffLibraryScope $scope,
|
||||
private readonly ClientIdentityScope $identity,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The clients and groups a subject is already shared with, as id/name
|
||||
* pairs. Neutral keys, so callers can nest it ('shares' on the details
|
||||
* panel) or flatten it (the edit pages' assigned_* props).
|
||||
*
|
||||
* **This is the unfiltered truth, and it is not what a screen should
|
||||
* show.** Everyone a file is really in front of is the right answer for
|
||||
* deciding something — VisibleCommentScope resolves notification
|
||||
* recipients from it, and a recipient left out of that list is one who
|
||||
* never hears about a message addressed to them. It is the wrong answer
|
||||
* for telling somebody, because a client-scoped viewer may hold a file
|
||||
* that is also shared with a client they have no business knowing
|
||||
* exists. Anything rendering these names wants assignedFor() below.
|
||||
*
|
||||
* @return array{clients: list<array{id: int, name: string}>, groups: list<array{id: int, name: string}>}
|
||||
*/
|
||||
public function assigned(File|Folder $subject): array
|
||||
@@ -47,6 +59,17 @@ class ShareTargets
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* assigned(), narrowed to the recipients this viewer may be told
|
||||
* about. The display half of the pair — see the warning above.
|
||||
*
|
||||
* @return array{clients: list<array{id: int, name: string}>, groups: list<array{id: int, name: string}>}
|
||||
*/
|
||||
public function assignedFor(File|Folder $subject, ?User $viewer): array
|
||||
{
|
||||
return $this->identity->filterShares($viewer, $this->assigned($subject));
|
||||
}
|
||||
|
||||
/**
|
||||
* The assigned lists plus everything still available to share with,
|
||||
* narrowed to what this viewer is allowed to reach.
|
||||
@@ -76,7 +99,12 @@ class ShareTargets
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$assigned = $this->assigned($subject);
|
||||
// assignedFor, not assigned: an edit page listing a recipient this
|
||||
// viewer may not identify would both name them and offer a control
|
||||
// for a share the viewer cannot otherwise reach. available_* below
|
||||
// was already narrowed this way; assigned_* was not, which is the
|
||||
// asymmetry that made the whole panel a roster listing.
|
||||
$assigned = $this->assignedFor($subject, $viewer);
|
||||
|
||||
return [
|
||||
'assigned_clients' => $assigned['clients'],
|
||||
|
||||
@@ -159,15 +159,52 @@ class StaffLibraryScope
|
||||
return null;
|
||||
}
|
||||
|
||||
$clientIds = $this->assignableClientIds($user) ?? [];
|
||||
return array_values($this->groups($user)->pluck('id')->map(fn ($id): int => (int) $id)->all());
|
||||
}
|
||||
|
||||
if ($clientIds === []) {
|
||||
return [];
|
||||
/**
|
||||
* Every group this staff member may be told about, as a query.
|
||||
*
|
||||
* The listing half of assignableGroupIds(), and the same rule: a
|
||||
* group counts as theirs because one of their clients is in it. The
|
||||
* two were not the same code, and the listing simply had none — so
|
||||
* `/groups` and `/api/v1/groups` showed a scoped staff member every
|
||||
* group on the installation, name, description and member count,
|
||||
* including groups whose every member was somebody else's client
|
||||
* (GHSA-r3hg-3fxw-rcmr).
|
||||
*
|
||||
* Deliberately the *sharing* rule rather than the change rule below.
|
||||
* A scoped staff member may already share a file with a mixed group,
|
||||
* so its existence is not news to them; what they may not do is
|
||||
* rename, publish or delete it.
|
||||
*
|
||||
* @return Builder<Group>
|
||||
*/
|
||||
public function groups(User $user): Builder
|
||||
{
|
||||
$query = Group::query();
|
||||
$clientIds = $this->assignableClientIds($user);
|
||||
|
||||
if ($clientIds === null) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return array_values(Group::query()
|
||||
->whereHas('members', fn (Builder $members) => $members->whereIn('users.id', $clientIds))
|
||||
->pluck('id')->map(fn ($id): int => (int) $id)->all());
|
||||
return $query->whereHas('members', fn (Builder $members) => $members->whereIn('users.id', $clientIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whose uploads a staff member may be told about when the file is in
|
||||
* nobody's library — a quarantined upload, which no client can see,
|
||||
* so files() never reaches it. Their own and their assigned clients',
|
||||
* or null when unrestricted.
|
||||
*
|
||||
* @return list<int>|null
|
||||
*/
|
||||
public function uploaderIds(User $user): ?array
|
||||
{
|
||||
$clientIds = $this->assignableClientIds($user);
|
||||
|
||||
return $clientIds === null ? null : [$user->id, ...$clientIds];
|
||||
}
|
||||
|
||||
public function canAssignClient(User $user, User $client): bool
|
||||
@@ -249,7 +286,53 @@ class StaffLibraryScope
|
||||
*/
|
||||
public function allowsGroupChange(User $user, Group $group): bool
|
||||
{
|
||||
return $this->groupReachesNoFurther($user, $group);
|
||||
return $this->groupIsNotWhollySomebodyElses($user, $group)
|
||||
&& $this->groupReachesNoFurther($user, $group);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this group is somebody else's entirely — every member
|
||||
* outside the staff member's roster, and none of theirs in it.
|
||||
*
|
||||
* The half allowsGroupChange() was missing. Reach answers "what would
|
||||
* this group hand somebody", which is the right question for putting a
|
||||
* client *into* it; it says nothing about who is already there. So a
|
||||
* group with nothing shared with it yet passed the reach check
|
||||
* vacuously, and a scoped staff member could rename it, delete it, or
|
||||
* publish it — a group made entirely of clients they had never been
|
||||
* assigned (GHSA-r3hg-3fxw-rcmr).
|
||||
*
|
||||
* **Not "every member is mine", which is the obvious reading and is
|
||||
* wrong.** A mixed group has to stay changeable: GHSA-whmp-p9hv-r7j7
|
||||
* settled that a scoped staff member opens such a group's edit screen
|
||||
* and is shown only their own clients in it, rather than being refused
|
||||
* the screen. Requiring every member to be theirs turns that narrowing
|
||||
* back into a 404 and undoes the earlier fix. What is left over — a
|
||||
* mixed group whose shared content reaches past their library — is
|
||||
* refused by groupReachesNoFurther() beside this, which is the check
|
||||
* that has always covered it.
|
||||
*
|
||||
* **And deliberately not folded into groupReachesNoFurther() either.**
|
||||
* That predicate is shared with allowsGroupMembership(), where a group
|
||||
* nobody has joined must stay usable so its creator can put the first
|
||||
* member in — the case that method's own docblock calls out.
|
||||
*
|
||||
* An empty group is nobody else's, so whoever just made it can still
|
||||
* name it.
|
||||
*/
|
||||
private function groupIsNotWhollySomebodyElses(User $user, Group $group): bool
|
||||
{
|
||||
$clientIds = $this->assignableClientIds($user);
|
||||
|
||||
if ($clientIds === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $group->members()->exists()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $group->members()->whereIn('users.id', $clientIds)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Console;
|
||||
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Jobs\ScanFileJob;
|
||||
use App\Modules\Files\MissingFileScanner;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Scanning\NotScannedReason;
|
||||
use App\Modules\Files\Scanning\ScanningConfig;
|
||||
use App\Modules\Files\Scanning\ScanStatus;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Checks daily that the files this installation lists are actually there.
|
||||
*
|
||||
* Its own command rather than part of scanning, because a missing file is
|
||||
* not a virus question and an installation with no scanner has exactly
|
||||
* the same problem. It was only ever noticed when something tried to read
|
||||
* the bytes — a download, or a scan — which means the first person to
|
||||
* find out was a client clicking a link.
|
||||
*
|
||||
* Recovery is part of the job: storage comes back, and a library that
|
||||
* kept insisting every file was gone would be its own bug.
|
||||
*/
|
||||
class CheckMissingFilesCommand extends Command
|
||||
{
|
||||
protected $signature = 'projectsend:check-missing-files';
|
||||
|
||||
protected $description = 'Check that every file in the database is still on disk (runs daily)';
|
||||
|
||||
public function handle(MissingFileScanner $scanner, ActivityLogger $activity, ScanningConfig $scanning): int
|
||||
{
|
||||
$gone = $scanner->scan();
|
||||
$newlyGone = 0;
|
||||
|
||||
foreach (array_chunk($gone, 200) as $chunk) {
|
||||
// Not a quarantined file. It is unavailable already, and
|
||||
// marking it missing would wipe the threat name and then, when
|
||||
// the bytes came back, send it round as a fresh upload — out
|
||||
// of quarantine with nobody having released it.
|
||||
$candidates = File::query()
|
||||
->whereIn('id', $chunk)
|
||||
->whereNotIn('scan_status', [
|
||||
ScanStatus::Missing->value,
|
||||
ScanStatus::Infected->value,
|
||||
ScanStatus::UnscannableBlocked->value,
|
||||
])
|
||||
->get();
|
||||
|
||||
foreach ($candidates as $file) {
|
||||
// Stamped like any other verdict: this is the moment the
|
||||
// file was last looked at, and without it a missing file
|
||||
// never appears in the Activity list — which is exactly
|
||||
// where somebody watching would look for it.
|
||||
$file->forceFill([
|
||||
'scan_status' => ScanStatus::Missing,
|
||||
'scan_note' => null,
|
||||
'scanned_at' => now(),
|
||||
])->save();
|
||||
|
||||
$activity->logSystem(Action::FileMissing, ['id' => $file->id, 'name' => $file->name]);
|
||||
$newlyGone++;
|
||||
}
|
||||
}
|
||||
|
||||
$back = $scanner->recovered();
|
||||
|
||||
foreach (array_chunk($back, 200) as $chunk) {
|
||||
// Back to the start rather than to whatever it was before:
|
||||
// nothing here knows what the scanner had decided about bytes
|
||||
// that have since been away, and re-checking them is cheap
|
||||
// next to trusting a verdict about a file that left.
|
||||
File::query()->whereIn('id', $chunk)->update($scanning->enabled()
|
||||
? ['scan_status' => ScanStatus::Pending->value, 'scan_note' => null]
|
||||
: ['scan_status' => ScanStatus::NotScanned->value, 'scan_note' => NotScannedReason::BeforeScanning->value]);
|
||||
|
||||
// Straight to the scanner rather than left for the hourly
|
||||
// sweep, which kept a file that had come back unavailable for
|
||||
// up to an hour for no reason.
|
||||
if ($scanning->enabled()) {
|
||||
foreach ($chunk as $id) {
|
||||
ScanFileJob::dispatch($id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'%d file(s) are missing from storage (%d newly), %d came back.',
|
||||
count($gone),
|
||||
$newlyGone,
|
||||
count($back),
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Console;
|
||||
|
||||
use App\Modules\Files\Jobs\ScanFileJob;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Scanning\NotScannedReason;
|
||||
use App\Modules\Files\Scanning\ScanningConfig;
|
||||
use App\Modules\Files\Scanning\ScanStatus;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
* Sends files back to the scanner: the ones still waiting, the ones that
|
||||
* went through unscanned because it was down, and — when asked — the
|
||||
* library that was already here before any of this existed.
|
||||
*
|
||||
* Hourly rather than daily. A file stuck pending is a file nobody can
|
||||
* download, and an installation set to hold has no other way forward
|
||||
* once its worker restarted and the job with it.
|
||||
*/
|
||||
class ScanFilesCommand extends Command
|
||||
{
|
||||
protected $signature = 'projectsend:scan-files
|
||||
{--existing : also work through files that were never scanned because scanning was off}
|
||||
{--all : check every file again, whatever it said last}';
|
||||
|
||||
protected $description = 'Scan files that are waiting, were missed, or were never checked (runs hourly)';
|
||||
|
||||
public function handle(ScanningConfig $config): int
|
||||
{
|
||||
if (! $config->enabled()) {
|
||||
$this->info('Virus scanning is switched off.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$waiting = $this->dispatchFor(File::query()->where('scan_status', ScanStatus::Pending));
|
||||
|
||||
// Allowed through while the scanner was unreachable. Now that it
|
||||
// may be back, they are asked again — a file found infected at
|
||||
// this point is quarantined like any other, and its quarantine
|
||||
// notice says it was available in the meantime.
|
||||
$missed = $this->dispatchFor(
|
||||
File::query()
|
||||
->where('scan_status', ScanStatus::NotScanned)
|
||||
->where('scan_note', NotScannedReason::ScannerUnavailable->value),
|
||||
rescan: true,
|
||||
);
|
||||
|
||||
$this->info("Re-queued {$waiting} waiting file(s) and {$missed} that were missed while the scanner was down.");
|
||||
|
||||
if ($this->option('all')) {
|
||||
// Every file somebody can have today. Not a file waiting for
|
||||
// its first verdict, not one with no bytes, and not one in or
|
||||
// released from quarantine — a scan is not how a file leaves
|
||||
// quarantine, and a release is not undone by one. Files keep
|
||||
// their current state, and stay downloadable, until a new
|
||||
// verdict arrives.
|
||||
$limit = $config->existingScanRatePerMinute() * 60;
|
||||
|
||||
$checked = $this->dispatchFor(
|
||||
File::query()->whereIn('scan_status', ScanFileJob::rescannableValues()),
|
||||
$limit,
|
||||
rescan: true,
|
||||
);
|
||||
|
||||
$this->info("Queued {$checked} file(s) to be checked again.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($this->option('existing')) {
|
||||
// Paced, because this can be a whole library at once and the
|
||||
// scanner is also serving today's uploads. An hour's worth per
|
||||
// run, since that is how often this command runs.
|
||||
$limit = $config->existingScanRatePerMinute() * 60;
|
||||
|
||||
$old = $this->dispatchFor(File::query()->neverScanned(), $limit, rescan: true);
|
||||
|
||||
$this->info("Queued {$old} file(s) that had never been scanned.");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing here changes a file's state before the scanner has spoken.
|
||||
*
|
||||
* An earlier version marked each file pending first, which reads as
|
||||
* tidy and is wrong twice over: pending means "withheld", so a
|
||||
* backfill would have hidden an entire library from its clients for
|
||||
* as long as it ran, and every file would then have been announced to
|
||||
* its recipients a second time when it came back. The job knows which
|
||||
* state it expects instead — see its $rescan.
|
||||
*
|
||||
* @param Builder<File> $query
|
||||
*/
|
||||
private function dispatchFor(Builder $query, ?int $limit = null, bool $rescan = false): int
|
||||
{
|
||||
if ($limit !== null) {
|
||||
$query->limit($limit);
|
||||
}
|
||||
|
||||
$ids = $query->orderBy('id')->pluck('id');
|
||||
|
||||
foreach ($ids as $id) {
|
||||
ScanFileJob::dispatch((int) $id, $rescan);
|
||||
}
|
||||
|
||||
return $ids->count();
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ class FileDelivery
|
||||
/**
|
||||
* The method in force, and whether it was detected or stated.
|
||||
*
|
||||
* @return array{method: DeliveryMethod, detected: bool}
|
||||
* @return array{method: DeliveryMethod, detected: bool, observed: bool}
|
||||
*/
|
||||
public function resolve(): array
|
||||
{
|
||||
@@ -69,10 +69,25 @@ class FileDelivery
|
||||
$explicit = is_string($configured) ? DeliveryMethod::tryFrom($configured) : null;
|
||||
|
||||
if ($explicit !== null) {
|
||||
return ['method' => $explicit, 'detected' => false];
|
||||
return ['method' => $explicit, 'detected' => false, 'observed' => true];
|
||||
}
|
||||
|
||||
return ['method' => $this->detect(), 'detected' => true];
|
||||
// Whether there was anything to detect *from*. detect() reads
|
||||
// SERVER_SOFTWARE, which only exists inside a request — so a
|
||||
// console process has nothing to look at and falls to the `php`
|
||||
// default. That default is right for the console (no web server is
|
||||
// handling this, so nothing could hand a file off), and wrong as a
|
||||
// statement about the installation, which is how somebody reading
|
||||
// it from `artisan tinker` will take it.
|
||||
//
|
||||
// Reported rather than papered over: a reader who runs
|
||||
// `describe()` from a shell on a perfectly good nginx box was
|
||||
// being told `php`, with `detected: true` vouching for it. That
|
||||
// cost somebody an afternoon before it was recognised as an
|
||||
// artefact of asking outside a request.
|
||||
$observed = is_string($this->request->server('SERVER_SOFTWARE'));
|
||||
|
||||
return ['method' => $this->detect(), 'detected' => true, 'observed' => $observed];
|
||||
}
|
||||
|
||||
public function method(): DeliveryMethod
|
||||
@@ -88,7 +103,12 @@ class FileDelivery
|
||||
* the installation from outside, and neither should change meaning if
|
||||
* the enum ever grows a JsonSerializable of its own.
|
||||
*
|
||||
* @return array{method: string, detected: bool}
|
||||
* `observed` is false only outside an HTTP request, where nothing can
|
||||
* be detected and `method` is a default rather than a finding. Both
|
||||
* screens that read this run in a request, so they always see true;
|
||||
* it exists for whoever asks from a console.
|
||||
*
|
||||
* @return array{method: string, detected: bool, observed: bool}
|
||||
*/
|
||||
public function describe(): array
|
||||
{
|
||||
@@ -100,6 +120,8 @@ 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,32 +31,65 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
* path, BinaryFileResponse when PHP is streaming — each dropping the
|
||||
* Content-Length passed here in favour of the range actually served.
|
||||
*
|
||||
* The two paths are not equally revocable, which is why the lifetimes
|
||||
* below differ. Every local delivery method authorises one response and
|
||||
* no more — nginx's X-Accel-Redirect, Apache's X-Sendfile, or PHP
|
||||
* streaming the bytes itself: these bytes, now, to this request, and
|
||||
* nothing that outlives it. A presigned URL is a bearer
|
||||
* credential — whoever holds it can fetch the file without passing the
|
||||
* caller's checks again, and it outlives them: a download cap that is
|
||||
* spent in the meantime, an expires_at that falls in between, an
|
||||
* assignment that is withdrawn. Nothing here can revoke one, so the only
|
||||
* dial is how long it lasts.
|
||||
*
|
||||
* A download needs to survive being followed, which is a redirect and a
|
||||
* request: a minute is generous. A preview is held by the player for as
|
||||
* long as somebody watches, and each seek outside the buffer is a fresh
|
||||
* Range request against the same URL, so it keeps the hour. That is the
|
||||
* trade, stated rather than left in a single number.
|
||||
*
|
||||
* Callers of inline() must have established that the mime type is
|
||||
* inline-safe first; PreviewKind is the allowlist, and the reason there
|
||||
* is one.
|
||||
*/
|
||||
class StoredFileResponse
|
||||
{
|
||||
/**
|
||||
* Long enough for a browser, a download manager or a queued transfer
|
||||
* to follow the redirect and start the request. An object store
|
||||
* checks the signature when the request arrives, not while it runs,
|
||||
* so a transfer that begins inside this window finishes however long
|
||||
* it takes.
|
||||
*/
|
||||
private const DOWNLOAD_LINK_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* A preview is watched, not fetched: the player holds this URL and
|
||||
* issues a Range request every time somebody seeks past the buffer,
|
||||
* so it has to outlive the viewing rather than the redirect.
|
||||
*/
|
||||
private const PREVIEW_LINK_SECONDS = 3600;
|
||||
|
||||
public function __construct(private readonly FileDelivery $delivery) {}
|
||||
|
||||
/** Shown in place — a preview. */
|
||||
public function inline(File $file): Response|RedirectResponse
|
||||
{
|
||||
return $this->make($file, ContentDisposition::inline($file->original_name));
|
||||
return $this->make($file, ContentDisposition::inline($file->original_name), self::PREVIEW_LINK_SECONDS);
|
||||
}
|
||||
|
||||
/** Handed over — a download. */
|
||||
public function attachment(File $file): Response|RedirectResponse
|
||||
{
|
||||
return $this->make($file, ContentDisposition::attachment($file->original_name));
|
||||
return $this->make($file, ContentDisposition::attachment($file->original_name), self::DOWNLOAD_LINK_SECONDS);
|
||||
}
|
||||
|
||||
private function make(File $file, string $disposition): Response|RedirectResponse
|
||||
private function make(File $file, string $disposition, int $linkSeconds): Response|RedirectResponse
|
||||
{
|
||||
if ($file->disk !== 'files') {
|
||||
$url = Storage::disk($file->disk)->temporaryUrl(
|
||||
$url = Storage::disk($this->signingDisk($file->disk))->temporaryUrl(
|
||||
$file->path,
|
||||
now()->addHour(),
|
||||
now()->addSeconds($linkSeconds),
|
||||
['ResponseContentDisposition' => $disposition],
|
||||
);
|
||||
|
||||
@@ -65,4 +98,30 @@ class StoredFileResponse
|
||||
|
||||
return $this->delivery->serve($file->path, $file->mime_type, $disposition, $file->size);
|
||||
}
|
||||
|
||||
/**
|
||||
* The disk whose credentials sign the link: the file's own, unless
|
||||
* that disk names another in `signing_disk`.
|
||||
*
|
||||
* A signed URL carries every restriction of the key that signed it.
|
||||
* A hosted instance's read-write key only works from our own servers,
|
||||
* which is right for the key and wrong for a link a browser follows:
|
||||
* every download, preview and public link got AccessDenied from the
|
||||
* bucket. So a platform can give the disk a second, read-only key,
|
||||
* free of that restriction and used for nothing but signing. The
|
||||
* signing disk must point at the same bucket and prefix. The platform
|
||||
* that configures one is also responsible for that.
|
||||
*
|
||||
* A name that points at no configured disk is ignored rather than
|
||||
* obeyed. Failing every download over a typo would be worse than
|
||||
* signing with the key the file was stored with.
|
||||
*/
|
||||
private function signingDisk(string $disk): string
|
||||
{
|
||||
$signing = config("filesystems.disks.{$disk}.signing_disk");
|
||||
|
||||
return is_string($signing) && $signing !== '' && is_array(config("filesystems.disks.{$signing}"))
|
||||
? $signing
|
||||
: $disk;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Editing;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Comments\CommentingRules;
|
||||
use App\Modules\Comments\CommentScope;
|
||||
use App\Modules\Files\Models\File;
|
||||
|
||||
/**
|
||||
* The one place that decides which fields an editor may actually write.
|
||||
*
|
||||
* Three surfaces edit a file — the staff editor, `/api/v1/files/{file}`,
|
||||
* and now a client's own uploads in the portal — and they had grown two
|
||||
* copies of the same eight permission checks with a third about to be
|
||||
* written. The checks are not hard; the problem is that they are *easy*,
|
||||
* so a new field gets added to one caller and the drift is invisible until
|
||||
* somebody finds the surface where the gate is missing.
|
||||
*
|
||||
* The split is deliberate: **callers normalise, this gates.** A caller
|
||||
* turns its own request shape into `$changes` — form semantics versus the
|
||||
* API's `sometimes`, a date string versus an instant — and this decides
|
||||
* what the actor is allowed to write, writes it, and records what happened.
|
||||
*
|
||||
* `$changes` uses array_key_exists semantics throughout: a key that is
|
||||
* absent is left alone, a key present with `null` is written as null. That
|
||||
* is the API's existing contract, and the web forms post every field they
|
||||
* own, so it is also the forms'.
|
||||
*
|
||||
* Two things deliberately do NOT live here, because they are the caller's
|
||||
* and getting them wrong is how a boundary breaks:
|
||||
*
|
||||
* - **Whether this actor may edit this file at all.** That is
|
||||
* `Gate::authorize('update', $file)` and FilePolicy. Nothing below
|
||||
* re-checks it.
|
||||
* - **Whether a destination folder is reachable.** Staff ask
|
||||
* StaffLibraryScope; a client asks `Folder::uploadableBy()`. Those are
|
||||
* different questions with the same shape, and the staff one answers
|
||||
* `true` for any client — see FilePolicy::update()'s note.
|
||||
*/
|
||||
class ApplyFileEdits
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly CommentingRules $commenting,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $changes only the fields the caller
|
||||
* wants written; absent keys
|
||||
* are left as they are
|
||||
*/
|
||||
public function apply(User $actor, File $file, array $changes): void
|
||||
{
|
||||
$attributes = [];
|
||||
|
||||
// Covered by the permission to edit the file at all, which the
|
||||
// policy has already settled by the time anything reaches here.
|
||||
foreach (['name', 'description', 'folder_id'] as $field) {
|
||||
if (array_key_exists($field, $changes)) {
|
||||
$attributes[$field] = $changes[$field];
|
||||
}
|
||||
}
|
||||
|
||||
// Only meaningful while the comment scope is `selected`, and only
|
||||
// offered by a form then — but a request reaching here directly
|
||||
// must not be able to set a flag the UI is currently hiding.
|
||||
if (array_key_exists('commentable', $changes) && $this->commenting->scope() === CommentScope::SelectedFiles) {
|
||||
$attributes['commentable'] = $changes['commentable'];
|
||||
}
|
||||
|
||||
// From here down, every field has a permission of its own, and the
|
||||
// rule for all of them is the same: lacking it leaves the field
|
||||
// exactly as it was rather than failing the request. An editor who
|
||||
// may rename a file but not publish it saves a rename, and the
|
||||
// public state does not move. The web and the API have always
|
||||
// behaved this way; it is why the portal can reuse both forms.
|
||||
if (array_key_exists('expires_at', $changes) && $actor->can('set_file_expiration_date')) {
|
||||
$attributes['expires_at'] = $changes['expires_at'];
|
||||
}
|
||||
|
||||
if (array_key_exists('download_limit', $changes) && $actor->can('limit_downloads')) {
|
||||
$attributes['download_limit'] = $changes['download_limit'];
|
||||
}
|
||||
|
||||
if (array_key_exists('download_limit_scope', $changes) && $actor->can('limit_downloads')) {
|
||||
$attributes['download_limit_scope'] = $changes['download_limit_scope'];
|
||||
}
|
||||
|
||||
$wasPublic = $file->public;
|
||||
|
||||
if (array_key_exists('public', $changes) && $actor->can('upload_public')) {
|
||||
$attributes['public'] = $changes['public'];
|
||||
|
||||
// A caller that offers the slug passes what was submitted; one
|
||||
// that does not simply omits the key and gets a derived slug.
|
||||
// The client portal is the second kind on purpose — an
|
||||
// installation-wide unique slug chosen by a client is a name to
|
||||
// squat and an existence oracle to probe, for no benefit over a
|
||||
// slug made from the name they already chose.
|
||||
//
|
||||
// Omitting the slug on an update keeps the current one: it must
|
||||
// not silently change just because the name did.
|
||||
$submitted = is_string($changes['slug'] ?? null) ? trim($changes['slug']) : '';
|
||||
|
||||
$attributes['slug'] = $submitted !== ''
|
||||
? $submitted
|
||||
: ($file->slug ?: File::uniqueSlugFrom(
|
||||
is_string($changes['name'] ?? null) ? $changes['name'] : $file->name,
|
||||
$file->id,
|
||||
));
|
||||
}
|
||||
|
||||
$file->update($attributes);
|
||||
|
||||
// After the write, not inside it: categories are a relation, not a
|
||||
// column. Gated by their own key, so an editor who may rename but
|
||||
// not categorise leaves them untouched.
|
||||
if (array_key_exists('categories', $changes) && $actor->can('set_file_categories')) {
|
||||
$file->categories()->sync($changes['categories']);
|
||||
}
|
||||
|
||||
$this->activity->log(Action::FileUpdated, subject: $file);
|
||||
|
||||
// Publishing and unpublishing are their own entries. A file
|
||||
// becoming reachable without a login is not a detail of "file
|
||||
// updated", and it is the line an audit is most likely to be read
|
||||
// for.
|
||||
if (! $wasPublic && $file->public) {
|
||||
$this->activity->log(Action::FileMadePublic, subject: $file, context: ['slug' => $file->slug]);
|
||||
} elseif ($wasPublic && ! $file->public) {
|
||||
$this->activity->log(Action::FileMadePrivate, subject: $file);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Editing;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Platform\Localization\DateInput;
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* Reading and writing a file's expiry in the zone of whoever is looking.
|
||||
*
|
||||
* The rule itself — a posted day means the end of that day where the
|
||||
* setter lives, and a form posts back what asShown() gave it — is
|
||||
* DateInput's, shared with a client account's expiry. This stays as the
|
||||
* file-shaped door onto it.
|
||||
*
|
||||
* Was three private copies — the staff editor, the API, and the client
|
||||
* portal — of which the API's was the only one that could read a
|
||||
* timestamp.
|
||||
*/
|
||||
class FileExpiry
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DateInput $dates,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The stored instant as the calendar day a form should show, in the
|
||||
* viewer's zone. Null when the file never expires.
|
||||
*/
|
||||
public function asShown(File $file, ?User $viewer): ?string
|
||||
{
|
||||
return $this->dates->asShown($file->expires_at, $viewer);
|
||||
}
|
||||
|
||||
/**
|
||||
* The instant a submitted value actually names. See DateInput::instant().
|
||||
*/
|
||||
public function instant(?string $value, ?User $setter): ?Carbon
|
||||
{
|
||||
return $this->dates->instant($value, $setter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Events;
|
||||
|
||||
use App\Modules\Files\Models\File;
|
||||
|
||||
/**
|
||||
* A file that could not be handed to anyone now can be.
|
||||
*
|
||||
* Dispatched by FileAvailability::markAvailable(), from all three ways a
|
||||
* file gets there: a clean scan, a scan this installation gave up waiting
|
||||
* for, and an administrator releasing a quarantined file.
|
||||
*
|
||||
* It exists so that "tell the recipients" is written once rather than at
|
||||
* each of those three, and so the private package can hook the same
|
||||
* moment — the same reasoning FileWasStored is dispatched under.
|
||||
*/
|
||||
final class FileBecameAvailable
|
||||
{
|
||||
public function __construct(
|
||||
public readonly File $file,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Events;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Models\File;
|
||||
|
||||
/**
|
||||
* A file's bytes are on a disk and its row exists.
|
||||
*
|
||||
* Dispatched from StoreUploadedFile, which every upload path goes through
|
||||
* — the chunked flow that staff and clients share, and the synchronous
|
||||
* POST beside it — so a listener sees every upload once and does not have
|
||||
* to know which route produced it.
|
||||
*
|
||||
* A notification rather than a filter: nothing here is mutable and no
|
||||
* listener can change what was stored. Anything that needs to influence
|
||||
* the upload has to do so before the bytes land, which is what
|
||||
* ResolvingUploadDisk is for.
|
||||
*
|
||||
* Fired after the row is created and before the caller has linked a
|
||||
* version or answered the request, so a listener sees a complete File and
|
||||
* can safely read it back.
|
||||
*/
|
||||
class FileWasStored
|
||||
{
|
||||
public function __construct(
|
||||
public readonly File $file,
|
||||
public readonly User $uploader,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Events;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Something a client should read before they upload.
|
||||
*
|
||||
* Asked each time the portal's upload page is rendered, and shown above
|
||||
* the uploader in every theme. The upload page is one page for all of
|
||||
* them, so this is the one place a rule about what happens to an upload
|
||||
* can be said where the upload happens — the announcement band is not,
|
||||
* since only one theme's shell draws it.
|
||||
*
|
||||
* **Core knows nothing about what it says.** The first caller is the
|
||||
* hosted edition's shared instance, telling a free customer how long
|
||||
* their files are kept, which is a rule of one offering and belongs in
|
||||
* that offering's code.
|
||||
*
|
||||
* Lines rather than one message: two unrelated rules can both apply, and
|
||||
* neither listener can know the other exists. Each line is a sentence,
|
||||
* already translated.
|
||||
*/
|
||||
class ResolvingUploadNotice
|
||||
{
|
||||
/** @var list<string> */
|
||||
public array $lines = [];
|
||||
|
||||
public function __construct(
|
||||
public readonly User $uploader,
|
||||
) {}
|
||||
|
||||
public function add(string $line): void
|
||||
{
|
||||
$this->lines[] = $line;
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,15 @@ use App\Modules\Files\Models\File;
|
||||
/**
|
||||
* Ownership rules as policy methods (brief §6.13): "own" versus
|
||||
* "others'" files map onto the v1 permission pairs. Clients may only
|
||||
* view/download what is assigned to them, directly or via a group. For
|
||||
* client-scoped staff, every action is additionally gated by the
|
||||
* StaffLibraryScope, so direct access can't reach out-of-scope files.
|
||||
* view/download what is assigned to them, directly or via a group, and may
|
||||
* edit or delete only what they uploaded themselves. For client-scoped
|
||||
* staff, every action is additionally gated by the StaffLibraryScope, so
|
||||
* direct access can't reach out-of-scope files.
|
||||
*
|
||||
* Every method here branches on isStaff() before it reaches the scope.
|
||||
* That is not stylistic: StaffLibraryScope answers "is this *restricted*
|
||||
* staff member allowed?", and its "no restriction" answer is `true`. A
|
||||
* client falling through to it is handed the whole library. See update().
|
||||
*/
|
||||
class FilePolicy
|
||||
{
|
||||
@@ -33,8 +39,25 @@ class FilePolicy
|
||||
|
||||
public function update(User $user, File $file): bool
|
||||
{
|
||||
// A client edits what they uploaded and nothing else. Deliberately
|
||||
// its own branch rather than a shared one, because the staff branch
|
||||
// below is unsafe for a client in two ways at once.
|
||||
//
|
||||
// First, `edit_others_files` must never be reachable here. It is a
|
||||
// staff key by construction: a client has no "others' files" they
|
||||
// could hold a legitimate claim over, only files somebody shared
|
||||
// with them, and being shown a file is not being given it. Granting
|
||||
// that key to the Client role does nothing, and a test pins that.
|
||||
//
|
||||
// Second, and the trap: StaffLibraryScope::allowsFile() returns
|
||||
// true outright for anyone who is not client-*scoped* staff —
|
||||
// User::isClientScoped() is `isStaff() && role->client_scoped`, so
|
||||
// it is false for every client. That predicate means "this staff
|
||||
// member is unrestricted", and a client reaching it would inherit
|
||||
// "unrestricted" over the whole library. Nothing here may touch the
|
||||
// staff scope.
|
||||
if (! $user->isStaff()) {
|
||||
return false;
|
||||
return $file->isOwnedBy($user) && $user->can('edit_files');
|
||||
}
|
||||
|
||||
$permitted = $file->isOwnedBy($user) ? $user->can('edit_files') : $user->can('edit_others_files');
|
||||
@@ -72,8 +95,11 @@ class FilePolicy
|
||||
|
||||
public function delete(User $user, File $file): bool
|
||||
{
|
||||
// Their own upload, and only with the key — same two reasons as
|
||||
// update() above, `delete_others_files` standing in for
|
||||
// `edit_others_files`.
|
||||
if (! $user->isStaff()) {
|
||||
return false;
|
||||
return $file->isOwnedBy($user) && $user->can('delete_files');
|
||||
}
|
||||
|
||||
$permitted = $file->isOwnedBy($user) ? $user->can('delete_files') : $user->can('delete_others_files');
|
||||
|
||||
@@ -4,13 +4,24 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files;
|
||||
|
||||
use App\Modules\Files\Access\ClientIdentityScope;
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Events\FileBecameAvailable;
|
||||
use App\Modules\Files\Folders\ClientHomeFolders;
|
||||
use App\Modules\Files\Events\FileWasStored;
|
||||
use App\Modules\Files\Listeners\AnnounceAvailableFile;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Files\Jobs\ScanFileJob;
|
||||
use App\Modules\Files\Notifications\FileShareDigestNotification;
|
||||
use App\Modules\Files\Notifications\FileSharedNotification;
|
||||
use App\Modules\Files\Notifications\NewVersionAvailableNotification;
|
||||
use App\Modules\Files\Notifications\NewVersionDigestNotification;
|
||||
use App\Modules\Files\Scanning\ClamAvScanner;
|
||||
use App\Modules\Files\Scanning\ScanningConfig;
|
||||
use App\Modules\Files\Scanning\ScanStatus;
|
||||
use App\Modules\Files\Scanning\VirusScanner;
|
||||
use App\Modules\Files\Thumbnails\Events\ImageRenderingChanged;
|
||||
use App\Modules\Files\Thumbnails\RenderedImageCache;
|
||||
use App\Modules\Notifications\NotificationTypeDefinition;
|
||||
@@ -30,6 +41,22 @@ class FilesServiceProvider extends ServiceProvider
|
||||
// reached twice. Scoped rather than a singleton so a long-lived
|
||||
// queue worker starts each job with an empty memo.
|
||||
$this->app->scoped(StaffLibraryScope::class);
|
||||
|
||||
// Same lifetime, same reason: the identity rule memoises a roster
|
||||
// per viewer and the file listings ask it once per row.
|
||||
$this->app->scoped(ClientIdentityScope::class);
|
||||
|
||||
// Scoped, so the settings screen and the scanner it resolves share
|
||||
// one instance: that is what lets the Test button try the address
|
||||
// being typed rather than the one on file. Scoped rather than a
|
||||
// singleton so a queue worker starts each job with a clean one.
|
||||
$this->app->scoped(ScanningConfig::class);
|
||||
|
||||
// One implementation ships, and the interface exists so the test
|
||||
// suite can state a verdict instead of producing a file that
|
||||
// provokes one — and so a commercial engine can be added later
|
||||
// without touching the job or the policy.
|
||||
$this->app->bind(VirusScanner::class, ClamAvScanner::class);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
@@ -37,6 +64,8 @@ class FilesServiceProvider extends ServiceProvider
|
||||
Gate::policy(File::class, FilePolicy::class);
|
||||
Gate::policy(Folder::class, FolderPolicy::class);
|
||||
|
||||
$this->keepClientHomeFolders();
|
||||
|
||||
// Cached renditions are written once and never revisited, so
|
||||
// whoever changes how they render has to say so — otherwise the
|
||||
// change is invisible on every file anyone has already looked at.
|
||||
@@ -92,8 +121,47 @@ class FilesServiceProvider extends ServiceProvider
|
||||
url: fn (array $data): string => route('my-files.index'),
|
||||
));
|
||||
|
||||
// Two audiences, two types, because they need different words and
|
||||
// different links. Staff get a queue to act on; the person who
|
||||
// uploaded gets told their file did not go through.
|
||||
$registry = $this->app->make(NotificationTypeRegistry::class);
|
||||
|
||||
$registry->register(new NotificationTypeDefinition(
|
||||
key: 'file_quarantined',
|
||||
label: 'A file was quarantined by the virus scanner',
|
||||
template: 'A virus was found in ":itemName", uploaded by :uploaderName',
|
||||
url: fn (array $data): string => route('files.quarantine'),
|
||||
));
|
||||
|
||||
$registry->register(new NotificationTypeDefinition(
|
||||
key: 'upload_blocked',
|
||||
label: 'One of your uploads was blocked',
|
||||
template: 'Your file ":itemName" was blocked: :threat',
|
||||
// Their own files list. Deliberately not the quarantine
|
||||
// screen, which they cannot open.
|
||||
url: fn (array $data): string => route('my-files.index'),
|
||||
));
|
||||
|
||||
// The other half of holding an announcement back while a file is
|
||||
// being checked — see FileSharing::assign and
|
||||
// AnnounceAvailableFile.
|
||||
Event::listen(FileBecameAvailable::class, AnnounceAvailableFile::class);
|
||||
|
||||
// Every upload path converges on FileWasStored, so this is the
|
||||
// one place a scan is started from. Dispatched rather than run
|
||||
// inline: a 5 GB file takes minutes to read, and an upload must
|
||||
// not wait for it — the file is already withheld until the
|
||||
// verdict arrives.
|
||||
Event::listen(FileWasStored::class, function (FileWasStored $event): void {
|
||||
if ($event->file->scan_status === ScanStatus::Pending) {
|
||||
ScanFileJob::dispatch($event->file->id);
|
||||
}
|
||||
});
|
||||
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->commands([
|
||||
Console\ScanFilesCommand::class,
|
||||
Console\CheckMissingFilesCommand::class,
|
||||
Console\PurgeStaleUploadsCommand::class,
|
||||
Console\PurgeZipDownloadsCommand::class,
|
||||
Console\PurgeExpiredFilesCommand::class,
|
||||
@@ -102,4 +170,43 @@ class FilesServiceProvider extends ServiceProvider
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Give a new client their home folder, and keep its name in step.
|
||||
*
|
||||
* On model events rather than in the handful of services that create
|
||||
* and rename clients, because there are more of those than anyone
|
||||
* remembers: ClientAccounts for the staff screens, the API and the
|
||||
* control plane; ClientProvisioning for self-registration, LDAP,
|
||||
* social sign-in and invitation redemption; the profile screen and two
|
||||
* update endpoints for a rename; AccountConversion for a staff member
|
||||
* becoming a client. A rule that had to be repeated in nine places
|
||||
* would be missing from the tenth.
|
||||
*
|
||||
* Here rather than in User::booted() so the identity model does not
|
||||
* have to know the files module exists -- the dependency points one
|
||||
* way, and this is the end that cares.
|
||||
*
|
||||
* Both listeners are cheap when the feature is off: `created` asks the
|
||||
* setting and returns, and `updated` asks whether the name actually
|
||||
* changed before it asks anything else.
|
||||
*/
|
||||
private function keepClientHomeFolders(): void
|
||||
{
|
||||
User::created(function (User $user): void {
|
||||
if ($user->isClient()) {
|
||||
$this->app->make(ClientHomeFolders::class)->ensureFor($user);
|
||||
}
|
||||
});
|
||||
|
||||
User::updated(function (User $user): void {
|
||||
// wasChanged, not isDirty: by `updated` the write has happened
|
||||
// and isDirty is empty. A save that did not touch the name --
|
||||
// which is most of them, every sign-in timestamp included --
|
||||
// costs one array lookup and stops here.
|
||||
if ($user->isClient() && $user->wasChanged('name')) {
|
||||
$this->app->make(ClientHomeFolders::class)->syncName($user);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,17 @@ class FolderPolicy
|
||||
public function update(User $user, Folder $folder): bool
|
||||
{
|
||||
if (! $user->isStaff()) {
|
||||
// A client owns their home folder -- created_by is them, which
|
||||
// is how they can see it at all -- so ownership alone would let
|
||||
// them rename it. It is structure rather than something of
|
||||
// theirs to arrange: its name follows the account, and the
|
||||
// administrator reading /files relies on that. Renaming it is
|
||||
// refused rather than allowed and then silently overwritten the
|
||||
// next time the account is edited.
|
||||
if ($folder->isHome()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $folder->isOwnedBy($user) && $user->can('create_own_folders');
|
||||
}
|
||||
|
||||
@@ -48,6 +59,16 @@ class FolderPolicy
|
||||
|
||||
public function delete(User $user, Folder $folder): bool
|
||||
{
|
||||
// Nobody deletes a home folder from a folder screen, staff
|
||||
// included. Deleting one cascades over everything the client has,
|
||||
// and it would leave their portal pointing at a folder that is not
|
||||
// there -- an account still gets erased through the erasure flow,
|
||||
// which is where destroying somebody's content is the declared
|
||||
// intent rather than a side effect of tidying a tree.
|
||||
if ($folder->isHome()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->isStaff()) {
|
||||
return $folder->isOwnedBy($user) && $user->can('create_own_folders');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Folders;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\UserType;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* A folder per client, named after them, standing in for the root.
|
||||
*
|
||||
* ## What it is for
|
||||
*
|
||||
* Without it, a client who may create folders creates them at the top of
|
||||
* the library, beside the ones staff made. Their uploads land at the root
|
||||
* too. An administrator opening /files sees one flat pile with no clue
|
||||
* which parts belong to whom. With it, each client gets one folder and
|
||||
* everything of theirs goes inside, so /files reads as a list of clients.
|
||||
*
|
||||
* ## What it is NOT
|
||||
*
|
||||
* It is not a boundary, and this is the important sentence in the file. A
|
||||
* folder staff shared with a client stays visible to that client, sitting
|
||||
* beside their own — Folder::scopeVisibleToClient is untouched by any of
|
||||
* this. Treating the home as a jail would silently revoke every share that
|
||||
* already exists, which is a data-access change wearing the clothes of a
|
||||
* tidying-up feature. "Root" here means *where new things go by default*,
|
||||
* nothing more.
|
||||
*
|
||||
* ## Why created_by is the client
|
||||
*
|
||||
* scopeVisibleToClient grants a client their own folders through
|
||||
* `created_by`. Creating the home as the client makes it theirs by the
|
||||
* rule that already exists, rather than needing an assignment row that
|
||||
* would then have to be kept in step with it. That is also why this writes
|
||||
* the row itself instead of calling FolderService::create(), which takes
|
||||
* `created_by` from `auth()->id()` — the creator here is whoever pressed a
|
||||
* button, and the owner has to be the client.
|
||||
*/
|
||||
class ClientHomeFolders
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Settings $settings,
|
||||
) {}
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return (bool) $this->settings->get(Setting::ClientsHomeFolders);
|
||||
}
|
||||
|
||||
/**
|
||||
* This client's home, or null if they have none.
|
||||
*
|
||||
* Asked of the column and not of the setting: a home that exists keeps
|
||||
* working after the switch is turned off again. The folder is real,
|
||||
* it holds real files, and pretending it is not there would strand
|
||||
* them somewhere no listing looks.
|
||||
*/
|
||||
public function for(?User $client): ?Folder
|
||||
{
|
||||
if ($client === null || ! $client->isClient()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Folder::query()->where('home_for_user_id', $client->id)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Give this client a home if the installation wants them to have one.
|
||||
*
|
||||
* Idempotent, and safe to call on a client who already has one. Returns
|
||||
* the folder either way, or null when the feature is off.
|
||||
*/
|
||||
public function ensureFor(User $client): ?Folder
|
||||
{
|
||||
if (! $client->isClient() || ! $this->enabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->create($client);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the row, or hand back the one that is already there.
|
||||
*
|
||||
* The unique index on home_for_user_id is what actually guarantees one
|
||||
* home per client; this check only avoids raising on the ordinary
|
||||
* second call. Two administrators pressing the backfill button at the
|
||||
* same moment is exactly the race the index is there for.
|
||||
*/
|
||||
private function create(User $client): Folder
|
||||
{
|
||||
return DB::transaction(function () use ($client): Folder {
|
||||
$existing = $this->for($client);
|
||||
|
||||
if ($existing !== null) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
return Folder::query()->create([
|
||||
'name' => $this->nameFor($client),
|
||||
'parent_id' => null,
|
||||
// Root, so an administrator sees it at the top of /files --
|
||||
// which is the whole point of the feature.
|
||||
'path' => '/',
|
||||
'created_by' => $client->id,
|
||||
'home_for_user_id' => $client->id,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the folder's name in step with the client's.
|
||||
*
|
||||
* Always, including over a name somebody typed by hand. That was the
|
||||
* product decision (2026-09-17) and it is the defensible one: the
|
||||
* folder exists to say whose things these are, so a folder still
|
||||
* called "Acme Ltd" after the account became "Acme Holdings" is
|
||||
* actively misleading to the administrator the feature is for. A
|
||||
* client cannot rename it anyway -- see FolderPolicy.
|
||||
*/
|
||||
public function syncName(User $client): void
|
||||
{
|
||||
$home = $this->for($client);
|
||||
|
||||
if ($home === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$name = $this->nameFor($client);
|
||||
|
||||
if ($home->name !== $name) {
|
||||
$home->update(['name' => $name]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How many clients would get a folder if the button were pressed.
|
||||
*
|
||||
* A count and not the rows: the settings screen only needs the number,
|
||||
* and an installation with thousands of clients should not load them
|
||||
* all to render one sentence.
|
||||
*/
|
||||
public function pendingCount(): int
|
||||
{
|
||||
return User::query()
|
||||
->where('type', UserType::Client)
|
||||
->whereNotExists(fn ($q) => $q
|
||||
->selectRaw('1')
|
||||
->from('folders')
|
||||
->whereColumn('folders.home_for_user_id', 'users.id')
|
||||
->whereNull('folders.deleted_at'))
|
||||
->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every client without a home gets one.
|
||||
*
|
||||
* Deliberately a button rather than something switching the setting on
|
||||
* does by itself: it writes a folder per client, and an administrator
|
||||
* trying the feature out should be able to turn it on, look, and change
|
||||
* their mind without having reorganised anything.
|
||||
*
|
||||
* Reports counts rather than staying quiet, because on an installation
|
||||
* with hundreds of clients "it worked" is not a useful answer -- the
|
||||
* administrator wants to know how many there were and how many are new.
|
||||
*
|
||||
* @return array{total: int, created: int, existing: int}
|
||||
*/
|
||||
public function backfill(): array
|
||||
{
|
||||
$clients = User::query()->where('type', UserType::Client)->orderBy('id')->get();
|
||||
$created = 0;
|
||||
$existing = 0;
|
||||
|
||||
foreach ($clients as $client) {
|
||||
if ($this->for($client) !== null) {
|
||||
$existing++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->create($client);
|
||||
$created++;
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => $clients->count(),
|
||||
'created' => $created,
|
||||
'existing' => $existing,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A blank name would render as an unclickable sliver in the tree, so
|
||||
* the address stands in -- every account has one, and it identifies
|
||||
* the person as well as a name does.
|
||||
*/
|
||||
private function nameFor(User $client): string
|
||||
{
|
||||
$name = trim($client->name);
|
||||
|
||||
return $name !== '' ? $name : $client->email;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Folders;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
|
||||
/**
|
||||
* The ancestors of a whole page of folders, in two queries however long the
|
||||
* page is, trimmed to what the viewer may see.
|
||||
*
|
||||
* BreadcrumbBuilder answers this for one folder on a screen. A list
|
||||
* endpoint needs it for every row, and a query per row is the cost a
|
||||
* listing must not have.
|
||||
*
|
||||
* Trimmed the way BreadcrumbBuilder::visible() trims the client portal's
|
||||
* trail: the list starts at the first ancestor the viewer can reach, since
|
||||
* a client-scoped staff member holding a client's folder deep in somebody
|
||||
* else's tree has no business reading the names of the folders above it.
|
||||
* An unscoped staff member reaches every folder, so for them nothing is
|
||||
* ever trimmed.
|
||||
*/
|
||||
class FolderTrails
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StaffLibraryScope $scope,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param iterable<Folder> $folders
|
||||
* @return array<int, list<array{id: int, name: string}>> folder id => its visible ancestors, root first, itself excluded
|
||||
*/
|
||||
public function ancestors(iterable $folders, User $viewer): array
|
||||
{
|
||||
$chains = [];
|
||||
$allIds = [];
|
||||
|
||||
foreach ($folders as $folder) {
|
||||
$ids = $folder->ancestorIds();
|
||||
$chains[$folder->id] = $ids;
|
||||
array_push($allIds, ...$ids);
|
||||
}
|
||||
|
||||
$allIds = array_values(array_unique($allIds));
|
||||
|
||||
if ($allIds === []) {
|
||||
return array_map(fn (): array => [], $chains);
|
||||
}
|
||||
|
||||
$names = Folder::query()->whereIn('id', $allIds)->pluck('name', 'id')->all();
|
||||
|
||||
$visible = $viewer->isClientScoped()
|
||||
? array_flip($this->scope->folders($viewer)->whereIn('folders.id', $allIds)->pluck('folders.id')->all())
|
||||
: array_flip($allIds);
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($chains as $folderId => $ids) {
|
||||
$trail = [];
|
||||
$reached = false;
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$reached = $reached || isset($visible[$id]);
|
||||
|
||||
if ($reached && isset($names[$id])) {
|
||||
$trail[] = ['id' => $id, 'name' => (string) $names[$id]];
|
||||
}
|
||||
}
|
||||
|
||||
$out[$folderId] = $trail;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Folders;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
* How many files in a folder's subtree a staff member may not delete.
|
||||
*
|
||||
* Deleting a folder cascades to every file in its subtree, and a File's
|
||||
* `deleted` hook removes the bytes from disk — there is no restore.
|
||||
* Authorizing the folder is not authorizing its contents: FilePolicy::delete
|
||||
* asks for `delete_others_files` on somebody else's upload, and for the
|
||||
* library boundary on top of that, and neither question is asked by
|
||||
* FolderPolicy. Every staff path that deletes a folder asks this first, so
|
||||
* the web screen and the API cannot disagree about what a cascade may take.
|
||||
*
|
||||
* Asked as one count rather than FilePolicy::delete per file: a folder can
|
||||
* hold thousands, Gate resolves a fresh policy for every check, and a
|
||||
* per-row policy check on a listing is the cost 0a8b609e went to some
|
||||
* trouble to remove. The two halves of FilePolicy::delete are expressible
|
||||
* in SQL — the permission half is constant for this viewer, and the
|
||||
* library half is the query StaffLibraryScope already memoises per request.
|
||||
*
|
||||
* Somebody holding both delete permissions and no library scope can delete
|
||||
* anything in the subtree by construction, so they never pay for the query
|
||||
* at all.
|
||||
*
|
||||
* The client half of the same rule is MyFoldersController::destroy.
|
||||
*/
|
||||
class UndeletableFiles
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StaffLibraryScope $scope,
|
||||
) {}
|
||||
|
||||
public function count(User $viewer, Folder $folder): int
|
||||
{
|
||||
$mayDeleteOwn = $viewer->can('delete_files');
|
||||
$mayDeleteOthers = $viewer->can('delete_others_files');
|
||||
$scoped = $viewer->isClientScoped();
|
||||
|
||||
if ($mayDeleteOwn && $mayDeleteOthers && ! $scoped) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return File::query()
|
||||
->whereIn('folder_id', $folder->subtreeFolderIds())
|
||||
->where(function (Builder $outer) use ($viewer, $mayDeleteOwn, $mayDeleteOthers, $scoped): void {
|
||||
if (! $mayDeleteOwn) {
|
||||
$outer->orWhere('uploaded_by', $viewer->id);
|
||||
}
|
||||
|
||||
if (! $mayDeleteOthers) {
|
||||
$outer->orWhere(fn (Builder $others): Builder => $others
|
||||
->whereNull('uploaded_by')->orWhere('uploaded_by', '!=', $viewer->id));
|
||||
}
|
||||
|
||||
if ($scoped) {
|
||||
$outer->orWhereNotIn('id', $this->scope->files($viewer)->select('id'));
|
||||
}
|
||||
})
|
||||
->count();
|
||||
}
|
||||
}
|
||||
@@ -10,23 +10,22 @@ use App\Modules\Api\Support\PollingQuery;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Clients\ClientStorageUsage;
|
||||
use App\Modules\Comments\CommentingRules;
|
||||
use App\Modules\Comments\CommentScope;
|
||||
use App\Modules\Files\Access\ClientIdentityScope;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Access\ViewableFileScope;
|
||||
use App\Modules\Files\DownloadLimitScope;
|
||||
use App\Modules\Files\Editing\ApplyFileEdits;
|
||||
use App\Modules\Files\Editing\FileExpiry;
|
||||
use App\Modules\Files\Http\Resources\Api\FileResource;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Scanning\ScanStatus;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Files\Storage\ResolvingUploadDisk;
|
||||
use App\Modules\Files\Uploads\StoreUploadedFile;
|
||||
use App\Modules\Files\Uploads\UploadExtensionPolicy;
|
||||
use App\Modules\Platform\Localization\LocalDay;
|
||||
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use App\Support\Rules;
|
||||
use Carbon\Carbon;
|
||||
use Closure;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
@@ -60,9 +59,10 @@ class FilesController extends Controller
|
||||
private readonly UploadExtensionPolicy $extensionPolicy,
|
||||
private readonly ClientStorageUsage $storageUsage,
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly CommentingRules $commenting,
|
||||
private readonly StaffLibraryScope $scope,
|
||||
private readonly TimezoneRegistry $timezones,
|
||||
private readonly ClientIdentityScope $identity,
|
||||
private readonly ApplyFileEdits $fileEdits,
|
||||
private readonly FileExpiry $expiry,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -103,7 +103,15 @@ class FilesController extends Controller
|
||||
'uploaded_by' => ['nullable', 'integer'],
|
||||
'search' => ['nullable', 'string', 'max:255'],
|
||||
'public' => ['nullable', 'boolean'],
|
||||
'visibility' => ['nullable', 'in:public,private'],
|
||||
'role_id' => ['nullable', 'integer'],
|
||||
'downloads' => ['nullable', 'in:none,any'],
|
||||
'version' => ['nullable', 'in:current,outdated'],
|
||||
'expired' => ['nullable', 'boolean'],
|
||||
// One of pending, clean, infected, released, not_scanned or
|
||||
// unscannable_blocked — so an integration can wait for a file
|
||||
// it just uploaded, or collect what is in quarantine.
|
||||
'scan_status' => ['nullable', 'string', Rule::enum(ScanStatus::class)],
|
||||
]);
|
||||
|
||||
$query = $this->viewable->for($user)
|
||||
@@ -114,6 +122,17 @@ class FilesController extends Controller
|
||||
}
|
||||
|
||||
if (array_key_exists('uploaded_by', $filters) && $filters['uploaded_by'] !== null) {
|
||||
// A filter is a question, and this one asks "did client N put
|
||||
// anything into my library". Answered plainly it is an oracle:
|
||||
// a client-scoped caller could walk the id space and learn
|
||||
// which clients off their roster share files with clients on
|
||||
// it, without ever reading a name. So an id this caller may
|
||||
// not identify matches nothing — indistinguishable from a
|
||||
// client who has uploaded nothing, which is the point.
|
||||
if (! $this->identity->permitsClientId($user, (int) $filters['uploaded_by'])) {
|
||||
$query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
$query->where('files.uploaded_by', $filters['uploaded_by']);
|
||||
}
|
||||
|
||||
@@ -129,10 +148,58 @@ class FilesController extends Controller
|
||||
->orWhere('files.original_name', 'like', "%{$search}%"));
|
||||
}
|
||||
|
||||
// Two overlapping questions, kept apart on purpose.
|
||||
//
|
||||
// `public` has always tested the column, and callers depend on that,
|
||||
// so its meaning is left exactly as it was -- changing what an
|
||||
// existing filter answers is a breaking change for everybody already
|
||||
// asking it, whatever the new answer is.
|
||||
//
|
||||
// `visibility` is the question the staff library's own filter asks:
|
||||
// File::isEffectivelyPublic(), the flag *or* a public folder anywhere
|
||||
// above the file. That is what the badge on a row means, so it is
|
||||
// what an integration comparing itself to the screen will expect.
|
||||
// Prefer it; `public` remains for compatibility.
|
||||
if ($request->has('public') && ($filters['public'] ?? null) !== null) {
|
||||
$query->where('files.public', $request->boolean('public'));
|
||||
}
|
||||
|
||||
if (($filters['visibility'] ?? null) !== null) {
|
||||
$query->effectivelyPublic($filters['visibility'] === 'public');
|
||||
}
|
||||
|
||||
// No identity guard here, unlike `uploaded_by` directly above, and
|
||||
// the difference is what the answer discloses. `uploaded_by` names a
|
||||
// person: a non-empty result confirms *which* client uploaded a file
|
||||
// whose uploader the response is redacting, which is the redaction
|
||||
// undone. A role names nobody. The files in the result are ones this
|
||||
// caller may already read, and learning that one of them came from
|
||||
// somebody holding the Client role narrows to a set the caller could
|
||||
// have guessed. Same reasoning, and same absence of a guard, as the
|
||||
// staff library's own role filter -- the two surfaces must not
|
||||
// disagree about what a role reveals.
|
||||
if (($filters['role_id'] ?? null) !== null) {
|
||||
$query->whereHas('uploader', fn (Builder $uploader) => $uploader->where('role_id', (int) $filters['role_id']));
|
||||
}
|
||||
|
||||
// has/doesn't-have rather than a comparison on a count: an aggregate
|
||||
// cannot be filtered in a WHERE, and a HAVING would be applied after
|
||||
// the page has already been sliced.
|
||||
if (($filters['downloads'] ?? null) !== null) {
|
||||
$filters['downloads'] === 'none'
|
||||
? $query->whereDoesntHave('downloads')
|
||||
: $query->whereHas('downloads');
|
||||
}
|
||||
|
||||
// "current" includes a file that was never versioned at all -- it is
|
||||
// the current version of itself. "outdated" is the word the version
|
||||
// badge uses, so the filter and the row agree.
|
||||
if (($filters['version'] ?? null) !== null) {
|
||||
$filters['version'] === 'current'
|
||||
? $query->whereDoesntHave('nextVersion')
|
||||
: $query->whereHas('nextVersion');
|
||||
}
|
||||
|
||||
// Expiry is a filter, not a default: staff see expired files in the
|
||||
// UI too (that is how they notice and act on them). Dropping them
|
||||
// is the client branch's rule, applied inside the visibility scopes
|
||||
@@ -144,6 +211,10 @@ class FilesController extends Controller
|
||||
$request->boolean('expired') ? $query->expired() : $query->notExpired();
|
||||
}
|
||||
|
||||
if (($filters['scan_status'] ?? null) !== null) {
|
||||
$query->where('files.scan_status', $filters['scan_status']);
|
||||
}
|
||||
|
||||
return FileResource::collection($this->polling->paginate($request, $query, 'files'));
|
||||
}
|
||||
|
||||
@@ -296,14 +367,17 @@ class FilesController extends Controller
|
||||
'slug' => Rules::slug('files', $file->id),
|
||||
'categories' => ['sometimes', 'array'],
|
||||
'categories.*' => ['integer', 'exists:categories,id'],
|
||||
'expires_at' => ['sometimes', 'nullable', 'date'],
|
||||
'expires_at' => ['sometimes', 'nullable', 'string', 'date'],
|
||||
'download_limit' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
||||
'download_limit_scope' => ['sometimes', Rule::enum(DownloadLimitScope::class)],
|
||||
]);
|
||||
|
||||
// Reparenting through update() must respect the same library scope as
|
||||
// Reparenting through update() must respect the same two rules as
|
||||
// the web move()/bulkUpdate() paths: the destination folder must be
|
||||
// one this user can see. Only enforced when folder_id actually
|
||||
// one this user can see, and one they may put content into. A public
|
||||
// destination publishes what lands in it, so the second question is
|
||||
// the one `upload_public` exists to ask and store() above already
|
||||
// asks (GHSA-rxf8-wh8v-jm9j). Only enforced when folder_id actually
|
||||
// changes, so re-saving a file that already sits in an out-of-scope
|
||||
// folder (reachable via a direct client share) still works. The
|
||||
// integer rule admits numeric strings, so cast before the strict
|
||||
@@ -312,48 +386,38 @@ class FilesController extends Controller
|
||||
$validated['folder_id'] = (int) $validated['folder_id'];
|
||||
|
||||
if ($validated['folder_id'] !== $file->folder_id) {
|
||||
$this->scope->folders($user)->findOrFail($validated['folder_id']);
|
||||
$destination = $this->scope->folders($user)->whereKey($validated['folder_id'])->firstOrFail();
|
||||
|
||||
abort_unless(Folder::uploadableBy($user, $destination), 403);
|
||||
}
|
||||
}
|
||||
|
||||
$attributes = array_intersect_key($validated, array_flip(['name', 'description', 'folder_id']));
|
||||
// `sometimes` throughout the rules above means $validated already
|
||||
// holds exactly the fields the caller sent, which is the same
|
||||
// array_key_exists contract ApplyFileEdits reads — so the payload
|
||||
// passes through almost untouched. Which of them this token's user
|
||||
// may actually write is that class's decision, shared with the
|
||||
// staff editor and the client portal.
|
||||
$changes = array_intersect_key($validated, array_flip([
|
||||
'name',
|
||||
'description',
|
||||
'folder_id',
|
||||
'commentable',
|
||||
'download_limit',
|
||||
'download_limit_scope',
|
||||
'public',
|
||||
'slug',
|
||||
'categories',
|
||||
]));
|
||||
|
||||
if (array_key_exists('expires_at', $validated) && $user->can('set_file_expiration_date')) {
|
||||
$attributes['expires_at'] = $this->expiryInstant($validated['expires_at'], $user);
|
||||
// The one field that needs converting rather than passing along: a
|
||||
// caller may send a calendar day or a full timestamp, and a day
|
||||
// means the end of that day where the caller is.
|
||||
if (array_key_exists('expires_at', $validated)) {
|
||||
$changes['expires_at'] = $this->expiry->instant($validated['expires_at'], $user);
|
||||
}
|
||||
|
||||
if (array_key_exists('download_limit', $validated) && $user->can('limit_downloads')) {
|
||||
$attributes['download_limit'] = $validated['download_limit'];
|
||||
}
|
||||
|
||||
if (array_key_exists('download_limit_scope', $validated) && $user->can('limit_downloads')) {
|
||||
$attributes['download_limit_scope'] = $validated['download_limit_scope'];
|
||||
}
|
||||
|
||||
if (array_key_exists('commentable', $validated) && $this->commenting->scope() === CommentScope::SelectedFiles) {
|
||||
$attributes['commentable'] = $validated['commentable'];
|
||||
}
|
||||
|
||||
$wasPublic = $file->public;
|
||||
|
||||
if (array_key_exists('public', $validated) && $user->can('upload_public')) {
|
||||
$attributes['public'] = $validated['public'];
|
||||
$attributes['slug'] = ($validated['slug'] ?? '') ?: ($file->slug ?: File::uniqueSlugFrom($validated['name'] ?? $file->name, $file->id));
|
||||
}
|
||||
|
||||
$file->update($attributes);
|
||||
|
||||
if (array_key_exists('categories', $validated) && $user->can('set_file_categories')) {
|
||||
$file->categories()->sync($validated['categories']);
|
||||
}
|
||||
|
||||
$this->activity->log(Action::FileUpdated, subject: $file);
|
||||
|
||||
if (! $wasPublic && $file->public) {
|
||||
$this->activity->log(Action::FileMadePublic, subject: $file, context: ['slug' => $file->slug]);
|
||||
} elseif ($wasPublic && ! $file->public) {
|
||||
$this->activity->log(Action::FileMadePrivate, subject: $file);
|
||||
}
|
||||
$this->fileEdits->apply($user, $file, $changes);
|
||||
|
||||
return new FileResource($file->fresh()?->load(['folder', 'uploader', 'categories']) ?? $file);
|
||||
}
|
||||
@@ -369,29 +433,4 @@ class FilesController extends Controller
|
||||
|
||||
return response()->json(status: 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* What an `expires_at` value means.
|
||||
*
|
||||
* A bare `YYYY-MM-DD` is a calendar day, and a calendar day ends where
|
||||
* the person naming it lives — the same rule the web form's date input
|
||||
* gets from FilesController::expiryInstant. Stored as it arrives it
|
||||
* would be midnight UTC instead, so a file asked to expire on the 12th
|
||||
* would die at the *start* of the 12th, and for a caller west of
|
||||
* Greenwich partway through the 11th.
|
||||
*
|
||||
* Anything carrying a time is an instant the caller named on purpose
|
||||
* and is stored as it arrives, unchanged from before: the API can
|
||||
* express a moment, and a date input cannot.
|
||||
*/
|
||||
private function expiryInstant(?string $value, User $setter): ?Carbon
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1
|
||||
? LocalDay::end($value, $this->timezones->resolve($setter))
|
||||
: Carbon::parse($value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Folders\FolderTrails;
|
||||
use App\Modules\Files\Http\Controllers\Concerns\ResolvesShareTargets;
|
||||
use App\Modules\Files\Http\Resources\Api\FolderResource;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Files\Sharing\FolderSharing;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
/**
|
||||
* Sharing a folder with a client or a group: `{type: client|group, id}`.
|
||||
*
|
||||
* A client a folder is shared with sees everything inside it, including
|
||||
* folders and files added later.
|
||||
*
|
||||
* Both the target resolution (ResolvesShareTargets) and the effects
|
||||
* (FolderSharing — the row, the activity entry, the in-app notification,
|
||||
* the digest email) are shared with the web controller, so the two surfaces
|
||||
* cannot drift. "May share" is "may edit", as on the web.
|
||||
*/
|
||||
class FolderAssignmentsController extends Controller
|
||||
{
|
||||
use ResolvesShareTargets;
|
||||
|
||||
public function __construct(
|
||||
private readonly StaffLibraryScope $scope,
|
||||
private readonly FolderSharing $sharing,
|
||||
private readonly FolderTrails $trails,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Share a folder.
|
||||
*
|
||||
* Sharing it again with the same client or group leaves one share.
|
||||
*/
|
||||
public function store(Request $request, Folder $folder): FolderResource
|
||||
{
|
||||
Gate::authorize('update', $folder);
|
||||
|
||||
[$assignable, $targetName] = $this->resolveRequestedTarget(
|
||||
$request,
|
||||
__('Folders can only be shared with clients or groups.'),
|
||||
);
|
||||
|
||||
$this->sharing->assign($folder, $assignable, $targetName);
|
||||
|
||||
return $this->resource($request, $folder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop sharing a folder.
|
||||
*/
|
||||
public function destroy(Request $request, Folder $folder): FolderResource
|
||||
{
|
||||
Gate::authorize('update', $folder);
|
||||
|
||||
[$assignable, $targetName] = $this->resolveRequestedTarget(
|
||||
$request,
|
||||
__('Folders can only be shared with clients or groups.'),
|
||||
);
|
||||
|
||||
$this->sharing->unassign($folder, $assignable, $targetName);
|
||||
|
||||
return $this->resource($request, $folder);
|
||||
}
|
||||
|
||||
private function resource(Request $request, Folder $folder): FolderResource
|
||||
{
|
||||
$folder = $folder->fresh() ?? $folder;
|
||||
$folder->load('assignments.assignable');
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if ($user !== null) {
|
||||
$folder->setRelation('trail', collect($this->trails->ancestors([$folder], $user)[$folder->id] ?? []));
|
||||
}
|
||||
|
||||
return new FolderResource($folder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\Api\Support\PollingQuery;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Folders\FolderService;
|
||||
use App\Modules\Files\Folders\FolderTrails;
|
||||
use App\Modules\Files\Folders\UndeletableFiles;
|
||||
use App\Modules\Files\Http\Resources\Api\FolderResource;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Support\Rules;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* The staff library's folders.
|
||||
*
|
||||
* Which folders a token sees is the same question the library screen
|
||||
* answers, so a staff member limited to their assigned clients gets exactly
|
||||
* the folders they see on the web. Every write goes through the same
|
||||
* service, policy and placement rule as the web screen.
|
||||
*/
|
||||
class FoldersController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StaffLibraryScope $scope,
|
||||
private readonly PollingQuery $polling,
|
||||
private readonly FolderService $folders,
|
||||
private readonly FolderTrails $trails,
|
||||
private readonly UndeletableFiles $undeletable,
|
||||
private readonly ActivityLogger $activity,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* List folders.
|
||||
*
|
||||
* Cursor paginated, like every list. Pass `updated_since` to poll for
|
||||
* folders created, renamed or moved since a point in time. `parent_id`
|
||||
* lists the folders directly inside one folder, and `top_level=1` the
|
||||
* folders at the top of the library.
|
||||
*
|
||||
* Moving a folder updates the folder itself and every folder under it,
|
||||
* so a poll sees the whole moved subtree.
|
||||
*/
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$user = $request->user();
|
||||
assert($user !== null);
|
||||
|
||||
$filters = $request->validate($this->polling->rules() + [
|
||||
'parent_id' => ['nullable', 'integer'],
|
||||
'top_level' => ['nullable', 'boolean'],
|
||||
'search' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$query = $this->scope->folders($user);
|
||||
|
||||
if (($filters['parent_id'] ?? null) !== null) {
|
||||
$query->where('folders.parent_id', (int) $filters['parent_id']);
|
||||
}
|
||||
|
||||
if ($request->boolean('top_level')) {
|
||||
$query->whereNull('folders.parent_id');
|
||||
}
|
||||
|
||||
if (($filters['search'] ?? null) !== null) {
|
||||
$query->where('folders.name', 'like', '%'.$filters['search'].'%');
|
||||
}
|
||||
|
||||
$page = $this->polling->paginate($request, $query, 'folders');
|
||||
|
||||
/** @var Collection<int, Folder> $items */
|
||||
$items = collect($page->items());
|
||||
$this->attachTrails($items, $user);
|
||||
|
||||
return FolderResource::collection($page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a folder, with the clients and groups it is shared with.
|
||||
*/
|
||||
public function show(Request $request, Folder $folder): FolderResource
|
||||
{
|
||||
Gate::authorize('view', $folder);
|
||||
|
||||
return $this->resource($folder, $request->user());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a folder.
|
||||
*
|
||||
* At the top of the library, or inside `parent_id`. Requires the
|
||||
* `create_own_folders` ability, and `upload` with it.
|
||||
*
|
||||
* If a folder with the same name already exists in the same place, that
|
||||
* folder is returned with a 200 instead of a second one being made, so
|
||||
* retrying a request is safe. A new folder answers 201.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
assert($user !== null);
|
||||
|
||||
// The same pair FoldersController::store asks on the web: a folder
|
||||
// nobody can put anything into is no use.
|
||||
abort_unless($user->can('create_own_folders') && $user->can('upload'), 403);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'parent_id' => Rules::folderId(),
|
||||
]);
|
||||
|
||||
$parent = $this->resolveParent($user, $validated['parent_id'] ?? null);
|
||||
|
||||
// A folder inside a public one is public, so creating one there is
|
||||
// placing content into it (Folder::uploadableBy).
|
||||
abort_unless(Folder::uploadableBy($user, $parent), 403);
|
||||
|
||||
$existing = $this->scope->folders($user)
|
||||
->where('folders.parent_id', $parent?->id)
|
||||
->where('folders.name', $validated['name'])
|
||||
->orderBy('folders.id')
|
||||
->first();
|
||||
|
||||
if ($existing instanceof Folder) {
|
||||
return $this->resource($existing, $user)->response()->setStatusCode(200);
|
||||
}
|
||||
|
||||
$folder = $this->folders->create($validated['name'], $parent);
|
||||
|
||||
$this->activity->log(Action::FolderCreated, subject: $folder);
|
||||
|
||||
return $this->resource($folder, $user)->response()->setStatusCode(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename or move a folder.
|
||||
*
|
||||
* Only the fields you send change. `parent_id: null` moves the folder to
|
||||
* the top of the library. A folder moves with everything inside it, and
|
||||
* cannot be moved into itself or one of its own subfolders.
|
||||
*/
|
||||
public function update(Request $request, Folder $folder): FolderResource
|
||||
{
|
||||
$user = $request->user();
|
||||
assert($user !== null);
|
||||
|
||||
Gate::authorize('update', $folder);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['sometimes', 'required', 'string', 'max:255'],
|
||||
'parent_id' => ['sometimes', ...Rules::folderId()],
|
||||
]);
|
||||
|
||||
if (array_key_exists('name', $validated) && $validated['name'] !== $folder->name) {
|
||||
$folder->update(['name' => $validated['name']]);
|
||||
$this->activity->log(Action::FolderRenamed, subject: $folder);
|
||||
}
|
||||
|
||||
if (array_key_exists('parent_id', $validated)) {
|
||||
$newParentId = $validated['parent_id'] === null ? null : (int) $validated['parent_id'];
|
||||
|
||||
if ($newParentId !== $folder->parent_id) {
|
||||
$newParent = $this->resolveParent($user, $newParentId);
|
||||
|
||||
// Dropping a folder into a public parent publishes its whole
|
||||
// subtree, the act FoldersController::move refuses without
|
||||
// `upload_public` (GHSA-rxf8-wh8v-jm9j).
|
||||
abort_unless(Folder::uploadableBy($user, $newParent), 403);
|
||||
|
||||
$this->folders->move($folder, $newParent);
|
||||
$this->activity->log(Action::FolderMoved, subject: $folder);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->resource($folder->fresh() ?? $folder, $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a folder.
|
||||
*
|
||||
* An empty folder is deleted straight away. A folder holding files or
|
||||
* other folders answers 409 unless you send
|
||||
* `content_action=cascade_delete`, which deletes the folder, every folder
|
||||
* under it and every file inside them, as the web screen does. There is
|
||||
* no restore.
|
||||
*
|
||||
* A cascade is refused with 403 if the folder holds any file this token
|
||||
* may not delete itself.
|
||||
*/
|
||||
public function destroy(Request $request, Folder $folder): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
assert($user !== null);
|
||||
|
||||
Gate::authorize('delete', $folder);
|
||||
|
||||
$validated = $request->validate([
|
||||
'content_action' => ['nullable', Rule::in(['cascade_delete'])],
|
||||
]);
|
||||
|
||||
$subtree = $folder->subtreeFolderIds();
|
||||
$hasContent = count($subtree) > 1
|
||||
|| File::query()->whereIn('folder_id', $subtree)->exists();
|
||||
|
||||
// A sync job with a bug in it must not be one request away from
|
||||
// emptying a client's folder: the cascade has to be asked for.
|
||||
abort_if(
|
||||
$hasContent && ($validated['content_action'] ?? null) !== 'cascade_delete',
|
||||
409,
|
||||
__('This folder is not empty. Send content_action=cascade_delete to delete it with everything inside it.'),
|
||||
);
|
||||
|
||||
$blocked = $this->undeletable->count($user, $folder);
|
||||
|
||||
abort_if($blocked > 0, 403, trans_choice(
|
||||
'This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.',
|
||||
$blocked,
|
||||
['count' => (string) $blocked],
|
||||
));
|
||||
|
||||
$name = $folder->name;
|
||||
|
||||
$this->folders->delete($folder);
|
||||
|
||||
$this->activity->log(Action::FolderDeleted, context: ['name' => $name]);
|
||||
|
||||
return response()->json(status: 204);
|
||||
}
|
||||
|
||||
private function resource(Folder $folder, ?User $user): FolderResource
|
||||
{
|
||||
$folder->load('assignments.assignable');
|
||||
|
||||
if ($user !== null) {
|
||||
$this->attachTrails(collect([$folder]), $user);
|
||||
}
|
||||
|
||||
return new FolderResource($folder);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Folder> $folders
|
||||
*/
|
||||
private function attachTrails(Collection $folders, User $user): void
|
||||
{
|
||||
$trails = $this->trails->ancestors($folders, $user);
|
||||
|
||||
foreach ($folders as $folder) {
|
||||
$folder->setRelation('trail', collect($trails[$folder->id] ?? []));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The parent must be a folder this caller's library shows them — the
|
||||
* same lookup the web screen makes, answering 404 otherwise.
|
||||
*/
|
||||
private function resolveParent(User $user, ?int $parentId): ?Folder
|
||||
{
|
||||
if ($parentId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var Builder<Folder> $folders */
|
||||
$folders = $this->scope->folders($user);
|
||||
|
||||
return $folders->findOrFail($parentId);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Clients\ClientStorageUsage;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Folders\ClientHomeFolders;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Files\Notifications\AdminClientUploadedNotification;
|
||||
use App\Modules\Files\Uploads\LocalPartStore;
|
||||
@@ -26,6 +27,7 @@ use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use App\Support\Rules;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -56,6 +58,7 @@ class ChunkedUploadsController extends Controller
|
||||
private readonly Notifier $notifier,
|
||||
private readonly PermissionChecker $permissions,
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly ClientHomeFolders $homeFolders,
|
||||
private readonly FileVersions $versions,
|
||||
) {}
|
||||
|
||||
@@ -89,14 +92,51 @@ class ChunkedUploadsController extends Controller
|
||||
assert($user !== null);
|
||||
|
||||
$folder = isset($validated['folder_id']) ? Folder::query()->whereKey($validated['folder_id'])->first() : null;
|
||||
|
||||
// A client uploading without naming a folder lands in their own,
|
||||
// where this installation gives them one. That is what makes the
|
||||
// home a root rather than just another folder: nothing in the
|
||||
// portal has to be told about it for their files to end up there.
|
||||
//
|
||||
// Only when no folder was named. A client who picked a destination
|
||||
// picked it, and uploadableBy() below is still what decides whether
|
||||
// they may -- this chooses a default, it never grants anything.
|
||||
if ($folder === null) {
|
||||
$folder = $this->homeFolders->for($user);
|
||||
}
|
||||
|
||||
abort_unless(Folder::uploadableBy($user, $folder), 403);
|
||||
|
||||
// One session per file, and a person uploads a handful at a time.
|
||||
// A cap is here because nothing else counts sessions: for anyone
|
||||
// without a quota to spend — staff, and clients on an installation
|
||||
// that sets no quotas — the number of sessions is the only thing
|
||||
// standing between a declared size and any multiple of it.
|
||||
$openSessions = UploadSession::query()->where('user_id', $user->id)->count();
|
||||
$maxOpen = max(1, (int) config('projectsend.uploads.max_open_sessions'));
|
||||
|
||||
if ($openSessions >= $maxOpen) {
|
||||
throw ValidationException::withMessages([
|
||||
'filename' => __('Too many uploads are already in progress. Finish or cancel one and try again.'),
|
||||
]);
|
||||
}
|
||||
|
||||
// The declared size here is client-supplied and unverified until
|
||||
// complete()'s real assembled byte count — re-checked there too.
|
||||
if ($user->isClient()) {
|
||||
$quotaBytes = $this->storageUsage->quotaBytes($user);
|
||||
|
||||
if ($quotaBytes > 0 && $this->storageUsage->usedBytes($user) + (int) $validated['size'] > $quotaBytes) {
|
||||
// Sessions already open count too, at the size they declared.
|
||||
// A quota measured against stored files alone is spent twice
|
||||
// over by opening the sessions one after another: each one is
|
||||
// told there is room, because the ones before it had not
|
||||
// finished and so had not become files. putPart() holds each
|
||||
// session to its declaration, so reserving the declarations
|
||||
// here is what puts bytes waiting on the temporary volume
|
||||
// under the same ceiling as bytes that landed.
|
||||
$pendingBytes = (int) UploadSession::query()->where('user_id', $user->id)->sum('size');
|
||||
|
||||
if ($quotaBytes > 0 && $this->storageUsage->usedBytes($user) + $pendingBytes + (int) $validated['size'] > $quotaBytes) {
|
||||
throw ValidationException::withMessages([
|
||||
'size' => __('This upload would exceed your storage quota of :quota MB.', [
|
||||
// The resolved quota, not the column: a client who
|
||||
@@ -187,13 +227,7 @@ class ChunkedUploadsController extends Controller
|
||||
// ownership of the session is still enforced below.
|
||||
$this->authorizeSession($request, $session);
|
||||
|
||||
// signPart() bounds the part number; bound the part body too, or a
|
||||
// session can absorb unlimited bytes. The quota is only enforceable
|
||||
// at complete(), against the assembled size — until then nothing
|
||||
// stops a client declaring a 1-byte upload and streaming gigabytes
|
||||
// of parts, which never becomes a File row and so never counts
|
||||
// against anything. Stale sessions are purged daily, so without a
|
||||
// cap here the exposure is a day's worth of disk.
|
||||
// signPart() bounds the part number; bound the part body too.
|
||||
abort_unless($part >= 1 && $part <= 10000, 422);
|
||||
|
||||
$maxPartBytes = max(1, (int) config('projectsend.upload_part_size_mb')) * 1024 * 1024;
|
||||
@@ -201,20 +235,61 @@ class ChunkedUploadsController extends Controller
|
||||
// chooses its own chunking and only the last part is short.
|
||||
$limit = $maxPartBytes * 2;
|
||||
|
||||
if ($request->header('Content-Length') !== null && (int) $request->header('Content-Length') > $limit) {
|
||||
$contentLength = $request->header('Content-Length');
|
||||
$reservationLimit = $contentLength !== null ? (int) $contentLength : $limit;
|
||||
|
||||
if ($reservationLimit < 1 || $reservationLimit > $limit) {
|
||||
abort(413);
|
||||
}
|
||||
|
||||
$stream = $request->getContent(true);
|
||||
// Bounding one request bounds one request, and nothing else. Ten
|
||||
// thousand part numbers at twice a 20 MB part is about 400 GB per
|
||||
// session, sessions were not counted against anything, and none of
|
||||
// it becomes a File row — so a client with a 1 MB quota could
|
||||
// declare a one-byte upload and fill the temporary volume, then do
|
||||
// it again. The session needs a ceiling of its own, and the room
|
||||
// for a part has to be claimed before the part is read: a body's
|
||||
// length is not known until it has arrived, and by then it is on
|
||||
// the disk this is protecting.
|
||||
//
|
||||
// The ceiling is the size the session declared, which store() has
|
||||
// already weighed against the file-size limit and the quota. So
|
||||
// what a part gets is whatever the session has left, and the write
|
||||
// is then capped at exactly that — an over-long body is cut off
|
||||
// mid-stream as it always was, just against a smaller number.
|
||||
// Reserve the declared request length when available. Reserving the
|
||||
// full per-part ceiling (40 MiB for a normal 20 MiB chunk) makes
|
||||
// concurrent final parts exhaust the session allowance prematurely.
|
||||
// Unknown-length requests retain the conservative ceiling, and the
|
||||
// streamed byte count is still enforced against the reservation.
|
||||
$reserve = $this->reservePartRoom($session, $part, $reservationLimit);
|
||||
|
||||
if ($reserve < 1) {
|
||||
// 413 rather than 422: this is about the size of what is being
|
||||
// sent, and a client's resume logic already understands it. The
|
||||
// session survives — the parts it holds are untouched, and it
|
||||
// can still be completed or aborted.
|
||||
abort(413);
|
||||
}
|
||||
|
||||
$stream = null;
|
||||
|
||||
try {
|
||||
$etag = $this->parts->storePart($session, $part, $stream, $limit);
|
||||
$stream = $request->getContent(true);
|
||||
$etag = $this->parts->storePart($session, $part, $stream, $reserve);
|
||||
} catch (PartTooLargeException) {
|
||||
abort(413);
|
||||
} finally {
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
|
||||
// In the finally, because every way out of here needs it: the
|
||||
// refused part was deleted and weighs nothing, a client that
|
||||
// hung up left a short one, and a clean write leaves exactly
|
||||
// what it reserved. Without this a client's own retries would
|
||||
// slowly exhaust a session that has plenty of room.
|
||||
$session->settleStaged($reserve, $this->parts->partSize($session, $part));
|
||||
}
|
||||
|
||||
return response('', 200, [
|
||||
@@ -231,6 +306,52 @@ class ChunkedUploadsController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim room for one part, returning how many bytes were claimed — 0
|
||||
* when the session has none left.
|
||||
*
|
||||
* Read-then-claim, under a lock held for the two statements and not
|
||||
* for the transfer. The protocol sends parts in parallel and how many
|
||||
* is the client's choice, so without it every part in flight reads the
|
||||
* same "room left" and they all claim it; and making the claim alone
|
||||
* atomic is no better, because then the honest parallel upload is the
|
||||
* one that gets refused. The lock is the same per-session shape
|
||||
* complete() already uses, and it is released before a byte is read.
|
||||
*/
|
||||
private function reservePartRoom(UploadSession $session, int $part, int $limit): int
|
||||
{
|
||||
$lock = Cache::lock('upload-part:'.$session->id, 30);
|
||||
|
||||
try {
|
||||
$lock->block(15);
|
||||
} catch (LockTimeoutException) {
|
||||
// Nothing is wrong with the upload — the queue for this one
|
||||
// session just did not clear. 503 with Retry-After is what the
|
||||
// client's own backoff is for.
|
||||
abort(503, headers: ['Retry-After' => '5']);
|
||||
}
|
||||
|
||||
try {
|
||||
$existing = $this->parts->partSize($session, $part);
|
||||
|
||||
$session->refresh();
|
||||
|
||||
// Re-sending a part replaces it rather than adding to it, so
|
||||
// what it already holds is room this request may spend again.
|
||||
// That is an ordinary resume.
|
||||
$room = max(0, $session->size - ($session->staged_bytes - $existing));
|
||||
$reserve = min($limit, $room);
|
||||
|
||||
if ($reserve > 0 && ! $session->reserveStaged($reserve, $existing)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $reserve;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List the parts already received, so an interrupted upload can resume
|
||||
* rather than start again.
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Modules\Files\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Access\ClientIdentityScope;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Models\Category;
|
||||
use App\Modules\Files\Models\File;
|
||||
@@ -28,6 +29,7 @@ class ClientFilesController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StaffLibraryScope $scope,
|
||||
private readonly ClientIdentityScope $identity,
|
||||
) {}
|
||||
|
||||
public function index(Request $request, User $client): Response
|
||||
@@ -66,7 +68,11 @@ class ClientFilesController extends Controller
|
||||
'size' => $file->size,
|
||||
'created_at' => $file->created_at?->toIso8601String(),
|
||||
'uploaded_by_client' => $file->uploaded_by === $client->id,
|
||||
'uploader' => $file->uploader?->name,
|
||||
// Being allowed to browse this client's files does not
|
||||
// extend to the other clients who shared files with them:
|
||||
// a file reaches this listing through the client in the
|
||||
// URL, and its uploader can be somebody else entirely.
|
||||
'uploader' => $this->identity->nameOf($viewer, $file->uploader),
|
||||
'downloads_count' => $file->downloads_count,
|
||||
'can_download' => Gate::forUser($viewer)->allows('view', $file),
|
||||
'categories' => $file->categories->map(fn (Category $category): array => [
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Audit\ActivityPresenter;
|
||||
use App\Modules\Audit\DownloadPresenter;
|
||||
use App\Modules\Comments\CommentingRules;
|
||||
use App\Modules\Files\Access\ClientIdentityScope;
|
||||
use App\Modules\Files\Access\DownloadAllowance;
|
||||
use App\Modules\Files\Access\ShareTargets;
|
||||
use App\Modules\Files\DownloadLimitScope;
|
||||
@@ -79,6 +80,7 @@ class FileDetailsController extends Controller
|
||||
private readonly ActivityPresenter $presenter,
|
||||
private readonly DownloadPresenter $downloadPresenter,
|
||||
private readonly ShareTargets $shareTargets,
|
||||
private readonly ClientIdentityScope $identity,
|
||||
private readonly CommentingRules $commenting,
|
||||
private readonly FileVersionLinks $versionLinks,
|
||||
private readonly DownloadAllowance $allowance,
|
||||
@@ -100,7 +102,10 @@ class FileDetailsController extends Controller
|
||||
'size' => $file->size,
|
||||
'mime_type' => $file->mime_type,
|
||||
'checksum' => $file->checksum,
|
||||
'uploader' => $file->uploader?->name,
|
||||
// Null when the uploader is a client this viewer may not
|
||||
// be told about, which reads the same as an uploader whose
|
||||
// account has since been deleted.
|
||||
'uploader' => $this->identity->nameOf($viewer, $file->uploader),
|
||||
'folder' => $file->folder?->only('id', 'name'),
|
||||
'categories' => $file->categories()->orderBy('name')->get()
|
||||
->map(fn (Category $category): array => ['id' => $category->id, 'name' => $category->name, 'color' => $category->color])
|
||||
@@ -140,7 +145,7 @@ class FileDetailsController extends Controller
|
||||
// Resolved from the chain root for a revision (ShareTargets
|
||||
// does that), so this names who really has the file. The panel
|
||||
// says where those recipients are set.
|
||||
'shares' => $this->shareTargets->assigned($file),
|
||||
'shares' => $this->shareTargets->assignedFor($file, $viewer),
|
||||
'sharing_root' => $file->isRevision()
|
||||
? File::query()->find($file->sharingOwnerId())?->only('id', 'name')
|
||||
: null,
|
||||
@@ -368,7 +373,7 @@ class FileDetailsController extends Controller
|
||||
'name' => $folder->name,
|
||||
'files_count' => $folder->files()->count(),
|
||||
'children_count' => $folder->children()->count(),
|
||||
'creator' => $folder->creator?->name,
|
||||
'creator' => $this->identity->nameOf($viewer, $folder->creator),
|
||||
'created_at' => $folder->created_at?->toIso8601String(),
|
||||
'open_url' => route('files.index', ['folder' => $folder->id], false),
|
||||
// Read-only here, same as a file's shares — sharing (and every
|
||||
@@ -377,7 +382,7 @@ class FileDetailsController extends Controller
|
||||
'edit_url' => route('folders.share', $folder, false),
|
||||
'can_update' => Gate::forUser($viewer)->allows('update', $folder),
|
||||
'can_view_activity' => $viewer->can('view_actions_log'),
|
||||
'shares' => $this->shareTargets->assigned($folder),
|
||||
'shares' => $this->shareTargets->assignedFor($folder, $viewer),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Access\DownloadAllowance;
|
||||
use App\Modules\Files\Delivery\StoredFileResponse;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Scanning\FileAvailability;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
@@ -29,12 +30,18 @@ class FileDownloadController extends Controller
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly DownloadAllowance $allowance,
|
||||
private readonly StoredFileResponse $bytes,
|
||||
private readonly FileAvailability $availability,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request, File $file): Response|RedirectResponse
|
||||
{
|
||||
Gate::authorize('view', $file);
|
||||
|
||||
// Before the download limit and before the log: a file the scanner
|
||||
// has not cleared is not served to anybody, and a refusal here is
|
||||
// not a download to count.
|
||||
$this->availability->guardDelivery($file);
|
||||
|
||||
// Separate from the policy on purpose: a spent download limit is
|
||||
// not "you may not see this file" — the file stays listed, and
|
||||
// the same person may still open its details. It is only the
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Modules\Files\Access\DownloadAllowance;
|
||||
use App\Modules\Files\Delivery\FileDelivery;
|
||||
use App\Modules\Files\Delivery\StoredFileResponse;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Scanning\FileAvailability;
|
||||
use App\Modules\Files\Preview\PreviewKind;
|
||||
use App\Modules\Files\Preview\PreviewLog;
|
||||
use App\Modules\Files\Thumbnails\Events\ResolvingImageRendering;
|
||||
@@ -78,12 +79,18 @@ class FileThumbnailController extends Controller
|
||||
private readonly LocalSourceFile $source,
|
||||
private readonly Settings $settings,
|
||||
private readonly FileDelivery $delivery,
|
||||
private readonly FileAvailability $availability,
|
||||
) {}
|
||||
|
||||
public function thumbnail(Request $request, File $file): Response
|
||||
{
|
||||
Gate::authorize('view', $file);
|
||||
|
||||
// A rendition is made by an image library reading the file, which
|
||||
// is itself a way in — so an unchecked file is not rendered, not
|
||||
// even as 300 pixels.
|
||||
$this->availability->guardDelivery($file);
|
||||
|
||||
// This one route serves both the staff file manager and the client
|
||||
// portal — the same URL, told apart only by who is asking. A client
|
||||
// and a staff member looking at the same file get different cached
|
||||
@@ -119,6 +126,8 @@ class FileThumbnailController extends Controller
|
||||
{
|
||||
Gate::authorize('view', $file);
|
||||
|
||||
$this->availability->guardDelivery($file);
|
||||
|
||||
// The inline allowlist. See the class docblock and PreviewKind —
|
||||
// the stored mime type is sniffed from the bytes, so an allowed
|
||||
// extension is not evidence of a safe-to-render payload.
|
||||
|
||||
@@ -10,9 +10,12 @@ use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Comments\CommentingRules;
|
||||
use App\Modules\Comments\CommentScope;
|
||||
use App\Modules\Files\Access\ClientIdentityScope;
|
||||
use App\Modules\Files\Access\ShareTargets;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\DownloadLimitScope;
|
||||
use App\Modules\Files\Editing\ApplyFileEdits;
|
||||
use App\Modules\Files\Editing\FileExpiry;
|
||||
use App\Modules\Files\Models\Category;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
@@ -22,13 +25,10 @@ use App\Modules\Files\Uploads\StoreUploadedFile;
|
||||
use App\Modules\Files\Uploads\UploadExtensionPolicy;
|
||||
use App\Modules\Files\Versions\FileVersionLinks;
|
||||
use App\Modules\Files\Versions\FileVersions;
|
||||
use App\Modules\Platform\Localization\LocalDay;
|
||||
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use App\Support\PublicUrl;
|
||||
use App\Support\Rules;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
@@ -49,10 +49,12 @@ class FilesController extends Controller
|
||||
private readonly StaffLibraryScope $scope,
|
||||
private readonly PublicUrl $publicUrl,
|
||||
private readonly ShareTargets $shareTargets,
|
||||
private readonly ClientIdentityScope $identity,
|
||||
private readonly CommentingRules $commenting,
|
||||
private readonly FileVersions $versions,
|
||||
private readonly FileVersionLinks $versionLinks,
|
||||
private readonly TimezoneRegistry $timezones,
|
||||
private readonly ApplyFileEdits $fileEdits,
|
||||
private readonly FileExpiry $expiry,
|
||||
) {}
|
||||
|
||||
public function create(Request $request): Response
|
||||
@@ -60,7 +62,22 @@ class FilesController extends Controller
|
||||
$user = $request->user();
|
||||
assert($user !== null);
|
||||
|
||||
// Opened from inside a folder, the upload goes into it (#1801).
|
||||
// The same two checks the portal's upload page makes, with the
|
||||
// staff library in place of the client's: a folder this person
|
||||
// cannot see is a 404, one they may not upload into is a 403.
|
||||
// ChunkedUploadsController checks the destination again when the
|
||||
// upload starts, so this decides what the page offers, not what
|
||||
// is allowed.
|
||||
$folder = null;
|
||||
if ($request->integer('folder') > 0) {
|
||||
$folder = Folder::query()->find($request->integer('folder'));
|
||||
abort_if($folder === null || ! app(StaffLibraryScope::class)->allowsFolder($user, $folder), 404);
|
||||
abort_unless(Folder::uploadableBy($user, $folder), 403);
|
||||
}
|
||||
|
||||
return Inertia::render('files/create', [
|
||||
'folder' => $folder === null ? null : ['id' => $folder->id, 'name' => $folder->name],
|
||||
'max_file_size_mb' => app(Settings::class)->get(Setting::MaxFileSizeMb),
|
||||
'part_size_mb' => (int) config('projectsend.upload_part_size_mb'),
|
||||
'allowed_extensions' => app(UploadExtensionPolicy::class)->hintFor($user),
|
||||
@@ -164,7 +181,7 @@ class FilesController extends Controller
|
||||
'original_name' => $file->original_name,
|
||||
'size' => $file->size,
|
||||
'mime_type' => $file->mime_type,
|
||||
'uploader' => $file->uploader?->name,
|
||||
'uploader' => $this->identity->nameOf($viewer, $file->uploader),
|
||||
'folder_id' => $file->folder_id,
|
||||
'public' => $file->public,
|
||||
'commentable' => $file->commentable,
|
||||
@@ -173,8 +190,20 @@ class FilesController extends Controller
|
||||
// calendar date the editor typed — read back in their
|
||||
// zone, not the server's, or a file set to expire on the
|
||||
// 12th reopens showing the 11th.
|
||||
'expires_at' => $this->expiryDateFor($file, $request->user()),
|
||||
'expires_at' => $this->expiry->asShown($file, $request->user()),
|
||||
'expired' => $file->isExpired(),
|
||||
// Said on the one screen that still shows a quarantined or
|
||||
// missing file, since the library no longer lists it: a
|
||||
// staff member who followed a link from Quarantine should
|
||||
// not have to work out why the download refuses.
|
||||
'scan_status' => $file->scan_status->value,
|
||||
'scan_note' => $file->scan_note,
|
||||
// Decided here rather than by the page comparing six
|
||||
// states: whether there are bytes to hand over at all.
|
||||
// Every button that would produce them is hidden when
|
||||
// there are not — a download that answers 423 is not an
|
||||
// affordance, it is a trap.
|
||||
'scan_available' => $file->scan_status->isAvailable(),
|
||||
'download_limit' => $file->download_limit,
|
||||
'download_limit_scope' => ($file->download_limit_scope ?? DownloadLimitScope::Total)->value,
|
||||
// The file's total downloads, so the editor can see what
|
||||
@@ -265,7 +294,7 @@ class FilesController extends Controller
|
||||
'slug' => Rules::slug('files', $file->id),
|
||||
'categories' => ['array'],
|
||||
'categories.*' => ['integer', 'exists:categories,id'],
|
||||
'expires_at' => ['nullable', 'date'],
|
||||
'expires_at' => ['nullable', 'string', 'date'],
|
||||
'download_limit' => ['nullable', 'integer', 'min:1'],
|
||||
'download_limit_scope' => ['nullable', Rule::enum(DownloadLimitScope::class)],
|
||||
]);
|
||||
@@ -274,6 +303,8 @@ class FilesController extends Controller
|
||||
// change comparison below matches the model's int.
|
||||
$folderId = isset($validated['folder_id']) ? (int) $validated['folder_id'] : null;
|
||||
$user = $request->user();
|
||||
// Gate::authorize above cannot pass without one.
|
||||
assert($user !== null);
|
||||
|
||||
// Reparenting through update() is the same privileged write as
|
||||
// move()/bulkUpdate(), so it needs the same guard: the destination
|
||||
@@ -281,80 +312,49 @@ class FilesController extends Controller
|
||||
// folder actually changes, so re-saving a file that already sits in
|
||||
// an out-of-scope folder (reachable via a direct client share) still
|
||||
// works.
|
||||
if ($folderId !== null && $folderId !== $file->folder_id && $user !== null) {
|
||||
$this->scope->folders($user)->findOrFail($folderId);
|
||||
if ($folderId !== null && $folderId !== $file->folder_id) {
|
||||
$destination = $this->scope->folders($user)->whereKey($folderId)->firstOrFail();
|
||||
|
||||
// And one they may publish into, if it is public. Reparenting
|
||||
// through the edit form is the same privileged write as move().
|
||||
abort_unless(Folder::uploadableBy($user, $destination), 403);
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
// Normalised into the shape ApplyFileEdits reads, then handed
|
||||
// over: which of these the actor may actually write is that
|
||||
// class's decision, and it is the same decision the API and the
|
||||
// client portal get. See its docblock for why the split is here.
|
||||
$changes = [
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'folder_id' => $folderId,
|
||||
// Present unconditionally; the comment scope decides whether it
|
||||
// is honoured. Defaulted to the stored value so a form that
|
||||
// does not render the field cannot clear it.
|
||||
'commentable' => $validated['commentable'] ?? $file->commentable,
|
||||
'download_limit' => $validated['download_limit'] ?? null,
|
||||
'download_limit_scope' => $validated['download_limit_scope'] ?? DownloadLimitScope::Total->value,
|
||||
'public' => $validated['public'] ?? $file->public,
|
||||
'slug' => $validated['slug'] ?? '',
|
||||
'categories' => $validated['categories'] ?? [],
|
||||
];
|
||||
|
||||
// Only meaningful while the comment scope is `selected`, and only
|
||||
// offered by the page then — but a request reaching here directly
|
||||
// must not be able to set a flag the UI is currently hiding, the
|
||||
// same shape as the upload_public gate below.
|
||||
if ($this->commenting->scope() === CommentScope::SelectedFiles) {
|
||||
$attributes['commentable'] = $validated['commentable'] ?? $file->commentable;
|
||||
// The one field that is conditionally *present* rather than
|
||||
// conditionally honoured, and the reason it cannot move into
|
||||
// ApplyFileEdits: the form was rendered with the stored instant
|
||||
// read back as a date in this viewer's zone, and posts it again
|
||||
// untouched with every other edit. Re-deriving it unconditionally
|
||||
// would move the expiry by the difference between two people's
|
||||
// zones each time somebody merely renamed the file. Compared
|
||||
// against the same string the form was given, so "unchanged" means
|
||||
// what the editor actually saw.
|
||||
$posted = $validated['expires_at'] ?? null;
|
||||
|
||||
if ($posted !== $this->expiry->asShown($file, $user)) {
|
||||
$changes['expires_at'] = $this->expiry->instant($posted, $user);
|
||||
}
|
||||
|
||||
// Only a user who can set expiration dates may change this file's
|
||||
// own expiry — same "leave it alone if you lack the permission"
|
||||
// rule as the upload_public gate below.
|
||||
if ($request->user()?->can('set_file_expiration_date') === true) {
|
||||
$posted = $validated['expires_at'] ?? null;
|
||||
|
||||
// Re-derived only when the date actually changed. The form was
|
||||
// rendered with the stored instant read back as a date in *this*
|
||||
// viewer's zone, and posts it again untouched with every other
|
||||
// edit — so deriving it unconditionally moves the expiry by the
|
||||
// difference between two people's zones each time somebody
|
||||
// merely renames the file. Compared against the same string the
|
||||
// form was given, above, so "unchanged" means what the editor
|
||||
// saw.
|
||||
if ($posted !== $this->expiryDateFor($file, $request->user())) {
|
||||
$attributes['expires_at'] = $this->expiryInstant($posted, $request->user());
|
||||
}
|
||||
}
|
||||
|
||||
// Same rule again for the download cap, behind its own
|
||||
// permission — the one that already gates a share link's
|
||||
// max_downloads, since both are the same question asked about
|
||||
// different objects.
|
||||
if ($request->user()?->can('limit_downloads') === true) {
|
||||
$attributes['download_limit'] = $validated['download_limit'] ?? null;
|
||||
$attributes['download_limit_scope'] = $validated['download_limit_scope'] ?? DownloadLimitScope::Total->value;
|
||||
}
|
||||
|
||||
$wasPublic = $file->public;
|
||||
|
||||
// Only a user who can manage public state may change it — a user
|
||||
// who can edit a file but lacks upload_public leaves its public
|
||||
// state exactly as it was, same rule as FoldersController::update.
|
||||
if ($request->user()?->can('upload_public') === true) {
|
||||
$attributes['public'] = $validated['public'] ?? $file->public;
|
||||
// Omitting the field on an update leaves the current slug
|
||||
// alone — it must not silently change just because the name
|
||||
// did.
|
||||
$attributes['slug'] = ($validated['slug'] ?? '') ?: ($file->slug ?: File::uniqueSlugFrom($validated['name'], $file->id));
|
||||
}
|
||||
|
||||
$file->update($attributes);
|
||||
|
||||
// Categories are gated by their own permission; leave them untouched
|
||||
// for a user who can edit the file but not set categories.
|
||||
if ($request->user()?->can('set_file_categories') === true) {
|
||||
$file->categories()->sync($validated['categories'] ?? []);
|
||||
}
|
||||
|
||||
$this->activity->log(Action::FileUpdated, subject: $file);
|
||||
|
||||
if (! $wasPublic && $file->public) {
|
||||
$this->activity->log(Action::FileMadePublic, subject: $file, context: ['slug' => $file->slug]);
|
||||
} elseif ($wasPublic && ! $file->public) {
|
||||
$this->activity->log(Action::FileMadePrivate, subject: $file);
|
||||
}
|
||||
$this->fileEdits->apply($user, $file, $changes);
|
||||
|
||||
return back()->with('success', __('File updated.'));
|
||||
}
|
||||
@@ -375,9 +375,18 @@ class FilesController extends Controller
|
||||
$folderId = $validated['folder_id'] ?? null;
|
||||
$user = $request->user();
|
||||
|
||||
// The target folder must be one the mover can actually see.
|
||||
if ($folderId !== null && $user !== null) {
|
||||
$this->scope->folders($user)->findOrFail($folderId);
|
||||
// The target folder must be one the mover can actually see, and one
|
||||
// they are allowed to put content into. Those are two questions:
|
||||
// a file in a public folder is published by being there, so the
|
||||
// destination reaches the property `upload_public` guards without
|
||||
// anybody touching the switch. Asking only the first let an editor
|
||||
// who is deliberately not allowed to publish do it by dragging
|
||||
// (GHSA-rxf8-wh8v-jm9j — the move half of GHSA-237r-jx85-j3hr,
|
||||
// whose fix was wired into the upload paths and no further).
|
||||
if ($folderId !== null && $user !== null && $folderId !== $file->folder_id) {
|
||||
$destination = $this->scope->folders($user)->whereKey($folderId)->firstOrFail();
|
||||
|
||||
abort_unless(Folder::uploadableBy($user, $destination), 403);
|
||||
}
|
||||
|
||||
$file->update(['folder_id' => $folderId]);
|
||||
@@ -411,7 +420,7 @@ class FilesController extends Controller
|
||||
'description' => ['nullable', 'string', 'max:2000'],
|
||||
|
||||
'expiration_action' => ['required', Rule::in(['no_change', 'set', 'clear'])],
|
||||
'expires_at' => ['nullable', 'date', 'required_if:expiration_action,set'],
|
||||
'expires_at' => ['nullable', 'string', 'date', 'required_if:expiration_action,set'],
|
||||
|
||||
// `sometimes` rather than `required` like the fields above:
|
||||
// a browser still running the previous build would start
|
||||
@@ -434,13 +443,19 @@ class FilesController extends Controller
|
||||
&& ($validated['remove_category_ids'] ?? []) === [];
|
||||
abort_if($touchesNothing, 422, __('Change at least one field before applying a bulk edit.'));
|
||||
|
||||
// The target folder must be one this user can actually see — same
|
||||
// rule move() already applies to a single file's target.
|
||||
// The target folder must be one this user can actually see, and one
|
||||
// they may put content into — the same two questions move() asks of
|
||||
// a single file's target. Checked once, on the destination, rather
|
||||
// than per file: the destination is one folder for the whole batch,
|
||||
// and if putting content there publishes it then no file in the
|
||||
// batch may go.
|
||||
$targetFolderId = null;
|
||||
if ($validated['folder_action'] === 'move') {
|
||||
$targetFolderId = $validated['folder_id'] ?? null;
|
||||
if ($targetFolderId !== null) {
|
||||
$this->scope->folders($user)->findOrFail($targetFolderId);
|
||||
$destination = $this->scope->folders($user)->whereKey($targetFolderId)->firstOrFail();
|
||||
|
||||
abort_unless(Folder::uploadableBy($user, $destination), 403);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,7 +490,7 @@ class FilesController extends Controller
|
||||
// update()'s expires_at handling.
|
||||
if ($validated['expiration_action'] !== 'no_change' && $canSetExpiration) {
|
||||
$attributes['expires_at'] = $validated['expiration_action'] === 'set'
|
||||
? $this->expiryInstant($validated['expires_at'], $user)
|
||||
? $this->expiry->instant($validated['expires_at'], $user)
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -542,8 +557,13 @@ class FilesController extends Controller
|
||||
Gate::authorize('delete', $file);
|
||||
|
||||
$name = $file->name;
|
||||
// Soft delete; the bytes stay on disk until a purge policy
|
||||
// lands with the retention work.
|
||||
// Soft delete of the row — but not of the bytes. File::booted()'s
|
||||
// `deleted` hook runs FileDiskCleanup on commit, so the upload and
|
||||
// every cached rendition of it are gone from disk by the time this
|
||||
// returns. The row is kept because version chains, the activity
|
||||
// log and the erasure grace period all still point at it; nothing
|
||||
// serves it (route-model binding 404s), and nothing ever
|
||||
// forceDelete()s it either.
|
||||
$file->delete();
|
||||
|
||||
$this->activity->log(Action::FileDeleted, context: ['name' => $name]);
|
||||
@@ -552,30 +572,39 @@ class FilesController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* The instant a `<input type="date">` expiry actually falls on.
|
||||
* Delete several files at once, from the staff selection bar (#1800).
|
||||
*
|
||||
* The form posts a bare `YYYY-MM-DD`, which Eloquent would otherwise
|
||||
* store as midnight UTC — so "expires on the 12th" would cut the file
|
||||
* off partway through the 11th for anyone in the Americas, and give
|
||||
* anyone east of Greenwich most of a day they were not promised. It
|
||||
* means the end of the 12th where the person setting it lives.
|
||||
* Each file is asked exactly what destroy() asks, through the same
|
||||
* policy, and gets the same soft delete and the same activity entry: a
|
||||
* batch is a shorthand for single deletes, never a way around one. A
|
||||
* file the person may not delete is dropped from the batch rather than
|
||||
* failing it, the convention bulkUpdate() follows. Nothing left to
|
||||
* delete is a 422, so the page does not report a success.
|
||||
*/
|
||||
private function expiryInstant(?string $date, ?User $setter): ?Carbon
|
||||
public function bulkDestroy(Request $request): RedirectResponse
|
||||
{
|
||||
return $date === null
|
||||
? null
|
||||
: LocalDay::end($date, $this->timezones->resolve($setter));
|
||||
}
|
||||
$user = $request->user();
|
||||
assert($user !== null);
|
||||
|
||||
/**
|
||||
* The inverse: the calendar date a stored expiry falls on for this
|
||||
* viewer, which is what the date input is given and what it posts back.
|
||||
*
|
||||
* The pair has to agree, or a re-save reads one date and writes
|
||||
* another.
|
||||
*/
|
||||
private function expiryDateFor(File $file, ?User $viewer): ?string
|
||||
{
|
||||
return $file->expires_at?->copy()->setTimezone($this->timezones->resolve($viewer))->toDateString();
|
||||
$validated = $request->validate([
|
||||
'file_ids' => ['required', 'array', 'min:1'],
|
||||
'file_ids.*' => ['integer', 'distinct'],
|
||||
]);
|
||||
|
||||
$files = File::query()->whereIn('id', $validated['file_ids'])->get()
|
||||
->filter(fn (File $file): bool => Gate::forUser($user)->allows('delete', $file));
|
||||
|
||||
abort_if($files->isEmpty(), 422, __('None of the selected files could be deleted.'));
|
||||
|
||||
DB::transaction(function () use ($files): void {
|
||||
foreach ($files as $file) {
|
||||
$name = $file->name;
|
||||
$file->delete();
|
||||
|
||||
$this->activity->log(Action::FileDeleted, context: ['name' => $name]);
|
||||
}
|
||||
});
|
||||
|
||||
return back()->with('success', trans_choice(':count file deleted.|:count files deleted.', $files->count(), ['count' => $files->count()]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,31 +5,26 @@ declare(strict_types=1);
|
||||
namespace App\Modules\Files\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Http\Controllers\Concerns\ResolvesShareTargets;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Files\Models\FolderAssignment;
|
||||
use App\Modules\Notifications\NotificationDigester;
|
||||
use App\Modules\Notifications\Notifier;
|
||||
use App\Modules\Files\Sharing\FolderSharing;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
/**
|
||||
* Sharing a folder with a client or group grants live access to its
|
||||
* whole subtree. Mirrors FileAssignmentsController.
|
||||
* whole subtree. Mirrors FileAssignmentsController; the effects live in
|
||||
* FolderSharing, shared with the API.
|
||||
*/
|
||||
class FolderAssignmentsController extends Controller
|
||||
{
|
||||
use ResolvesShareTargets;
|
||||
|
||||
public function __construct(
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly StaffLibraryScope $scope,
|
||||
private readonly NotificationDigester $digester,
|
||||
private readonly Notifier $notifier,
|
||||
private readonly FolderSharing $sharing,
|
||||
) {}
|
||||
|
||||
public function store(Request $request, Folder $folder): RedirectResponse
|
||||
@@ -41,20 +36,7 @@ class FolderAssignmentsController extends Controller
|
||||
__('Folders can only be shared with clients or groups.'),
|
||||
);
|
||||
|
||||
FolderAssignment::query()->firstOrCreate([
|
||||
'folder_id' => $folder->id,
|
||||
'assignable_type' => $this->assignableType($assignable),
|
||||
'assignable_id' => $assignable->getKey(),
|
||||
]);
|
||||
|
||||
$this->activity->log(Action::FolderShared, subject: $folder, context: ['target' => $targetName]);
|
||||
|
||||
$recipients = $this->shareRecipients($assignable);
|
||||
$this->notifier->send('file_shared', $recipients, subject: $folder, data: ['itemName' => $folder->name]);
|
||||
|
||||
// The master switch and each recipient's own preference are the
|
||||
// digester's job now — every caller was repeating them.
|
||||
$this->digester->queue('file_shared', $recipients, $folder->name, ['is_folder' => true]);
|
||||
$this->sharing->assign($folder, $assignable, $targetName);
|
||||
|
||||
return back();
|
||||
}
|
||||
@@ -68,15 +50,7 @@ class FolderAssignmentsController extends Controller
|
||||
__('Folders can only be shared with clients or groups.'),
|
||||
);
|
||||
|
||||
$deleted = FolderAssignment::query()
|
||||
->where('folder_id', $folder->id)
|
||||
->where('assignable_type', $this->assignableType($assignable))
|
||||
->where('assignable_id', $assignable->getKey())
|
||||
->delete();
|
||||
|
||||
if ($deleted > 0) {
|
||||
$this->activity->log(Action::FolderUnshared, subject: $folder, context: ['target' => $targetName]);
|
||||
}
|
||||
$this->sharing->unassign($folder, $assignable, $targetName);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
@@ -10,16 +10,22 @@ use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Comments\Access\VisibleCommentScope;
|
||||
use App\Modules\Comments\CommentingRules;
|
||||
use App\Modules\Files\Access\ClientIdentityScope;
|
||||
use App\Modules\Files\Access\DownloadAllowance;
|
||||
use App\Modules\Files\Access\ShareTargets;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Folders\BreadcrumbBuilder;
|
||||
use App\Modules\Files\Folders\FolderService;
|
||||
use App\Modules\Files\Folders\UndeletableFiles;
|
||||
use App\Modules\Files\Models\Category;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Scanning\NotScannedReason;
|
||||
use App\Modules\Files\Scanning\ScanningConfig;
|
||||
use App\Modules\Files\Scanning\ScanStatus;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Files\Versions\FileVersionLinks;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use App\Support\ConcatenatedPagination;
|
||||
use App\Support\Pagination;
|
||||
use App\Support\PublicUrl;
|
||||
@@ -54,11 +60,13 @@ class FoldersController extends Controller
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly PublicUrl $publicUrl,
|
||||
private readonly ShareTargets $shareTargets,
|
||||
private readonly ClientIdentityScope $identity,
|
||||
private readonly BreadcrumbBuilder $breadcrumbs,
|
||||
private readonly CommentingRules $commenting,
|
||||
private readonly VisibleCommentScope $comments,
|
||||
private readonly FileVersionLinks $versionLinks,
|
||||
private readonly DownloadAllowance $allowance,
|
||||
private readonly UndeletableFiles $undeletable,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -82,6 +90,15 @@ class FoldersController extends Controller
|
||||
'search' => ['nullable', 'string', 'max:255'],
|
||||
'folder' => ['nullable', 'integer'],
|
||||
'category' => ['nullable', 'integer', 'exists:categories,id'],
|
||||
'uploader' => ['nullable', 'integer', 'exists:users,id'],
|
||||
'visibility' => ['nullable', 'in:public,private'],
|
||||
'downloads' => ['nullable', 'in:none,any'],
|
||||
'role' => ['nullable', 'integer', 'exists:roles,id'],
|
||||
// "current" is every file nothing has replaced, which includes
|
||||
// a file that was never versioned at all -- it is the current
|
||||
// version of itself. "outdated" is the same word the version
|
||||
// badge uses, so the filter and the row agree.
|
||||
'version' => ['nullable', 'in:current,outdated'],
|
||||
// Not a 'boolean' rule: that only accepts true/false/0/1/'0'/'1',
|
||||
// rejecting the literal "true"/"" the frontend checkbox sends.
|
||||
// $request->boolean() below coerces any of those safely, so
|
||||
@@ -90,12 +107,25 @@ class FoldersController extends Controller
|
||||
$search = trim($validated['search'] ?? '');
|
||||
$searching = $search !== '';
|
||||
$categoryId = $validated['category'] ?? null;
|
||||
// Cast, because `integer` validates a numeric string without
|
||||
// converting it -- so these arrive as "5" from the query string.
|
||||
// permitsClientId() below takes a strict ?int and 500s on a string,
|
||||
// and the props these become are typed `number | null` on the page.
|
||||
$uploaderId = isset($validated['uploader']) ? (int) $validated['uploader'] : null;
|
||||
$visibility = $validated['visibility'] ?? null;
|
||||
$downloads = $validated['downloads'] ?? null;
|
||||
$roleId = isset($validated['role']) ? (int) $validated['role'] : null;
|
||||
$version = $validated['version'] ?? null;
|
||||
$expired = $request->boolean('expired');
|
||||
|
||||
// A search term, a category filter, or the expired-only filter all
|
||||
// switch to a flat view across the whole visible library;
|
||||
// otherwise it's folder browsing.
|
||||
$flat = $searching || $categoryId !== null || $expired;
|
||||
// A search term or any filter switches to a flat view across the
|
||||
// whole visible library; otherwise it's folder browsing. Every
|
||||
// filter here is a property of a *file*, so in flat mode the folder
|
||||
// sequence stays empty unless there is a search term to match names
|
||||
// against -- which is what the existing branch below already does.
|
||||
$flat = $searching || $categoryId !== null || $expired
|
||||
|| $uploaderId !== null || $visibility !== null
|
||||
|| $downloads !== null || $roleId !== null || $version !== null;
|
||||
|
||||
$folderQuery = $this->scope->folders($user)->withCount(['children', 'files']);
|
||||
// `downloads` unconditionally — the library has always shown a
|
||||
@@ -103,7 +133,18 @@ class FoldersController extends Controller
|
||||
// something on this install is actually limited.
|
||||
$fileQuery = $this->allowance->withOwnCount(
|
||||
$this->scope->files($user)->with('uploader.role', 'categories', 'folder')
|
||||
->withCount(['assignments', 'downloads']),
|
||||
->withCount(['assignments', 'downloads'])
|
||||
// A file the scanner refused, or one whose bytes are gone,
|
||||
// is not a file anybody can work with: every button on its
|
||||
// row leads somewhere that refuses, and the download leads
|
||||
// to an error page. They are listed on the two screens
|
||||
// that exist to act on them — Quarantine, and Files
|
||||
// missing from storage — and left out here.
|
||||
->whereNotIn('scan_status', [
|
||||
ScanStatus::Infected->value,
|
||||
ScanStatus::UnscannableBlocked->value,
|
||||
ScanStatus::Missing->value,
|
||||
]),
|
||||
$user,
|
||||
);
|
||||
|
||||
@@ -118,6 +159,29 @@ class FoldersController extends Controller
|
||||
->when($categoryId !== null, fn (Builder $q) => $q
|
||||
->whereHas('categories', fn (Builder $c) => $c->where('categories.id', $categoryId)))
|
||||
->when($expired, fn (Builder $q) => $q->expired())
|
||||
// The same guard /api/v1/files puts on `uploaded_by`, and it
|
||||
// is needed for the same reason. A filter is a question, and
|
||||
// this one asks "did user N put anything into my library".
|
||||
// fileRow() already withholds an uploader's name from a
|
||||
// viewer who may not identify them -- so answering this
|
||||
// plainly would hand back, as a row count, precisely the
|
||||
// identity the row itself is redacting. An id this caller
|
||||
// may not identify matches nothing, which is
|
||||
// indistinguishable from someone who has uploaded nothing.
|
||||
->when($uploaderId !== null && ! $this->identity->permitsClientId($user, $uploaderId),
|
||||
fn (Builder $q) => $q->whereRaw('1 = 0'))
|
||||
->when($uploaderId !== null, fn (Builder $q) => $q->where('uploaded_by', $uploaderId))
|
||||
->when($roleId !== null, fn (Builder $q) => $q
|
||||
->whereHas('uploader', fn (Builder $u) => $u->where('role_id', $roleId)))
|
||||
// has/doesn't-have rather than a comparison on the
|
||||
// withCount alias: an aggregate cannot be filtered in a
|
||||
// WHERE, and `downloads_count = 0` in a HAVING would be
|
||||
// applied after the pagination slice above.
|
||||
->when($downloads === 'none', fn (Builder $q) => $q->whereDoesntHave('downloads'))
|
||||
->when($downloads === 'any', fn (Builder $q) => $q->whereHas('downloads'))
|
||||
->when($version === 'current', fn (Builder $q) => $q->whereDoesntHave('nextVersion'))
|
||||
->when($version === 'outdated', fn (Builder $q) => $q->whereHas('nextVersion'))
|
||||
->when($visibility !== null, fn (Builder $q) => $q->effectivelyPublic($visibility === 'public'))
|
||||
->orderBy('name');
|
||||
} else {
|
||||
$current = $request->integer('folder') > 0
|
||||
@@ -160,6 +224,11 @@ class FoldersController extends Controller
|
||||
'search' => $search !== '' ? $search : null,
|
||||
'folder' => $current?->id,
|
||||
'category' => $categoryId,
|
||||
'uploader' => $uploaderId,
|
||||
'visibility' => $visibility,
|
||||
'downloads' => $downloads,
|
||||
'role' => $roleId,
|
||||
'version' => $version,
|
||||
'expired' => $expired ? 'true' : null,
|
||||
'page' => Pagination::redirectPage($sliced['paginator']),
|
||||
]));
|
||||
@@ -174,15 +243,29 @@ class FoldersController extends Controller
|
||||
// as the comment counts above.
|
||||
$versions = $this->versionLinks->forMany($fileRows, $user, fn (File $other): string => route('files.edit', $other, false));
|
||||
|
||||
// Two queries for the whole page, not one per row. `distinct` on an
|
||||
// indexed foreign key rather than a join, because all this needs is
|
||||
// the set of ids -- the names come back with the roles in one go.
|
||||
$uploaders = User::query()
|
||||
->whereIn('id', $this->scope->files($user)->whereNotNull('uploaded_by')->distinct()->pluck('uploaded_by'))
|
||||
->with('role')
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'role_id']);
|
||||
|
||||
return Inertia::render('files/index', [
|
||||
'folder' => $current === null ? null : ['id' => $current->id, 'name' => $current->name],
|
||||
'breadcrumb' => $flat ? [] : $this->breadcrumbs->for($current),
|
||||
'breadcrumb' => $flat ? [] : $this->breadcrumb($user, $current),
|
||||
'folders' => $folderRows->map(fn (Folder $folder): array => $this->folderRow($user, $folder))->all(),
|
||||
'files' => $fileRows->map(fn (File $file): array => $this->fileRow($user, $file, $commentCounts, $pendingCounts, $versions))->all(),
|
||||
'pagination' => Pagination::meta($sliced['paginator']),
|
||||
'search' => $search,
|
||||
'searching' => $flat,
|
||||
'category' => $categoryId,
|
||||
'uploader' => $uploaderId,
|
||||
'visibility' => $visibility,
|
||||
'downloads' => $downloads,
|
||||
'role' => $roleId,
|
||||
'version' => $version,
|
||||
'expired' => $expired,
|
||||
'categories' => Category::query()->orderBy('name')->get(['id', 'name', 'color'])
|
||||
->map(fn (Category $category): array => ['id' => $category->id, 'name' => $category->name, 'color' => $category->color])->all(),
|
||||
@@ -192,6 +275,18 @@ class FoldersController extends Controller
|
||||
// every folder name and id on the installation.
|
||||
'folder_options' => $this->scope->folders($user)->orderBy('path')->orderBy('name')->get()
|
||||
->map(fn (Folder $folder): array => ['id' => $folder->id, 'name' => $folder->name])->all(),
|
||||
// Only people who actually uploaded something *this viewer can
|
||||
// see*, and their roles taken from the same set. Narrowed for
|
||||
// the reason folder_options directly above is: an unscoped list
|
||||
// would hand a client-scoped staffer the name and id of every
|
||||
// account on the installation, through a filter dropdown.
|
||||
// Through filterClientPairs, so the dropdown never offers a name
|
||||
// this viewer may not be told -- the same rule fileRow() applies
|
||||
// to the uploader on each row, asked once for the whole list.
|
||||
'uploader_options' => $this->identity->filterClientPairs($user, array_values($uploaders
|
||||
->map(fn (User $uploader): array => ['id' => $uploader->id, 'name' => $uploader->name])->all())),
|
||||
'role_options' => $uploaders->pluck('role')->filter()->unique('id')->sortBy('name')->values()
|
||||
->map(fn (Role $role): array => ['id' => $role->id, 'name' => $role->name])->all(),
|
||||
'can_create_folders' => $user->can('create_own_folders'),
|
||||
'can_upload' => $user->can('upload'),
|
||||
'can_manage_public' => $user->can('upload_public'),
|
||||
@@ -199,6 +294,39 @@ class FoldersController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the scanner made of a file, for a staff member's list.
|
||||
*
|
||||
* Staff see every file they always saw, with its state on it —
|
||||
* withholding applies to recipients, not to the library. Null while
|
||||
* scanning is off so nothing is decorated on an installation that does
|
||||
* not use it.
|
||||
*
|
||||
* @return array{status: string, note: string|null}|null
|
||||
*/
|
||||
private function scanState(File $file): ?array
|
||||
{
|
||||
if (! app(ScanningConfig::class)->enabled() && $file->scan_status === ScanStatus::NotScanned) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A file from before the scanner existed carries no reason — see
|
||||
// File::scopeNeverScanned — and "Not scanned" with no explanation
|
||||
// is the one badge somebody would have to come and ask about.
|
||||
$note = $file->scan_note ?? ($file->scan_status === ScanStatus::NotScanned
|
||||
? NotScannedReason::BeforeScanning->value
|
||||
: null);
|
||||
|
||||
return [
|
||||
'status' => $file->scan_status->value,
|
||||
// A reason is a key and is translated here; a threat name is
|
||||
// the scanner's own words and is passed through.
|
||||
'note' => $note === null ? null : (NotScannedReason::tryFrom($note)?->label() !== null
|
||||
? (string) __(NotScannedReason::from($note)->label())
|
||||
: $note),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -240,13 +368,20 @@ class FoldersController extends Controller
|
||||
'original_name' => $file->original_name,
|
||||
'mime_type' => $file->mime_type,
|
||||
'size' => $file->size,
|
||||
'uploader' => $file->uploader ? [
|
||||
// The whole block goes, not just the name: type and role
|
||||
// describe the same person, and "a client uploaded this" on a
|
||||
// row whose uploader is off this viewer's roster narrows who
|
||||
// it could be just as effectively as naming them.
|
||||
'uploader' => ($file->uploader !== null && $this->identity->permits($user, $file->uploader)) ? [
|
||||
'name' => $file->uploader->name,
|
||||
'type' => $file->uploader->type->value,
|
||||
'role' => $file->uploader->role?->name,
|
||||
] : null,
|
||||
'public' => $file->isEffectivelyPublic(),
|
||||
'expired' => $file->isExpired(),
|
||||
// Null while scanning is off, so a library that does not use
|
||||
// it carries no badge.
|
||||
'scan' => $this->scanState($file),
|
||||
// No link at all once expired — the public route 404s past
|
||||
// expiry too (see File::scopeNotExpired's callers), so there's
|
||||
// no point offering a button that leads to a dead page.
|
||||
@@ -293,7 +428,7 @@ class FoldersController extends Controller
|
||||
'public_url' => $folder->public
|
||||
? $this->publicUrl->for($folder)
|
||||
: null,
|
||||
'breadcrumb' => $this->breadcrumbs->for($folder),
|
||||
'breadcrumb' => $this->breadcrumb($user, $folder),
|
||||
'can_update' => Gate::forUser($user)->allows('update', $folder),
|
||||
'can_manage_public' => $user->can('upload_public'),
|
||||
...$this->shareTargets->forSubject($folder, $user),
|
||||
@@ -317,6 +452,11 @@ class FoldersController extends Controller
|
||||
|
||||
$parent = $this->resolveParent($user, $validated['parent_id'] ?? null);
|
||||
|
||||
// A folder inside a public one is public, so creating it there is
|
||||
// placing content into a public folder: the question every other
|
||||
// write of a parent_id already asks (Folder::uploadableBy).
|
||||
abort_unless(Folder::uploadableBy($user, $parent), 403);
|
||||
|
||||
$folder = $this->folders->create($validated['name'], $parent);
|
||||
|
||||
// Only a user who can manage public state may set it on create —
|
||||
@@ -396,7 +536,20 @@ class FoldersController extends Controller
|
||||
'parent_id' => Rules::folderId(),
|
||||
]);
|
||||
|
||||
$newParent = $this->resolveParent($request->user(), $validated['parent_id'] ?? null);
|
||||
$user = $request->user();
|
||||
$newParent = $this->resolveParent($user, $validated['parent_id'] ?? null);
|
||||
|
||||
// A folder carries its contents with it, and a folder inside a
|
||||
// public one is public — isEffectivelyPublic() reads the whole
|
||||
// ancestry. So dropping a private folder into a public parent
|
||||
// publishes every file in its subtree at once, which is the same
|
||||
// act the upload path refuses without `upload_public`. The flag on
|
||||
// this screen is already guarded (update() above leaves public
|
||||
// state alone without the permission); the placement was not
|
||||
// (GHSA-rxf8-wh8v-jm9j).
|
||||
if ($user !== null) {
|
||||
abort_unless(Folder::uploadableBy($user, $newParent), 403);
|
||||
}
|
||||
|
||||
$this->folders->move($folder, $newParent);
|
||||
|
||||
@@ -412,16 +565,9 @@ class FoldersController extends Controller
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
// Deleting a folder cascades to every file in its subtree, and a
|
||||
// File's `deleted` hook removes the bytes from disk — there is no
|
||||
// restore. Authorizing the folder is not authorizing its contents:
|
||||
// FilePolicy::delete asks for `delete_others_files` on somebody
|
||||
// else's upload, and for the library boundary on top of that, and
|
||||
// neither question is asked anywhere on this path.
|
||||
//
|
||||
// MyFoldersController::destroy already refuses for the client half
|
||||
// of the same cascade, in the same words. This is the staff half.
|
||||
$blocked = $this->undeletableFileCount($viewer, $folder);
|
||||
// Authorizing the folder is not authorizing the files the cascade
|
||||
// takes with it — see UndeletableFiles, which the API asks too.
|
||||
$blocked = $this->undeletable->count($viewer, $folder);
|
||||
|
||||
if ($blocked > 0) {
|
||||
return back()->with('error', trans_choice(
|
||||
@@ -442,47 +588,28 @@ class FoldersController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* How many files in this folder's subtree the viewer may not delete.
|
||||
* The trail to $folder, trimmed for a client-scoped staff member to
|
||||
* start at the first folder their library shows them: one of their
|
||||
* clients' folders can sit inside somebody else's tree, and the names
|
||||
* above it are not theirs to read. The client portal trims the same way.
|
||||
*
|
||||
* Asked as one count rather than FilePolicy::delete per file: a folder
|
||||
* can hold thousands, Gate resolves a fresh policy for every check, and
|
||||
* a per-row policy check on a listing is the cost 0a8b609e went to
|
||||
* some trouble to remove. The two halves of FilePolicy::delete are
|
||||
* expressible in SQL — the permission half is constant for this
|
||||
* viewer, and the library half is the query StaffLibraryScope already
|
||||
* memoises per request.
|
||||
*
|
||||
* Somebody holding both delete permissions and no library scope can
|
||||
* delete anything in the subtree by construction, so they never pay for
|
||||
* the query at all.
|
||||
* @return list<array{id: int, name: string}>
|
||||
*/
|
||||
private function undeletableFileCount(User $viewer, Folder $folder): int
|
||||
private function breadcrumb(User $user, ?Folder $folder): array
|
||||
{
|
||||
$mayDeleteOwn = $viewer->can('delete_files');
|
||||
$mayDeleteOthers = $viewer->can('delete_others_files');
|
||||
$scoped = $viewer->isClientScoped();
|
||||
|
||||
if ($mayDeleteOwn && $mayDeleteOthers && ! $scoped) {
|
||||
return 0;
|
||||
if ($folder === null || ! $user->isClientScoped()) {
|
||||
return $this->breadcrumbs->for($folder);
|
||||
}
|
||||
|
||||
return File::query()
|
||||
->whereIn('folder_id', $folder->subtreeFolderIds())
|
||||
->where(function (Builder $outer) use ($viewer, $mayDeleteOwn, $mayDeleteOthers, $scoped): void {
|
||||
if (! $mayDeleteOwn) {
|
||||
$outer->orWhere('uploaded_by', $viewer->id);
|
||||
}
|
||||
$visibleIds = array_values(array_map(
|
||||
'intval',
|
||||
$this->scope->folders($user)
|
||||
->whereIn('folders.id', [...$folder->ancestorIds(), $folder->id])
|
||||
->pluck('folders.id')
|
||||
->all(),
|
||||
));
|
||||
|
||||
if (! $mayDeleteOthers) {
|
||||
$outer->orWhere(fn (Builder $others): Builder => $others
|
||||
->whereNull('uploaded_by')->orWhere('uploaded_by', '!=', $viewer->id));
|
||||
}
|
||||
|
||||
if ($scoped) {
|
||||
$outer->orWhereNotIn('id', $this->scope->files($viewer)->select('id'));
|
||||
}
|
||||
})
|
||||
->count();
|
||||
return $this->breadcrumbs->visible($folder, $visibleIds);
|
||||
}
|
||||
|
||||
private function resolveParent(?User $user, ?int $parentId): ?Folder
|
||||
|
||||
@@ -5,14 +5,25 @@ declare(strict_types=1);
|
||||
namespace App\Modules\Files\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Clients\ClientStorageUsage;
|
||||
use App\Modules\Comments\Access\VisibleCommentScope;
|
||||
use App\Modules\Comments\CommentingRules;
|
||||
use App\Modules\Comments\CommentScope;
|
||||
use App\Modules\Files\Access\DownloadAllowance;
|
||||
use App\Modules\Files\Access\OwnFileDownloads;
|
||||
use App\Modules\Files\DownloadLimitScope;
|
||||
use App\Modules\Files\Editing\ApplyFileEdits;
|
||||
use App\Modules\Files\Editing\FileExpiry;
|
||||
use App\Modules\Files\Events\ResolvingUploadNotice;
|
||||
use App\Modules\Files\Folders\BreadcrumbBuilder;
|
||||
use App\Modules\Files\Folders\ClientHomeFolders;
|
||||
use App\Modules\Files\Models\Category;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Files\Models\ShareLink;
|
||||
use App\Modules\Files\Sharing\ClientShareLinks;
|
||||
use App\Modules\Files\Uploads\UploadExtensionPolicy;
|
||||
use App\Modules\Files\Versions\FileVersionLinks;
|
||||
use App\Modules\Files\Versions\FileVersions;
|
||||
@@ -22,6 +33,7 @@ use App\Modules\Platform\Settings\Settings;
|
||||
use App\Modules\Platform\Theming\PublicThemeRegistry;
|
||||
use App\Support\ConcatenatedPagination;
|
||||
use App\Support\Pagination;
|
||||
use App\Support\Rules;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -61,11 +73,17 @@ class MyFilesController extends Controller
|
||||
private readonly PublicThemeRegistry $themes,
|
||||
private readonly CapabilityRegistry $capabilities,
|
||||
private readonly BreadcrumbBuilder $breadcrumbs,
|
||||
private readonly ClientHomeFolders $homeFolders,
|
||||
private readonly CommentingRules $commenting,
|
||||
private readonly VisibleCommentScope $comments,
|
||||
private readonly DownloadAllowance $allowance,
|
||||
private readonly FileVersions $versions,
|
||||
private readonly FileVersionLinks $versionLinks,
|
||||
private readonly ClientShareLinks $shareLinks,
|
||||
private readonly OwnFileDownloads $ownDownloads,
|
||||
private readonly ApplyFileEdits $fileEdits,
|
||||
private readonly FileExpiry $expiry,
|
||||
private readonly ActivityLogger $activity,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response|RedirectResponse
|
||||
@@ -92,6 +110,17 @@ class MyFilesController extends Controller
|
||||
// folder they created themselves, anywhere in that visible tree.
|
||||
$visibleIds = array_values(Folder::query()->visibleToClient($client)->pluck('id')->map(fn ($id): int => (int) $id)->all());
|
||||
|
||||
// Where this installation gives clients a folder of their own, it
|
||||
// stands in for the root: the client opens the portal and sees what
|
||||
// is inside it, not a folder named after themselves that they have
|
||||
// to click through. Their own name is not information to them.
|
||||
//
|
||||
// It does not replace what else they can see. Folders staff shared
|
||||
// with them still sit alongside -- the home is where their own
|
||||
// things live, not a boundary around them.
|
||||
$home = $this->homeFolders->for($client);
|
||||
$homeId = $home?->id;
|
||||
|
||||
// A search term, a category filter, or an owner filter all switch to
|
||||
// a flat, global view across everything the client may see — same
|
||||
// convention as the staff library (FoldersController) uses for
|
||||
@@ -132,7 +161,24 @@ class MyFilesController extends Controller
|
||||
if ($current === null) {
|
||||
$folders = Folder::query()
|
||||
->whereIn('id', $visibleIds)
|
||||
->where(fn ($q) => $q->whereNull('parent_id')->orWhereNotIn('parent_id', $visibleIds))
|
||||
->where(function ($q) use ($visibleIds, $homeId): void {
|
||||
// The home's own children, standing in for the root's.
|
||||
if ($homeId !== null) {
|
||||
$q->where('parent_id', $homeId);
|
||||
}
|
||||
|
||||
// Plus the top of every other subtree they can see,
|
||||
// with the home itself removed -- it is the level
|
||||
// they are looking at, not something inside it.
|
||||
$q->{$homeId === null ? 'where' : 'orWhere'}(function ($outer) use ($visibleIds, $homeId): void {
|
||||
$outer->where(fn ($inner) => $inner
|
||||
->whereNull('parent_id')->orWhereNotIn('parent_id', $visibleIds));
|
||||
|
||||
if ($homeId !== null) {
|
||||
$outer->where('id', '!=', $homeId);
|
||||
}
|
||||
});
|
||||
})
|
||||
->orderBy('name');
|
||||
} else {
|
||||
$folders = Folder::query()
|
||||
@@ -147,7 +193,12 @@ class MyFilesController extends Controller
|
||||
// own listing) show here with no folder context.
|
||||
$filesQuery = File::query()->visibleToClient($client);
|
||||
if ($current === null) {
|
||||
$filesQuery->where(fn (Builder $q) => $q->whereNull('folder_id')->orWhereNotIn('folder_id', $visibleIds));
|
||||
$filesQuery->where(fn (Builder $q) => $q
|
||||
->whereNull('folder_id')
|
||||
->orWhereNotIn('folder_id', $visibleIds)
|
||||
// Files sitting directly in the home belong to this
|
||||
// level too, for the same reason its subfolders do.
|
||||
->when($homeId !== null, fn (Builder $w) => $w->orWhere('folder_id', $homeId)));
|
||||
} else {
|
||||
$filesQuery->where('folder_id', $current->id);
|
||||
}
|
||||
@@ -207,15 +258,28 @@ class MyFilesController extends Controller
|
||||
$fileRows = $sliced['items']['files'];
|
||||
|
||||
$commentCounts = $this->comments->countsFor($client, $fileRows);
|
||||
// Two queries for the page, not two per row. No URL resolver: the
|
||||
// portal has no per-file page to link to, so a counterpart is named
|
||||
// and not linked (see docs/theming-files-checklist.md).
|
||||
// Two queries for the page, not two per row. Still no URL resolver:
|
||||
// the portal's per-file page is an *editor* for a client's own
|
||||
// uploads, and a version counterpart is frequently neither theirs
|
||||
// nor editable — so a counterpart stays named and not linked (see
|
||||
// docs/theming-files-checklist.md).
|
||||
$versions = $this->versionLinks->forMany($fileRows, $client);
|
||||
$unreadComments = $this->comments->unreadCountsFor($client, array_values(array_map(intval(...), $fileRows->pluck('id')->all())));
|
||||
// One query for the page. Already narrowed to links this client
|
||||
// minted on files this client uploaded — see ClientShareLinks for
|
||||
// why both halves are required.
|
||||
$shareUrls = $this->shareLinks->forMany($fileRows, $client);
|
||||
// Also one query for the page, and also own files only — see
|
||||
// OwnFileDownloads for why telling a recipient the count would be
|
||||
// telling them about the other recipients.
|
||||
$downloads = $this->ownDownloads->forMany($fileRows, $client);
|
||||
|
||||
return Inertia::render("portal/themes/{$this->themeKey()}/my-files", [
|
||||
'folder' => $current === null ? null : ['id' => $current->id, 'name' => $current->name],
|
||||
'breadcrumb' => $flat ? [] : $this->breadcrumbs->visible($current, $visibleIds),
|
||||
// Trimmed of the home, which is the root here and so is not a
|
||||
// step in the trail -- a client browsing their own subfolder
|
||||
// should see "Invoices", not "Acme Ltd / Invoices".
|
||||
'breadcrumb' => $flat ? [] : $this->trimHome($this->breadcrumbs->visible($current, $visibleIds), $home),
|
||||
'folders' => $folderRows->map(fn (Folder $folder): array => [
|
||||
'id' => $folder->id,
|
||||
'name' => $folder->name,
|
||||
@@ -236,7 +300,19 @@ class MyFilesController extends Controller
|
||||
'mime_type' => $file->mime_type,
|
||||
'size' => $file->size,
|
||||
'created_at' => $file->created_at?->toIso8601String(),
|
||||
// When it stops being available. Shown on the row rather
|
||||
// than only on the editor, because a file that is about to
|
||||
// go should say so where the client looks for it.
|
||||
'expires_at' => $file->expires_at?->toIso8601String(),
|
||||
'is_mine' => $file->uploaded_by === $client->id,
|
||||
// Decided per row by FilePolicy, exactly as the folder rows
|
||||
// above are: a client's own uploads are theirs to manage
|
||||
// and files shared with them are not, and both kinds sit in
|
||||
// the same list. A theme reads these and never works them
|
||||
// out from is_mine — holding the file is only half of it,
|
||||
// the role's keys are the other half.
|
||||
'can_update' => Gate::forUser($client)->allows('update', $file),
|
||||
'can_delete' => Gate::forUser($client)->allows('delete', $file),
|
||||
// Effective status (own flag or inherited from a public
|
||||
// folder) — same "will visitors on the public site see
|
||||
// this" badge as the staff library shows.
|
||||
@@ -247,6 +323,19 @@ class MyFilesController extends Controller
|
||||
// counterpart they were not given is null, not hidden by
|
||||
// the theme. A theme must never filter this itself.
|
||||
'version' => $versions[$file->id] ?? ['previous' => null, 'next' => null],
|
||||
// The public URL for a file of their own, where one
|
||||
// exists. Null on a file somebody shared with them, and
|
||||
// null on their own file that has no link — a client has
|
||||
// no way to mint one, so this is populated only where the
|
||||
// installation did it for them. Never derived from
|
||||
// is_mine: a theme renders what is here and nothing else.
|
||||
'share_url' => $shareUrls[$file->id] ?? null,
|
||||
// How often this went out and when it last did — the
|
||||
// answer to "did it arrive?", which on a link-only
|
||||
// account is the only evidence there is. Null on a file
|
||||
// somebody shared with this client: not zero, which would
|
||||
// be a claim about other people's activity, but nothing.
|
||||
'downloads' => $downloads[$file->id] ?? null,
|
||||
'categories' => $file->categories->map(fn (Category $category): array => [
|
||||
'id' => $category->id, 'name' => $category->name, 'color' => $category->color,
|
||||
])->values()->all(),
|
||||
@@ -291,7 +380,13 @@ class MyFilesController extends Controller
|
||||
abort_unless(Folder::uploadableBy($client, $folder), 403);
|
||||
}
|
||||
|
||||
$notice = new ResolvingUploadNotice($client);
|
||||
event($notice);
|
||||
|
||||
return Inertia::render('portal/upload', [
|
||||
// Rules a package wants read before the upload — see
|
||||
// ResolvingUploadNotice. An empty list shows nothing.
|
||||
'notice' => $notice->lines,
|
||||
'allowed_extensions' => $this->extensionPolicy->hintFor($client),
|
||||
'max_file_size_mb' => (int) $this->settings->get(Setting::MaxFileSizeMb),
|
||||
'part_size_mb' => (int) config('projectsend.upload_part_size_mb'),
|
||||
@@ -306,6 +401,226 @@ class MyFilesController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor page for a file this client uploaded.
|
||||
*
|
||||
* One page for every theme, not one per theme — the same shape
|
||||
* `upload()` uses, and for the same reason: this is a form, and a form
|
||||
* rebuilt four times is four places for a field to go missing. The
|
||||
* `theme` prop picks the shell (see portal/edit-file.tsx), which is the
|
||||
* only part that differs.
|
||||
*
|
||||
* Every `can_*` prop below is the *same* question ApplyFileEdits will
|
||||
* ask when the form posts. A control this page hides is not a control
|
||||
* the server then trusts: hiding it is a courtesy so a client is not
|
||||
* shown a switch that will silently do nothing, and the refusal is
|
||||
* server-side either way.
|
||||
*/
|
||||
public function edit(Request $request, File $file): Response
|
||||
{
|
||||
$client = $request->user();
|
||||
abort_unless($client !== null && $client->isClient(), 404);
|
||||
|
||||
Gate::authorize('update', $file);
|
||||
|
||||
$file->loadMissing('categories');
|
||||
|
||||
return Inertia::render('portal/edit-file', [
|
||||
'theme' => $this->themeKey(),
|
||||
'file' => [
|
||||
'id' => $file->id,
|
||||
'name' => $file->name,
|
||||
'description' => $file->description,
|
||||
'original_name' => $file->original_name,
|
||||
'size' => $file->size,
|
||||
'public' => $file->public,
|
||||
'commentable' => $file->commentable,
|
||||
// The stored instant as the calendar day this client's own
|
||||
// zone shows — the value the form posts back untouched, and
|
||||
// the one update() compares against to tell a real change
|
||||
// from a date that merely came along with a rename.
|
||||
'expires_at' => $this->expiry->asShown($file, $client),
|
||||
'download_limit' => $file->download_limit,
|
||||
'download_limit_scope' => ($file->download_limit_scope ?? DownloadLimitScope::Total)->value,
|
||||
'folder_id' => $file->folder_id,
|
||||
'categories' => $file->categories->pluck('id')->all(),
|
||||
],
|
||||
'can_delete' => Gate::forUser($client)->allows('delete', $file),
|
||||
// Their own root, where the installation gives them one. The
|
||||
// form offers no "No folder" beside it: there is no such place
|
||||
// for this client, and update() resolves it here anyway.
|
||||
'home_folder_id' => $this->homeFolders->for($client)?->id,
|
||||
'can_publish' => $client->can('upload_public'),
|
||||
// The public links on this file, and where to make and revoke
|
||||
// one — the same shape the staff screen uses. A file marked
|
||||
// public used to say "anyone with the link can open it" and
|
||||
// then show no link at all.
|
||||
'share_links' => $file->shareLinks()->orderByDesc('created_at')->get()
|
||||
->map(fn (ShareLink $link): array => [
|
||||
'id' => $link->id,
|
||||
'url' => route('share.show', $link->token),
|
||||
'expires_at' => $link->expires_at?->toIso8601String(),
|
||||
'downloads_count' => $link->downloads_count,
|
||||
'revoke_url' => route('share-links.destroy', $link, false),
|
||||
])->values()->all(),
|
||||
'share_link_store_url' => route('files.share-links.store', $file, false),
|
||||
'can_set_expiration' => $client->can('set_file_expiration_date'),
|
||||
'can_set_categories' => $client->can('set_file_categories'),
|
||||
'can_limit_downloads' => $client->can('limit_downloads'),
|
||||
// Only while the installation asks per file; otherwise the
|
||||
// setting decides and the switch would be a lie.
|
||||
'can_set_commentable' => $this->commenting->scope() === CommentScope::SelectedFiles,
|
||||
'categories' => Category::query()->orderBy('name')->get(['id', 'name', 'color'])
|
||||
->map(fn (Category $category): array => [
|
||||
'id' => $category->id, 'name' => $category->name, 'color' => $category->color,
|
||||
])->all(),
|
||||
// Somewhere this client could have uploaded it in the first
|
||||
// place — the same rule update() enforces, so the picker cannot
|
||||
// offer a destination the save would refuse.
|
||||
'folders' => Folder::query()->visibleToClient($client)->orderBy('name')->get()
|
||||
->filter(fn (Folder $folder): bool => Folder::uploadableBy($client, $folder))
|
||||
->map(fn (Folder $folder): array => [
|
||||
'id' => $folder->id,
|
||||
'name' => $folder->name,
|
||||
// A destination can publish the file without the public
|
||||
// switch being touched: File::isEffectivelyPublic() is
|
||||
// "my own flag OR my folder's", and a client holding
|
||||
// upload_to_public_folders may move into a public
|
||||
// folder without holding upload_public. That is the
|
||||
// established meaning of the two keys, and it is what
|
||||
// uploading there has always done — but in a picker of
|
||||
// bare names it would be invisible, so the name carries
|
||||
// the consequence with it.
|
||||
'public' => $folder->isEffectivelyPublic(),
|
||||
])
|
||||
->values()->all(),
|
||||
// Public files are reachable at the installation's one public
|
||||
// slug; without it configured, publishing shows nowhere and the
|
||||
// page says so rather than offering a switch that does nothing
|
||||
// visible.
|
||||
'public_listing_slug' => $this->settings->get(Setting::PublicListingSlug),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a file this client uploaded.
|
||||
*
|
||||
* The client portal's counterpart to the staff file editor, and
|
||||
* deliberately a separate route rather than the staff one opened up:
|
||||
* `files.*` renders assignments, share links, activity and download
|
||||
* history, which are staff surfaces, and its folder guard asks
|
||||
* StaffLibraryScope — which answers "allowed" for every client (see
|
||||
* FilePolicy::update()).
|
||||
*
|
||||
* Who may edit at all is FilePolicy: the file must be this client's own
|
||||
* upload and they must hold `edit_files`. Which *fields* they may
|
||||
* write is ApplyFileEdits, the same decision the staff editor and the
|
||||
* API get, so a client holding `set_file_categories` but not
|
||||
* `upload_public` gets exactly what those keys say and nothing is
|
||||
* decided twice.
|
||||
*/
|
||||
public function update(Request $request, File $file): RedirectResponse
|
||||
{
|
||||
$client = $request->user();
|
||||
abort_unless($client !== null && $client->isClient(), 404);
|
||||
|
||||
Gate::authorize('update', $file);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:2000'],
|
||||
'folder_id' => Rules::folderId(),
|
||||
'public' => ['sometimes', 'boolean'],
|
||||
'commentable' => ['sometimes', 'boolean'],
|
||||
'categories' => ['array'],
|
||||
'categories.*' => ['integer', 'exists:categories,id'],
|
||||
'expires_at' => ['nullable', 'string', 'date'],
|
||||
'download_limit' => ['nullable', 'integer', 'min:1'],
|
||||
'download_limit_scope' => ['nullable', Rule::enum(DownloadLimitScope::class)],
|
||||
]);
|
||||
|
||||
// No `slug`, on purpose, and its absence is what makes
|
||||
// ApplyFileEdits derive one from the name. An installation-wide
|
||||
// unique slug that a client picks is a name to squat and an
|
||||
// existence oracle to probe against every file on the
|
||||
// installation, for nothing a derived slug does not already give
|
||||
// them.
|
||||
|
||||
$folderId = isset($validated['folder_id']) ? (int) $validated['folder_id'] : null;
|
||||
|
||||
// "No folder" means the top of what this client sees, which on an
|
||||
// installation that gives them a home folder is inside it — not the
|
||||
// root of the library, beside the staff folders. Uploading and
|
||||
// creating a folder already resolve it this way; the editor did
|
||||
// not, so a client could move their own file out of their home and
|
||||
// into the administrator's root by choosing "No folder" (reported
|
||||
// by binghuo).
|
||||
$folderId ??= $this->homeFolders->for($client)?->id;
|
||||
|
||||
// The client rule, not the staff one: somewhere they could have
|
||||
// uploaded it in the first place. Same check the upload path makes,
|
||||
// so moving a file cannot reach a folder that uploading it could
|
||||
// not. Only when the folder actually changes, so re-saving a file
|
||||
// that already sits somewhere unusual still works.
|
||||
if ($folderId !== null && $folderId !== $file->folder_id) {
|
||||
$folder = Folder::query()->visibleToClient($client)->find($folderId);
|
||||
|
||||
abort_unless($folder !== null && Folder::uploadableBy($client, $folder), 403);
|
||||
}
|
||||
|
||||
$changes = [
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'folder_id' => $folderId,
|
||||
'commentable' => $validated['commentable'] ?? $file->commentable,
|
||||
'download_limit' => $validated['download_limit'] ?? null,
|
||||
'download_limit_scope' => $validated['download_limit_scope'] ?? DownloadLimitScope::Total->value,
|
||||
'public' => $validated['public'] ?? $file->public,
|
||||
'categories' => $validated['categories'] ?? [],
|
||||
];
|
||||
|
||||
// Only when the date actually moved — the form posts back what it
|
||||
// was rendered with, and re-deriving it on every save would shift
|
||||
// the expiry by a timezone difference each time somebody renamed
|
||||
// the file. See FileExpiry.
|
||||
$posted = $validated['expires_at'] ?? null;
|
||||
|
||||
if ($posted !== $this->expiry->asShown($file, $client)) {
|
||||
$changes['expires_at'] = $this->expiry->instant($posted, $client);
|
||||
}
|
||||
|
||||
$this->fileEdits->apply($client, $file, $changes);
|
||||
|
||||
return back()->with('success', __('File updated.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file this client uploaded.
|
||||
*
|
||||
* Their own upload and `delete_files`, both settled by
|
||||
* FilePolicy::delete(). A file merely shared with them is not theirs to
|
||||
* remove, and no permission changes that.
|
||||
*
|
||||
* The row is soft-deleted and the bytes are not: File::booted()'s
|
||||
* `deleted` hook removes the upload and every cached rendition on
|
||||
* commit, so the client's storage quota — which sums untrashed rows —
|
||||
* frees up by exactly what the disk does.
|
||||
*/
|
||||
public function destroy(Request $request, File $file): RedirectResponse
|
||||
{
|
||||
$client = $request->user();
|
||||
abort_unless($client !== null && $client->isClient(), 404);
|
||||
|
||||
Gate::authorize('delete', $file);
|
||||
|
||||
$name = $file->name;
|
||||
$file->delete();
|
||||
|
||||
$this->activity->log(Action::FileDeleted, context: ['name' => $name]);
|
||||
|
||||
return redirect()->route('my-files.index')->with('success', __('File deleted.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Files this client may name as the previous version of what they are
|
||||
* uploading — THEIR OWN UPLOADS ONLY.
|
||||
@@ -339,4 +654,23 @@ class MyFilesController extends Controller
|
||||
|
||||
return $this->themes->resolve(is_string($value) ? $value : 'default', $this->capabilities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the home folder from the front of a breadcrumb.
|
||||
*
|
||||
* Only from the front, and only when it is actually there: a folder
|
||||
* shared with the client from elsewhere in the library has a trail of
|
||||
* its own that the home has nothing to do with.
|
||||
*
|
||||
* @param list<array{id: int, name: string}> $trail
|
||||
* @return list<array{id: int, name: string}>
|
||||
*/
|
||||
private function trimHome(array $trail, ?Folder $home): array
|
||||
{
|
||||
if ($home === null || $trail === [] || $trail[0]['id'] !== $home->id) {
|
||||
return $trail;
|
||||
}
|
||||
|
||||
return array_slice($trail, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Modules\Files\Http\Controllers;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Folders\ClientHomeFolders;
|
||||
use App\Modules\Files\Folders\FolderService;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
@@ -30,6 +31,7 @@ class MyFoldersController extends Controller
|
||||
public function __construct(
|
||||
private readonly FolderService $folders,
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly ClientHomeFolders $homeFolders,
|
||||
) {}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
@@ -52,6 +54,13 @@ class MyFoldersController extends Controller
|
||||
$parent = Folder::query()->visibleToClient($client)->whereKey($validated['parent_id'])->firstOrFail();
|
||||
}
|
||||
|
||||
// No parent named means the top of what this client sees -- which,
|
||||
// where the installation gives them a home, is inside it rather
|
||||
// than at the root of the library. Without this a client creating a
|
||||
// folder would put it beside the staff folders, which is precisely
|
||||
// the mess the home folder exists to end.
|
||||
$parent ??= $this->homeFolders->for($client);
|
||||
|
||||
$folder = $this->folders->create($validated['name'], $parent);
|
||||
|
||||
$this->activity->log(Action::FolderCreated, subject: $folder);
|
||||
|
||||
@@ -7,7 +7,9 @@ namespace App\Modules\Files\Http\Controllers;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\OrphanFileScanner;
|
||||
use App\Modules\Files\Scanning\ScanStatus;
|
||||
use App\Modules\Files\Uploads\StoreUploadedFile;
|
||||
use App\Support\Pagination;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
@@ -45,6 +47,14 @@ class OrphanFilesController extends Controller
|
||||
$validated = $request->validate(['search' => ['nullable', 'string', 'max:255']]);
|
||||
$search = trim($validated['search'] ?? '');
|
||||
|
||||
// The mirror image of this screen, on the same screen: bytes with
|
||||
// no row, and rows with no bytes. They are the same fault seen
|
||||
// from either end, and an administrator looking into one has
|
||||
// every reason to look at the other.
|
||||
if ($request->query('tab') === 'missing') {
|
||||
return $this->missing($request);
|
||||
}
|
||||
|
||||
// A full disk scan (potentially thousands of entries, across
|
||||
// every scanned disk) happens once per request regardless of
|
||||
// page — Storage::allFiles() has no server-side paging of its
|
||||
@@ -75,10 +85,51 @@ class OrphanFilesController extends Controller
|
||||
);
|
||||
|
||||
return Inertia::render('files/orphans', [
|
||||
'tab' => 'orphans',
|
||||
'orphans' => $paginator->items(),
|
||||
'pagination' => Pagination::meta($paginator),
|
||||
'search' => $search,
|
||||
'scanned_disks' => $this->scanner->scannedDisks(),
|
||||
'missing_count' => File::query()->where('scan_status', ScanStatus::Missing)->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Files this installation lists and cannot produce.
|
||||
*
|
||||
* Read from the rows rather than from the disk: the daily check
|
||||
* (projectsend:check-missing-files) has already done the comparing,
|
||||
* and repeating a full disk listing on every page load would make
|
||||
* this screen slower the worse the problem is.
|
||||
*/
|
||||
private function missing(Request $request): Response
|
||||
{
|
||||
$missing = File::query()
|
||||
->where('scan_status', ScanStatus::Missing)
|
||||
->with('uploader')
|
||||
->orderBy('name')
|
||||
->paginate(self::PER_PAGE)
|
||||
->withQueryString();
|
||||
|
||||
$missing->through(fn (File $file): array => [
|
||||
'id' => $file->id,
|
||||
'name' => $file->name,
|
||||
'original_name' => $file->original_name,
|
||||
'size' => $file->size,
|
||||
'disk' => $file->disk,
|
||||
'path' => $file->path,
|
||||
'uploader' => $file->uploader?->name,
|
||||
'created_at' => $file->created_at?->toIso8601String(),
|
||||
]);
|
||||
|
||||
return Inertia::render('files/orphans', [
|
||||
'tab' => 'missing',
|
||||
'orphans' => [],
|
||||
'pagination' => Pagination::meta($missing),
|
||||
'search' => '',
|
||||
'scanned_disks' => $this->scanner->scannedDisks(),
|
||||
'missing' => $missing->items(),
|
||||
'missing_count' => $missing->total(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ use App\Modules\Files\Access\DownloadAllowance;
|
||||
use App\Modules\Files\Delivery\StoredFileResponse;
|
||||
use App\Modules\Files\Models\Category;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Scanning\FileAvailability;
|
||||
use App\Modules\Files\Scanning\ScanningConfig;
|
||||
use App\Modules\Files\Scanning\ScanStatus;
|
||||
use App\Modules\Files\Models\ShareLink;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
@@ -29,6 +32,8 @@ class PublicShareController extends Controller
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly DownloadAllowance $allowance,
|
||||
private readonly StoredFileResponse $bytes,
|
||||
private readonly FileAvailability $availability,
|
||||
private readonly ScanningConfig $scanning,
|
||||
) {}
|
||||
|
||||
public function show(string $token): InertiaResponse
|
||||
@@ -36,7 +41,12 @@ class PublicShareController extends Controller
|
||||
$shareLink = ShareLink::query()->where('token', $token)->first();
|
||||
$file = $shareLink?->shareable;
|
||||
|
||||
if ($shareLink === null || ! $file instanceof File) {
|
||||
// A withdrawn file answers exactly as a link that never existed.
|
||||
// Its uploader deleted their account, and "this was here once" is
|
||||
// itself something they asked to stop saying. The link row stays,
|
||||
// so an account that is restored is served again. See
|
||||
// SelfDeletion.
|
||||
if ($shareLink === null || ! $file instanceof File || $file->isWithdrawn()) {
|
||||
return Inertia::render('share/show', ['status' => 'not_found']);
|
||||
}
|
||||
|
||||
@@ -47,6 +57,16 @@ class PublicShareController extends Controller
|
||||
return Inertia::render('share/show', ['status' => 'expired']);
|
||||
}
|
||||
|
||||
// A link can be minted the moment a file is stored — the hosted
|
||||
// free plan does exactly that — so the link routinely exists
|
||||
// before the scanner has finished. It says so rather than 404ing:
|
||||
// the visitor was sent a real link and it will work shortly.
|
||||
if (! $this->availability->isAvailable($file)) {
|
||||
return Inertia::render('share/show', [
|
||||
'status' => $file->scan_status === ScanStatus::Pending ? 'checking' : 'unavailable',
|
||||
]);
|
||||
}
|
||||
|
||||
// Two separate caps reach the same page: the link's own
|
||||
// max_downloads, and the file's. A visitor here has no account,
|
||||
// so the file's limit is measured against the whole file — see
|
||||
@@ -71,6 +91,11 @@ class PublicShareController extends Controller
|
||||
])->values()->all(),
|
||||
],
|
||||
'download_url' => route('share.download', $token),
|
||||
// Said to the one person who can neither see the setting nor
|
||||
// chose it. The uploader and the staff library both show this
|
||||
// file as "not scanned"; whoever follows the link had no way
|
||||
// of knowing.
|
||||
'unscanned' => $this->scanning->enabled() && $file->wasLetThrough(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -79,7 +104,13 @@ class PublicShareController extends Controller
|
||||
$shareLink = ShareLink::query()->where('token', $token)->first();
|
||||
$file = $shareLink?->shareable;
|
||||
|
||||
if ($shareLink === null || ! $file instanceof File || $shareLink->isExpired() || $file->isExpired()) {
|
||||
if ($shareLink === null || ! $file instanceof File || $file->isWithdrawn() || $shareLink->isExpired() || $file->isExpired()) {
|
||||
return redirect()->route('share.show', $token);
|
||||
}
|
||||
|
||||
// Same for a file still being checked, and for the same reason
|
||||
// the limit is asked before the counter moves.
|
||||
if (! $this->availability->isAvailable($file)) {
|
||||
return redirect()->route('share.show', $token);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Scanning\FileAvailability;
|
||||
use App\Modules\Files\Scanning\NotScannedReason;
|
||||
use App\Modules\Files\Scanning\ScanStatus;
|
||||
use App\Support\Pagination;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
/**
|
||||
* The files the virus scanner refused, and the one decision a person can
|
||||
* make about them.
|
||||
*
|
||||
* Nothing is deleted here automatically and nothing expires out of this
|
||||
* list: a quarantined file waits for somebody. Deleting one is the
|
||||
* ordinary file deletion, with its ordinary permission — this screen only
|
||||
* adds the other answer, which is that the scanner was wrong.
|
||||
*
|
||||
* Releasing is gated by a permission of its own that only the
|
||||
* administrator role holds by default, and by password confirmation on
|
||||
* top of it, because it is the one action in the application that
|
||||
* deliberately hands out a file something reported as malicious.
|
||||
*/
|
||||
class QuarantineController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly FileAvailability $availability,
|
||||
private readonly StaffLibraryScope $scope,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
assert($user !== null);
|
||||
|
||||
$files = $this->quarantined($user)
|
||||
->with('uploader')
|
||||
->orderByDesc('scanned_at')
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
$files->through(fn (File $file): array => [
|
||||
'id' => $file->id,
|
||||
'name' => $file->name,
|
||||
'original_name' => $file->original_name,
|
||||
'size' => $file->size,
|
||||
'uploader' => $file->uploader?->name,
|
||||
// The threat name, or — for a file nothing could open — what
|
||||
// stopped it being read.
|
||||
'threat' => $file->scan_status === ScanStatus::UnscannableBlocked
|
||||
? __(NotScannedReason::tryFrom((string) $file->scan_note)?->label() ?? 'Could not be scanned')
|
||||
: $file->scan_note,
|
||||
'status' => $file->scan_status->value,
|
||||
'scanned_at' => $file->scanned_at?->toIso8601String(),
|
||||
// True only for a file that went out unscanned while the
|
||||
// scanner was unreachable and was caught later — which is the
|
||||
// one case where somebody may already have a copy.
|
||||
'was_available' => $file->scan_was_available,
|
||||
'downloads_count' => $file->downloads()->count(),
|
||||
]);
|
||||
|
||||
return Inertia::render('files/quarantine', [
|
||||
'files' => $files->items(),
|
||||
'pagination' => Pagination::meta($files),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrule the scanner for one file.
|
||||
*
|
||||
* The reason is required and is recorded against the person who gave
|
||||
* it. A release is not undone by a later scan: the file stays
|
||||
* released until somebody deletes it, which is the point — an
|
||||
* administrator who has decided a detection is wrong should not have
|
||||
* to decide it again every hour.
|
||||
*/
|
||||
public function release(Request $request, File $file): RedirectResponse
|
||||
{
|
||||
$actor = $request->user();
|
||||
assert($actor !== null);
|
||||
|
||||
abort_unless($this->quarantined($actor)->whereKey($file->id)->exists(), 404);
|
||||
|
||||
$validated = $request->validate([
|
||||
'reason' => ['required', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
$file->forceFill([
|
||||
'scan_status' => ScanStatus::Released,
|
||||
'released_by' => $actor->id,
|
||||
'released_at' => now(),
|
||||
])->save();
|
||||
|
||||
$this->activity->log(Action::FileReleased, subject: $file, context: [
|
||||
'reason' => $validated['reason'],
|
||||
'threat' => $file->scan_note,
|
||||
]);
|
||||
|
||||
// Everything that was waiting on this file — a share email, a new
|
||||
// version notice — goes out now, exactly as it would have if the
|
||||
// scan had passed.
|
||||
$this->availability->markAvailable($file);
|
||||
|
||||
return back()->with('success', __('The file has been released.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The quarantined files this person may see and release.
|
||||
*
|
||||
* A client-scoped staff member gets their own clients' uploads and
|
||||
* their own, the same boundary as the rest of the library. The
|
||||
* permission alone let one read every quarantined file on the
|
||||
* installation, and release a file belonging to a client they could
|
||||
* not otherwise open.
|
||||
*
|
||||
* @return Builder<File>
|
||||
*/
|
||||
private function quarantined(User $user): Builder
|
||||
{
|
||||
$query = File::query()
|
||||
->whereIn('scan_status', [ScanStatus::Infected->value, ScanStatus::UnscannableBlocked->value]);
|
||||
|
||||
$uploaders = $this->scope->uploaderIds($user);
|
||||
|
||||
return $uploaders === null ? $query : $query->whereIn('uploaded_by', $uploaders);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\ShareLink;
|
||||
use App\Modules\Files\Sharing\CreateShareLink;
|
||||
use App\Modules\Platform\Localization\LocalDay;
|
||||
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -30,19 +31,29 @@ class ShareLinksController extends Controller
|
||||
public function __construct(
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly TimezoneRegistry $timezones,
|
||||
private readonly CreateShareLink $links,
|
||||
) {}
|
||||
|
||||
public function store(Request $request, File $file): RedirectResponse
|
||||
{
|
||||
Gate::authorize('update', $file);
|
||||
|
||||
$user = $request->user();
|
||||
assert($user !== null);
|
||||
|
||||
// Making a link is publishing, so it asks the publishing key. Staff
|
||||
// are not asked for it, as they never have been: `update` on the
|
||||
// file is their boundary and this would be a new refusal on every
|
||||
// installation that upgraded.
|
||||
abort_unless($user->isStaff() || $user->can('upload_public'), 403);
|
||||
|
||||
$validated = $request->validate([
|
||||
// Deliberately not `after:now`: that rule reads the bare
|
||||
// YYYY-MM-DD the picker posts as midnight UTC, so a creator
|
||||
// far enough east would be told today's date is in the past
|
||||
// while it is plainly still today where they are. The check
|
||||
// moves below, onto the instant the date actually resolves to.
|
||||
'expires_at' => ['nullable', 'date'],
|
||||
'expires_at' => ['nullable', 'string', 'date'],
|
||||
'max_downloads' => ['nullable', 'integer', 'min:1'],
|
||||
// A custom token is optional — leave blank for a random one,
|
||||
// same as before. Must not collide with the file's own
|
||||
@@ -80,16 +91,27 @@ class ShareLinksController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
ShareLink::query()->create([
|
||||
'shareable_type' => $file->getMorphClass(),
|
||||
'shareable_id' => $file->id,
|
||||
'token' => $validated['token'] ?? Str::random(32),
|
||||
'created_by' => $user->id,
|
||||
'expires_at' => $user->can('set_file_expiration_date') ? $expiresAt : null,
|
||||
'max_downloads' => $user->can('limit_downloads') ? $validated['max_downloads'] ?? null : null,
|
||||
]);
|
||||
|
||||
$this->activity->log(Action::ShareLinkCreated, subject: $file);
|
||||
// The permission gates stay here, where the request is: whether
|
||||
// this person may set an expiry or a cap is a fact about them,
|
||||
// not about link creation, and the action has no viewer to ask.
|
||||
$this->links->for(
|
||||
file: $file,
|
||||
creator: $user,
|
||||
expiresAt: $user->can('set_file_expiration_date') ? $expiresAt : null,
|
||||
// Cast, and null kept as null rather than falling through a
|
||||
// bare (int) that would turn "no cap" into a cap of zero. The
|
||||
// `integer` rule validates a numeric string without converting
|
||||
// it, and this file is strict_types, so an uncast "5" is a
|
||||
// TypeError against `?int $maxDownloads`. Nothing sends one
|
||||
// today only because files/edit.tsx calls Number() first --
|
||||
// which is a fact about a frontend file, not a guarantee this
|
||||
// signature has. It cost a 500 on the client form, where the
|
||||
// same field was typed as a string.
|
||||
maxDownloads: $user->can('limit_downloads') && ($validated['max_downloads'] ?? null) !== null
|
||||
? (int) $validated['max_downloads']
|
||||
: null,
|
||||
token: $validated['token'] ?? null,
|
||||
);
|
||||
|
||||
return back()->with('success', __('Public link created.'));
|
||||
}
|
||||
@@ -99,6 +121,9 @@ class ShareLinksController extends Controller
|
||||
$file = $shareLink->shareable;
|
||||
abort_unless($file instanceof File, 404);
|
||||
|
||||
// Deliberately without the publishing key that store() asks for:
|
||||
// revoking takes access away. Somebody whose permission to publish
|
||||
// was withdrawn must still be able to undo what they published.
|
||||
Gate::authorize('update', $file);
|
||||
|
||||
$shareLink->delete();
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Jobs\ScanFileJob;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Scanning\NotScannedReason;
|
||||
use App\Modules\Files\Scanning\ScannerAddress;
|
||||
use App\Modules\Files\Scanning\ScanningConfig;
|
||||
use App\Modules\Files\Scanning\ScanOutcome;
|
||||
use App\Modules\Files\Scanning\ScanStatus;
|
||||
use App\Modules\Files\Scanning\VirusScanner;
|
||||
use App\Modules\Platform\Capabilities\Capability;
|
||||
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
/**
|
||||
* The virus scanning screen.
|
||||
*
|
||||
* Two of these settings decide what happens when the scanner cannot
|
||||
* answer, and both default to letting files through. That is a
|
||||
* deliberate choice (see docs/feature-virus-scanning.md) and it is the
|
||||
* reason this screen states the count of files currently allowed through
|
||||
* unscanned rather than leaving it to be discovered: a scanner that has
|
||||
* quietly stopped protecting anything looks exactly like one that is
|
||||
* working.
|
||||
*
|
||||
* Where a managed configuration names a scanner, the connection is not
|
||||
* this screen's to change and scanning cannot be switched off — the
|
||||
* policies still are. Same shape as the CAPTCHA screen under managed
|
||||
* keys.
|
||||
*/
|
||||
class VirusScanningSettingsController extends Controller
|
||||
{
|
||||
/**
|
||||
* A zip holding check.txt ("ProjectSend checks that the scanner
|
||||
* reports encrypted archives."), encrypted with the password
|
||||
* "projectsend". Made with `zip -P`.
|
||||
*/
|
||||
private const ENCRYPTED_ARCHIVE = 'UEsDBBQACQAIAACon1toMefdSgAAAEAAAAAJAAAAY2hlY2sudHh0prvUiniNGfEwEalXOcDbsYylfm2yAcyjplSfHJqk2sSxcVWFx0omz5AvASvSRdDbfeSQ+CC2qu6JEP/NYbBKy+g5t2nJr4swpv9QSwcIaDHn3UoAAABAAAAAUEsBAh4DFAAJAAgAAKifW2gx591KAAAAQAAAAAkAAAAAAAAAAQAAALSBAAAAAGNoZWNrLnR4dFBLBQYAAAAAAQABADcAAACBAAAAAAA=';
|
||||
|
||||
public function __construct(
|
||||
private readonly Settings $settings,
|
||||
private readonly ScanningConfig $config,
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly CapabilityRegistry $capabilities,
|
||||
) {}
|
||||
|
||||
public function edit(Request $request): Response
|
||||
{
|
||||
return Inertia::render('system/settings/virus-scanning', [
|
||||
// Which half of the screen is open. The connection and the
|
||||
// policies are two different jobs — one is done once when the
|
||||
// scanner is set up, the other is revisited — and a single
|
||||
// column of fields with two Save buttons reads as one form
|
||||
// that saves half of itself.
|
||||
'tab' => in_array($request->query('tab'), ['options', 'activity'], true)
|
||||
? (string) $request->query('tab')
|
||||
: 'scanner',
|
||||
// Read from the session here rather than shared as a flash
|
||||
// prop: HandleInertiaRequests shares `success` and `error` and
|
||||
// nothing else, which is why the Test button appeared to do
|
||||
// nothing at all. Same shape the CAPTCHA screen uses.
|
||||
'test_result' => $request->session()->get('scanner_test_result'),
|
||||
'enabled' => $this->config->enabled(),
|
||||
// Two different reasons the connection is not this screen's to
|
||||
// change: a managed configuration names the scanner, or this
|
||||
// edition does not connect scanners at all. The screen says
|
||||
// the same thing for both, since to the person reading it
|
||||
// they are the same fact.
|
||||
'managed' => $this->config->isManaged() || ! $this->canConnect(),
|
||||
// Distinct from `managed`, which covers two different reasons
|
||||
// the address is not editable. A managed installation still
|
||||
// has a scanner worth testing; one that does not connect
|
||||
// scanners at all has nothing to test, and the endpoint says
|
||||
// so with a 403.
|
||||
'can_test' => $this->canConnect(),
|
||||
'address' => $this->config->isManaged() ? '' : $this->settings->get(Setting::VirusScannerAddress),
|
||||
'max_size_mb' => $this->settings->get(Setting::VirusScanMaxSizeMb),
|
||||
'unscannable_policy' => $this->settings->get(Setting::VirusUnscannablePolicy),
|
||||
'scanner_down_policy' => $this->settings->get(Setting::VirusScannerDownPolicy),
|
||||
'wait_minutes' => $this->settings->get(Setting::VirusScannerWaitMinutes),
|
||||
'existing_rate_per_minute' => $this->settings->get(Setting::VirusScanExistingRatePerMinute),
|
||||
'counts' => $this->counts(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'enabled' => ['required', 'boolean'],
|
||||
'address' => ['nullable', 'string', 'max:255'],
|
||||
'max_size_mb' => ['required', 'integer', 'min:0', 'max:4096'],
|
||||
'unscannable_policy' => ['required', Rule::in(['allow', 'block'])],
|
||||
'scanner_down_policy' => ['required', Rule::in(['allow', 'hold'])],
|
||||
'wait_minutes' => ['required', 'integer', 'min:1', 'max:1440'],
|
||||
'existing_rate_per_minute' => ['required', 'integer', 'min:1', 'max:6000'],
|
||||
]);
|
||||
|
||||
// A managed installation may still choose its policies. The
|
||||
// connection and the switch are not on the screen there, and a
|
||||
// request that sends them anyway changes nothing.
|
||||
if (! $this->config->isManaged() && $this->canConnect()) {
|
||||
$address = trim((string) ($validated['address'] ?? ''));
|
||||
|
||||
// Refused rather than saved and quietly inert: switching this
|
||||
// on with nowhere to send files would leave every upload
|
||||
// waiting for a scanner that does not exist.
|
||||
if ($request->boolean('enabled') && $address === '') {
|
||||
return back()->withErrors(['address' => __('Enter the address of your scanner first.')]);
|
||||
}
|
||||
|
||||
// Checked here rather than left to the socket, which accepts
|
||||
// more than it should — see ScannerAddress.
|
||||
if ($address !== '' && ! ScannerAddress::isValid($address)) {
|
||||
return back()->withErrors(['address' => __(ScannerAddress::message())]);
|
||||
}
|
||||
|
||||
$this->settings->set(Setting::VirusScannerAddress, $address);
|
||||
$this->settings->set(Setting::VirusScanningEnabled, $request->boolean('enabled'));
|
||||
}
|
||||
|
||||
$this->settings->set(Setting::VirusScanMaxSizeMb, (int) $validated['max_size_mb']);
|
||||
$this->settings->set(Setting::VirusUnscannablePolicy, $validated['unscannable_policy']);
|
||||
$this->settings->set(Setting::VirusScannerDownPolicy, $validated['scanner_down_policy']);
|
||||
$this->settings->set(Setting::VirusScannerWaitMinutes, (int) $validated['wait_minutes']);
|
||||
$this->settings->set(Setting::VirusScanExistingRatePerMinute, (int) $validated['existing_rate_per_minute']);
|
||||
|
||||
// The scans worker is the one process that acts on every setting
|
||||
// above, and it holds them in memory from the job it started on.
|
||||
// Without this, switching scanning off or pointing it at another
|
||||
// scanner changed the screen and nothing else until somebody
|
||||
// restarted the worker.
|
||||
Artisan::call('queue:restart');
|
||||
|
||||
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'virus_scanning']);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prove the scanner is there, and that it is actually detecting.
|
||||
*
|
||||
* Three steps, reported separately, because "cannot connect" and
|
||||
* "connects and finds nothing" are different problems and the second
|
||||
* is the one that looks fine from the outside. The third sends the
|
||||
* EICAR test string — a harmless sequence every engine recognises by
|
||||
* agreement — so the answer is "it detected something" rather than
|
||||
* "it did not complain".
|
||||
*/
|
||||
public function test(Request $request, VirusScanner $scanner): RedirectResponse
|
||||
{
|
||||
// Nothing to test where the connection is not this installation's
|
||||
// to make.
|
||||
abort_unless($this->canConnect(), 403);
|
||||
|
||||
$typed = trim((string) $request->input('address', ''));
|
||||
|
||||
// What the button is for: the address on screen, which on a first
|
||||
// attempt has never been saved. Falls back to the stored one when
|
||||
// the field is empty, so the button still answers on a screen
|
||||
// somebody has not touched.
|
||||
if ($typed !== '') {
|
||||
if (! ScannerAddress::isValid($typed)) {
|
||||
// Answered as a test result rather than as a field error:
|
||||
// the person pressed Test, and this is what the test
|
||||
// found. Nothing is dialled.
|
||||
return back()->with('scanner_test_result', [
|
||||
'ok' => false,
|
||||
'message' => __(ScannerAddress::message()),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->config->preview($typed);
|
||||
}
|
||||
|
||||
$status = $scanner->status();
|
||||
|
||||
if (! $status->reachable) {
|
||||
return back()->with('scanner_test_result', [
|
||||
'ok' => false,
|
||||
'message' => $status->error ?? __('The scanner could not be reached.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$stream = fopen('php://temp', 'r+');
|
||||
assert($stream !== false);
|
||||
fwrite($stream, $this->eicar());
|
||||
rewind($stream);
|
||||
|
||||
$verdict = $scanner->scan($stream, strlen($this->eicar()));
|
||||
fclose($stream);
|
||||
|
||||
if ($verdict->outcome === ScanOutcome::Infected) {
|
||||
// Detecting is half of it. A clamd left on its own defaults
|
||||
// answers "OK" for an archive it could not open, and every
|
||||
// encrypted zip would be recorded as clean — while this test
|
||||
// passed. So ask it about one.
|
||||
if ($this->passesEncryptedArchives($scanner)) {
|
||||
return back()->with('scanner_test_result', [
|
||||
'ok' => false,
|
||||
'message' => __(':engine detects viruses, but reports encrypted archives as clean, so a password-protected zip would get through unchecked. Add AlertEncrypted, AlertEncryptedArchive, AlertEncryptedDoc and AlertExceedsMax, each set to yes, to its clamd.conf and restart it.', [
|
||||
'engine' => $status->engine ?? __('The scanner'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('scanner_test_result', [
|
||||
'ok' => true,
|
||||
'message' => __('Working. :engine detected the EICAR test file as ":threat". EICAR is a harmless file made only for testing, and every antivirus recognises it.', [
|
||||
'engine' => $status->engine ?? __('The scanner'),
|
||||
'threat' => $verdict->detail ?? '',
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
// Reachable, and did not recognise a file every engine is supposed
|
||||
// to. Almost always empty or broken virus definitions, which is
|
||||
// exactly the failure nothing else would show.
|
||||
return back()->with('scanner_test_result', [
|
||||
'ok' => false,
|
||||
'message' => __(':engine answered but did not detect the EICAR test file, a harmless file made only for testing. Check that its virus definitions are installed and up to date.', [
|
||||
'engine' => $status->engine ?? __('The scanner'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check every file people can download again.
|
||||
*
|
||||
* The work itself is the command's, so this button does not hold a
|
||||
* request open for a library of any size, and the pace is the setting
|
||||
* above rather than "as fast as the queue will go".
|
||||
*/
|
||||
public function scanExisting(): RedirectResponse
|
||||
{
|
||||
abort_unless($this->config->enabled(), 422);
|
||||
|
||||
// The screen disables the button while a scan is working through
|
||||
// the queue. Refused here too, because each press queues the whole
|
||||
// library again, and the throttle alone allows six a minute.
|
||||
if ($this->scansInQueue() > 0) {
|
||||
return redirect()
|
||||
->route('system-settings.virus-scanning.edit', ['tab' => 'activity'])
|
||||
->with('error', __('A scan is already running. Wait for it to finish.'));
|
||||
}
|
||||
|
||||
// --all rather than --existing: this is "New scan", and on a
|
||||
// library already scanned once --existing finds nothing to do.
|
||||
Artisan::queue('projectsend:scan-files', ['--all' => true]);
|
||||
|
||||
// Onto the tab that shows it happening rather than back where they
|
||||
// were: somebody who just started a scan wants to watch it, and a
|
||||
// screen that looks unchanged reads as a button that did nothing.
|
||||
return redirect()
|
||||
->route('system-settings.virus-scanning.edit', ['tab' => 'activity'])
|
||||
->with('success', __('The scan has started.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* What the scanner is doing right now, and what it last decided.
|
||||
*
|
||||
* Polled by the Activity tab rather than rendered with the page: a
|
||||
* backfill takes minutes to hours, and a screen that only tells you
|
||||
* where things stood when you opened it is the screen somebody
|
||||
* reloads repeatedly instead of watching.
|
||||
*
|
||||
* JSON rather than an Inertia partial, the way the notification bell
|
||||
* and the zip builder already poll — see use-notification-poll.ts.
|
||||
*/
|
||||
public function activity(): JsonResponse
|
||||
{
|
||||
$recent = File::query()
|
||||
->whereNotNull('scanned_at')
|
||||
->orderByDesc('scanned_at')
|
||||
->limit(20)
|
||||
->get(['id', 'name', 'scan_status', 'scan_note', 'scanned_at', 'scan_engine']);
|
||||
|
||||
$waiting = File::query()->where('scan_status', ScanStatus::Pending)->count();
|
||||
|
||||
// Counted as well as the files above, and this is the half that
|
||||
// makes a backfill visible: re-scanning a file that already went
|
||||
// out unchecked deliberately leaves it available, so it is not
|
||||
// "pending" and a screen watching only that count says nothing is
|
||||
// happening while the queue works through a whole library.
|
||||
$queued = $this->scansInQueue();
|
||||
|
||||
return response()->json([
|
||||
// "Something is happening" is the one thing a person watching
|
||||
// this screen wants to know, and it is worth being explicit
|
||||
// about rather than left to be inferred from a count.
|
||||
'running' => $waiting > 0 || $queued > 0,
|
||||
'waiting' => $waiting,
|
||||
'queued' => $queued,
|
||||
'checked_last_hour' => File::query()->where('scanned_at', '>=', now()->subHour())->count(),
|
||||
'last_scanned_at' => $recent->first()?->scanned_at?->toIso8601String(),
|
||||
'never_scanned' => File::query()->neverScanned()->count(),
|
||||
'quarantined' => File::query()->whereIn('scan_status', [
|
||||
ScanStatus::Infected->value,
|
||||
ScanStatus::UnscannableBlocked->value,
|
||||
])->count(),
|
||||
'recent' => $recent->map(fn (File $file): array => [
|
||||
'id' => $file->id,
|
||||
'name' => $file->name,
|
||||
'status' => $file->scan_status->value,
|
||||
// A reason is a key and is translated; a threat name is
|
||||
// the scanner's own words and is passed through.
|
||||
'note' => $this->noteFor($file),
|
||||
'scanned_at' => $file->scanned_at?->toIso8601String(),
|
||||
'engine' => $file->scan_engine,
|
||||
])->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan jobs waiting to run or running now.
|
||||
*
|
||||
* Not size(), which counts delayed jobs too. A file held while the
|
||||
* scanner was down leaves a retry scheduled for up to five minutes
|
||||
* after the scanner is back and the file already checked, and for
|
||||
* that long the screen said "Scanning now" and refused a new scan.
|
||||
*/
|
||||
private function scansInQueue(): int
|
||||
{
|
||||
$queue = Queue::connection();
|
||||
|
||||
if (method_exists($queue, 'pendingSize') && method_exists($queue, 'reservedSize')) {
|
||||
return (int) $queue->pendingSize('scans') + (int) $queue->reservedSize('scans');
|
||||
}
|
||||
|
||||
return $queue->size('scans');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this installation connects its own scanner.
|
||||
*
|
||||
* Community only, through the registry rather than an edition check —
|
||||
* see Capability::VirusScanningConnect for the division.
|
||||
*/
|
||||
private function canConnect(): bool
|
||||
{
|
||||
return $this->capabilities->has(Capability::VirusScanningConnect);
|
||||
}
|
||||
|
||||
private function noteFor(File $file): ?string
|
||||
{
|
||||
$note = $file->scan_note;
|
||||
|
||||
if ($note === null) {
|
||||
return $file->scan_status === ScanStatus::NotScanned
|
||||
? (string) __(NotScannedReason::BeforeScanning->label())
|
||||
: null;
|
||||
}
|
||||
|
||||
$reason = NotScannedReason::tryFrom($note);
|
||||
|
||||
return $reason === null ? $note : (string) __($reason->label());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function counts(): array
|
||||
{
|
||||
return [
|
||||
'pending' => File::query()->where('scan_status', ScanStatus::Pending)->count(),
|
||||
'quarantined' => File::query()->whereIn('scan_status', [
|
||||
ScanStatus::Infected->value,
|
||||
ScanStatus::UnscannableBlocked->value,
|
||||
])->count(),
|
||||
'never_scanned' => File::query()->neverScanned()->count(),
|
||||
'let_through' => File::query()->letThrough()->count(),
|
||||
// What a New scan would actually check — see
|
||||
// ScanFileJob::rescannableValues().
|
||||
'scannable' => File::query()
|
||||
->whereIn('scan_status', ScanFileJob::rescannableValues())
|
||||
->count(),
|
||||
// So the New scan button can refuse a second scan while one is
|
||||
// still working through the queue.
|
||||
'queued' => $this->scansInQueue(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the scanner calls a password-protected zip clean.
|
||||
*
|
||||
* The archive holds one line of text and nothing else; what matters
|
||||
* is only that it cannot be opened without the password. A scanner
|
||||
* set up as documented answers "encrypted".
|
||||
*/
|
||||
private function passesEncryptedArchives(VirusScanner $scanner): bool
|
||||
{
|
||||
$archive = (string) base64_decode(self::ENCRYPTED_ARCHIVE, true);
|
||||
|
||||
$stream = fopen('php://temp', 'r+');
|
||||
assert($stream !== false);
|
||||
fwrite($stream, $archive);
|
||||
rewind($stream);
|
||||
|
||||
$verdict = $scanner->scan($stream, strlen($archive));
|
||||
fclose($stream);
|
||||
|
||||
return $verdict->outcome === ScanOutcome::Clean;
|
||||
}
|
||||
|
||||
private function eicar(): string
|
||||
{
|
||||
// Assembled rather than written out, so the repository itself
|
||||
// never contains the literal string: antivirus software on a
|
||||
// developer's machine quarantines files that do, and a checkout
|
||||
// that deletes its own test fixtures is a bad afternoon.
|
||||
return 'X5O!P%@AP[4\\PZX54(P^)7CC)7}$'.'EICAR-STANDARD-'.'ANTIVIRUS-TEST-FILE!'.'$H+H*';
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ use App\Modules\Files\Access\DownloadAllowance;
|
||||
use App\Modules\Files\Access\ViewableFileScope;
|
||||
use App\Modules\Files\Jobs\BuildZipDownloadJob;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Scanning\ScanStatus;
|
||||
use App\Modules\Files\Scanning\FileAvailability;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Files\Models\ZipDownload;
|
||||
use App\Modules\Files\Uploads\StoreUploadedFile;
|
||||
@@ -45,6 +47,7 @@ class ZipDownloadsController extends Controller
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly ViewableFileScope $viewable,
|
||||
private readonly DownloadAllowance $allowance,
|
||||
private readonly FileAvailability $availability,
|
||||
private readonly Settings $settings,
|
||||
private readonly FileDelivery $delivery,
|
||||
) {}
|
||||
@@ -102,7 +105,11 @@ class ZipDownloadsController extends Controller
|
||||
// as many times as they were meant to is the whole point of not
|
||||
// hiding exhausted files.
|
||||
$selected = $files->count();
|
||||
$files = $files->filter(fn (File $file): bool => $this->allowance->allows($file, $user));
|
||||
// A file still being checked, or quarantined, is left out of the
|
||||
// selection the same way a spent allowance leaves one out: the zip
|
||||
// is bytes leaving the server, and nothing unchecked goes into one.
|
||||
$files = $files->filter(fn (File $file): bool => $this->availability->isAvailable($file)
|
||||
&& $this->allowance->allows($file, $user));
|
||||
|
||||
abort_if(
|
||||
$files->isEmpty() && $folders->isEmpty() && $selected > 0,
|
||||
@@ -180,6 +187,21 @@ class ZipDownloadsController extends Controller
|
||||
$path = $zipDownload->path;
|
||||
abort_unless($zipDownload->status === ZipDownload::STATUS_READY && $path !== null, 404);
|
||||
|
||||
// The build left out anything not yet available, but a file can be
|
||||
// quarantined after its archive was built — a rescan with newer
|
||||
// definitions, say. Nothing can be taken out of a finished zip, so
|
||||
// the whole archive is refused and a fresh one leaves the file out.
|
||||
$contained = $zipDownload->contained_file_ids;
|
||||
|
||||
abort_if(
|
||||
$contained !== null && File::query()
|
||||
->whereIn('id', $contained)
|
||||
->whereNotIn('scan_status', ScanStatus::availableValues())
|
||||
->exists(),
|
||||
423,
|
||||
__('A file in this archive is no longer available. Download the selection again.'),
|
||||
);
|
||||
|
||||
// Only the first time. Re-fetching one prepared archive is the
|
||||
// same delivery, not a fresh download of everything inside it.
|
||||
if ($zipDownload->delivered_at === null) {
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Http\Resources\Api;
|
||||
|
||||
use App\Modules\Files\Access\ClientIdentityScope;
|
||||
use App\Modules\Files\DownloadLimitScope;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\FileAssignment;
|
||||
@@ -26,6 +27,23 @@ use Illuminate\Http\Resources\Json\JsonResource;
|
||||
* - `checksum` is included deliberately, since verifying an integration's
|
||||
* own download is a real use case, and it reveals nothing about
|
||||
* location.
|
||||
*
|
||||
* Two fields are narrowed to the caller: the uploader and the assignment
|
||||
* list both name clients, and a client-scoped account may hold a file whose
|
||||
* uploader or co-recipients are clients off their own roster — the file is
|
||||
* theirs to read, those names are not theirs to see. ClientIdentityScope is
|
||||
* the rule; a name dropped here is dropped to null or out of the list, and
|
||||
* an unscoped account is unaffected.
|
||||
*
|
||||
* That narrowing happens here rather than in the controllers, which is the opposite of how the version counterparts are
|
||||
* handled a few files over — and deliberately so. Whether a counterpart may
|
||||
* be named is a set-shaped question with a query to express it, so it is
|
||||
* asked once in the caller's eager load. Whether a client may be named is a
|
||||
* per-row check against the viewer's roster with no query to fold it into,
|
||||
* and this resource is built at eight call sites across four controllers,
|
||||
* two of them re-loading `assignments.assignable` after a write. Asking at
|
||||
* the point of serialisation is the only version of this rule that cannot
|
||||
* be forgotten by the ninth caller.
|
||||
*/
|
||||
class FileResource extends JsonResource
|
||||
{
|
||||
@@ -34,6 +52,15 @@ class FileResource extends JsonResource
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$viewer = $request->user();
|
||||
$identity = app(ClientIdentityScope::class);
|
||||
|
||||
// The morph class rather than ::class, matching ShareTargets: with
|
||||
// a morph map registered the two disagree, and this line now
|
||||
// decides which roster an entry is checked against, so getting it
|
||||
// wrong would mean checking a group id against the client list.
|
||||
$groupMorph = (new Group)->getMorphClass();
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
@@ -51,6 +78,18 @@ class FileResource extends JsonResource
|
||||
'expires_at' => $this->expires_at?->toIso8601String(),
|
||||
'expired' => $this->isExpired(),
|
||||
|
||||
// What the virus scanner made of this file. `pending` and
|
||||
// `infected` mean the bytes are not available: the download
|
||||
// endpoint answers 423 for both, and a caller that has just
|
||||
// uploaded should poll this rather than the download. `note`
|
||||
// carries the threat name, or why a file was not scanned.
|
||||
'scan' => [
|
||||
'status' => $this->scan_status->value,
|
||||
'available' => $this->scan_status->isAvailable(),
|
||||
'note' => $this->scan_note,
|
||||
'scanned_at' => $this->scanned_at?->toIso8601String(),
|
||||
],
|
||||
|
||||
// Null when the file may be downloaded any number of times.
|
||||
// `download_limit_scope` says what the number counts —
|
||||
// "total" across everyone, or "per_user" for each person
|
||||
@@ -90,17 +129,24 @@ class FileResource extends JsonResource
|
||||
'name' => $this->nextVersion->name,
|
||||
]),
|
||||
|
||||
// GET /folders/{id} has the rest, its place in the tree included.
|
||||
'folder' => $this->whenLoaded('folder', fn (): ?array => $this->folder === null ? null : [
|
||||
'id' => $this->folder->id,
|
||||
'name' => $this->folder->name,
|
||||
'parent_id' => $this->folder->parent_id,
|
||||
]),
|
||||
|
||||
// Name only. The uploader is a user record; their email address
|
||||
// is not part of what "this file exists" needs to say.
|
||||
'uploaded_by' => $this->whenLoaded('uploader', fn (): ?array => $this->uploader === null ? null : [
|
||||
'id' => $this->uploader->id,
|
||||
'name' => $this->uploader->name,
|
||||
]),
|
||||
// is not part of what "this file exists" needs to say. Null
|
||||
// when the uploader is a client the token's owner is not
|
||||
// scoped to; an unscoped account always gets the name.
|
||||
'uploaded_by' => $this->whenLoaded(
|
||||
'uploader',
|
||||
fn (): ?array => $identity->permits($viewer, $this->uploader) && $this->uploader !== null ? [
|
||||
'id' => $this->uploader->id,
|
||||
'name' => $this->uploader->name,
|
||||
] : null,
|
||||
),
|
||||
|
||||
'categories' => $this->whenLoaded('categories', fn (): array => $this->categories
|
||||
->map(fn ($category): array => [
|
||||
@@ -109,15 +155,22 @@ class FileResource extends JsonResource
|
||||
])
|
||||
->all()),
|
||||
|
||||
// Who the file is shared with, as far as this caller is
|
||||
// concerned: a recipient the token's owner is not scoped to is
|
||||
// left out rather than returned without a name.
|
||||
'assignments' => $this->whenLoaded('assignments', fn (): array => $this->assignments
|
||||
->filter(fn (FileAssignment $assignment): bool => $assignment->assignable_type === $groupMorph
|
||||
? $identity->permitsGroupId($viewer, (int) $assignment->assignable_id)
|
||||
: $identity->permitsClientId($viewer, (int) $assignment->assignable_id))
|
||||
->map(fn (FileAssignment $assignment): array => [
|
||||
'type' => $assignment->assignable_type === Group::class ? 'group' : 'client',
|
||||
'type' => $assignment->assignable_type === $groupMorph ? 'group' : 'client',
|
||||
'id' => $assignment->assignable_id,
|
||||
// getAttribute() rather than ->name: the relation is a
|
||||
// MorphTo over User|Group, so the property is only
|
||||
// knowable at runtime. Both targets carry a name.
|
||||
'name' => $assignment->assignable?->getAttribute('name'),
|
||||
])
|
||||
->values()
|
||||
->all()),
|
||||
|
||||
'links' => [
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Http\Resources\Api;
|
||||
|
||||
use App\Modules\Files\Access\ClientIdentityScope;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Files\Models\FolderAssignment;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin Folder
|
||||
*
|
||||
* Every field is listed explicitly, never $folder->toArray(), for the same
|
||||
* reason as FileResource: the next migration must not publish itself.
|
||||
*
|
||||
* `ancestors` and `path` come from FolderTrails, loaded by the controller
|
||||
* for a whole page at once, and are trimmed to the folders the caller may
|
||||
* see. The assignment list is narrowed per entry by ClientIdentityScope,
|
||||
* exactly as FileResource narrows a file's.
|
||||
*/
|
||||
class FolderResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$viewer = $request->user();
|
||||
$identity = app(ClientIdentityScope::class);
|
||||
$groupMorph = (new Group)->getMorphClass();
|
||||
|
||||
$ancestors = $this->ancestors();
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'parent_id' => $this->parent_id,
|
||||
// The folders above this one, root first, as far up as the
|
||||
// caller may see. Empty for a folder at the top of the library.
|
||||
'ancestors' => $ancestors,
|
||||
// The same trail as one string, this folder included:
|
||||
// "Clients / Acme / 2026". For display; match on ids, since a
|
||||
// folder name may itself contain " / ".
|
||||
'path' => implode(' / ', [...array_column($ancestors, 'name'), $this->name]),
|
||||
// Read-only here. Making a folder public publishes everything
|
||||
// inside it, and is done on the web.
|
||||
'public' => (bool) $this->public,
|
||||
'created_at' => $this->created_at?->toIso8601String(),
|
||||
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||
'assignments' => $this->whenLoaded('assignments', fn (): array => $this->assignments
|
||||
->filter(fn (FolderAssignment $assignment): bool => $assignment->assignable_type === $groupMorph
|
||||
? $identity->permitsGroupId($viewer, (int) $assignment->assignable_id)
|
||||
: $identity->permitsClientId($viewer, (int) $assignment->assignable_id))
|
||||
->map(fn (FolderAssignment $assignment): array => [
|
||||
'type' => $assignment->assignable_type === $groupMorph ? 'group' : 'client',
|
||||
'id' => $assignment->assignable_id,
|
||||
'name' => $assignment->assignable?->getAttribute('name'),
|
||||
])
|
||||
->values()
|
||||
->all()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id: int, name: string}>
|
||||
*/
|
||||
private function ancestors(): array
|
||||
{
|
||||
if (! $this->resource->relationLoaded('trail')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @var list<array{id: int, name: string}> $trail */
|
||||
$trail = $this->resource->getRelation('trail')->all();
|
||||
|
||||
return $trail;
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,11 @@ use App\Models\User;
|
||||
use App\Modules\Files\Access\DownloadAllowance;
|
||||
use App\Modules\Files\Access\ViewableFileScope;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Scanning\FileAvailability;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Files\Models\ZipDownload;
|
||||
use App\Modules\Platform\Capabilities\Capability;
|
||||
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -86,6 +89,23 @@ class BuildZipDownloadJob implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
// A build queued before this installation was told to stop
|
||||
// offering zips. The route refuses new ones; this refuses the ones
|
||||
// already waiting, so the work the key exists to save is not done
|
||||
// anyway. Checked before started_at is stamped, so the row goes
|
||||
// straight from waiting to failed and never looks like a build in
|
||||
// hand. Failed, not left pending: pending is polled by the page
|
||||
// and counted by StalledZipBuilds, and neither should wait on a
|
||||
// build that will never run.
|
||||
if (! app(CapabilityRegistry::class)->has(Capability::ZipDownloads)) {
|
||||
$zipDownload->update([
|
||||
'status' => ZipDownload::STATUS_FAILED,
|
||||
'error' => 'Zip downloads are not available on this site.',
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Stamped before any of the work, because the only thing this is
|
||||
// for is telling "a worker has this in hand" apart from "nobody
|
||||
// is listening to the zips queue". A build that waits and never
|
||||
@@ -114,6 +134,7 @@ class BuildZipDownloadJob implements ShouldQueue
|
||||
|
||||
$visible = app(ViewableFileScope::class)->for($requester);
|
||||
$allowance = app(DownloadAllowance::class);
|
||||
$availability = app(FileAvailability::class);
|
||||
|
||||
try {
|
||||
$relativePath = 'zips/'.$zipDownload->id.'.zip';
|
||||
@@ -148,7 +169,11 @@ class BuildZipDownloadJob implements ShouldQueue
|
||||
// Re-checked here for the same reason visibility is: the
|
||||
// archive is built some time after it was asked for, and
|
||||
// the allowance may have been spent in between.
|
||||
if (! $allowance->allows($file, $requester)) {
|
||||
// Availability is re-checked here for a sharper reason
|
||||
// than the allowance is: a file can be quarantined between
|
||||
// the request and the build, and an archive is exactly how
|
||||
// an infected file would leave anyway.
|
||||
if (! $availability->isAvailable($file) || ! $allowance->allows($file, $requester)) {
|
||||
$skipped[] = ['id' => $file->id, 'name' => $file->name];
|
||||
|
||||
continue;
|
||||
@@ -382,6 +407,7 @@ class BuildZipDownloadJob implements ShouldQueue
|
||||
private function addFolder(ZipArchive $zip, Folder $folder, User $requester, array &$usedNames, array &$tempFiles, Builder $visible, array &$skipped, array &$added): int
|
||||
{
|
||||
$allowance = app(DownloadAllowance::class);
|
||||
$availability = app(FileAvailability::class);
|
||||
|
||||
$subtreeIds = $folder->subtreeFolderIds();
|
||||
/** @var Collection<int, Folder> $foldersById */
|
||||
@@ -403,7 +429,7 @@ class BuildZipDownloadJob implements ShouldQueue
|
||||
// inside it whose own allowance is spent — same reason the
|
||||
// per-file visibility filter is re-derived rather than
|
||||
// inherited from the folder.
|
||||
if (! $allowance->allows($file, $requester)) {
|
||||
if (! $availability->isAvailable($file) || ! $allowance->allows($file, $requester)) {
|
||||
$skipped[] = ['id' => $file->id, 'name' => $file->name];
|
||||
|
||||
continue;
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Jobs;
|
||||
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Scanning\ScanningConfig;
|
||||
use App\Modules\Files\Scanning\ScanOutcome;
|
||||
use App\Modules\Files\Scanning\ScanPolicy;
|
||||
use App\Modules\Files\Scanning\ScanStatus;
|
||||
use App\Modules\Files\Scanning\ScanVerdict;
|
||||
use App\Modules\Files\Scanning\VirusScanner;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Reads one file to the scanner and records what comes back.
|
||||
*
|
||||
* On its own queue (`scans`) with its own worker, for the reason
|
||||
* BuildZipDownloadJob has one: a 5 GB file streaming to a scanner would
|
||||
* otherwise sit in front of every notification email on the default
|
||||
* queue.
|
||||
*
|
||||
* Retries are about the scanner being down, not about the file. While it
|
||||
* is unreachable the job puts itself back with a growing delay, and only
|
||||
* once this installation's patience runs out does the configured policy
|
||||
* decide the file's fate. An installation set to "hold" never runs out:
|
||||
* the file stays pending and ScanFilesCommand keeps this job coming back.
|
||||
*/
|
||||
class ScanFileJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Unlimited attempts, bounded by time instead — see retryUntil(). A
|
||||
* fixed count would give up on a scanner that is merely being
|
||||
* restarted, and the file would be decided by a timeout rather than
|
||||
* by the policy.
|
||||
*/
|
||||
public int $tries = 0;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $fileId,
|
||||
/**
|
||||
* A file that has already been through here — one let through
|
||||
* while the scanner was down, one that predates scanning, or one
|
||||
* being checked again on purpose. It keeps its current state, and
|
||||
* therefore stays downloadable, until a verdict actually arrives.
|
||||
* Marking it pending first would take a library offline for the
|
||||
* length of a backfill, and would announce every file a second
|
||||
* time when it came back.
|
||||
*/
|
||||
public readonly bool $rescan = false,
|
||||
) {
|
||||
$this->onQueue('scans');
|
||||
}
|
||||
|
||||
/**
|
||||
* A day. Long enough that an overnight outage is survived by a
|
||||
* "hold" installation, short enough that a job for a file somebody
|
||||
* deleted does not live forever.
|
||||
*/
|
||||
public function retryUntil(): \DateTimeInterface
|
||||
{
|
||||
return now()->addDay();
|
||||
}
|
||||
|
||||
public function handle(
|
||||
VirusScanner $scanner,
|
||||
ScanPolicy $policy,
|
||||
ScanningConfig $config,
|
||||
): void {
|
||||
$file = File::query()->find($this->fileId);
|
||||
|
||||
if ($file === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A new upload is only scanned while it is still pending: this job
|
||||
// is dispatched from the upload and from the hourly sweep, and
|
||||
// both can land on the same file.
|
||||
if (! $this->rescan && $file->scan_status !== ScanStatus::Pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A rescan asks again about a file people can have today — after
|
||||
// new definitions, or because somebody asked. Nothing else:
|
||||
//
|
||||
// - a file waiting for its first verdict belongs to the job above;
|
||||
// - a quarantined file leaves quarantine only by being released,
|
||||
// and a rescan that came back "the scanner is down" or "too
|
||||
// large" would otherwise have let it out through the policy for
|
||||
// those answers;
|
||||
// - a released file stays released (see QuarantineController);
|
||||
// - a missing file has no bytes to read.
|
||||
if ($this->rescan && ! self::rescannable($file->scan_status)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $config->enabled()) {
|
||||
// A rescan that finds scanning switched off has learned
|
||||
// nothing, and a file already checked keeps its verdict.
|
||||
if (! $this->rescan) {
|
||||
$policy->markNeverScanned($file);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// A file identical to one already quarantined needs no second
|
||||
// opinion, and asking for one would send the same malware past
|
||||
// the scanner again. Checksums are already computed at upload.
|
||||
$known = File::query()
|
||||
->where('checksum', $file->checksum)
|
||||
->where('scan_status', ScanStatus::Infected)
|
||||
->whereKeyNot($file->id)
|
||||
->first();
|
||||
|
||||
if ($known !== null) {
|
||||
$policy->record($file, ScanVerdict::infected((string) $known->scan_note));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$verdict = $this->read($file, $scanner);
|
||||
|
||||
// Same reasoning for a rescan the scanner could not answer: the
|
||||
// file keeps the verdict it had. One let through while the scanner
|
||||
// was down still says so, and the hourly sweep asks again.
|
||||
if ($this->rescan && $verdict->outcome === ScanOutcome::Unavailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($verdict->outcome === ScanOutcome::Unavailable && $this->keepWaiting($file, $config)) {
|
||||
$file->forceFill(['scan_attempts' => $file->scan_attempts + 1])->save();
|
||||
|
||||
// 30 seconds, then a minute, then two, up to five. Long
|
||||
// enough not to hammer a scanner that is starting up; short
|
||||
// enough that a brief blip does not hold an upload for the
|
||||
// whole patience window.
|
||||
$this->release(min(300, 30 * (2 ** min(4, $file->scan_attempts))));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$policy->record($file, $verdict);
|
||||
}
|
||||
|
||||
/**
|
||||
* The states a rescan may act on: the ones a person can download.
|
||||
* Shared with ScanFilesCommand and the settings screen's count, so
|
||||
* what "New scan" says it will check is what it checks.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function rescannableValues(): array
|
||||
{
|
||||
return [ScanStatus::Clean->value, ScanStatus::NotScanned->value];
|
||||
}
|
||||
|
||||
private static function rescannable(ScanStatus $status): bool
|
||||
{
|
||||
return in_array($status->value, self::rescannableValues(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the file should wait rather than be decided now.
|
||||
*
|
||||
* "Hold" waits forever, by design. Otherwise the wait is measured
|
||||
* from when the file was stored, not from this attempt: what the
|
||||
* setting promises is that nobody's upload sits unavailable for
|
||||
* longer than that, however many times the job has run.
|
||||
*/
|
||||
private function keepWaiting(File $file, ScanningConfig $config): bool
|
||||
{
|
||||
if ($config->holdsWhileUnavailable()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$storedAt = $file->created_at ?? now();
|
||||
|
||||
return $storedAt->copy()->addMinutes($config->unavailableWaitMinutes())->isFuture();
|
||||
}
|
||||
|
||||
private function read(File $file, VirusScanner $scanner): ScanVerdict
|
||||
{
|
||||
try {
|
||||
$stream = Storage::disk($file->disk)->readStream($file->path);
|
||||
} catch (Throwable $e) {
|
||||
$stream = null;
|
||||
Log::warning("Could not open file {$file->id} for scanning: ".$e->getMessage());
|
||||
}
|
||||
|
||||
if ($stream === null) {
|
||||
// Not the scanner's fault, and not something waiting will fix
|
||||
// — an orphaned row, or storage that moved. It goes through
|
||||
// the same policy as a file the scanner could not open, and
|
||||
// deliberately not through the scanner-unavailable path,
|
||||
// which is retried hourly and would retry this forever.
|
||||
return ScanVerdict::unreadable(__('The file could not be read from storage.'));
|
||||
}
|
||||
|
||||
try {
|
||||
return $scanner->scan($stream, $file->size);
|
||||
} finally {
|
||||
fclose($stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Listeners;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Events\FileBecameAvailable;
|
||||
use App\Modules\Files\Models\FileAssignment;
|
||||
use App\Modules\Files\Versions\FileVersions;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use App\Modules\Notifications\NotificationDigester;
|
||||
use App\Modules\Notifications\Notifier;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Tells the people a file was shared with, once it can actually be had.
|
||||
*
|
||||
* Sharing a file that is still being scanned writes the assignment and
|
||||
* says nothing (FileSharing::assign). This is the other half: when the
|
||||
* scan finishes, or the file is let through, or an administrator releases
|
||||
* it from quarantine, whoever it was shared with hears about it then.
|
||||
*
|
||||
* Recipients are derived from the assignments as they stand *now* rather
|
||||
* than remembered from the moment of sharing. A share taken back while
|
||||
* the file was being checked should not produce an email afterwards, and
|
||||
* one added in the meantime should — and deriving costs one query,
|
||||
* against a table that already has to be read to answer the same question
|
||||
* anywhere else.
|
||||
*/
|
||||
class AnnounceAvailableFile
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Notifier $notifier,
|
||||
private readonly NotificationDigester $digester,
|
||||
private readonly FileVersions $versions,
|
||||
) {}
|
||||
|
||||
public function handle(FileBecameAvailable $event): void
|
||||
{
|
||||
$file = $event->file;
|
||||
|
||||
$recipients = $this->recipients($file->id);
|
||||
|
||||
if ($recipients->isNotEmpty()) {
|
||||
$this->notifier->send('file_shared', $recipients, subject: $file, data: ['itemName' => $file->name]);
|
||||
$this->digester->queue('file_shared', $recipients, $file->name, ['is_folder' => false]);
|
||||
}
|
||||
|
||||
$previous = $file->previousVersion;
|
||||
|
||||
if ($previous === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The same intersection rule the linking itself follows: only
|
||||
// somebody who can see both files is told, and it is asked again
|
||||
// here because while the new file was being checked the visibility
|
||||
// scope hid it and the audience came out empty.
|
||||
$audience = $this->versions->sharedAudience($file, $previous);
|
||||
|
||||
if ($audience->isNotEmpty()) {
|
||||
$this->notifier->send('file_new_version', $audience, subject: $file, data: [
|
||||
'itemName' => $file->name,
|
||||
'previousName' => $previous->name,
|
||||
]);
|
||||
|
||||
$this->digester->queue('file_new_version', $audience, $file->name, [
|
||||
'previousName' => $previous->name,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everybody the file is assigned to, directly or through a group.
|
||||
*
|
||||
* @return Collection<int, User>
|
||||
*/
|
||||
private function recipients(int $fileId): Collection
|
||||
{
|
||||
return FileAssignment::query()
|
||||
->where('file_id', $fileId)
|
||||
->get()
|
||||
->flatMap(function (FileAssignment $assignment): array {
|
||||
$target = $assignment->assignable;
|
||||
|
||||
if ($target instanceof Group) {
|
||||
return $target->members->all();
|
||||
}
|
||||
|
||||
return $target instanceof User ? [$target] : [];
|
||||
})
|
||||
->unique(fn (User $user): int => $user->id)
|
||||
->values();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files;
|
||||
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Scanning\ScanStatus;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Rows whose bytes are not there — the other half of the orphan problem.
|
||||
*
|
||||
* OrphanFileScanner finds bytes with no row. This finds rows with no
|
||||
* bytes, which is the worse of the two: an orphan is disk space nobody
|
||||
* claimed, while this is a file somebody was told they had. It happens
|
||||
* when a volume is remounted somewhere else, when a backup is restored
|
||||
* without its storage, when an external bucket is swapped, and when
|
||||
* something deleted the bytes behind the application's back.
|
||||
*
|
||||
* Asked by listing each disk once and comparing, rather than by asking
|
||||
* "does this exist?" per row: on object storage that would be one request
|
||||
* per file, and a library of ten thousand files would answer with ten
|
||||
* thousand HEADs every day.
|
||||
*
|
||||
* Only disks this installation can enumerate are checked, which is the
|
||||
* same set the orphan scan walks. A row on any other disk is left alone
|
||||
* rather than declared missing — never having looked is not evidence.
|
||||
*/
|
||||
class MissingFileScanner
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OrphanFileScanner $orphans,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The files whose bytes are gone, as ids.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public function scan(): array
|
||||
{
|
||||
$missing = [];
|
||||
|
||||
foreach (array_keys($this->orphans->scannedDisks()) as $diskName) {
|
||||
$onDisk = array_flip(Storage::disk($diskName)->allFiles());
|
||||
|
||||
File::query()
|
||||
->where('disk', $diskName)
|
||||
->select(['id', 'path'])
|
||||
->chunkById(500, function ($files) use ($onDisk, &$missing): void {
|
||||
foreach ($files as $file) {
|
||||
if (! isset($onDisk[$file->path])) {
|
||||
$missing[] = (int) $file->id;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return $missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Files this installation has marked missing whose bytes are back.
|
||||
*
|
||||
* A remount, a restored backup, a bucket reconnected. Recovery is not
|
||||
* optional politeness: the alternative is an administrator who fixed
|
||||
* their storage and still has a library that says every file is gone.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public function recovered(): array
|
||||
{
|
||||
$back = [];
|
||||
|
||||
foreach (array_keys($this->orphans->scannedDisks()) as $diskName) {
|
||||
$onDisk = array_flip(Storage::disk($diskName)->allFiles());
|
||||
|
||||
File::query()
|
||||
->where('disk', $diskName)
|
||||
->where('scan_status', ScanStatus::Missing)
|
||||
->select(['id', 'path'])
|
||||
->chunkById(500, function ($files) use ($onDisk, &$back): void {
|
||||
foreach ($files as $file) {
|
||||
if (isset($onDisk[$file->path])) {
|
||||
$back[] = (int) $file->id;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return $back;
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,11 @@ use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Files\Access\SharingIdentity;
|
||||
use App\Modules\Files\DownloadLimitScope;
|
||||
use App\Modules\Files\FileDiskCleanup;
|
||||
use App\Modules\Files\Scanning\NotScannedReason;
|
||||
use App\Modules\Files\Scanning\ScanStatus;
|
||||
use App\Modules\Files\Versions\FileVersions;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use App\Modules\Identity\Erasure\SelfDeletion;
|
||||
use App\Support\Concerns\HasUniqueSlug;
|
||||
use Database\Factories\FileFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -40,6 +43,14 @@ use Illuminate\Support\Carbon;
|
||||
* @property string $mime_type
|
||||
* @property int $size
|
||||
* @property string $checksum
|
||||
* @property ScanStatus $scan_status
|
||||
* @property string|null $scan_note the threat name, or a NotScannedReason
|
||||
* @property Carbon|null $scanned_at
|
||||
* @property string|null $scan_engine
|
||||
* @property int $scan_attempts
|
||||
* @property bool $scan_was_available
|
||||
* @property int|null $released_by
|
||||
* @property Carbon|null $released_at
|
||||
* @property bool $public
|
||||
* @property Carbon|null $expires_at
|
||||
* @property int|null $download_limit
|
||||
@@ -74,6 +85,14 @@ class File extends Model
|
||||
{
|
||||
return [
|
||||
'public' => 'boolean',
|
||||
// Where this file stands with the virus scanner. Cast to the
|
||||
// enum so nothing compares raw strings — see ScanStatus and
|
||||
// FileAvailability.
|
||||
'scan_status' => ScanStatus::class,
|
||||
'scanned_at' => 'datetime',
|
||||
'released_at' => 'datetime',
|
||||
'scan_attempts' => 'integer',
|
||||
'scan_was_available' => 'boolean',
|
||||
'commentable' => 'boolean',
|
||||
'expires_at' => 'datetime',
|
||||
'download_limit' => 'integer',
|
||||
@@ -271,6 +290,100 @@ class File extends Model
|
||||
return $this->expires_at !== null && $this->expires_at->isPast();
|
||||
}
|
||||
|
||||
/**
|
||||
* Files the virus scanner has finished with, one way or another.
|
||||
*
|
||||
* Sits beside notExpired() in every scope that answers "what may this
|
||||
* person be shown", and for the same reason: a file nobody has
|
||||
* checked yet is not a file anybody may be handed. The uploader is
|
||||
* the exception while it is being checked — their own upload stays on
|
||||
* their screen, because a file that vanishes for ten minutes after
|
||||
* you send it reads as a failed upload.
|
||||
*
|
||||
* Only while it is being checked. A quarantined or missing upload
|
||||
* stayed listed for its uploader too, with a download button that
|
||||
* answered with an error page; they are told about a blocked upload
|
||||
* by notification instead, and there is nothing to offer them here.
|
||||
*
|
||||
* @param Builder<File> $query
|
||||
*/
|
||||
public function scopeAvailable(Builder $query, ?User $viewer = null): void
|
||||
{
|
||||
$query->where(function (Builder $inner) use ($viewer): void {
|
||||
$inner->whereIn('scan_status', ScanStatus::availableValues());
|
||||
|
||||
if ($viewer !== null) {
|
||||
$inner->orWhere(fn (Builder $own) => $own
|
||||
->where('uploaded_by', $viewer->id)
|
||||
->where('scan_status', ScanStatus::Pending));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a file went out unchecked. Deliberately not
|
||||
* NotScannedReason::BeforeScanning: a file stored while this
|
||||
* installation did not scan at all is not a scanner letting something
|
||||
* past, and on an installation that has never scanned it would mean
|
||||
* saying it about every file there is.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const LET_THROUGH_REASONS = [
|
||||
NotScannedReason::ScannerUnavailable->value,
|
||||
NotScannedReason::TooLarge->value,
|
||||
NotScannedReason::Encrypted->value,
|
||||
];
|
||||
|
||||
/**
|
||||
* Files people can download that nothing checked: let through while
|
||||
* the scanner was down, or because it could not open them.
|
||||
*
|
||||
* A state, not a history. The dashboard used to count "let through"
|
||||
* entries in the activity log, which counted a file once per attempt,
|
||||
* and went on counting files that had since been deleted, gone
|
||||
* missing or been scanned clean — none of which is going out
|
||||
* unscanned.
|
||||
*
|
||||
* @param Builder<File> $query
|
||||
*/
|
||||
public function scopeLetThrough(Builder $query): void
|
||||
{
|
||||
$query->where('scan_status', ScanStatus::NotScanned)
|
||||
->whereIn('scan_note', self::LET_THROUGH_REASONS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this particular file is one of those — the row's own answer
|
||||
* to scopeLetThrough(), for a page that already has the file.
|
||||
*/
|
||||
public function wasLetThrough(): bool
|
||||
{
|
||||
return $this->scan_status === ScanStatus::NotScanned
|
||||
&& in_array((string) $this->scan_note, self::LET_THROUGH_REASONS, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Files nothing has ever looked at.
|
||||
*
|
||||
* Two ways to be one, and the second is the common one: a file stored
|
||||
* while scanning was off carries the reason, and a file that predates
|
||||
* the scanner entirely carries none at all — the migration gives the
|
||||
* column its default and writes no note, and the v1 import inserts
|
||||
* rows the same way. Reading only the reason missed every file on
|
||||
* every real installation, which is exactly the set "Scan existing
|
||||
* files" exists for.
|
||||
*
|
||||
* @param Builder<File> $query
|
||||
*/
|
||||
public function scopeNeverScanned(Builder $query): void
|
||||
{
|
||||
$query->where('scan_status', ScanStatus::NotScanned)
|
||||
->where(fn (Builder $inner) => $inner
|
||||
->whereNull('scan_note')
|
||||
->orWhere('scan_note', NotScannedReason::BeforeScanning->value));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<File> $query
|
||||
*/
|
||||
@@ -279,6 +392,36 @@ class File extends Model
|
||||
$query->where(fn (Builder $q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Files whose uploader has not deleted their own account — the first
|
||||
* rule in SelfDeletion. Every surface that serves somebody other than
|
||||
* staff narrows by this: the client scope, and the three public
|
||||
* listing scopes. Single files ask isWithdrawn().
|
||||
*
|
||||
* The null branch is not tidiness. `uploaded_by NOT IN (...)` is never
|
||||
* true for a NULL uploader, so a file whose uploader was erased long
|
||||
* ago would vanish from every client along with the withdrawn ones.
|
||||
*
|
||||
* @param Builder<File> $query
|
||||
*/
|
||||
public function scopeNotWithdrawn(Builder $query): void
|
||||
{
|
||||
$query->where(fn (Builder $q) => $q
|
||||
->whereNull('uploaded_by')
|
||||
->orWhereNotIn('uploaded_by', app(SelfDeletion::class)->withdrawnAccounts()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The single-file twin of scopeNotWithdrawn(). Asked by the routes
|
||||
* that reach one file without an account behind them — share links
|
||||
* and the public listing — which have no client scope to lean on.
|
||||
*/
|
||||
public function isWithdrawn(): bool
|
||||
{
|
||||
return $this->uploaded_by !== null
|
||||
&& app(SelfDeletion::class)->withdrawnAccounts()->whereKey($this->uploaded_by)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a cap has been set on how many times this may be
|
||||
* downloaded. Unlike expiry, reaching it does not hide the file:
|
||||
@@ -316,6 +459,41 @@ class File extends Model
|
||||
return $this->public || ($this->folder?->isEffectivelyPublic() ?? false);
|
||||
}
|
||||
|
||||
/**
|
||||
* The query-side twin of isEffectivelyPublic(): narrow to files that
|
||||
* are, or are not, publicly reachable.
|
||||
*
|
||||
* Here rather than in a controller because two surfaces now ask it --
|
||||
* the staff library's visibility filter and /api/v1/files -- and a
|
||||
* predicate that has to agree with isEffectivelyPublic() should not
|
||||
* exist twice. The folder half resolves once into a list of ids rather
|
||||
* than as a correlated subquery, because Folder::scopePubliclyVisible()
|
||||
* already expresses the subtree rule and is the only place it lives.
|
||||
*
|
||||
* The null branch in the private half is not tidiness: `folder_id NOT
|
||||
* IN (...)` is never true for a NULL folder_id, so a file at the
|
||||
* library root would otherwise be neither public nor private and
|
||||
* vanish from both halves of the filter.
|
||||
*
|
||||
* @param Builder<File> $query
|
||||
*/
|
||||
public function scopeEffectivelyPublic(Builder $query, bool $public): void
|
||||
{
|
||||
$publicFolderIds = Folder::query()->publiclyVisible()->pluck('id')->all();
|
||||
|
||||
if ($public) {
|
||||
$query->where(fn (Builder $inner) => $inner
|
||||
->where('files.public', true)
|
||||
->orWhereIn('files.folder_id', $publicFolderIds));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$query->where('files.public', false)->where(fn (Builder $inner) => $inner
|
||||
->whereNull('files.folder_id')
|
||||
->orWhereNotIn('files.folder_id', $publicFolderIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* A client can access a file that is assigned to them directly or
|
||||
* via a group, that sits in a folder shared with them (self or
|
||||
@@ -352,7 +530,9 @@ class File extends Model
|
||||
$outer->orWhere('uploaded_by', $client->id);
|
||||
});
|
||||
|
||||
$query->notExpired();
|
||||
// Withdrawn last, beside expiry, because it is the same kind of
|
||||
// rule: not "who may see this" but "may anybody besides staff".
|
||||
$query->notExpired()->notWithdrawn()->available($client);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -393,7 +573,7 @@ class File extends Model
|
||||
$outer->orWhereIn('folder_id', $subtreeFolderIds);
|
||||
});
|
||||
|
||||
$query->notExpired();
|
||||
$query->notExpired()->notWithdrawn()->available();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -407,7 +587,7 @@ class File extends Model
|
||||
*/
|
||||
public function scopePubliclyVisibleForFolder(Builder $query, Folder $folder): void
|
||||
{
|
||||
$query->whereIn('folder_id', $folder->subtreeFolderIds())->notExpired();
|
||||
$query->whereIn('folder_id', $folder->subtreeFolderIds())->notExpired()->notWithdrawn()->available();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -458,6 +638,8 @@ class File extends Model
|
||||
->where(function (Builder $folder) use ($publicFolderSubtreeIds): void {
|
||||
$folder->whereNull('folder_id')->orWhereNotIn('folder_id', $publicFolderSubtreeIds);
|
||||
})
|
||||
->notExpired();
|
||||
->notExpired()
|
||||
->notWithdrawn()
|
||||
->available();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,12 @@ class Folder extends Model
|
||||
return $this->created_by === $user->id;
|
||||
}
|
||||
|
||||
/** Whether this folder stands in as some client's root. */
|
||||
public function isHome(): bool
|
||||
{
|
||||
return $this->home_for_user_id !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Self or any ancestor is public — the inheritance every file in this
|
||||
* folder's subtree relies on (File::isEffectivelyPublic()), and what
|
||||
@@ -152,15 +158,26 @@ class Folder extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether $user may upload a new file directly into $folder (null =
|
||||
* loose at the root, always allowed).
|
||||
* Whether $user may put content into $folder (null = loose at the
|
||||
* root, always allowed).
|
||||
*
|
||||
* **Read the name as "may place into", not "may upload into".** Every
|
||||
* way a file arrives in a folder has to come through here, and the
|
||||
* name cost us one advisory already: the publication rule below was
|
||||
* written for GHSA-237r-jx85-j3hr and wired into the upload paths
|
||||
* alone, because those are what the name suggested. Moving a file in,
|
||||
* bulk-moving a selection in, reparenting one through the edit form,
|
||||
* and dragging a whole folder into a public parent all put content
|
||||
* somewhere too, and none of them asked (GHSA-rxf8-wh8v-jm9j). They
|
||||
* ask now. Anything new that writes a `folder_id` or a `parent_id`
|
||||
* belongs on this list.
|
||||
*
|
||||
* Staff are held to the library boundary they are held to everywhere
|
||||
* else: an unscoped staff member may use any folder, a client-scoped
|
||||
* one only the folders StaffLibraryScope already shows them. This is
|
||||
* the only place that decides it: every upload path — the web form,
|
||||
* the API and the chunked flow the browser actually posts to — comes
|
||||
* through here rather than checking folder_id for itself.
|
||||
* one only the folders StaffLibraryScope already shows them. Callers
|
||||
* that have already resolved the destination through
|
||||
* StaffLibraryScope::folders() have answered that half — the two are
|
||||
* the same query — and call this for the publication half.
|
||||
*
|
||||
* For a client this is unchanged, and is still the whole of the
|
||||
* check: they own the folder, or it is a public folder that opts into
|
||||
@@ -174,7 +191,30 @@ class Folder extends Model
|
||||
}
|
||||
|
||||
if ($user->isStaff()) {
|
||||
return app(StaffLibraryScope::class)->allowsFolder($user, $folder);
|
||||
if (! app(StaffLibraryScope::class)->allowsFolder($user, $folder)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Being allowed to reach the folder is not the same as being
|
||||
// allowed to publish, and putting a file in a public folder
|
||||
// publishes it: isEffectivelyPublic() is "my own flag, or my
|
||||
// folder's". So the destination reaches the property that
|
||||
// `upload_public` guards, without ever touching the switch
|
||||
// (GHSA-237r-jx85-j3hr).
|
||||
//
|
||||
// The keys already say this. The client branch below has always
|
||||
// asked for `upload_to_public_folders` here, and
|
||||
// MyFilesController's picker calls that the established meaning
|
||||
// of the two — it was simply never asked on a staff role, which
|
||||
// left that permission doing nothing at all for staff.
|
||||
//
|
||||
// Effectively public, not `public`: the flag is inherited down
|
||||
// a subtree, so a private folder inside a public one publishes
|
||||
// just the same and a check on the folder's own flag would walk
|
||||
// straight past it.
|
||||
return ! $folder->isEffectivelyPublic()
|
||||
|| $user->can('upload_public')
|
||||
|| $user->can('upload_to_public_folders');
|
||||
}
|
||||
|
||||
return $folder->isOwnedBy($user)
|
||||
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Modules\Files\Queue;
|
||||
|
||||
use App\Modules\Files\Models\ZipDownload;
|
||||
use App\Modules\Platform\Capabilities\Capability;
|
||||
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
@@ -56,6 +58,15 @@ class StalledZipBuilds
|
||||
*/
|
||||
public function oldestUnstarted(): ?Carbon
|
||||
{
|
||||
// An installation that does not offer zips has no reason to be
|
||||
// serving their queue, and one that stopped offering them may
|
||||
// still hold rows queued before it did. BuildZipDownloadJob fails
|
||||
// those when a worker reaches them; until one does, they are not
|
||||
// a worker problem worth a banner.
|
||||
if (! app(CapabilityRegistry::class)->has(Capability::ZipDownloads)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->buildInHand()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Scanning;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Talks to ClamAV's daemon, `clamd`, over a Unix socket or TCP.
|
||||
*
|
||||
* The protocol is small enough to own: a command is `z<COMMAND>\0`, and
|
||||
* INSTREAM is that followed by length-prefixed chunks and a zero-length
|
||||
* chunk to finish. Taking a library for this would be more dependency
|
||||
* than code.
|
||||
*
|
||||
* **Three of clamd's own settings decide whether this class can tell the
|
||||
* truth**, and without them a file it could not open comes back as `OK`:
|
||||
* `AlertExceedsMax`, `AlertEncrypted` and its two companions turn those
|
||||
* cases into answers, which arrive here as `Heuristics.Limits.Exceeded.*`
|
||||
* and `Heuristics.Encrypted.*` and are mapped below to tooLarge and
|
||||
* encrypted rather than to a threat. An encrypted archive full of malware
|
||||
* reported as clean is the failure this exists to prevent, so the
|
||||
* shipped Docker configuration sets all of them and the documentation
|
||||
* says so for manual installs.
|
||||
*
|
||||
* Nothing here throws for a scanner that is down, slow or misconfigured:
|
||||
* the caller has a policy for that, and an exception would read as a bug
|
||||
* in the job rather than as the state of somebody's server.
|
||||
*/
|
||||
class ClamAvScanner implements VirusScanner
|
||||
{
|
||||
/** 64 KiB — clamd's own read buffer size, and small enough to stream 5 GB without holding it. */
|
||||
private const CHUNK = 65536;
|
||||
|
||||
/** Seconds to wait for an answer to VERSION. */
|
||||
private const VERSION_TIMEOUT = 10;
|
||||
|
||||
/**
|
||||
* What the daemon said it was, the first time this instance asked.
|
||||
*
|
||||
* Every verdict records the engine and definitions that reached it, so
|
||||
* a stored "clean" can be read back against what knew it. Asking on
|
||||
* every scan would double the connections; asking once per instance
|
||||
* means once per queue job, and the worker is recycled hourly.
|
||||
*/
|
||||
private ?string $engine = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly ScanningConfig $config,
|
||||
) {}
|
||||
|
||||
public function scan(mixed $stream, int $size): ScanVerdict
|
||||
{
|
||||
$max = $this->config->maxScanBytes();
|
||||
|
||||
// Asked before opening a socket: a file this installation has
|
||||
// decided not to scan should not spend a connection, and clamd
|
||||
// would refuse it anyway once it passed StreamMaxLength.
|
||||
if ($max > 0 && $size > $max) {
|
||||
return ScanVerdict::tooLarge($this->engine());
|
||||
}
|
||||
|
||||
if (! $this->addressIsUsable()) {
|
||||
return ScanVerdict::unavailable(__(ScannerAddress::message()));
|
||||
}
|
||||
|
||||
$socket = $this->connect();
|
||||
|
||||
if ($socket === null) {
|
||||
return ScanVerdict::unavailable(__('The scanner could not be reached at :address.', [
|
||||
'address' => $this->config->address(),
|
||||
]));
|
||||
}
|
||||
|
||||
try {
|
||||
$sent = $this->send($socket, "zINSTREAM\0");
|
||||
|
||||
while ($sent && ! feof($stream)) {
|
||||
$chunk = fread($stream, self::CHUNK);
|
||||
|
||||
if ($chunk === false) {
|
||||
return ScanVerdict::unavailable(__('The file could not be read for scanning.'));
|
||||
}
|
||||
|
||||
if ($chunk === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Big-endian length, then the bytes.
|
||||
$sent = $this->send($socket, pack('N', strlen($chunk)).$chunk);
|
||||
}
|
||||
|
||||
if ($sent) {
|
||||
$this->send($socket, pack('N', 0));
|
||||
}
|
||||
|
||||
// Read whether or not every byte went: a write that fails
|
||||
// means clamd hung up mid-stream, and it only does that after
|
||||
// saying why — usually its own size limit. Treating the
|
||||
// failed write as "the scanner is down" instead sent every
|
||||
// file over that limit round the retry loop forever, and past
|
||||
// the unscannable policy.
|
||||
$reply = $this->readReply($socket);
|
||||
} catch (Throwable $e) {
|
||||
return ScanVerdict::unavailable($e->getMessage());
|
||||
} finally {
|
||||
fclose($socket);
|
||||
}
|
||||
|
||||
if ($reply === null) {
|
||||
return ScanVerdict::unavailable(__('The scanner did not answer in time.'));
|
||||
}
|
||||
|
||||
return $this->verdictFor($reply, $this->engine());
|
||||
}
|
||||
|
||||
public function status(): ScannerStatus
|
||||
{
|
||||
// Named rather than reported as "no answer". A managed address
|
||||
// comes from the environment and never passed the settings
|
||||
// screen's validation, so this is the only place it is checked —
|
||||
// and the socket would accept a malformed one by reading the
|
||||
// digits at the front of the port and ignoring the rest, which is
|
||||
// how an address with a typo on the end came to look like it
|
||||
// worked.
|
||||
if (! $this->addressIsUsable()) {
|
||||
return ScannerStatus::unreachable(__(ScannerAddress::message()));
|
||||
}
|
||||
|
||||
// A short wait rather than the scan's: VERSION is answered at once
|
||||
// by anything that is clamd, and the Test button waits on this.
|
||||
$socket = $this->connect(self::VERSION_TIMEOUT);
|
||||
|
||||
if ($socket === null) {
|
||||
return ScannerStatus::unreachable(__('No answer from :address.', ['address' => $this->config->address()]));
|
||||
}
|
||||
|
||||
try {
|
||||
$this->send($socket, "zVERSION\0");
|
||||
$reply = $this->readReply($socket);
|
||||
} catch (Throwable $e) {
|
||||
return ScannerStatus::unreachable($e->getMessage());
|
||||
} finally {
|
||||
fclose($socket);
|
||||
}
|
||||
|
||||
if ($reply === null || $reply === '') {
|
||||
return ScannerStatus::unreachable(__('The scanner did not answer in time.'));
|
||||
}
|
||||
|
||||
// "ClamAV 1.4.1/27412/Mon Sep 15 09:12:03 2026" — engine,
|
||||
// signature database number, and when that database was built.
|
||||
// Older builds answer with the engine alone, so every part after
|
||||
// the first is optional rather than assumed.
|
||||
$parts = explode('/', $reply);
|
||||
|
||||
// Anything listening on the port answers something. Without this a
|
||||
// database or a web server "answered", and the first bytes of its
|
||||
// greeting were shown as the engine's name.
|
||||
if (! str_starts_with($parts[0], 'ClamAV ')) {
|
||||
return ScannerStatus::unreachable(__('Something answered at :address, but it is not a ClamAV scanner.', [
|
||||
'address' => $this->config->address(),
|
||||
]));
|
||||
}
|
||||
|
||||
$definitions = isset($parts[1]) && is_numeric(trim($parts[1])) ? (int) trim($parts[1]) : null;
|
||||
$built = null;
|
||||
|
||||
if (isset($parts[2])) {
|
||||
try {
|
||||
$built = Carbon::parse(trim($parts[2]));
|
||||
} catch (Throwable) {
|
||||
$built = null;
|
||||
}
|
||||
}
|
||||
|
||||
return new ScannerStatus(true, trim($parts[0]), $definitions, $built);
|
||||
}
|
||||
|
||||
private function verdictFor(string $reply, ?string $engine): ScanVerdict
|
||||
{
|
||||
// The whole reply, not its last two letters: "clean" is the one
|
||||
// answer that hands a file out, so nothing else may be read as it.
|
||||
if ($reply === 'stream: OK') {
|
||||
return ScanVerdict::clean($engine);
|
||||
}
|
||||
|
||||
// "stream: Win.Test.EICAR_HDB-1 FOUND"
|
||||
if (str_starts_with($reply, 'stream: ') && str_ends_with($reply, ' FOUND')) {
|
||||
$threat = trim(str_replace(['stream:', 'FOUND'], '', $reply));
|
||||
|
||||
// Not threats: clamd's way of saying "I could not look
|
||||
// inside". Which one it is decides the file's fate, and both
|
||||
// are the installation's policy rather than a detection.
|
||||
if (str_contains($threat, 'Heuristics.Encrypted')) {
|
||||
return ScanVerdict::encrypted($engine);
|
||||
}
|
||||
|
||||
if (str_contains($threat, 'Heuristics.Limits.Exceeded')) {
|
||||
return ScanVerdict::tooLarge($engine);
|
||||
}
|
||||
|
||||
return ScanVerdict::infected($threat === '' ? 'unknown' : $threat, $engine);
|
||||
}
|
||||
|
||||
// "INSTREAM size limit exceeded. ERROR" — the stream was longer
|
||||
// than clamd's StreamMaxLength. Same meaning as the heuristic
|
||||
// above, reached when this installation's own maximum is the
|
||||
// larger of the two.
|
||||
if (str_contains($reply, 'size limit exceeded')) {
|
||||
return ScanVerdict::tooLarge($engine);
|
||||
}
|
||||
|
||||
return ScanVerdict::unavailable($reply);
|
||||
}
|
||||
|
||||
/**
|
||||
* "ClamAV 1.5.4/28122" — engine and signature database, as recorded
|
||||
* against every verdict. Null when the daemon did not say.
|
||||
*/
|
||||
private function engine(): ?string
|
||||
{
|
||||
if ($this->engine !== null) {
|
||||
return $this->engine;
|
||||
}
|
||||
|
||||
$status = $this->status();
|
||||
|
||||
if (! $status->reachable || $status->engine === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->engine = $status->definitionsVersion === null
|
||||
? $status->engine
|
||||
: $status->engine.'/'.$status->definitionsVersion;
|
||||
}
|
||||
|
||||
/** Whether the configured address is one at all — see ScannerAddress. */
|
||||
private function addressIsUsable(): bool
|
||||
{
|
||||
return ScannerAddress::isValid($this->config->address());
|
||||
}
|
||||
|
||||
/** @return resource|null */
|
||||
private function connect(?int $replyTimeout = null): mixed
|
||||
{
|
||||
$address = $this->config->address();
|
||||
|
||||
if ($address === '' || ! $this->addressIsUsable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$socket = @stream_socket_client(
|
||||
$address,
|
||||
$code,
|
||||
$message,
|
||||
$this->config->connectTimeoutSeconds(),
|
||||
STREAM_CLIENT_CONNECT,
|
||||
);
|
||||
|
||||
if ($socket === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Without this a scanner that accepts the connection and then
|
||||
// stops answering holds the worker open indefinitely.
|
||||
stream_set_timeout($socket, $replyTimeout ?? $this->config->replyTimeoutSeconds());
|
||||
|
||||
return $socket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write all of it, or say that it could not.
|
||||
*
|
||||
* fwrite() may take part of a buffer and return how much, and on a
|
||||
* connection the other end has closed it raises a warning — which the
|
||||
* framework's error handler turns into an exception. Silenced and
|
||||
* checked here instead, so a hang-up reads as a hang-up and the reply
|
||||
* explaining it can still be read.
|
||||
*
|
||||
* @param resource $socket
|
||||
*/
|
||||
private function send(mixed $socket, string $bytes): bool
|
||||
{
|
||||
while ($bytes !== '') {
|
||||
$written = @fwrite($socket, $bytes);
|
||||
|
||||
if ($written === false || $written === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$bytes = substr($bytes, $written);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* clamd's replies end with a NUL in `z` mode. Returns null when the
|
||||
* socket timed out rather than answered.
|
||||
*
|
||||
* @param resource $socket
|
||||
*/
|
||||
private function readReply(mixed $socket): ?string
|
||||
{
|
||||
$reply = '';
|
||||
|
||||
while (! feof($socket)) {
|
||||
// Silenced for the same reason as send(): a connection clamd
|
||||
// has reset raises a warning here, and the framework would
|
||||
// turn that into an exception before the loop could stop.
|
||||
$byte = @fread($socket, 1);
|
||||
|
||||
if ($byte === false || $byte === '') {
|
||||
break;
|
||||
}
|
||||
|
||||
if ($byte === "\0") {
|
||||
break;
|
||||
}
|
||||
|
||||
$reply .= $byte;
|
||||
}
|
||||
|
||||
if (stream_get_meta_data($socket)['timed_out']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim($reply);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Scanning;
|
||||
|
||||
use App\Modules\Files\Events\FileBecameAvailable;
|
||||
use App\Modules\Files\Models\File;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
|
||||
/**
|
||||
* Whether a file may be seen and served, and what happens the moment it
|
||||
* may be.
|
||||
*
|
||||
* The one predicate every other rule asks. Three states mean yes and
|
||||
* three mean no (see ScanStatus), and the reason this is a class rather
|
||||
* than a comparison at each call site is that the list of "yes" states
|
||||
* has already changed once — `released` was added when quarantine gained
|
||||
* an override — and the day it changes again, it has to change in one
|
||||
* place or a file becomes downloadable through one route and not another.
|
||||
*
|
||||
* "Available" is about everyone *other than* staff and the uploader. Staff
|
||||
* see their library at all times, with each file's state on it; what
|
||||
* availability governs is whether recipients and visitors see a file at
|
||||
* all, and whether its bytes may leave the server.
|
||||
*/
|
||||
class FileAvailability
|
||||
{
|
||||
public function isAvailable(File $file): bool
|
||||
{
|
||||
return $file->scan_status->isAvailable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse to serve a file's bytes unless it is available.
|
||||
*
|
||||
* Called by every route that puts bytes on the wire — the download,
|
||||
* the thumbnail, the preview, the share link, the public listing and
|
||||
* the zip builder. Not by the listings: a staff member's library shows
|
||||
* a pending file with its state on it, and the uploader sees their own.
|
||||
* What this governs is the bytes.
|
||||
*
|
||||
* It refuses everybody, including staff and the file's own uploader.
|
||||
* A file the scanner has not cleared is not one this application
|
||||
* hands out, and an administrator who wants it anyway has a way to say
|
||||
* so on the record: release it from quarantine.
|
||||
*
|
||||
* 423 rather than 403: the refusal is about the file's state and it is
|
||||
* temporary in the pending case, which is exactly what "Locked" means
|
||||
* and what "Forbidden" does not. ProblemDetails renders it as JSON for
|
||||
* the API, which shares these controllers.
|
||||
*/
|
||||
public function guardDelivery(File $file): void
|
||||
{
|
||||
if ($this->isAvailable($file)) {
|
||||
return;
|
||||
}
|
||||
|
||||
abort(423, match ($file->scan_status) {
|
||||
ScanStatus::Pending => __('This file is still being checked for viruses.'),
|
||||
// Said plainly, because it is not a refusal: there is nothing
|
||||
// to serve, and whoever hits this can stop looking for a
|
||||
// permission that would let them through.
|
||||
ScanStatus::Missing => __('This file is no longer on the server.'),
|
||||
default => __('This file is not available.'),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A file has finished being checked, one way or another.
|
||||
*
|
||||
* Three roads lead here and they are not interchangeable: the scan
|
||||
* passed, the scanner could not be reached and this installation lets
|
||||
* files through, or an administrator released it from quarantine. What
|
||||
* they share is the only thing this announces — the file can now be
|
||||
* had by the people it was shared with, which is when everything that
|
||||
* was waiting on it (a share email, a new-version notice) is allowed
|
||||
* to go out.
|
||||
*/
|
||||
public function markAvailable(File $file): void
|
||||
{
|
||||
if (! $this->isAvailable($file)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Event::dispatch(new FileBecameAvailable($file));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Scanning;
|
||||
|
||||
/**
|
||||
* Why a file carries ScanStatus::NotScanned — stored in `scan_note`.
|
||||
*
|
||||
* Four different things to say to a person, and two of them are the
|
||||
* installation's own doing rather than the file's, so a single "not
|
||||
* scanned" badge with no reason would be unactionable.
|
||||
*/
|
||||
enum NotScannedReason: string
|
||||
{
|
||||
/** Bigger than the largest file this installation scans. */
|
||||
case TooLarge = 'too_large';
|
||||
|
||||
/** An encrypted archive or document the scanner cannot open. */
|
||||
case Encrypted = 'encrypted';
|
||||
|
||||
/** The scanner could not be reached in time, and the policy lets files through. */
|
||||
case ScannerUnavailable = 'scanner_unavailable';
|
||||
|
||||
/** Uploaded before scanning was switched on, or while it is off. */
|
||||
case BeforeScanning = 'before_scanning';
|
||||
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::TooLarge => 'Too large to scan',
|
||||
self::Encrypted => 'Encrypted, so it could not be scanned',
|
||||
self::ScannerUnavailable => 'The scanner could not be reached',
|
||||
self::BeforeScanning => 'Uploaded before virus scanning was switched on',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Scanning;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Identity\Permissions\Permission;
|
||||
use App\Modules\Identity\Permissions\PermissionChecker;
|
||||
use App\Modules\Identity\UserType;
|
||||
use App\Modules\Notifications\Notifier;
|
||||
|
||||
/**
|
||||
* Who hears about a quarantined file.
|
||||
*
|
||||
* Two audiences, deliberately not three. Staff who can do something about
|
||||
* it are told, because a file sitting in quarantine that nobody looks at
|
||||
* is the same as a file silently lost. The person who uploaded it is
|
||||
* told, because on an honest account this is how they find out their own
|
||||
* machine has something on it — and because otherwise their file simply
|
||||
* never arrives and they have no idea why.
|
||||
*
|
||||
* The people the file was shared with are **not** told. They never
|
||||
* received it, and a message about a virus in a file they never saw
|
||||
* would alarm without informing.
|
||||
*
|
||||
* Recipients are resolved here rather than inside Notifier, which
|
||||
* authorizes nothing by design — see its security contract.
|
||||
*/
|
||||
class QuarantineNotifier
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Notifier $notifier,
|
||||
private readonly PermissionChecker $permissions,
|
||||
private readonly StaffLibraryScope $scope,
|
||||
) {}
|
||||
|
||||
public function quarantined(File $file, string $threat): void
|
||||
{
|
||||
$uploader = $file->uploader;
|
||||
$staff = $this->staff($file);
|
||||
|
||||
$this->notifier->send('file_quarantined', $staff, subject: $file, data: [
|
||||
'itemName' => $file->name,
|
||||
'uploaderName' => $uploader->name ?? __('a deleted account'),
|
||||
'threat' => $threat,
|
||||
]);
|
||||
|
||||
// The uploader hears it once. Without this check a staff member
|
||||
// who uploaded an infected file would get both messages, which
|
||||
// read as two different files.
|
||||
if ($uploader !== null && ! $staff->contains(fn (User $member): bool => $member->is($uploader))) {
|
||||
$this->notifier->send('upload_blocked', [$uploader], subject: $file, data: [
|
||||
'itemName' => $file->name,
|
||||
'threat' => $threat,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff who can release this file — the permission, and a client
|
||||
* scope that reaches its uploader (see QuarantineController).
|
||||
*
|
||||
* @return \Illuminate\Support\Collection<int, User>
|
||||
*/
|
||||
private function staff(File $file): \Illuminate\Support\Collection
|
||||
{
|
||||
return User::query()
|
||||
->where('type', UserType::Staff)
|
||||
->where('active', true)
|
||||
->get()
|
||||
->filter(fn (User $staff): bool => $this->permissions->allows($staff, Permission::ReleaseQuarantinedFiles))
|
||||
->filter(function (User $staff) use ($file): bool {
|
||||
$uploaders = $this->scope->uploaderIds($staff);
|
||||
|
||||
return $uploaders === null || in_array($file->uploaded_by, $uploaders, true);
|
||||
})
|
||||
->values();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Scanning;
|
||||
|
||||
enum ScanOutcome
|
||||
{
|
||||
case Clean;
|
||||
case Infected;
|
||||
case TooLarge;
|
||||
case Encrypted;
|
||||
/** The file's own bytes could not be read. Nothing to do with the scanner. */
|
||||
case Unreadable;
|
||||
|
||||
case Unavailable;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Scanning;
|
||||
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Thumbnails\ThumbnailGenerator;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* What a verdict means for a file, on this installation.
|
||||
*
|
||||
* The scanner answers a question of fact — clean, infected, could not
|
||||
* open it, did not answer. Three of those four are only half an answer:
|
||||
* whether a file nobody could check may be handed to a client is a
|
||||
* decision about somebody's business, not about the file, so it is a
|
||||
* setting and it is applied here. Keeping that split is why ClamAvScanner
|
||||
* knows nothing about settings and this class knows nothing about
|
||||
* sockets.
|
||||
*
|
||||
* Every write to a file's scan columns goes through this class. They are
|
||||
* not fillable and nothing else sets them.
|
||||
*/
|
||||
class ScanPolicy
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ScanningConfig $config,
|
||||
private readonly FileAvailability $availability,
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly QuarantineNotifier $notifier,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Record a verdict, and return the state the file ended up in.
|
||||
*
|
||||
* Returns null when the verdict was "the scanner did not answer" and
|
||||
* this installation waits: nothing is written, the file stays
|
||||
* pending, and the caller retries.
|
||||
*/
|
||||
public function record(File $file, ScanVerdict $verdict): ?ScanStatus
|
||||
{
|
||||
return match ($verdict->outcome) {
|
||||
ScanOutcome::Clean => $this->settle($file, ScanStatus::Clean, null, $verdict->engine),
|
||||
ScanOutcome::Infected => $this->quarantine($file, $verdict->detail ?? 'unknown', $verdict->engine),
|
||||
ScanOutcome::TooLarge => $this->unscannable($file, NotScannedReason::TooLarge, $verdict->engine),
|
||||
ScanOutcome::Encrypted => $this->unscannable($file, NotScannedReason::Encrypted, $verdict->engine),
|
||||
ScanOutcome::Unreadable => $this->missing($file),
|
||||
ScanOutcome::Unavailable => $this->unavailable($file, $verdict->detail),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The file existed before there was a scanner, or scanning is off.
|
||||
* Not a verdict, so it is never logged: nothing happened to this
|
||||
* file, it simply was never looked at.
|
||||
*/
|
||||
public function markNeverScanned(File $file): void
|
||||
{
|
||||
$file->forceFill([
|
||||
'scan_status' => ScanStatus::NotScanned,
|
||||
'scan_note' => NotScannedReason::BeforeScanning->value,
|
||||
])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* A threat was found. The bytes stay — a scanner can be wrong, and an
|
||||
* administrator may release it — but nothing may reach them, and the
|
||||
* thumbnails already rendered from this file have to go: they are
|
||||
* derived from the same bytes and are served by their own routes.
|
||||
*/
|
||||
private function quarantine(File $file, string $threat, ?string $engine): ScanStatus
|
||||
{
|
||||
$wasAvailable = $this->availability->isAvailable($file);
|
||||
|
||||
$file->forceFill(['scan_was_available' => $wasAvailable])->save();
|
||||
|
||||
$this->settle($file, ScanStatus::Infected, $threat, $engine);
|
||||
$this->purgeRenditions($file);
|
||||
|
||||
$this->activity->logSystem(Action::FileQuarantined, [
|
||||
'id' => $file->id,
|
||||
'name' => $file->name,
|
||||
'threat' => $threat,
|
||||
// Said out loud because it changes what an administrator has
|
||||
// to do: a file that was downloadable while it waited for a
|
||||
// scanner may already be on somebody's machine, and its
|
||||
// download history is the only way to know.
|
||||
'was_available' => $wasAvailable,
|
||||
]);
|
||||
|
||||
$this->notifier->quarantined($file, $threat);
|
||||
|
||||
return ScanStatus::Infected;
|
||||
}
|
||||
|
||||
/**
|
||||
* The row is here and the bytes are not.
|
||||
*
|
||||
* Not a scanning verdict at all, and deliberately not run through the
|
||||
* unscannable policy: "allow files nobody could scan" is a decision
|
||||
* about risk, and there is no risk in a file that cannot be served.
|
||||
* What there is, is a problem somebody has to look at — see
|
||||
* MissingFileScanner and the Files → Missing screen.
|
||||
*/
|
||||
private function missing(File $file): ScanStatus
|
||||
{
|
||||
$this->settle($file, ScanStatus::Missing, null, null);
|
||||
|
||||
return ScanStatus::Missing;
|
||||
}
|
||||
|
||||
/** The scanner could not open the file: too large, or encrypted. */
|
||||
private function unscannable(File $file, NotScannedReason $reason, ?string $engine): ScanStatus
|
||||
{
|
||||
if ($this->config->blocksUnscannable()) {
|
||||
$this->settle($file, ScanStatus::UnscannableBlocked, $reason->value, $engine);
|
||||
$this->purgeRenditions($file);
|
||||
|
||||
$this->activity->logSystem(Action::FileQuarantined, [
|
||||
'id' => $file->id,
|
||||
'name' => $file->name,
|
||||
'threat' => $reason->label(),
|
||||
'was_available' => false,
|
||||
]);
|
||||
|
||||
$this->notifier->quarantined($file, $reason->label());
|
||||
|
||||
return ScanStatus::UnscannableBlocked;
|
||||
}
|
||||
|
||||
return $this->letThrough($file, $reason, $engine);
|
||||
}
|
||||
|
||||
/** The scanner never answered. Either wait for it, or let the file go. */
|
||||
private function unavailable(File $file, ?string $reason): ?ScanStatus
|
||||
{
|
||||
if ($this->config->holdsWhileUnavailable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->letThrough($file, NotScannedReason::ScannerUnavailable, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allowed through without being checked.
|
||||
*
|
||||
* Always logged, even though it is the configured behaviour: this is
|
||||
* the state where the installation looks protected and is not, and
|
||||
* the log is what makes "we were unprotected between these two dates"
|
||||
* answerable afterwards.
|
||||
*/
|
||||
private function letThrough(File $file, NotScannedReason $reason, ?string $engine): ScanStatus
|
||||
{
|
||||
$this->settle($file, ScanStatus::NotScanned, $reason->value, $engine);
|
||||
|
||||
$this->activity->logSystem(Action::FileNotScanned, [
|
||||
'id' => $file->id,
|
||||
'name' => $file->name,
|
||||
'reason' => $reason->value,
|
||||
]);
|
||||
|
||||
return ScanStatus::NotScanned;
|
||||
}
|
||||
|
||||
private function settle(File $file, ScanStatus $status, ?string $note, ?string $engine): ScanStatus
|
||||
{
|
||||
// Asked before the write, because what the announcement means is
|
||||
// "this can now be had" and a file that could already be had has
|
||||
// nothing to announce. Without this, re-scanning a file that went
|
||||
// out unscanned would tell its recipients a second time.
|
||||
$wasAvailable = $this->availability->isAvailable($file);
|
||||
|
||||
$file->forceFill([
|
||||
'scan_status' => $status,
|
||||
'scan_note' => $note,
|
||||
'scanned_at' => now(),
|
||||
'scan_engine' => $engine,
|
||||
])->save();
|
||||
|
||||
if (! $wasAvailable) {
|
||||
$this->availability->markAvailable($file);
|
||||
}
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thumbnails and previews are cached copies of the same bytes, served
|
||||
* by routes of their own, so a quarantined file with a rendition
|
||||
* already on disk would still be showing part of itself.
|
||||
*/
|
||||
private function purgeRenditions(File $file): void
|
||||
{
|
||||
foreach (ThumbnailGenerator::pathsFor($file->id, $file->mime_type) as $path) {
|
||||
Storage::disk('files')->delete($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Scanning;
|
||||
|
||||
/**
|
||||
* Where a file stands with the virus scanner.
|
||||
*
|
||||
* Availability is not a case here on purpose: three of these mean the
|
||||
* file may be served and three mean it may not, and asking
|
||||
* FileAvailability rather than comparing cases is what keeps that rule in
|
||||
* one place. See docs/feature-virus-scanning.md.
|
||||
*/
|
||||
enum ScanStatus: string
|
||||
{
|
||||
/** Waiting to be scanned, or being scanned right now. */
|
||||
case Pending = 'pending';
|
||||
|
||||
/** Scanned, nothing found. */
|
||||
case Clean = 'clean';
|
||||
|
||||
/** A threat was found. Quarantined; `scan_note` is the threat name. */
|
||||
case Infected = 'infected';
|
||||
|
||||
/** Was infected, and an administrator decided to allow it anyway. */
|
||||
case Released = 'released';
|
||||
|
||||
/** Not checked, and allowed through. `scan_note` is a NotScannedReason. */
|
||||
case NotScanned = 'not_scanned';
|
||||
|
||||
/** Could not be checked, and this installation blocks those. Quarantined. */
|
||||
case UnscannableBlocked = 'unscannable_blocked';
|
||||
|
||||
/**
|
||||
* The row is here and the bytes are not.
|
||||
*
|
||||
* Its own state rather than a kind of "not scanned", because what it
|
||||
* means for the file is different: nothing can be served, so nothing
|
||||
* is offered. A client listing it and getting an error on the
|
||||
* download is worse than not seeing it, and staff need to see it
|
||||
* precisely because somebody has to decide what to do about it.
|
||||
*/
|
||||
case Missing = 'missing';
|
||||
|
||||
/**
|
||||
* Whether a file in this state may be seen and downloaded by people
|
||||
* other than staff and its uploader.
|
||||
*/
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::Clean, self::Released, self::NotScanned => true,
|
||||
self::Pending, self::Infected, self::UnscannableBlocked, self::Missing => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The states a query may hand to somebody other than staff.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function availableValues(): array
|
||||
{
|
||||
return array_values(array_map(
|
||||
fn (self $status): string => $status->value,
|
||||
array_filter(self::cases(), fn (self $status): bool => $status->isAvailable()),
|
||||
));
|
||||
}
|
||||
|
||||
/** Whether this state is waiting on an administrator's decision. */
|
||||
public function isQuarantined(): bool
|
||||
{
|
||||
return $this === self::Infected || $this === self::UnscannableBlocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* English, and the translation key — what staff see on the file.
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Pending => 'Checking for viruses',
|
||||
self::Clean => 'Checked',
|
||||
self::Infected => 'Quarantined',
|
||||
self::Released => 'Released by an administrator',
|
||||
self::NotScanned => 'Not scanned',
|
||||
self::UnscannableBlocked => 'Blocked: could not be scanned',
|
||||
self::Missing => 'Missing from storage',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Scanning;
|
||||
|
||||
/**
|
||||
* What a scanner answered about one file.
|
||||
*
|
||||
* Five outcomes rather than a boolean, because four of them are not
|
||||
* "clean or not": a file the scanner refused to open, one too big for it,
|
||||
* and a scanner that never answered are three different facts, and this
|
||||
* installation's settings decide what each one means for the file. That
|
||||
* decision lives in ScanPolicy, not here.
|
||||
*/
|
||||
final class ScanVerdict
|
||||
{
|
||||
private function __construct(
|
||||
public readonly ScanOutcome $outcome,
|
||||
/** The threat name, the reason a scan was refused, or null. */
|
||||
public readonly ?string $detail = null,
|
||||
/** Engine and definitions, as the scanner reported them. */
|
||||
public readonly ?string $engine = null,
|
||||
) {}
|
||||
|
||||
public static function clean(?string $engine = null): self
|
||||
{
|
||||
return new self(ScanOutcome::Clean, null, $engine);
|
||||
}
|
||||
|
||||
public static function infected(string $threat, ?string $engine = null): self
|
||||
{
|
||||
return new self(ScanOutcome::Infected, $threat, $engine);
|
||||
}
|
||||
|
||||
public static function tooLarge(?string $engine = null): self
|
||||
{
|
||||
return new self(ScanOutcome::TooLarge, null, $engine);
|
||||
}
|
||||
|
||||
public static function encrypted(?string $engine = null): self
|
||||
{
|
||||
return new self(ScanOutcome::Encrypted, null, $engine);
|
||||
}
|
||||
|
||||
/** The file could not be read, so nothing was scanned. */
|
||||
public static function unreadable(string $reason): self
|
||||
{
|
||||
return new self(ScanOutcome::Unreadable, $reason);
|
||||
}
|
||||
|
||||
/** The scanner could not be reached, or did not answer in time. */
|
||||
public static function unavailable(string $reason): self
|
||||
{
|
||||
return new self(ScanOutcome::Unavailable, $reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Scanning;
|
||||
|
||||
/**
|
||||
* Whether a scanner address is one, and not merely one PHP will accept.
|
||||
*
|
||||
* `stream_socket_client()` reads a port the way `atoi` does: it takes the
|
||||
* digits at the front and ignores whatever follows. So
|
||||
* `tcp://clamav:3310djlkasjdlk` connects happily to port 3310, and an
|
||||
* address with a typo on the end is saved, tested, and reported as
|
||||
* working — until the day something parses it differently. Meanwhile
|
||||
* `tcp://clamav:33101` goes somewhere else entirely and fails, so the
|
||||
* feedback an operator gets is inconsistent with the mistake they made.
|
||||
*
|
||||
* This refuses both, and says so while the field is still on screen.
|
||||
*/
|
||||
final class ScannerAddress
|
||||
{
|
||||
/**
|
||||
* A TCP address: host, then a port of one to five digits and nothing
|
||||
* after it. The host is a hostname, an IPv4 address, or an IPv6
|
||||
* address in brackets — the three forms PHP itself accepts.
|
||||
*/
|
||||
private const TCP = '#^tcp://(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9._-]+):([0-9]{1,5})$#';
|
||||
|
||||
/** A Unix socket: an absolute path, and nothing clever. */
|
||||
private const UNIX = '#^unix://(/[^\x00]+)$#';
|
||||
|
||||
public static function isValid(string $address): bool
|
||||
{
|
||||
$address = trim($address);
|
||||
|
||||
if (preg_match(self::UNIX, $address) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preg_match(self::TCP, $address, $matches) !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$port = (int) $matches[1];
|
||||
|
||||
return $port >= 1 && $port <= 65535;
|
||||
}
|
||||
|
||||
/**
|
||||
* English, and the translation key: what to type instead.
|
||||
*/
|
||||
public static function message(): string
|
||||
{
|
||||
return 'Enter the scanner as tcp://host:3310 or unix:///path/to/clamd.sock.';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Scanning;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* What the scanner said about itself — for the Test button, the dashboard
|
||||
* warning and `projectsend:status`.
|
||||
*/
|
||||
final class ScannerStatus
|
||||
{
|
||||
public function __construct(
|
||||
public readonly bool $reachable,
|
||||
/** e.g. "ClamAV 1.4.1", or null when unreachable. */
|
||||
public readonly ?string $engine = null,
|
||||
/** The signature database number, when the scanner reports one. */
|
||||
public readonly ?int $definitionsVersion = null,
|
||||
public readonly ?Carbon $definitionsDate = null,
|
||||
/** Why it could not be reached, for a person to act on. */
|
||||
public readonly ?string $error = null,
|
||||
) {}
|
||||
|
||||
public static function unreachable(string $error): self
|
||||
{
|
||||
return new self(false, error: $error);
|
||||
}
|
||||
|
||||
/**
|
||||
* How old the definitions are, in hours. Null when the scanner does
|
||||
* not say — absent and zero are different answers, and a caller
|
||||
* warning on "older than three days" must not treat "did not say" as
|
||||
* "brand new".
|
||||
*/
|
||||
public function definitionsAgeHours(): ?int
|
||||
{
|
||||
if ($this->definitionsDate === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// diffInHours() answers with a float; whole hours is what the
|
||||
// warning threshold and the status document both speak in.
|
||||
return (int) $this->definitionsDate->diffInHours(now());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Scanning;
|
||||
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
|
||||
/**
|
||||
* What this installation's scanning setup actually is, once the managed
|
||||
* configuration and the settings screen have both had their say.
|
||||
*
|
||||
* The rule is the one Captcha::resolve() already follows: an address
|
||||
* named in the environment wins, and where it wins the screen stops
|
||||
* offering the choice. That is how a hosted fleet points every site at one
|
||||
* scanning service without a per-site setting to get wrong, and it is
|
||||
* deliberately not an edition check — a self-hosted operator who prefers
|
||||
* to configure this in the environment gets the same behaviour. Edition
|
||||
* differences flow through the capability registry; this is not one.
|
||||
*
|
||||
* The two policies stay editable either way. What to do with a file that
|
||||
* cannot be scanned, and what to do while the scanner is down, are
|
||||
* decisions about somebody's own files.
|
||||
*/
|
||||
class ScanningConfig
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Settings $settings,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Whether new uploads are scanned at all.
|
||||
*
|
||||
* Forced on under a managed configuration: a platform that supplies
|
||||
* the scanner is not offering the tenant a switch for it.
|
||||
*/
|
||||
public function enabled(): bool
|
||||
{
|
||||
return $this->isManaged() || $this->settings->get(Setting::VirusScanningEnabled) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* An address to use instead of the stored one, for this request only.
|
||||
*
|
||||
* The Test button exists to answer "is *this* address right?", and
|
||||
* the address in question is the one being typed — testing what is
|
||||
* saved would make the button useless exactly when it is needed, on
|
||||
* the first attempt, before anything is saved. Set by
|
||||
* VirusScanningSettingsController::test() and never persisted.
|
||||
*/
|
||||
private ?string $preview = null;
|
||||
|
||||
public function preview(string $address): void
|
||||
{
|
||||
$this->preview = trim($address);
|
||||
}
|
||||
|
||||
public function isManaged(): bool
|
||||
{
|
||||
return $this->managedAddress() !== '';
|
||||
}
|
||||
|
||||
public function address(): string
|
||||
{
|
||||
// Ahead of the managed address too: an operator on a managed
|
||||
// installation has no field to type in, so nothing sets this
|
||||
// there — and where something does, it was asked for.
|
||||
if ($this->preview !== null && $this->preview !== '') {
|
||||
return $this->preview;
|
||||
}
|
||||
|
||||
if ($this->isManaged()) {
|
||||
return $this->managedAddress();
|
||||
}
|
||||
|
||||
$stored = $this->settings->get(Setting::VirusScannerAddress);
|
||||
|
||||
return is_string($stored) ? trim($stored) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The largest file this installation sends to the scanner, in bytes.
|
||||
* Zero means no limit of our own — clamd's StreamMaxLength still
|
||||
* applies, and answers with tooLarge when it is reached.
|
||||
*/
|
||||
public function maxScanBytes(): int
|
||||
{
|
||||
return max(0, (int) $this->settings->get(Setting::VirusScanMaxSizeMb)) * 1024 * 1024;
|
||||
}
|
||||
|
||||
/** What happens to a file the scanner could not open. */
|
||||
public function blocksUnscannable(): bool
|
||||
{
|
||||
return $this->settings->get(Setting::VirusUnscannablePolicy) === 'block';
|
||||
}
|
||||
|
||||
/** Whether uploads wait for a scanner that is not answering. */
|
||||
public function holdsWhileUnavailable(): bool
|
||||
{
|
||||
return $this->settings->get(Setting::VirusScannerDownPolicy) === 'hold';
|
||||
}
|
||||
|
||||
/** How long a file waits for an unreachable scanner before the policy applies. */
|
||||
public function unavailableWaitMinutes(): int
|
||||
{
|
||||
return max(1, (int) $this->settings->get(Setting::VirusScannerWaitMinutes));
|
||||
}
|
||||
|
||||
/** How many already-stored files an hour-long backfill may scan per minute. */
|
||||
public function existingScanRatePerMinute(): int
|
||||
{
|
||||
return max(1, (int) $this->settings->get(Setting::VirusScanExistingRatePerMinute));
|
||||
}
|
||||
|
||||
public function connectTimeoutSeconds(): int
|
||||
{
|
||||
return max(1, (int) config('projectsend.scanning.connect_timeout', 5));
|
||||
}
|
||||
|
||||
public function replyTimeoutSeconds(): int
|
||||
{
|
||||
return max(1, (int) config('projectsend.scanning.reply_timeout', 600));
|
||||
}
|
||||
|
||||
private function managedAddress(): string
|
||||
{
|
||||
$address = config('projectsend.scanning.address', '');
|
||||
|
||||
return is_string($address) ? trim($address) : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Scanning;
|
||||
|
||||
/**
|
||||
* The seam between this application and whatever actually reads the
|
||||
* bytes.
|
||||
*
|
||||
* One implementation ships (ClamAvScanner) and one more lives in the test
|
||||
* suite. It exists as an interface because a commercial engine is a
|
||||
* plausible later addition and because every test that is *about* policy
|
||||
* — what happens to a file the scanner could not open — should be able to
|
||||
* state the verdict rather than produce a file that provokes it.
|
||||
*/
|
||||
interface VirusScanner
|
||||
{
|
||||
/**
|
||||
* Read a file and say what it is.
|
||||
*
|
||||
* Implementations never throw for a scanner that is down or slow:
|
||||
* that is ScanVerdict::unavailable(), because the caller has a policy
|
||||
* for it and an exception would look like a bug in the job.
|
||||
*
|
||||
* @param resource $stream the file's bytes, at position 0
|
||||
* @param int $size the file's size in bytes
|
||||
*/
|
||||
public function scan(mixed $stream, int $size): ScanVerdict;
|
||||
|
||||
/**
|
||||
* Whether the scanner answers, and what it is running.
|
||||
*/
|
||||
public function status(): ScannerStatus;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Scanning\FileAvailability;
|
||||
use App\Modules\Files\Models\FileAssignment;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use App\Modules\Notifications\NotificationDigester;
|
||||
@@ -35,6 +36,7 @@ class FileSharing
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly NotificationDigester $digester,
|
||||
private readonly Notifier $notifier,
|
||||
private readonly FileAvailability $availability,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -51,6 +53,18 @@ class FileSharing
|
||||
|
||||
$this->activity->log(Action::FileAssigned, subject: $file, context: ['target' => $targetName]);
|
||||
|
||||
// Sharing itself is never held up — the assignment above is
|
||||
// written, and the file is theirs the moment it can be had. What
|
||||
// waits is the telling: a file still being checked for viruses
|
||||
// cannot be downloaded, so an email now would send somebody to a
|
||||
// page that refuses them, and a file about to be quarantined would
|
||||
// have been announced to everyone before anybody knew. The
|
||||
// announcement goes out from AnnounceAvailableFile instead, on the
|
||||
// event that says the file can be handed over.
|
||||
if (! $this->availability->isAvailable($file)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$recipients = $this->recipients($assignable);
|
||||
|
||||
$this->notifier->send('file_shared', $recipients, subject: $file, data: ['itemName' => $file->name]);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Sharing;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Files\Models\FolderAssignment;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use App\Modules\Notifications\NotificationDigester;
|
||||
use App\Modules\Notifications\Notifier;
|
||||
|
||||
/**
|
||||
* What actually happens when a folder is shared with a client or a group —
|
||||
* the assignment row, the activity entry, the in-app notification and the
|
||||
* debounced digest email, in that order. The folder twin of FileSharing.
|
||||
*
|
||||
* Extracted for the same reason FileSharing was: the web controller and the
|
||||
* API controller must not be able to answer the question differently. The
|
||||
* AI connector in the hosted edition repeated these four steps too, because
|
||||
* there was nothing here to call.
|
||||
*
|
||||
* Unlike a file, a folder has no scan to wait for: the files inside it are
|
||||
* held back individually until they can be had, and sharing the folder
|
||||
* does not change that. So the telling is never deferred here.
|
||||
*
|
||||
* Authorization is the caller's job — both callers reach this after
|
||||
* Gate::authorize('update', $folder), and the target has already been
|
||||
* resolved and scope-checked by ResolvesShareTargets.
|
||||
*/
|
||||
class FolderSharing
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly NotificationDigester $digester,
|
||||
private readonly Notifier $notifier,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Idempotent for the row: sharing the same folder with the same target
|
||||
* twice leaves one assignment, which matters for an API caller retrying
|
||||
* a request.
|
||||
*/
|
||||
public function assign(Folder $folder, User|Group $assignable, string $targetName): void
|
||||
{
|
||||
FolderAssignment::query()->firstOrCreate([
|
||||
'folder_id' => $folder->id,
|
||||
'assignable_type' => $assignable->getMorphClass(),
|
||||
'assignable_id' => $assignable->getKey(),
|
||||
]);
|
||||
|
||||
$this->activity->log(Action::FolderShared, subject: $folder, context: ['target' => $targetName]);
|
||||
|
||||
$recipients = $this->recipients($assignable);
|
||||
|
||||
$this->notifier->send('file_shared', $recipients, subject: $folder, data: ['itemName' => $folder->name]);
|
||||
|
||||
// The master switch and each recipient's own preference are the
|
||||
// digester's job now — every caller was repeating them.
|
||||
$this->digester->queue('file_shared', $recipients, $folder->name, ['is_folder' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool whether an assignment was actually removed
|
||||
*/
|
||||
public function unassign(Folder $folder, User|Group $assignable, string $targetName): bool
|
||||
{
|
||||
$deleted = FolderAssignment::query()
|
||||
->where('folder_id', $folder->id)
|
||||
->where('assignable_type', $assignable->getMorphClass())
|
||||
->where('assignable_id', $assignable->getKey())
|
||||
->delete();
|
||||
|
||||
if ($deleted > 0) {
|
||||
$this->activity->log(Action::FolderUnshared, subject: $folder, context: ['target' => $targetName]);
|
||||
}
|
||||
|
||||
return $deleted > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifier performs no authorization of its own — see its SECURITY
|
||||
* CONTRACT docblock — so the recipient list is resolved here, from the
|
||||
* assignment itself.
|
||||
*
|
||||
* @return iterable<User>
|
||||
*/
|
||||
private function recipients(User|Group $assignable): iterable
|
||||
{
|
||||
return $assignable instanceof Group ? $assignable->members : [$assignable];
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ 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;
|
||||
|
||||
@@ -102,12 +104,111 @@ 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);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user