mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-04 05:25:51 +00:00
Compare commits
307 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 | |||
| 96107fdcd5 | |||
| eecd5b804d | |||
| 616aa49867 | |||
| 525c464327 | |||
| 97596da7d0 | |||
| 2ebadf0793 | |||
| 25e4f77b63 | |||
| 90ed2d60b9 | |||
| d6fd5a917d | |||
| 6340b71dca | |||
| fb931819e2 | |||
| 41b22d003e | |||
| 78d5067c6b | |||
| b7cc5e8615 | |||
| ed82d748ea | |||
| fe3b7b7018 | |||
| 6783fa0b81 | |||
| 4556ccf691 | |||
| 85572eb45e | |||
| 227a08dfce | |||
| 43e9985b2b | |||
| f931c6a492 | |||
| 1a3260a397 | |||
| 07e7132747 | |||
| f4fd194991 | |||
| ea45943f40 | |||
| ce96313710 | |||
| 77dd5ff90b | |||
| 8984aba7d8 | |||
| 188848b549 | |||
| 7264c44fd7 | |||
| 3e24ccd42f | |||
| 74077993de | |||
| da7eb6f67d | |||
| 35d68a792b | |||
| bde86c10e4 | |||
| c72adadc44 | |||
| 1ed29ec072 | |||
| 9af0d643b1 | |||
| 19ee9d9833 | |||
| cad112522d | |||
| 81bb136e9e | |||
| a2bc3fa163 | |||
| ef6f8fea56 | |||
| 927c8fc991 | |||
| 144f5fc578 | |||
| 383c3b2ff5 | |||
| b9807bf610 | |||
| d91cf97bcb | |||
| 89b3d34c8f | |||
| d09cb602c1 | |||
| b7a94d4479 | |||
| fdcdad7fb2 | |||
| ff26fac9c5 | |||
| a7e883ef70 | |||
| 90009b7029 | |||
| 9508750c60 | |||
| 9c6f4df5bc | |||
| 2903a1da6d | |||
| c11cb3cc63 | |||
| 5117511946 | |||
| b6f4770795 | |||
| 037439e1f2 | |||
| 6b99e37d01 | |||
| f676e09bb2 | |||
| eb3d6e321d | |||
| d89807b237 | |||
| 7ff2674e4f | |||
| 262cb2457a | |||
| a285f86b93 | |||
| bc68a24ef5 | |||
| 1644d634d5 | |||
| abbe9a3acc | |||
| 3dc407a777 | |||
| d8ef21bb6a | |||
| 479dc61d2d | |||
| 530f30606d | |||
| afc2c74617 | |||
| d62c62f788 | |||
| 7be81d3586 | |||
| 92f50fdb85 | |||
| 27c289a4d6 | |||
| 5e60d2ef88 | |||
| 21cae2acb1 | |||
| 2029309126 | |||
| defe488391 | |||
| d83d2d9acb | |||
| fc5651faad | |||
| b838036a9a | |||
| 5a9133bb07 | |||
| 02eafb473b | |||
| 674781e57a | |||
| f39ad46dd6 | |||
| a1773cad5e | |||
| 17fc9ff4cb | |||
| 776d3d99f4 | |||
| 9ddd39c41d | |||
| 19c449ee20 | |||
| 4b998cda92 | |||
| 4164678ebc | |||
| fc758c701a | |||
| f2b705beee | |||
| 250e8664d3 | |||
| 640c5db591 | |||
| e1cd010f9d | |||
| c2dd2c758a | |||
| 9d4b096c19 | |||
| 763777d282 | |||
| f424fe5365 | |||
| cd8da6a117 | |||
| db1dd71f3c | |||
| c8de16101f | |||
| cb53120779 | |||
| 84e9f6e2fe | |||
| 06c364d29a | |||
| 046be36861 | |||
| 4a35c25894 | |||
| 58497ef776 | |||
| d751314196 | |||
| 00d118559d | |||
| abaca20261 | |||
| b16d780ebe | |||
| 602c7bed94 | |||
| 76f79d53a0 | |||
| 3f81dd5eab | |||
| 1cefdee610 | |||
| f2e7820f5c | |||
| a92feed3ad | |||
| 73d93495c9 | |||
| 2eb23dbc07 | |||
| 13b56186f4 | |||
| e272f19045 | |||
| 28e18497b5 | |||
| b44c6bf098 | |||
| eade690f73 | |||
| 3e15237f90 | |||
| 9cc469b111 | |||
| 4469648d82 | |||
| 26205082c2 | |||
| ab6e9eecf3 | |||
| 1dc274e896 | |||
| 7045da7450 | |||
| d58e48301f | |||
| c49811f3c0 | |||
| 351da21e8d | |||
| c172d0d645 |
@@ -4,8 +4,36 @@ PROJECTSEND_EDITION=community
|
|||||||
# Emergency off switch for the CAPTCHA on public forms, for an operator who
|
# 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
|
# has a shell but no working login. Everything else about the feature is
|
||||||
# configured at /system/settings/captcha.
|
# 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
|
# PROJECTSEND_CAPTCHA_DISABLED=true
|
||||||
|
|
||||||
|
# How downloads leave the server. Left unset (or "auto"), ProjectSend hands
|
||||||
|
# files to nginx when it is running behind nginx, and streams them through
|
||||||
|
# PHP on anything else -- which works everywhere but holds a PHP worker for
|
||||||
|
# the whole of each download. Set "xsendfile" for Apache with mod_xsendfile
|
||||||
|
# (or LiteSpeed) once XSendFilePath allows storage/app/files, "nginx" when
|
||||||
|
# an nginx proxy in front is the one serving /protected-files/, or "php" to
|
||||||
|
# stream deliberately. The dashboard's System panel shows which is in use.
|
||||||
|
# PROJECTSEND_FILE_DELIVERY=auto
|
||||||
|
|
||||||
|
# Optional: the virus scanner every upload is checked against, as
|
||||||
|
# tcp://host:3310 or unix:///path/to/clamd.sock. Naming it here makes
|
||||||
|
# scanning managed: it is used, it cannot be switched off from the settings
|
||||||
|
# screen, and the address does not appear there. Leave it unset to
|
||||||
|
# configure scanning in Settings instead, which is the ordinary way.
|
||||||
|
# PROJECTSEND_SCANNER_ADDRESS=tcp://clamav:3310
|
||||||
|
|
||||||
|
# Optional: the scanner a fresh installation starts out pointed at, written
|
||||||
|
# into the settings on first boot and ignored on every later one. Unlike the
|
||||||
|
# variable above it leaves both the address and the switch on the settings
|
||||||
|
# screen, which is what a self-hosted install wants: configured out of the
|
||||||
|
# box, and still yours.
|
||||||
|
# PROJECTSEND_SCANNER_DEFAULT_ADDRESS=tcp://clamav:3310
|
||||||
|
|
||||||
# Optional: uid/gid the app/web containers' internal user runs as, so the
|
# Optional: uid/gid the app/web containers' internal user runs as, so the
|
||||||
# bind-mounted repo needs no permission fixes. Defaults to 1000; override
|
# bind-mounted repo needs no permission fixes. Defaults to 1000; override
|
||||||
# if your host user's `id -u`/`id -g` differ.
|
# 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
|
# `issue.pull_request` is present only when the comment is on a pull
|
||||||
# request; comments on ordinary issues have nothing for this action to
|
# request; comments on ordinary issues have nothing for this action to
|
||||||
# check.
|
# 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: >-
|
if: >-
|
||||||
github.event_name == 'pull_request_target'
|
github.event_name == 'pull_request_target'
|
||||||
|| (github.event.issue.pull_request
|
|| (github.event.issue.pull_request
|
||||||
&& (github.event.comment.body == 'recheck'
|
&& (startsWith(github.event.comment.body, 'recheck')
|
||||||
|| github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA'))
|
|| contains(github.event.comment.body, 'I have read the CLA Document and I hereby sign the CLA')))
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: CLA check
|
- name: CLA check
|
||||||
@@ -74,6 +81,12 @@ jobs:
|
|||||||
Please read the **[CLA]($pathToCLADocument)**, then post exactly this as a comment
|
Please read the **[CLA]($pathToCLADocument)**, then post exactly this as a comment
|
||||||
on this pull request:
|
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-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.'
|
custom-allsigned-prcomment: 'CLA signed — thanks. A maintainer will review this shortly.'
|
||||||
lock-pullrequest-aftermerge: false
|
lock-pullrequest-aftermerge: false
|
||||||
|
|||||||
@@ -44,12 +44,6 @@ on:
|
|||||||
- 'docker/production/dockerhub-overview.md'
|
- 'docker/production/dockerhub-overview.md'
|
||||||
- '.github/screenshots/**'
|
- '.github/screenshots/**'
|
||||||
|
|
||||||
# A second push supersedes the first: there is no value in finishing a run
|
|
||||||
# for a commit nobody will look at again.
|
|
||||||
concurrency:
|
|
||||||
group: tests-${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
# A second push supersedes the first — the later run covers a superset of
|
# A second push supersedes the first — the later run covers a superset of
|
||||||
# what the earlier one was checking, so finishing both buys nothing and
|
# what the earlier one was checking, so finishing both buys nothing and
|
||||||
# costs a runner.
|
# costs a runner.
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ yarn-error.log
|
|||||||
/database/seeders/DevDataSeeder.php
|
/database/seeders/DevDataSeeder.php
|
||||||
/docs/*.md
|
/docs/*.md
|
||||||
!/docs/api-guide.md
|
!/docs/api-guide.md
|
||||||
|
!/docs/api-modules.md
|
||||||
!/docs/email-oauth.md
|
!/docs/email-oauth.md
|
||||||
!/docs/api-zapier.md
|
!/docs/api-zapier.md
|
||||||
|
|
||||||
@@ -46,3 +47,6 @@ yarn-error.log
|
|||||||
# mkcert certificates and the nginx config that terminates HTTPS on the
|
# mkcert certificates and the nginx config that terminates HTTPS on the
|
||||||
# dev `web` container. Machine-specific, and one of them is a private key.
|
# dev `web` container. Machine-specific, and one of them is a private key.
|
||||||
/docker/web/local/
|
/docker/web/local/
|
||||||
|
|
||||||
|
# Written into an artifact by build-release.sh, never into a checkout.
|
||||||
|
/config/build.php
|
||||||
|
|||||||
+506
-395
@@ -6,12 +6,494 @@ 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
|
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.
|
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
|
Mostly fixes: files and folders are easier to tidy, the sign-in pages carry your brand better, and
|
||||||
a version is cut.
|
someone who deletes their own account stops being published straight away.
|
||||||
|
|
||||||
|
**Added**
|
||||||
|
|
||||||
|
- **Delete several files at once** from the selection bar on the Files page, with one confirmation.
|
||||||
|
- **Upload inside a folder puts the files in that folder**, and brings you back to it afterwards.
|
||||||
|
- **Show your site name under the logo** on the sign-in and download pages, from Branding → Logo.
|
||||||
|
- **Choose what happens to someone's files when they delete their own account**: removed right away,
|
||||||
|
or at the end of the grace period — and whether that applies to everyone or only to clients.
|
||||||
|
Found under Settings → Privacy.
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
|
||||||
|
- **A deleted account's files stop being shared at once.** From the moment someone deletes their own
|
||||||
|
account, their files are visible only to staff — not to the clients and groups they were shared
|
||||||
|
with, not through share links, not on the public pages — until the account is erased. Restoring
|
||||||
|
the account brings them back.
|
||||||
|
- **Your logo is shown larger on the sign-in and download pages**, so a square logo is clearly
|
||||||
|
visible. The Branding screen now says what size to use.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
|
||||||
|
- Saving a settings form could show an error dialog containing `{"count":0}` instead of saving.
|
||||||
|
- A file upload running several parts at once could be refused near the end with "too large".
|
||||||
|
- The System card reported the server's free disk space as file storage on installations that keep
|
||||||
|
files in S3; it now shows the two separately.
|
||||||
|
- Confirming your password no longer throws away the form you were filling in.
|
||||||
|
- LDAP accounts can now get past the password confirmation, which kept them from turning on
|
||||||
|
two-factor authentication.
|
||||||
|
- With per-client folders on, a client choosing "No folder" moved the file out of their own folder.
|
||||||
|
- PHP 8.5 no longer prints deprecation warnings from the database configuration.
|
||||||
|
- The Docker quick start now saves `compose.yaml`, so the `docker compose` commands in the other
|
||||||
|
guides work as written. If you saved `compose.example.yaml`, rename it to `compose.yaml`.
|
||||||
|
- The Docker and migration guides now cover installing Docker, and migrating from a Legacy install
|
||||||
|
on the same machine.
|
||||||
|
|
||||||
|
Thanks to [@JensS](https://github.com/JensS), binghuo, [@lukatong](https://github.com/lukatong),
|
||||||
|
[@jjoelc](https://github.com/jjoelc), [@jiits](https://github.com/jiits),
|
||||||
|
[@0xVavaldi](https://github.com/0xVavaldi) and [@lolgufdHD](https://github.com/lolgufdHD) for
|
||||||
|
reporting and fixing.
|
||||||
|
|
||||||
|
### Issues closed since 2.5.0
|
||||||
|
|
||||||
|
- [#1635](https://github.com/projectsend/projectsend/issues/1635) — Docs: consider moving the Docker quick start above screenshots
|
||||||
|
- [#1795](https://github.com/projectsend/projectsend/issues/1795) — Slightly confused regarding the reviews
|
||||||
|
- [#1796](https://github.com/projectsend/projectsend/issues/1796) — PHP 8.5.10: PDO::MYSQL_ATTR_SSL_CA Deprecated Warning
|
||||||
|
- [#1799](https://github.com/projectsend/projectsend/issues/1799) — Inertia error from notifications/unread-count because of JSON response
|
||||||
|
- [#1800](https://github.com/projectsend/projectsend/issues/1800) — Add Bulk-Edit for Moving and Deleting files
|
||||||
|
- [#1801](https://github.com/projectsend/projectsend/issues/1801) — Add Upload into Folder
|
||||||
|
|
||||||
|
## 2.5.0 — 18 September 2026
|
||||||
|
|
||||||
|
**Added**
|
||||||
|
|
||||||
|
- **Uploaded files can be checked for viruses before anyone can download them.**
|
||||||
|
- **Files the virus scanner refuses go to a Quarantine screen, where you can read the verdict and
|
||||||
|
release a file if the report is wrong.**
|
||||||
|
- **You choose what happens to a file the scanner cannot open, and to uploads arriving while the
|
||||||
|
scanner is unreachable.**
|
||||||
|
- **A Test button says which scanner answered and how old its virus definitions are, and fails a
|
||||||
|
scanner that reports password-protected archives as clean.**
|
||||||
|
- **The dashboard says when virus scanning has stopped protecting anything.**
|
||||||
|
- **Files uploaded before scanning was switched on can be worked through in the background, at a
|
||||||
|
pace you set.**
|
||||||
|
- **Invite a client to set their own password instead of handing them one.** Contributed by
|
||||||
|
[@mash2k3](https://github.com/mash2k3).
|
||||||
|
- **Staff who administer clients are notified in the bell when a client account appears.**
|
||||||
|
- **Client accounts can expire on a date.** Requested by
|
||||||
|
[@Drardollan](https://github.com/Drardollan) in
|
||||||
|
[#1310](https://github.com/projectsend/projectsend/issues/1310).
|
||||||
|
- **Each role, and each person, can choose where they land after signing in.** Requested by
|
||||||
|
[@Zodiac1978](https://github.com/Zodiac1978) in
|
||||||
|
[#1777](https://github.com/projectsend/projectsend/issues/1777).
|
||||||
|
- **Each client can be given a folder of their own, which becomes their root.**
|
||||||
|
- **The file library filters by uploader, by the uploader's role, by public or private, by whether
|
||||||
|
a file was ever downloaded, and by current or outdated version.**
|
||||||
|
- **The same five filters are available through the API.**
|
||||||
|
- **Files whose bytes have gone missing from storage are found daily and listed on their own
|
||||||
|
screen.**
|
||||||
|
- **Clients see on each file the date it stops being available.**
|
||||||
|
- **Clients can make a public link for a file they uploaded**, where their role may publish files —
|
||||||
|
the switch that marks a file public now comes with the link it promises, and they can copy or
|
||||||
|
revoke it. Reported by Ricardo Cazati.
|
||||||
|
- **A public page says when the file on it was never checked for viruses**, which happens where an
|
||||||
|
installation scans and chooses to let through what the scanner could not read.
|
||||||
|
|
||||||
|
**Closed holes in who can see what**
|
||||||
|
|
||||||
|
- **A staff member limited to certain clients can no longer reach past that limit through an
|
||||||
|
invitation.** The invitation form listed every group on the installation, and an invited client
|
||||||
|
could be pre-assigned into a group outside that staff member's own clients. Reported by
|
||||||
|
[@hackchang](https://github.com/hackchang).
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
|
||||||
|
- **Setting a folder inside the bucket no longer breaks every page that touches storage.** Reported
|
||||||
|
by [@veenone](https://github.com/veenone) in
|
||||||
|
[#1788](https://github.com/projectsend/projectsend/issues/1788).
|
||||||
|
- **The Microsoft sign-in settings now say why new accounts still wait for approval.** They wait
|
||||||
|
until the `xms_edov` optional claim is added to the app registration, because until then Microsoft
|
||||||
|
does not confirm that the person owns the address — which is a rule ProjectSend already had and
|
||||||
|
nothing on screen said. Reported by Ricardo Cazati.
|
||||||
|
- **Someone who signs in with Microsoft, Google or another provider can now set a password.** The
|
||||||
|
screen asked for the current one, which such an account never had — so it could not get a
|
||||||
|
password, and therefore could not turn on two-factor authentication. Where two-factor is
|
||||||
|
compulsory, that locked those accounts out of everything. Reported by Ricardo Cazati.
|
||||||
|
- **Creating a client with a storage quota typed in no longer answers with an error page.**
|
||||||
|
- **A file expiry date sent to the API as a number now answers 422 instead of 500.**
|
||||||
|
- **A quarantined file, or one missing from storage, is no longer listed in the library with a
|
||||||
|
download that cannot work.**
|
||||||
|
- A dependency advisory in the bundled `js-yaml`.
|
||||||
|
|
||||||
|
### Issues closed since 2.4.1
|
||||||
|
|
||||||
|
- [#1310](https://github.com/projectsend/projectsend/issues/1310) — expire client accounts
|
||||||
|
- [#1658](https://github.com/projectsend/projectsend/issues/1658) — Docker services have no restart
|
||||||
|
policy
|
||||||
|
- [#1770](https://github.com/projectsend/projectsend/issues/1770) — Docker upgrade fails with
|
||||||
|
external storage configured
|
||||||
|
- [#1776](https://github.com/projectsend/projectsend/issues/1776) — logo gives error 404
|
||||||
|
- [#1777](https://github.com/projectsend/projectsend/issues/1777) — customization / branding
|
||||||
|
(start pages)
|
||||||
|
- [#1778](https://github.com/projectsend/projectsend/issues/1778) — error 500 on install
|
||||||
|
- [#1779](https://github.com/projectsend/projectsend/issues/1779) — client invitations
|
||||||
|
- [#1788](https://github.com/projectsend/projectsend/issues/1788) — a bucket folder makes every
|
||||||
|
storage page a 500
|
||||||
|
- [#1789](https://github.com/projectsend/projectsend/issues/1789) — S3 not working after an update
|
||||||
|
|
||||||
|
## 2.4.1 — 11 September 2026
|
||||||
|
|
||||||
|
### ⚠️ Important — do these yourself
|
||||||
|
|
||||||
|
Everything else in this release happens on its own. These do not: each one leaves something working
|
||||||
|
differently from how you expect until you act on it. Nothing here stops the upgrade or the
|
||||||
|
installation from starting.
|
||||||
|
|
||||||
|
- **If you set `PROJECTSEND_CAPTCHA_DISABLED`, check what you set it to.** Only `true` or `1`
|
||||||
|
switches the CAPTCHA off now. Anything else — including `no`, `off`, `yes` and a misspelling —
|
||||||
|
used to be read as "yes, disabled" and is now read as "leave it on". If you meant it off, write
|
||||||
|
`true`.
|
||||||
|
- **If a staff role uploads into public folders, give it "Upload to public folders".** That
|
||||||
|
permission was not being asked of staff, and now is. Roles holding "Upload public files" are
|
||||||
|
unaffected, and ordinary uploads need nothing new.
|
||||||
|
- **If you use Microsoft sign-in, add the `xms_edov` optional claim to your app registration.** In
|
||||||
|
the Entra portal: your app registration → Token configuration → Add optional claim → ID →
|
||||||
|
`xms_edov`. Until you do, Microsoft sign-in keeps working and keeps creating new accounts, but it
|
||||||
|
will no longer attach itself to an account that already exists.
|
||||||
|
|
||||||
|
**Added**
|
||||||
|
|
||||||
|
- **Your logo now appears on the sign-in screen.** Requested by
|
||||||
|
[@Zodiac1978](https://github.com/Zodiac1978) in
|
||||||
|
[#1777](https://github.com/projectsend/projectsend/issues/1777).
|
||||||
|
- **An installation on AWS can authenticate as its own IAM role instead of storing an access key.**
|
||||||
|
- **Clients can now see how often their own files were downloaded, and when.**
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
|
||||||
|
- **A lookalike domain can no longer hand somebody else's account to an OIDC sign-in.** Reported by
|
||||||
|
[@choewonwoo1817](https://github.com/choewonwoo1817).
|
||||||
|
- **Changing your own email address now asks for your password.** Reported by
|
||||||
|
[@Noorkhalel](https://github.com/Noorkhalel).
|
||||||
|
- **Microsoft sign-in now checks that the person owns the address they presented.** Reported by
|
||||||
|
[@archnexus707](https://github.com/archnexus707).
|
||||||
|
- **Two people filling in the first-run setup screen at the same moment can no longer both become
|
||||||
|
administrators.** Reported by [@ry2811](https://github.com/ry2811).
|
||||||
|
- **Uploading into a public folder now needs a permission that says so.** Reported by
|
||||||
|
[@skeletonsec](https://github.com/skeletonsec).
|
||||||
|
- **Moving a file into a public folder now needs that same permission.** Reported by
|
||||||
|
[@skeletonsec](https://github.com/skeletonsec).
|
||||||
|
- **A staff member limited to some clients can no longer see or change other people's groups.**
|
||||||
|
Reported by [@Drescargot](https://github.com/Drescargot).
|
||||||
|
- **Deleting a client can no longer hand their files to a client you do not manage.** Reported by
|
||||||
|
[@skeletonsec](https://github.com/skeletonsec).
|
||||||
|
- **Erasing a staff account no longer hands their files to a client.**
|
||||||
|
- **An interrupted upload can no longer park unlimited bytes on the server.** Reported by
|
||||||
|
[@ry2811](https://github.com/ry2811).
|
||||||
|
- **The password reset screen no longer says whether an email address has an account here.**
|
||||||
|
- **An expired password reset link now says so before asking for a new password.**
|
||||||
|
- **A Docker upgrade no longer fails when external storage is already configured.**
|
||||||
|
[#1770](https://github.com/projectsend/projectsend/issues/1770).
|
||||||
|
- **A public gallery no longer renders the same thumbnail several times at once.**
|
||||||
|
- **`PROJECTSEND_CAPTCHA_DISABLED` no longer reads a "no" as a "yes".**
|
||||||
|
|
||||||
|
### Issues closed since 2.4.0
|
||||||
|
|
||||||
|
The summary above is what changed. This is the paper trail, for anyone who wants to read the
|
||||||
|
original report.
|
||||||
|
|
||||||
|
- [#1768](https://github.com/projectsend/projectsend/issues/1768) — Search in file not restricted in directory
|
||||||
|
- [#1773](https://github.com/projectsend/projectsend/issues/1773) — Feature Request : Support AWS IAM roles / default credential provider chain for S3 storage
|
||||||
|
- [#1774](https://github.com/projectsend/projectsend/issues/1774) — HTTP Error by upload on R2098
|
||||||
|
- [#1778](https://github.com/projectsend/projectsend/issues/1778) — [Documentation] Error 500 on install
|
||||||
|
|
||||||
|
## 2.4.0 — 8 September 2026
|
||||||
|
|
||||||
|
Clients can now look after the files they uploaded, and this release closes three ways somebody
|
||||||
|
could see a little more than they should.
|
||||||
|
|
||||||
|
**New**
|
||||||
|
|
||||||
|
- **Clients can edit and delete the files they uploaded**, with the name, description, expiry,
|
||||||
|
categories, download limit and public flag each behind the permission that already governs it.
|
||||||
|
A file shared *with* a client is still not theirs to touch.
|
||||||
|
- **A switch to stop this installation fetching the project news**, on Settings → General. On by
|
||||||
|
default; off means the request is never made.
|
||||||
|
|
||||||
|
**Closed holes in who can see what**
|
||||||
|
|
||||||
|
- A staff member limited to their assigned clients could read other clients' names, and their IDs,
|
||||||
|
out of file details and the uploader filter. Reported by
|
||||||
|
[@Noorkhalel](https://github.com/Noorkhalel) (GHSA-whmp-p9hv-r7j7).
|
||||||
|
- Download links to external storage now last a minute instead of an hour. Previews keep the hour.
|
||||||
|
- Eight advisories in bundled dependencies, including an XSS bypass in the markdown renderer that
|
||||||
|
builds your email templates.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
|
||||||
|
- A failed upload keeps its parts, so retrying it works instead of needing the whole file again.
|
||||||
|
- `projectsend:captcha-off` no longer claims success on an installation whose CAPTCHA keys are
|
||||||
|
supplied centrally, where it changed nothing.
|
||||||
|
|
||||||
|
### Upgrade notes
|
||||||
|
|
||||||
|
- **Resuming an interrupted download from external storage more than a minute after it started now
|
||||||
|
fails.** Start it again from ProjectSend. Local-disk installations and zip bundles are unaffected.
|
||||||
|
- **If your temporary directory is on a small or separate volume, allow headroom for twice your
|
||||||
|
largest allowed upload.** Only while a file is being assembled, and nothing needs configuring.
|
||||||
|
|
||||||
|
Thanks to [@Noorkhalel](https://github.com/Noorkhalel), [@denkfabrik-li](https://github.com/denkfabrik-li)
|
||||||
|
and [@mehmedturk](https://github.com/mehmedturk) for reporting and fixing.
|
||||||
|
|
||||||
|
### Issues closed since 2.3.0
|
||||||
|
|
||||||
|
The summary above is what changed. This is the paper trail, for anyone who wants to read the
|
||||||
|
original report.
|
||||||
|
|
||||||
|
- [#1765](https://github.com/projectsend/projectsend/issues/1765) — Projectsend 2.2.1 thumbnail issue after file upload
|
||||||
|
- [#1771](https://github.com/projectsend/projectsend/issues/1771) — Permissions granted to the Client role are not applied to client accounts
|
||||||
|
|
||||||
|
## 2.3.0 — 1 September 2026
|
||||||
|
|
||||||
|
If you run ProjectSend on Apache or LiteSpeed, this is the release to take. It installed fine on
|
||||||
|
both before. Then every download arrived empty and every thumbnail was broken. That is fixed, and
|
||||||
|
you do not have to configure anything. Installations on nginx were never affected and nothing
|
||||||
|
changes for them.
|
||||||
|
|
||||||
|
The rest is mostly security work. Most of it is the same kind of thing: a screen or an API endpoint
|
||||||
|
that showed a little more than the person asking was allowed to see.
|
||||||
|
|
||||||
|
**New**
|
||||||
|
|
||||||
|
- **Downloads work on any web server.** Your files sit outside the web root, so ProjectSend checks
|
||||||
|
permission on every download before anything is sent. The fast way to finish is to hand the file
|
||||||
|
to the web server. Each web server wants that asked for differently, and until now ProjectSend
|
||||||
|
only knew how to ask nginx. On Apache and LiteSpeed it asked anyway, nothing answered, and the
|
||||||
|
visitor got an empty file. Now it works out what it is talking to. If it cannot hand the file
|
||||||
|
over, it sends the file itself, which is slower under load but works everywhere.
|
||||||
|
- **Apache and LiteSpeed can still have the fast version.** Install `mod_xsendfile` (LiteSpeed
|
||||||
|
needs no module), point `XSendFilePath` at your storage directory, and set
|
||||||
|
`PROJECTSEND_FILE_DELIVERY=xsendfile`. See the upgrade notes.
|
||||||
|
- **The dashboard tells you which way downloads are going out.** If PHP is sending them, there is a
|
||||||
|
warning next to it and a short explanation of what that costs you and how to change it. This is
|
||||||
|
the kind of thing that is invisible until the day the site falls over, so it says so up front.
|
||||||
|
- **Your logo and your watermark, on every installation.** Upload a logo and it replaces ours in
|
||||||
|
the sidebar and on your public pages. Add a watermark and it goes on the thumbnails and previews
|
||||||
|
your clients and visitors see. Staff still see the originals, and the watermark is never written
|
||||||
|
into the stored file, so you can turn it off again.
|
||||||
|
- **You can find out which build you are running.** Two images can say "2.2.1" and contain
|
||||||
|
different code. `projectsend:status` now reports the commit it was built from.
|
||||||
|
- **You will know if the nightly jobs stop running.** When the scheduler dies, nothing looks wrong.
|
||||||
|
You find out weeks later, when a file you expired is still downloadable. ProjectSend now reports
|
||||||
|
when its scheduled work last ran and whether any of it failed.
|
||||||
|
- **You get told when the mailbox stops working**, even when a send noticed the problem before the
|
||||||
|
scheduled check did.
|
||||||
|
|
||||||
|
**Closed holes in who can see what**
|
||||||
|
|
||||||
|
- [#1745](https://github.com/projectsend/projectsend/pull/1745) — Gate the comment moderation
|
||||||
|
surfaces on reading, not just on the library. Permission to moderate comments was letting somebody
|
||||||
|
read them, which is not the same thing: on the moderation screen and through the API, a role that
|
||||||
|
could moderate comments but could not open any file was shown every comment in the installation —
|
||||||
|
the text, staff-only notes, the client each conversation belongs to, and a visitor's IP address —
|
||||||
|
about files it would be refused on. Approving a comment over the API handed back its body the same
|
||||||
|
way.
|
||||||
|
|
||||||
|
**Who this affected.** Only installations with a custom role built that way. None of the roles
|
||||||
|
ProjectSend ships is affected: Account Manager, the only one that moderates comments, can read
|
||||||
|
files as well, and so can a System Administrator. If you did build such a role, it can no longer
|
||||||
|
moderate — give it one of the file permissions (upload, edit files, or edit other people's files)
|
||||||
|
and it works again, now seeing only the comments on files it can actually open.
|
||||||
|
|
||||||
|
- [#1759](https://github.com/projectsend/projectsend/pull/1759) — Publish the example Docker
|
||||||
|
quickstart on the loopback address instead of every network interface. The example set
|
||||||
|
`TRUSTED_PROXIES: "*"`, which tells ProjectSend to believe the client address forwarded by
|
||||||
|
whoever connects to it. That is right behind a reverse proxy and wrong when anyone can reach the
|
||||||
|
container directly, because then anyone can claim any address: enough to walk past the login
|
||||||
|
lockout, every rate limit, and the address written to the download log and to guest comments.
|
||||||
|
|
||||||
|
**Who this affected.** Installations started from `compose.example.yaml` or from the Docker Hub
|
||||||
|
page, where port 8080 was reachable from outside the machine. A published Docker port is not
|
||||||
|
covered by a host firewall such as `ufw`, so this was often open without anyone intending it.
|
||||||
|
|
||||||
|
- [#1760](https://github.com/projectsend/projectsend/pull/1760) — Have the Docker image default to
|
||||||
|
production. On first boot the image copied its settings from the development template, which sets
|
||||||
|
`APP_ENV=local` and `APP_DEBUG=true`. Two things followed that you could not see from inside the
|
||||||
|
application: every server error showed its stack trace — file, line and surrounding source — to
|
||||||
|
whoever triggered it, signed in or not; and **"reject known-breached passwords" never actually
|
||||||
|
ran**, while the security settings screen went on reporting it as switched on.
|
||||||
|
|
||||||
|
**Who this affected.** Anyone who started the container without setting those two values: a plain
|
||||||
|
`docker run` with a database address, the Portainer, unRAID and TrueNAS templates, or a Kubernetes
|
||||||
|
manifest naming only the database and `APP_URL`. Installations using `compose.example.yaml`, which
|
||||||
|
sets both correctly, were never affected.
|
||||||
|
|
||||||
|
- The client portal dashboard lists only files that client can open. The API dashboard's recent
|
||||||
|
activity is cut the same way.
|
||||||
|
- Three lists were showing more than the viewer was allowed to see: the reassignment picker, the
|
||||||
|
account conversion list, and the membership an API member write handed back.
|
||||||
|
- Mail and storage credentials no longer end up in the boot configuration cache. A settings form
|
||||||
|
that gets rejected no longer sends the credential back to the browser.
|
||||||
|
- Connecting a sign-in provider asks for your password again. Every password prompt in front of an
|
||||||
|
account now has its own rate limit instead of sharing one. A two-factor code is claimed in a
|
||||||
|
single step, so the same code cannot be used twice.
|
||||||
|
- An expired file no longer locks a whole group shut for staff assigned to particular clients. A
|
||||||
|
shared folder's contents count towards what a client can reach. A client is added to the roster
|
||||||
|
of the staff member who created them.
|
||||||
|
- Whether something is an API request is decided by the route, not by a header the caller sets.
|
||||||
|
- The interface font is served from your own installation. Loading a page no longer tells a font
|
||||||
|
CDN who is reading it.
|
||||||
|
- A stored filename can no longer push a control character into a response header.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
|
||||||
|
- The zip progress bar stops polling when you leave the page.
|
||||||
|
- A zip that fails to build no longer tells the person who asked for it why, in the server's words.
|
||||||
|
- Previews are written to a temporary file first, so a half-written one is never served. A file's
|
||||||
|
previews are deleted even when its storage cannot be reached.
|
||||||
|
- An expiry date no longer moves because somebody else saved the file at the same time. Setting one
|
||||||
|
through the API means what it means on the web form.
|
||||||
|
- Updating a client through the API no longer wipes custom fields the request never mentioned.
|
||||||
|
- The transfers chart lines up with the timezone its data is stored in.
|
||||||
|
- Creating an account over a deleted one's email address is refused instead of crashing.
|
||||||
|
- A comment still shows who wrote it after that account is deleted.
|
||||||
|
- Marking a file as a new version no longer emails people about a file they already had.
|
||||||
|
- The password reset and confirm-password screens say where the account's password actually lives,
|
||||||
|
which matters if you use LDAP or a sign-in provider.
|
||||||
|
- A refused upload names the quota you are actually up against. A bulk edit that is refused says
|
||||||
|
which permission was missing.
|
||||||
|
- Uploaded folders get the permissions the storage library actually asks for.
|
||||||
|
- The public preview log no longer records the same view repeatedly.
|
||||||
|
- Updating with `update.sh` no longer silently switches off route, event and view caching. The
|
||||||
|
script wiped the compiled caches while replacing the files, which is also how ProjectSend
|
||||||
|
recognised that you had cached them in the first place — so it rebuilt nothing, and every update
|
||||||
|
quietly left the site slower than the install instructions promised.
|
||||||
|
- Every new screen in this release is translated into all sixteen languages.
|
||||||
|
|
||||||
|
**Before you upgrade, read the notes below.**
|
||||||
|
|
||||||
|
### Upgrade notes
|
||||||
|
|
||||||
|
- **This upgrade adds two indexes to the activity log, and on a big installation that takes
|
||||||
|
minutes.** It is the slowest part. Nothing goes offline while it runs — the application keeps
|
||||||
|
answering — but do not expect the migration to finish in seconds.
|
||||||
|
- **On Apache or LiteSpeed you need to do nothing, but there is something worth doing.** Downloads
|
||||||
|
will start working on their own. PHP will be sending them, which ties up a worker process for the
|
||||||
|
whole of each download. That is fine on a quiet site and not fine on a busy one. To move to the
|
||||||
|
fast path: install `mod_xsendfile` (LiteSpeed needs no module), allow your storage directory with
|
||||||
|
`XSendFilePath`, then set `PROJECTSEND_FILE_DELIVERY=xsendfile` in `.env`. The dashboard will
|
||||||
|
confirm the change.
|
||||||
|
|
||||||
|
- **If you copied the example Docker file, `http://<your-server-ip>:8080` will stop answering.**
|
||||||
|
That is the change. Reach the application through your reverse proxy, as `APP_URL` describes. If
|
||||||
|
your proxy runs on a different machine, publish the port on the interface it arrives from and
|
||||||
|
replace `TRUSTED_PROXIES: "*"` with that address or subnet — the two settings only make sense
|
||||||
|
together.
|
||||||
|
|
||||||
|
- **Docker: `APP_ENV` and `APP_DEBUG` set inside `storage/.env` no longer take effect.** The image
|
||||||
|
now sets them itself, and a real environment variable always beats that file. If you had turned
|
||||||
|
debug on by editing `storage/.env`, pass `-e APP_DEBUG=true` (or `environment:` in compose)
|
||||||
|
instead. Anything you already set that way keeps working unchanged.
|
||||||
|
|
||||||
|
Thanks to [@denkfabrik-li](https://github.com/denkfabrik-li), who wrote all forty-four pull
|
||||||
|
requests in this release, and to [@prbt2016](https://github.com/prbt2016), who reported the Apache
|
||||||
|
download failure that started the delivery work.
|
||||||
|
|
||||||
|
### Pull requests merged since 2.2.1
|
||||||
|
|
||||||
|
The summary above is what changed. This is the paper trail, for anyone who wants to read the
|
||||||
|
original change. No issues were closed in this cycle — the work arrived as pull requests.
|
||||||
|
|
||||||
|
- [#1718](https://github.com/projectsend/projectsend/pull/1718) — Narrow the reassignment picker to what a viewer may see
|
||||||
|
- [#1719](https://github.com/projectsend/projectsend/pull/1719) — Count a shared folder's contents as reach, not just the folder
|
||||||
|
- [#1720](https://github.com/projectsend/projectsend/pull/1720) — Stop an expired file locking a group shut for a scoped staff member
|
||||||
|
- [#1721](https://github.com/projectsend/projectsend/pull/1721) — Scope the API dashboard's recent actions to what the viewer may read
|
||||||
|
- [#1722](https://github.com/projectsend/projectsend/pull/1722) — Show the portal dashboard the files a client can actually open
|
||||||
|
- [#1723](https://github.com/projectsend/projectsend/pull/1723) — Stop a client PATCH clearing custom fields it never mentioned
|
||||||
|
- [#1725](https://github.com/projectsend/projectsend/pull/1725) — Write a rendition through a temporary file, and never serve an empty one
|
||||||
|
- [#1726](https://github.com/projectsend/projectsend/pull/1726) — Delete a file's renditions even when its own disk cannot be resolved
|
||||||
|
- [#1727](https://github.com/projectsend/projectsend/pull/1727) — Give an API expiry date the same meaning the web gives it
|
||||||
|
- [#1728](https://github.com/projectsend/projectsend/pull/1728) — Stop an expiry moving because somebody else saved the file
|
||||||
|
- [#1729](https://github.com/projectsend/projectsend/pull/1729) — Decide what is an API request from the route, not from the caller's headers
|
||||||
|
- [#1730](https://github.com/projectsend/projectsend/pull/1730) — Refuse to provision over a deleted account's address instead of crashing
|
||||||
|
- [#1731](https://github.com/projectsend/projectsend/pull/1731) — Fail a zip build without handing the requester the server's reason
|
||||||
|
- [#1732](https://github.com/projectsend/projectsend/pull/1732) — Debounce the public preview log the way the signed-in one already is
|
||||||
|
- [#1734](https://github.com/projectsend/projectsend/pull/1734) — Name the quota a client is actually held to when an upload is refused
|
||||||
|
- [#1735](https://github.com/projectsend/projectsend/pull/1735) — Stop an editable-once checkbox locking before anybody ticks it
|
||||||
|
- [#1736](https://github.com/projectsend/projectsend/pull/1736) — Put a client on the roster of the scoped staff member who created them
|
||||||
|
- [#1737](https://github.com/projectsend/projectsend/pull/1737) — Compare the transfers window against the column's own timezone
|
||||||
|
- [#1738](https://github.com/projectsend/projectsend/pull/1738) — Claim a TOTP code atomically instead of checking then writing
|
||||||
|
- [#1739](https://github.com/projectsend/projectsend/pull/1739) — Refresh a mailbox on the schedule under the lock a send would hold
|
||||||
|
- [#1740](https://github.com/projectsend/projectsend/pull/1740) — Leave the caches update.sh's own update command needs to see
|
||||||
|
- [#1741](https://github.com/projectsend/projectsend/pull/1741) — Ask about the zips queue on every path that could answer it
|
||||||
|
- [#1742](https://github.com/projectsend/projectsend/pull/1742) — Set the directory permission Flysystem actually reads
|
||||||
|
- [#1743](https://github.com/projectsend/projectsend/pull/1743) — Check the read half of the redirect rule at every door, not one
|
||||||
|
- [#1744](https://github.com/projectsend/projectsend/pull/1744) — Stop a version link telling people about a file they already had
|
||||||
|
- [#1745](https://github.com/projectsend/projectsend/pull/1745) — Gate the comment moderation surfaces on reading, not just on the library
|
||||||
|
- [#1746](https://github.com/projectsend/projectsend/pull/1746) — Say what expiry does to a client-scoped staff member's library
|
||||||
|
- [#1747](https://github.com/projectsend/projectsend/pull/1747) — Say which permission a bulk edit was actually missing
|
||||||
|
- [#1748](https://github.com/projectsend/projectsend/pull/1748) — Let a password reset know where the account's credentials live
|
||||||
|
- [#1749](https://github.com/projectsend/projectsend/pull/1749) — A deleted account is still the person who wrote the comment
|
||||||
|
- [#1750](https://github.com/projectsend/projectsend/pull/1750) — Tell the admins the mailbox is dead, even when a send noticed first
|
||||||
|
- [#1751](https://github.com/projectsend/projectsend/pull/1751) — Keep the mail and storage credentials out of the boot-config cache
|
||||||
|
- [#1752](https://github.com/projectsend/projectsend/pull/1752) — Bound the two preference endpoints by their own registries
|
||||||
|
- [#1753](https://github.com/projectsend/projectsend/pull/1753) — Narrow the conversion list to the clients its own refusal allows
|
||||||
|
- [#1754](https://github.com/projectsend/projectsend/pull/1754) — Narrow the membership an API member write hands back
|
||||||
|
- [#1755](https://github.com/projectsend/projectsend/pull/1755) — Give every password check in front of an account its own bucket
|
||||||
|
- [#1756](https://github.com/projectsend/projectsend/pull/1756) — Make linking a provider re-prove the password
|
||||||
|
- [#1757](https://github.com/projectsend/projectsend/pull/1757) — Stop a rejected settings form flashing the credential it carried
|
||||||
|
- [#1758](https://github.com/projectsend/projectsend/pull/1758) — Let the confirm-password screen ask where the password lives
|
||||||
|
- [#1759](https://github.com/projectsend/projectsend/pull/1759) — Publish the quickstart on loopback, since it trusts any proxy
|
||||||
|
- [#1760](https://github.com/projectsend/projectsend/pull/1760) — Have the production image default to production
|
||||||
|
- [#1761](https://github.com/projectsend/projectsend/pull/1761) — Serve the interface font from the installation, not from a font CDN
|
||||||
|
- [#1762](https://github.com/projectsend/projectsend/pull/1762) — Run the auth and settings screens through the translator
|
||||||
|
- [#1763](https://github.com/projectsend/projectsend/pull/1763) — Stop the zip poll when its page goes away
|
||||||
|
- [#1764](https://github.com/projectsend/projectsend/pull/1764) — Honour Laravel's placeholder case convention in t()
|
||||||
|
|
||||||
|
## 2.2.1 — 28 August 2026
|
||||||
|
|
||||||
|
A security release. Most of it closes ways somebody could reach past a boundary the rest of the
|
||||||
|
application already enforced — including two that could lock you out of your own installation.
|
||||||
|
|
||||||
|
**Merged**
|
||||||
|
|
||||||
|
- [#1708](https://github.com/projectsend/projectsend/pull/1708) — Let an enforced user reach the far side of the confirm-password screen
|
||||||
|
- [#1716](https://github.com/projectsend/projectsend/pull/1716) — Refuse the last administrator deleting themselves, and keep setup shut
|
||||||
|
- [#1710](https://github.com/projectsend/projectsend/pull/1710) — Stop a folder deleting the files inside it that its owner may not delete
|
||||||
|
- [#1714](https://github.com/projectsend/projectsend/pull/1714) — Hold the group edit screen to the same library boundary as the rest
|
||||||
|
- [#1717](https://github.com/projectsend/projectsend/pull/1717) — Keep a private reply private after the client is deleted
|
||||||
|
- [#1713](https://github.com/projectsend/projectsend/pull/1713) — Refuse self-deactivation over the API however the boolean is written
|
||||||
|
- [#1709](https://github.com/projectsend/projectsend/pull/1709) — Ask the seat cap where a pending client is approved through edit()
|
||||||
|
- [#1715](https://github.com/projectsend/projectsend/pull/1715) — Add a file to a zip once, however many ways the selection reaches it
|
||||||
|
- [#1707](https://github.com/projectsend/projectsend/pull/1707) — Leave the test workflow one concurrency block, so it parses again
|
||||||
|
- [#1711](https://github.com/projectsend/projectsend/pull/1711) — Stop the update tests emptying bootstrap/cache for every other worker
|
||||||
|
- [#1712](https://github.com/projectsend/projectsend/pull/1712) — Make the storage durability dashboard test assert the verdict
|
||||||
|
|
||||||
|
**Also fixed**
|
||||||
|
|
||||||
|
- The plain-text version of an email no longer shows the link twice, wrapped in brackets.
|
||||||
|
- The message you get when an account would exceed a limit no longer reads "limited to 1 staff
|
||||||
|
accounts".
|
||||||
|
|
||||||
|
### Upgrade notes
|
||||||
|
|
||||||
|
- **Nothing to do.** Drop in the new files and run `php artisan migrate` as usual; this release adds
|
||||||
|
no migrations, no settings and no new environment values.
|
||||||
|
|
||||||
|
- **One thing changes behaviour.** If somebody on your team has been deleting a folder as a way of
|
||||||
|
clearing out files other people uploaded, that now refuses and says how many files are in the way.
|
||||||
|
It is the same rule the file list has always applied one screen over — the folder was the way
|
||||||
|
around it, and what it removed was not recoverable.
|
||||||
|
|
||||||
|
Thanks to [@denkfabrik-li](https://github.com/denkfabrik-li), who reported, diagnosed and fixed
|
||||||
|
every one of the above.
|
||||||
|
|
||||||
|
### Issues closed since 2.2.0
|
||||||
|
|
||||||
|
The summary above is what changed. This is the paper trail, for anyone who wants to read the
|
||||||
|
original report.
|
||||||
|
|
||||||
|
- [#1706](https://github.com/projectsend/projectsend/issues/1706) — V1 migration imports $2a$ bcrypt hashes that cause HTTP 500 on login
|
||||||
|
|
||||||
## 2.2.0 — 27 August 2026
|
## 2.2.0 — 27 August 2026
|
||||||
|
|
||||||
@@ -77,401 +559,30 @@ installed ProjectSend by hand.
|
|||||||
a banner naming the problem and the fix.
|
a banner naming the problem and the fix.
|
||||||
|
|
||||||
- **If you run behind a reverse proxy, check `TRUSTED_PROXIES`.** It is now read correctly, which it
|
- **If you run behind a reverse proxy, check `TRUSTED_PROXIES`.** It is now read correctly, which it
|
||||||
was not before — see the fix below. Set it in `.env`, and do not run `config:cache`, which stops
|
was not before. Set it in `.env`, and do not run `config:cache`, which stops `.env` being read at
|
||||||
`.env` being read at all.
|
all.
|
||||||
|
|
||||||
### Added
|
Thanks to [@denkfabrik-li](https://github.com/denkfabrik-li), who found, diagnosed and fixed most
|
||||||
|
of the boundary work above, and to [@mstewart14](https://github.com/mstewart14),
|
||||||
|
[@elibrachas](https://github.com/elibrachas), [@mueller7382](https://github.com/mueller7382) and
|
||||||
|
[@pabloalvarez44](https://github.com/pabloalvarez44) for reports and fixes.
|
||||||
|
|
||||||
- **Google Cloud Storage as a storage backend.** External storage used to mean S3 and nothing else.
|
### Issues closed since 2.1.0
|
||||||
The Storage settings screen now asks which provider you are using first, and offers Google Cloud
|
|
||||||
Storage alongside the S3-compatible option: choose it, paste a service account key with read and
|
|
||||||
write access to your bucket, and new uploads go there. The key is stored encrypted and never shown
|
|
||||||
again, and **Test connection** checks it can actually reach the bucket before you switch anything
|
|
||||||
over — using a probe that works with a least-privilege key, rather than one that needs permission
|
|
||||||
to read the bucket's own settings. Downloads and previews are handed to the visitor as a
|
|
||||||
short-lived signed link, exactly as they already were for S3.
|
|
||||||
|
|
||||||
Nothing changes for an existing installation. Configurations saved before this release are S3, are
|
The summary above is what changed. This is the paper trail, for anyone who wants to read the
|
||||||
still S3, and are not asked to say so. Files already stored stay where they are — the setting
|
original report.
|
||||||
applies to new uploads, and there is still no migration between backends.
|
|
||||||
|
|
||||||
- **A maximum size for zip downloads.** A new Settings → Downloads screen sets the largest selection
|
- [#1627](https://github.com/projectsend/projectsend/issues/1627) — Errors while installing via Docker
|
||||||
anyone can ask for as a single zip — 2 GB out of the box, any figure you like, or 0 for no limit.
|
- [#1648](https://github.com/projectsend/projectsend/issues/1648) — A deleted account's email address can never be used again
|
||||||
Building an archive costs disk space and occupies the background worker for as long as it takes to
|
- [#1661](https://github.com/projectsend/projectsend/issues/1661) — Docker update instructions do not update ProjectSend when using official Compose setup
|
||||||
write, so one person asking for a whole library at once used to hold up every notification email
|
- [#1662](https://github.com/projectsend/projectsend/issues/1662) — Preview files not available on v2.1.0
|
||||||
behind it. Ask for more than the limit and you are told how large your selection is and what the
|
- [#1663](https://github.com/projectsend/projectsend/issues/1663) — Dashboard 500s on shared hosting: container detection trips open_basedir
|
||||||
ceiling is, rather than simply refused; each person can have one archive being prepared at a time,
|
- [#1664](https://github.com/projectsend/projectsend/issues/1664) — INSTALL.md: the nginx-in-front-of-Apache path needs the buffer advice too
|
||||||
for the same reason.
|
- [#1668](https://github.com/projectsend/projectsend/issues/1668) — INSTALL.md: X-Accel downloads fail when nginx and PHP-FPM run as different users
|
||||||
|
- [#1672](https://github.com/projectsend/projectsend/issues/1672) — Projectsend 2 behind Traefik issues 419 when logging in or hitting an error?
|
||||||
- **ProjectSend tells you if nothing is building your zip downloads.** The change below gives zip
|
- [#1673](https://github.com/projectsend/projectsend/issues/1673) — Projectsend 2: Setting Widget Columns throws error
|
||||||
building its own queue, which a manual install's background worker has to be told about. Miss that
|
- [#1675](https://github.com/projectsend/projectsend/issues/1675) — Success toast shows twice after create/delete redirects
|
||||||
and the failure is silent: email keeps going out, zip downloads simply never finish, and nothing
|
- [#1706](https://github.com/projectsend/projectsend/issues/1706) — V1 migration imports $2a$ bcrypt hashes that cause HTTP 500 on login
|
||||||
in any log says why. Staff who can see system information now get a banner naming the problem and
|
|
||||||
the one-line fix, so nobody has to work it out from a spinner that never stops.
|
|
||||||
|
|
||||||
- **Zip downloads no longer hold up your email.** Preparing a large archive can take a while, and it
|
|
||||||
used to run on the same queue as everything else — so one big zip could delay every notification
|
|
||||||
email behind it. Zip building now has a queue of its own, and the Docker images run a second
|
|
||||||
background worker for it.
|
|
||||||
|
|
||||||
**Manual installs:** your background worker has to be told about the new queue, or zips will never
|
|
||||||
finish and nothing will say why. `update.sh` spots this and offers to fix the worker service for
|
|
||||||
you, keeping a copy of the old one — so for most people there is nothing to do but say yes. If you
|
|
||||||
update by hand, or your worker already names its own queues (the updater will say so rather than
|
|
||||||
edit a deliberate arrangement), add `zips` to its `--queue` list and reload systemd. Docker
|
|
||||||
installations need no change. See INSTALL.md for the two-worker setup if you would rather keep the
|
|
||||||
two kinds of work apart.
|
|
||||||
|
|
||||||
- **A deleted account's email address can be used again.** Deleting an account keeps its record for
|
|
||||||
a grace period before erasing it for good, and the address stays reserved until that happens — but
|
|
||||||
only accounts that deleted *themselves* were ever scheduled for erasure. An account an
|
|
||||||
administrator deleted sat in that state permanently, and its address could never be reused, with
|
|
||||||
nothing on screen to explain why. Every deletion now schedules the erasure the same way, whoever
|
|
||||||
performed it, and the staff screens explain a reserved address rather than saying only that it is
|
|
||||||
taken: which date it frees up, or which command frees it sooner. Public registration deliberately
|
|
||||||
keeps the plain "already taken" message, since telling a stranger the address once had an account
|
|
||||||
here is the disclosure that message exists to avoid.
|
|
||||||
|
|
||||||
Accounts deleted before this change keep their old state on purpose — stamping them during an
|
|
||||||
update would quietly start a countdown to erasure that nobody chose. The console command named in
|
|
||||||
the new message handles those.
|
|
||||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
|
||||||
[#1678](https://github.com/projectsend/projectsend/pull/1678), closing
|
|
||||||
[#1648](https://github.com/projectsend/projectsend/issues/1648))
|
|
||||||
|
|
||||||
- **A staff role limited to its own clients now stays limited.** Several ways around that limit are
|
|
||||||
closed together, because any one of them made the rest decorative. A role holding the "manage
|
|
||||||
users" permission could edit its own role and simply switch the limit off; it could hand itself
|
|
||||||
clients it was never assigned; it could promote any client on the installation to a staff account,
|
|
||||||
which is the most far-reaching thing that can be done to a client record. Uploading into, or
|
|
||||||
moving a file into, a folder belonging to somebody else's clients is refused too, as is browsing
|
|
||||||
the folder pickers past your own tree. None of this was reachable with any role that ships with
|
|
||||||
ProjectSend — each needed a custom role built on the roles screen — but the combinations are ones
|
|
||||||
the screen offers, so anyone who built one should update.
|
|
||||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
|
||||||
[#1681](https://github.com/projectsend/projectsend/pull/1681),
|
|
||||||
[#1694](https://github.com/projectsend/projectsend/pull/1694),
|
|
||||||
[#1697](https://github.com/projectsend/projectsend/pull/1697),
|
|
||||||
[#1700](https://github.com/projectsend/projectsend/pull/1700) and
|
|
||||||
[#1702](https://github.com/projectsend/projectsend/pull/1702))
|
|
||||||
|
|
||||||
- **A public file's private notes stay private.** The comment thread on a publicly listed file is
|
|
||||||
meant to show what any visitor sees. It was instead answering signed-in visitors as themselves, so
|
|
||||||
simply having an account — any account — showed staff-only notes on that file, or the messages
|
|
||||||
addressed to that file's clients. Being signed in now shows you what a visitor sees, plus your own
|
|
||||||
comments, unless you were entitled to see the file anyway.
|
|
||||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
|
||||||
[#1695](https://github.com/projectsend/projectsend/pull/1695))
|
|
||||||
|
|
||||||
- **A client is no longer shown the names of folders they cannot open.** Browsing into a folder in
|
|
||||||
the client portal listed every subfolder inside it, including ones shared with somebody else.
|
|
||||||
Opening one was always refused, so what escaped was the name — which can be enough, when folders
|
|
||||||
are named after the people they belong to.
|
|
||||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
|
||||||
[#1690](https://github.com/projectsend/projectsend/pull/1690))
|
|
||||||
|
|
||||||
- **The maximum file size now applies to large uploads.** Big files are sent in pieces, and the size
|
|
||||||
limit was only checked against the size the sender *claimed* before sending anything. Declaring a
|
|
||||||
tiny upload and then sending gigabytes passed every check. The assembled file is now measured
|
|
||||||
against the limit before it is accepted.
|
|
||||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
|
||||||
[#1682](https://github.com/projectsend/projectsend/pull/1682))
|
|
||||||
|
|
||||||
- **A download limit now holds when a zip is collected.** Preparing an archive never spent anybody's
|
|
||||||
download allowance, and only collecting one did — so an archive prepared while a file was still
|
|
||||||
available stayed collectable after its limit was spent, and several could be held that way at
|
|
||||||
once. The limit is now checked at the moment the archive is handed over, which is also the moment
|
|
||||||
it is spent. Archives also record exactly which files went into them, so the download history
|
|
||||||
counts what was actually delivered rather than re-guessing it afterwards.
|
|
||||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
|
||||||
[#1692](https://github.com/projectsend/projectsend/pull/1692))
|
|
||||||
|
|
||||||
- **Public downloads work on installations using external storage.** The public listing's download
|
|
||||||
link always answered as though the file were on the server's own disk, so on an installation
|
|
||||||
keeping files in object storage it pointed at a path that had never been written. Its neighbours
|
|
||||||
on the same page — thumbnails and previews — already handled both. Now it does too.
|
|
||||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
|
||||||
[#1693](https://github.com/projectsend/projectsend/pull/1693))
|
|
||||||
|
|
||||||
- **A large upload cannot be finished twice at once.** A retry or a double submit arriving while the
|
|
||||||
first was still assembling could interleave with it, storing bytes that no longer matched the
|
|
||||||
file's own checksum, or recording the same upload twice. Finishing an upload now takes a lock for
|
|
||||||
that upload, and a second attempt is turned away rather than joining in.
|
|
||||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
|
||||||
[#1686](https://github.com/projectsend/projectsend/pull/1686))
|
|
||||||
|
|
||||||
- **Deleting an account either finishes or does nothing.** Removing an account and dealing with the
|
|
||||||
files it owns were two separate steps with nothing holding them together, so a failure in the
|
|
||||||
second left the account gone and its files still pointing at it — most easily when the person
|
|
||||||
chosen to inherit them was deleted in between. Both now happen together or not at all. Relatedly,
|
|
||||||
a file's stored bytes are now removed once the deletion is committed rather than as it happens, so
|
|
||||||
a cancelled bulk deletion no longer restores records whose files are already gone.
|
|
||||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
|
||||||
[#1688](https://github.com/projectsend/projectsend/pull/1688) and
|
|
||||||
[#1691](https://github.com/projectsend/projectsend/pull/1691))
|
|
||||||
|
|
||||||
- **Creating something with a create-only role no longer ends in an error page.** Roles can grant
|
|
||||||
permission to create clients, staff accounts, groups or categories without permission to edit
|
|
||||||
them. Creating one worked, but the page it sent you to afterwards was the edit page, which such a
|
|
||||||
role may not open — so the record was created and you were shown a permission error, with no way
|
|
||||||
to tell whether it had worked. You now land back on the create form with the confirmation message.
|
|
||||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
|
||||||
[#1684](https://github.com/projectsend/projectsend/pull/1684))
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Accounts migrated from v1 can sign in again.** On some installations brought over from
|
|
||||||
ProjectSend Legacy, every migrated person got an error page instead of a login screen — while
|
|
||||||
anybody whose account was created in v2 signed in perfectly. The cause was the label on the stored
|
|
||||||
password. Older versions of PHP wrote `$2a$` or `$2b$` where newer ones write `$2y$`; all three are
|
|
||||||
the same algorithm, but ProjectSend only recognised the last one and gave up before it had even
|
|
||||||
looked at the password. Upgrading relabels the affected accounts in place. Nothing about anybody's
|
|
||||||
password changes, so there is no reset mail to send and nothing for you to do — the password they
|
|
||||||
already had simply starts working again. The migration tool no longer creates the problem in the
|
|
||||||
first place, from version 1.0.3 onwards.
|
|
||||||
([#1706](https://github.com/projectsend/projectsend/issues/1706), reported by
|
|
||||||
[@pabloalvarez44](https://github.com/pabloalvarez44))
|
|
||||||
|
|
||||||
- **Sessions no longer break behind a reverse proxy.** Signing in, or submitting the first-run setup
|
|
||||||
form, could answer with a page-filling error instead — most visibly for anyone running behind
|
|
||||||
Traefik, Nginx Proxy Manager or Caddy. `TRUSTED_PROXIES` was being read too early in the boot
|
|
||||||
sequence to be seen at all, so the setting had never had any effect on a web request. Without it
|
|
||||||
ProjectSend believed every visitor was arriving from the proxy over plain HTTP, built its links and
|
|
||||||
cookies accordingly, and rejected the form that came back as though it had come from somewhere
|
|
||||||
else. Docker installations that set the value as an environment variable were unaffected the whole
|
|
||||||
time; manual installs, where the guide tells you to put it in `.env`, were not — which is why this
|
|
||||||
looked so inconsistent. **Upgrade note:** if you run behind a proxy, set `TRUSTED_PROXIES` and do
|
|
||||||
not run `config:cache`, which stops `.env` being read at all. Both are covered in INSTALL.md.
|
|
||||||
([#1672](https://github.com/projectsend/projectsend/issues/1672), reported by
|
|
||||||
[@mstewart14](https://github.com/mstewart14); fixed by
|
|
||||||
[@elibrachas](https://github.com/elibrachas) in
|
|
||||||
[#1674](https://github.com/projectsend/projectsend/pull/1674))
|
|
||||||
|
|
||||||
- **Saving something after your session has expired now takes you to the login page.** Instead of
|
|
||||||
being told to sign in again, you got an unexplained error — the dashboard's widget settings and
|
|
||||||
several settings screens were the usual places to meet it. The cause was a detail of how browsers
|
|
||||||
follow redirects: they repeat the original request at the new address, so "save this" became "save
|
|
||||||
this to the login page", which the login page has no idea what to do with. It now answers in a way
|
|
||||||
that sends the browser to read the page rather than repeat the save. The same thing could happen to
|
|
||||||
an account that was deactivated while someone was working in it, or one being asked to set up
|
|
||||||
two-factor authentication, and both are fixed with it.
|
|
||||||
([#1673](https://github.com/projectsend/projectsend/issues/1673), reported by
|
|
||||||
[@mstewart14](https://github.com/mstewart14); found, diagnosed and fixed by
|
|
||||||
[@denkfabrik-li](https://github.com/denkfabrik-li) in
|
|
||||||
[#1680](https://github.com/projectsend/projectsend/pull/1680))
|
|
||||||
|
|
||||||
- **An upload that cannot be stored now fails instead of disappearing.** When files are kept in
|
|
||||||
object storage and the storage backend refuses a write — an expired key, a bucket that has been
|
|
||||||
renamed or removed, a permission that changed underneath you — the upload used to report success
|
|
||||||
and record the file anyway. The entry appeared in the file list, and the download it promised was
|
|
||||||
never going to work, because the bytes had gone nowhere. The upload now stops and says so, and no
|
|
||||||
file is recorded. Installations keeping files on local disk were never affected.
|
|
||||||
|
|
||||||
- **Downloads and thumbnails for installations using external storage.** Two places assumed every
|
|
||||||
file sat on the server's own disk, which stopped being true the moment S3-compatible storage was
|
|
||||||
switched on. A share link to a file held in a bucket produced a broken download, and a public
|
|
||||||
listing could not draw a thumbnail for one at all — while the same file downloaded and previewed
|
|
||||||
correctly everywhere else, which made it look like the share link or the listing was at fault
|
|
||||||
rather than where the file lived. Both now read the file from wherever it actually is. Nothing
|
|
||||||
changes for installations keeping files on local disk, which is most of them.
|
|
||||||
|
|
||||||
- **One confirmation message instead of two.** Saving a new client, system user or role showed the
|
|
||||||
same green "Client created." twice, stacked. So did deleting one. It was only ever cosmetic —
|
|
||||||
nothing happened twice — but it read as though something had, which is the last thing a
|
|
||||||
confirmation should do. Saves that stay on the same screen, such as the email settings, were never
|
|
||||||
affected.
|
|
||||||
([#1675](https://github.com/projectsend/projectsend/issues/1675), reported and diagnosed by
|
|
||||||
[@denkfabrik-li](https://github.com/denkfabrik-li))
|
|
||||||
|
|
||||||
- **Connecting a provider to an account that already has one.** Signing in with Google, Microsoft or
|
|
||||||
a custom provider worked, but attaching one to an existing account did not: the **Connect** button
|
|
||||||
on Settings → Connected accounts appeared to do nothing at all. The button asks the server in the
|
|
||||||
background, and the server answered by redirecting to the provider — a redirect a browser will not
|
|
||||||
follow out of a background request to another site. The page sat there with no consent screen and
|
|
||||||
no error to explain it, so the only reading available was that the button was dead. The server now
|
|
||||||
tells the browser to go to the provider itself, and the flow starts as it should. Signing in from
|
|
||||||
the login page was never affected, and neither is it now.
|
|
||||||
([#1676](https://github.com/projectsend/projectsend/pull/1676), found and fixed by
|
|
||||||
[@denkfabrik-li](https://github.com/denkfabrik-li))
|
|
||||||
|
|
||||||
- **Downloads on a host where the web server is not PHP's user.** A download is not served by PHP:
|
|
||||||
PHP checks permissions and then hands the web server the path to stream. Where the two run as
|
|
||||||
different users — cPanel and Plesk commonly arrange it that way — the web server could not open
|
|
||||||
the file, because uploads are written readable only by the account that wrote them. The rest of
|
|
||||||
the site gave no sign of it: uploading worked, the library listed everything, and only downloads
|
|
||||||
failed, in the browser as `ERR_INVALID_RESPONSE`. Setting `FILES_WEB_SERVER_READABLE=true` now
|
|
||||||
writes uploads so the web server can read them. It is opt-in, and deliberately so — the modes it
|
|
||||||
uses are readable by every account on the machine, which is the wrong trade on a server where the
|
|
||||||
web server and PHP are the same user, as they are in the Docker image and on most servers people
|
|
||||||
set up themselves. The install guide has the full procedure, including the one thing no
|
|
||||||
application setting can fix: a PHP-FPM pool with a restrictive umask, which caps new directories
|
|
||||||
no matter what ProjectSend asks for.
|
|
||||||
([#1668](https://github.com/projectsend/projectsend/issues/1668), reported by
|
|
||||||
[@denkfabrik-li](https://github.com/denkfabrik-li))
|
|
||||||
|
|
||||||
- **An installation that builds its own containers is no longer told to pull.** ProjectSend prints
|
|
||||||
the update instructions for the way you installed it, and it had two answers where it needed
|
|
||||||
three: anything running in a container was handed `docker compose pull && docker compose up -d`,
|
|
||||||
including the Compose stack that builds from a checkout of the repository. There is no image
|
|
||||||
behind those containers to pull, so both commands ran, reported success and changed nothing — and
|
|
||||||
the dashboard went on offering the same release. Those installations are now recognised and given
|
|
||||||
`git pull && docker compose up -d --build` instead, with the two extra steps a checkout needs when
|
|
||||||
a release moves its dependencies or its frontend.
|
|
||||||
([#1661](https://github.com/projectsend/projectsend/issues/1661), reported by
|
|
||||||
[@mueller7382](https://github.com/mueller7382))
|
|
||||||
|
|
||||||
- **The dashboard no longer fails on shared hosting.** To decide which update instructions to print,
|
|
||||||
ProjectSend asks whether it is running inside a container by looking for a file in the root of the
|
|
||||||
filesystem. On shared hosting PHP is usually confined to your own directory, and looking outside it
|
|
||||||
is treated as an error rather than as a "no" — so the one page that asks the question, the
|
|
||||||
dashboard, returned a 500 while every other page worked. It now takes the restriction as the answer
|
|
||||||
it always was: a server that keeps PHP inside a single directory is not our container image, and
|
|
||||||
gets the manual update instructions, which is correct for shared hosting anyway. Nothing to change
|
|
||||||
on your side, and no setting you would have been able to change if there were.
|
|
||||||
([#1663](https://github.com/projectsend/projectsend/issues/1663), reported by
|
|
||||||
[@denkfabrik-li](https://github.com/denkfabrik-li))
|
|
||||||
|
|
||||||
- **502 Bad Gateway behind a reverse proxy.** Every page carried a `Link:` header listing its
|
|
||||||
frontend assets, duplicating tags the page already had in its `<head>` — twenty of them on the
|
|
||||||
login screen, more on a heavier page. nginx buffers a response's headers into a single block that
|
|
||||||
defaults to 4 KB, so the file list, at over 6 KB of headers, was refused with `upstream sent too
|
|
||||||
big header` and the proxy answered 502. Which pages went over depended on how many assets they
|
|
||||||
loaded, so it looked like an intermittent fault: the login screen appeared, and then the
|
|
||||||
application did not. The duplicate header is gone — the same pages now send under 1.3 KB — and no
|
|
||||||
browser loses anything, because the tags it actually reads were always in the document. The
|
|
||||||
install guide gained the proxy buffer settings for anyone on an older version or behind a proxy
|
|
||||||
holding a tighter default.
|
|
||||||
([#1664](https://github.com/projectsend/projectsend/issues/1664), reported by
|
|
||||||
[@denkfabrik-li](https://github.com/denkfabrik-li))
|
|
||||||
|
|
||||||
- **`docker logs` now shows the web server's log.** The container runs nginx, PHP-FPM, the queue
|
|
||||||
worker and the scheduler, and all of them reported to Docker except the one you need when a
|
|
||||||
request fails: nginx opened the log files named in its own configuration and wrote to them inside
|
|
||||||
the container, where nothing looks. The effect was that a proxy problem produced no logs on either
|
|
||||||
side — the reason for every 502 and every 403 existed, in a file nobody knew to open. Both its
|
|
||||||
access and error logs now go to the container's output, and the Docker guide has a section on
|
|
||||||
running behind a reverse proxy that says which side a given message points at.
|
|
||||||
|
|
||||||
- **A zip download is never offered over an archive that was not written.** Archives are built in the
|
|
||||||
background, and the writing all happens at the very end — so a source file deleted while the build
|
|
||||||
waited its turn, or a disk that filled up, produced no archive at all while the download was still
|
|
||||||
marked ready. Clicking it then failed with an unexplained error. The same went for a selection
|
|
||||||
whose files had all become unavailable: an archive with nothing in it is not written to disk
|
|
||||||
either. Both now fail the build and say why. Large archives were affected differently: a build
|
|
||||||
taking longer than a minute was killed by the queue worker and the download simply spun forever,
|
|
||||||
waiting for something that had already stopped. Builds now get the time they need, a build the
|
|
||||||
queue gives up on reports itself as failed, and the partial files an interrupted build leaves
|
|
||||||
behind are cleaned up rather than sitting on disk unnoticed.
|
|
||||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
|
||||||
[#1687](https://github.com/projectsend/projectsend/pull/1687))
|
|
||||||
|
|
||||||
- **Comment moderation now stops at the same boundary everything else does.** A staff role can be
|
|
||||||
limited to its own assigned clients, and everything in the library respects that — listings,
|
|
||||||
downloads, file details, and the moderation queue itself. Deleting or approving a single comment
|
|
||||||
did not. Someone with a client-limited role who also held the comment moderation permission could
|
|
||||||
remove any comment on the installation by its id, including conversations belonging to clients
|
|
||||||
they were not assigned to, on files they could not open. No role that ships with ProjectSend
|
|
||||||
combines those two things, so this needed a custom role to reach; if you have built one, it is
|
|
||||||
worth updating for. The boundary now lives in the rule itself rather than being restated by each
|
|
||||||
screen, which is how the gap opened in the first place.
|
|
||||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
|
||||||
[#1698](https://github.com/projectsend/projectsend/pull/1698))
|
|
||||||
|
|
||||||
- **The dashboard's recent activity now respects a limited role's boundary.** A staff role can be
|
|
||||||
limited to its own assigned clients, and the activity page has always honoured that — showing only
|
|
||||||
entries about files, folders and clients in that person's scope. The dashboard's Recent activity
|
|
||||||
widget did not: it listed the eight most recent entries from the whole installation, file names
|
|
||||||
and all, to someone who would be refused the files themselves. The Client Manager role ships with
|
|
||||||
the permission this widget needs, so any installation using it was affected. Both screens now
|
|
||||||
answer the same way. Nothing changes for an administrator or any unrestricted role.
|
|
||||||
|
|
||||||
- **Cached previews are no longer mistaken for stray files.** The tool that finds files sitting on
|
|
||||||
disk with no database record knew to ignore cached thumbnails, but had never been told about the
|
|
||||||
larger previews added alongside them. So every cached preview was listed as an unclaimed file:
|
|
||||||
offered for import on the orphan-files screen, and deleted by the daily cleanup once past its
|
|
||||||
grace period. Importing one also created a file entry pointing at a path the preview cache owns,
|
|
||||||
which then vanished the next time that cache was cleared. The list of what counts as a generated
|
|
||||||
copy is now derived from the copies themselves, so a new kind cannot be left off it again.
|
|
||||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
|
||||||
[#1683](https://github.com/projectsend/projectsend/pull/1683))
|
|
||||||
|
|
||||||
- **Group membership now respects a limited role's boundary.** A staff role can be limited to its
|
|
||||||
own assigned clients. Adding somebody to a group, or taking them out, checked only that the person
|
|
||||||
held the "edit groups" permission — not that the group was any of their business. Because joining a
|
|
||||||
group hands the new member everything shared with it, someone with a limited role could put one of
|
|
||||||
their own clients into any group on the installation and, through that client, reach files they
|
|
||||||
had been refused a moment earlier. Approving or denying a membership request was the same write
|
|
||||||
through a second door, and the requests screen listed every pending request by name and email,
|
|
||||||
including clients outside the viewer's roster. All of it is now held to the same boundary the rest
|
|
||||||
of the library uses, and the sidebar count agrees with the screen behind it. No role that ships
|
|
||||||
with ProjectSend combines the two permissions this needed, so reaching it took a custom role.
|
|
||||||
Nothing changes for an administrator or any unrestricted role.
|
|
||||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
|
||||||
[#1701](https://github.com/projectsend/projectsend/pull/1701))
|
|
||||||
|
|
||||||
- **Declining a group membership request now happens once.** Approving a request that had already
|
|
||||||
been decided was refused; declining one was not, and declining is not a repeatable act. Each
|
|
||||||
repeat re-dated the decision — which is what the client's waiting period before asking again
|
|
||||||
counts from — so the same stale request, sent again, could keep somebody out of a group
|
|
||||||
indefinitely without anyone deciding anything. It also wrote a second entry in the activity log
|
|
||||||
and sent the client a second "your request was declined" email for one decision. The queue only
|
|
||||||
ever lists requests still waiting, so nothing on screen offered this. Both actions now behave the
|
|
||||||
same way.
|
|
||||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
|
||||||
[#1705](https://github.com/projectsend/projectsend/pull/1705))
|
|
||||||
|
|
||||||
- **The dashboard's expired-files list says whose files it is showing.** For a staff role limited to
|
|
||||||
its own clients it lists that person's own uploads, since an expired file is already out of reach
|
|
||||||
of the clients it was shared with. It now says so — "Your expired files", and a line explaining
|
|
||||||
what is not in the list — rather than presenting a short list as though it were the whole picture.
|
|
||||||
A warning about what is due to be deleted is worth nothing if it is quietly narrower than it looks.
|
|
||||||
|
|
||||||
- **A limited staff role no longer reaches every client record, or every file name on the
|
|
||||||
dashboard.** Two more places where holding a permission was treated as holding a boundary. The
|
|
||||||
clients screen listed every client on the installation by name and email, and a role limited to
|
|
||||||
its own assigned clients could open, rename, or delete any of them — the same through the API.
|
|
||||||
Separately, the dashboard's largest-files, expired-files and top-clients widgets named files and
|
|
||||||
clients from across the whole installation, which mattered more because the Client Manager role
|
|
||||||
that ships with ProjectSend holds the permission those widgets need. Both now use the same rule
|
|
||||||
the rest of the library already did. Installation-wide totals stay installation-wide: a count
|
|
||||||
carries no names. Nothing changes for an administrator or any unrestricted role.
|
|
||||||
|
|
||||||
- **Notification settings accept only the switches they offer.** Saving your notification
|
|
||||||
preferences would store a row for any name a request happened to carry, including ones nothing in
|
|
||||||
ProjectSend can send. Such a row was never read again and could not be seen or removed from the
|
|
||||||
screen, so the table quietly collected entries nobody could reach. The form now checks what comes
|
|
||||||
back against the same list it offered, so the two cannot drift apart. Nothing reachable from the
|
|
||||||
screen changes — it only ever sends back switches it was given.
|
|
||||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
|
||||||
[#1689](https://github.com/projectsend/projectsend/pull/1689))
|
|
||||||
|
|
||||||
- **A two-factor recovery code is now spent exactly once.** Using a code removed it from your list
|
|
||||||
by rewriting the whole list, so two sign-in attempts arriving at the same moment could each save
|
|
||||||
their own copy and put back the code the other had just spent. Nobody could get in who was not
|
|
||||||
already holding a valid code, but a code you had crossed off a printed sheet — or watched somebody
|
|
||||||
type — could quietly start working again, which is the one thing recovery codes promise not to do.
|
|
||||||
The code is now removed from the record as it stands at that moment, under a lock, so a second
|
|
||||||
attempt cannot undo the first.
|
|
||||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
|
||||||
[#1704](https://github.com/projectsend/projectsend/pull/1704))
|
|
||||||
|
|
||||||
- **A file can no longer be filed into a folder that has been deleted.** Deleting a folder deletes
|
|
||||||
everything inside it, so a file that lands in one afterwards sits somewhere that was already
|
|
||||||
emptied — reachable by link and in search, but missing from the folder listing its uploader would
|
|
||||||
look in. Uploading or moving a file into a deleted folder now says so instead, and picks up the
|
|
||||||
case where a folder is deleted while a large upload is still transferring: the finished file lands
|
|
||||||
at the top level rather than being thrown away, since the transfer had already happened. The
|
|
||||||
message says the folder no longer exists rather than that the value was invalid.
|
|
||||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
|
||||||
[#1703](https://github.com/projectsend/projectsend/pull/1703))
|
|
||||||
|
|
||||||
- **A limited staff role can no longer rename or delete a group it has no part in.** Group
|
|
||||||
membership was already held to that boundary; the group itself was not, which was the sharper half
|
|
||||||
— sharing a file with a group is how its members reach that file, so deleting the group takes the
|
|
||||||
access away from every one of them, including clients outside the person's own list. A role
|
|
||||||
limited to its own clients can still manage any group that shares nothing beyond what it can
|
|
||||||
already see, so a group it created, or one holding its own clients, stays fully editable. Nothing
|
|
||||||
changes for an administrator or any unrestricted role.
|
|
||||||
|
|
||||||
## 2.1.0 — 18 August 2026
|
## 2.1.0 — 18 August 2026
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,13 @@ Without it every visitor appears to come from the proxy. The login rate limiter
|
|||||||
your users as one attacker, and the download log records the proxy's address instead of the
|
your users as one attacker, and the download log records the proxy's address instead of the
|
||||||
person's. `compose.example.yaml` already sets this.
|
person's. `compose.example.yaml` already sets this.
|
||||||
|
|
||||||
|
`"*"` means "trust whoever connected to me", so it belongs with a published port only the proxy can
|
||||||
|
reach — which is why `compose.example.yaml` publishes on `127.0.0.1`. If anybody can open the
|
||||||
|
container's port directly, they are the proxy as far as this setting is concerned, and the
|
||||||
|
`X-Forwarded-For` they send is the address the rate limiters and the download log will use. Where
|
||||||
|
the proxy runs on another host, publish on the interface it arrives from and name that address or
|
||||||
|
subnet here instead of `"*"`.
|
||||||
|
|
||||||
Leaving it unset does not cause a `502` — that means your proxy could not get a usable response out
|
Leaving it unset does not cause a `502` — that means your proxy could not get a usable response out
|
||||||
of the container at all, which is a different problem with a different fix. It does cause a **419
|
of the container at all, which is a different problem with a different fix. It does cause a **419
|
||||||
"page expired"**. Without it the application never learns the proxy terminated TLS, so it builds
|
"page expired"**. Without it the application never learns the proxy terminated TLS, so it builds
|
||||||
@@ -177,7 +184,7 @@ is the quickest way to separate "the app is down" from "the proxy cannot reach t
|
|||||||
during an outage, from the same machine:
|
during an outage, from the same machine:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl -s -o /dev/null -w '%{http_code}\n' http://<host-ip>:8080/up # straight at the container
|
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/up # straight at the container
|
||||||
curl -s -o /dev/null -w '%{http_code}\n' https://files.example.com/up
|
curl -s -o /dev/null -w '%{http_code}\n' https://files.example.com/up
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -211,7 +218,7 @@ its own directory the first time it starts.
|
|||||||
|
|
||||||
### 2. Point the compose file at them
|
### 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:
|
rather than layering an override on top:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -355,6 +362,33 @@ restored is a hypothesis, not a backup.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Virus scanning
|
||||||
|
|
||||||
|
Uploads can be checked before anybody can download them. The scanner is an extra container, off
|
||||||
|
unless you ask for it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose --profile scanner up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Then go to **System → Settings → Virus scanning**, switch it on, and use `tcp://clamav:3310` as the
|
||||||
|
address. On a brand-new installation you can skip that step: uncomment
|
||||||
|
`PROJECTSEND_SCANNER_DEFAULT_ADDRESS` in the compose file before the first start and the site comes
|
||||||
|
up already pointed at the scanner. It is a starting value, not a lock — the address and the switch
|
||||||
|
stay on that screen. **Test scanner** sends EICAR, a harmless file made only for testing that every antivirus
|
||||||
|
recognises, and tells you whether it was actually detected. It also sends a password-protected zip, and fails if the scanner calls it clean.
|
||||||
|
|
||||||
|
The compose file gives the scanner the settings ProjectSend needs, in the `configs` section at the
|
||||||
|
bottom. Keep them if you change that file. On its own defaults ClamAV reports an archive it cannot
|
||||||
|
open as clean, so a password-protected zip would get through unchecked.
|
||||||
|
|
||||||
|
Two things to know before you turn it on. It needs about **1–1.5 GB of memory**, because the virus
|
||||||
|
definitions are held in memory. And the first start downloads those definitions, which takes a few
|
||||||
|
minutes — until it finishes, the scanner does not answer, and the Test button says so.
|
||||||
|
|
||||||
|
The scanner is reachable only from the application's own network. That is deliberate: ClamAV has no
|
||||||
|
password of any kind, so anything that can reach it can use it.
|
||||||
|
|
||||||
## Upgrading
|
## Upgrading
|
||||||
|
|
||||||
With the data outside the containers, an upgrade touches only the containers:
|
With the data outside the containers, an upgrade touches only the containers:
|
||||||
|
|||||||
+179
-41
@@ -21,7 +21,7 @@ to create a database — this is not an install you can do over FTP alone.
|
|||||||
| **PHP** | 8.4 or newer, both the command-line PHP and PHP-FPM |
|
| **PHP** | 8.4 or newer, both the command-line PHP and PHP-FPM |
|
||||||
| **PHP extensions** | `bcmath` `ctype` `curl` `dom` `fileinfo` `filter` `gd` `iconv` `intl` `json` `ldap` `mbstring` `openssl` `pcntl` `pdo_mysql` `session` `simplexml` `tokenizer` `zip` |
|
| **PHP extensions** | `bcmath` `ctype` `curl` `dom` `fileinfo` `filter` `gd` `iconv` `intl` `json` `ldap` `mbstring` `openssl` `pcntl` `pdo_mysql` `session` `simplexml` `tokenizer` `zip` |
|
||||||
| **Database** | MySQL 8.0 or newer (we test on 8.4 LTS) |
|
| **Database** | MySQL 8.0 or newer (we test on 8.4 LTS) |
|
||||||
| **Web server** | **nginx**, with PHP-FPM — see the note below |
|
| **Web server** | Any, with PHP-FPM. **nginx is strongly recommended** — see the note below |
|
||||||
| **Disk space** | The app itself is small; plan for whatever your users will upload |
|
| **Disk space** | The app itself is small; plan for whatever your users will upload |
|
||||||
|
|
||||||
A few notes on that list:
|
A few notes on that list:
|
||||||
@@ -29,48 +29,92 @@ A few notes on that list:
|
|||||||
- **`ldap` is required even if you never use LDAP.** One of the libraries ProjectSend depends on
|
- **`ldap` is required even if you never use LDAP.** One of the libraries ProjectSend depends on
|
||||||
declares it, so PHP will refuse to start the app without it. On Debian/Ubuntu it is
|
declares it, so PHP will refuse to start the app without it. On Debian/Ubuntu it is
|
||||||
`php8.4-ldap`; on RHEL-family systems, `php-ldap`.
|
`php8.4-ldap`; on RHEL-family systems, `php-ldap`.
|
||||||
- **nginx is not a preference, it is a requirement.** See [Why nginx](#why-nginx) — it is worth
|
- **nginx is recommended, not required.** ProjectSend runs on Apache and LiteSpeed too, and
|
||||||
two minutes of reading before you commit to a server, because Apache cannot be made to work by
|
downloads work on them out of the box. What differs is *how* the bytes are sent: on nginx the
|
||||||
configuring it differently.
|
web server sends them, and everywhere else PHP does, which costs a worker process for the
|
||||||
|
duration of every download. See [How downloads are sent](#how-downloads-are-sent) before you
|
||||||
|
commit to a server — it is a capacity decision, not a compatibility one.
|
||||||
- **Redis is optional.** The Docker setup uses it, but a manual install works fine with the
|
- **Redis is optional.** The Docker setup uses it, but a manual install works fine with the
|
||||||
database for sessions, cache and queues. If you already have Redis, see
|
database for sessions, cache and queues. If you already have Redis, see
|
||||||
[Optional extras](#optional-extras) below.
|
[Optional extras](#optional-extras) below.
|
||||||
|
|
||||||
### Why nginx
|
### How downloads are sent
|
||||||
|
|
||||||
Your uploaded files do not live under `public/`. They sit in `storage/app/files/`, outside the web
|
Your uploaded files do not live under `public/`. They sit in `storage/app/files/`, outside the web
|
||||||
root, where no URL can reach them — which is the whole point: a file is only yours to download if
|
root, where no URL can reach them — which is the whole point: a file is only yours to download if
|
||||||
ProjectSend says so, and a file sitting in a guessable public folder has already lost that
|
ProjectSend says so, and a file sitting in a guessable public folder has already lost that
|
||||||
argument.
|
argument.
|
||||||
|
|
||||||
So every download has to pass through a permission check. The obvious way to do that is to let PHP
|
So every download has to pass through a permission check in PHP first. What happens *after* that
|
||||||
read the file and echo it back to the browser, and that is what most PHP applications do. It works,
|
check passes is the thing this section is about, and ProjectSend can do it two ways.
|
||||||
and it is a bad idea at any real size: a single 5 GB download occupies a PHP process for its entire
|
|
||||||
duration, so a handful of people downloading at once can exhaust every worker your server has while
|
|
||||||
the CPU sits idle. Resumable downloads, byte ranges and progress bars all have to be reimplemented
|
|
||||||
by hand, usually incorrectly.
|
|
||||||
|
|
||||||
ProjectSend does the other thing. PHP checks permissions, logs the download, and then answers with
|
**PHP sends the file.** It opens the file and writes it out to the visitor. This works on every
|
||||||
an empty response carrying a header that says *"nginx, please send this file."* nginx streams the
|
web server and needs no configuration, which is why it is what ProjectSend falls back to. The cost
|
||||||
bytes with the same code it uses for any static file — sendfile, byte ranges, resume support, no
|
is that one PHP worker process is occupied for the whole of each download — three minutes for a
|
||||||
PHP process held open — and the visitor never sees the real path. The header is
|
large file on a slow connection is three minutes that worker cannot answer anything else. A
|
||||||
`X-Accel-Redirect`, and the matching `location /protected-files/` block in
|
handful of concurrent large downloads can therefore occupy every worker you have and the site
|
||||||
[step 6](#step-6--point-your-web-server-at-it) is marked `internal`, which is what stops anyone
|
stops responding, with the processor idle and the workers all waiting on network transfers.
|
||||||
from requesting that path directly.
|
|
||||||
|
|
||||||
**Apache has no equivalent that ProjectSend can use.** Apache's closest feature, `mod_xsendfile`,
|
**The web server sends the file.** PHP answers with an empty response and a header naming the
|
||||||
reads a differently-named header (`X-Sendfile`) that ProjectSend does not send, and it is not
|
file, and finishes immediately; the web server streams the bytes with the same code it uses for
|
||||||
installed by default anyway. LiteSpeed has its own third spelling. On any of them the application
|
any static file — `sendfile`, byte ranges, resume support, no PHP process held open — and the
|
||||||
installs fine and every page works — you can log in, upload, manage clients, browse the library —
|
visitor never sees the real path. This is what you want on anything busy.
|
||||||
but **every download returns an empty response or a 404**, because nothing is listening for the
|
|
||||||
instruction PHP just gave. There is no setting to change; the header names simply do not match.
|
|
||||||
|
|
||||||
Two ways out, if nginx really is impossible on your hosting:
|
The second option needs a header, and **each web server reads a different one**, which is why
|
||||||
|
ProjectSend has to know which one it is talking to. It works this out from the server itself and
|
||||||
|
you can override it.
|
||||||
|
|
||||||
- Put nginx in front of Apache as a reverse proxy, serving `/protected-files/` itself. This works
|
| Your server | What ProjectSend does | What you need to configure |
|
||||||
but is more moving parts than just using nginx. Give the proxy some header headroom while you are
|
|---|---|---|
|
||||||
there — the same headroom the reference configuration in Step 6 gives PHP-FPM, in the directives a
|
| nginx | `X-Accel-Redirect` | The `location /protected-files/` block in [step 6](#step-6--point-your-web-server-at-it). Detected automatically |
|
||||||
proxy uses instead:
|
| Apache | PHP sends the file, unless you enable `mod_xsendfile` | See below |
|
||||||
|
| LiteSpeed / OpenLiteSpeed | PHP sends the file, unless you turn on X-Sendfile | See below |
|
||||||
|
| Anything else | PHP sends the file | Nothing |
|
||||||
|
|
||||||
|
**The dashboard tells you which one is in use.** The System panel has a "Downloads sent by" line,
|
||||||
|
with a warning icon and an explanation whenever PHP is doing the sending. You do not have to
|
||||||
|
remember to check this file.
|
||||||
|
|
||||||
|
#### Enabling X-Sendfile on Apache or LiteSpeed
|
||||||
|
|
||||||
|
Apache needs [`mod_xsendfile`](https://github.com/nmaier/mod_xsendfile) installed and enabled, and
|
||||||
|
a directive allowing it to serve your storage directory:
|
||||||
|
|
||||||
|
```apache
|
||||||
|
XSendFile On
|
||||||
|
XSendFilePath /home/projectsend/storage/app/files
|
||||||
|
```
|
||||||
|
|
||||||
|
LiteSpeed and OpenLiteSpeed read the same header without an extra module; enable it in the server
|
||||||
|
configuration.
|
||||||
|
|
||||||
|
Then tell ProjectSend to use it, in `.env`:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
PROJECTSEND_FILE_DELIVERY=xsendfile
|
||||||
|
```
|
||||||
|
|
||||||
|
**ProjectSend will not switch this on by itself**, even when it can see the module is loaded,
|
||||||
|
because it cannot see whether `XSendFilePath` allows the storage directory. Guessing wrong there
|
||||||
|
produces empty downloads rather than slow ones, and an empty download is a much worse failure than
|
||||||
|
a slow one — so this stays something you turn on having configured it.
|
||||||
|
|
||||||
|
#### Choosing explicitly
|
||||||
|
|
||||||
|
`PROJECTSEND_FILE_DELIVERY` accepts:
|
||||||
|
|
||||||
|
| Value | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `auto` | The default. nginx if the server says it is nginx, PHP otherwise |
|
||||||
|
| `nginx` | Always `X-Accel-Redirect`. Use this if nginx is proxying another server |
|
||||||
|
| `xsendfile` | Always `X-Sendfile`, for Apache with `mod_xsendfile`, or LiteSpeed |
|
||||||
|
| `php` | Always PHP. Correct and slow, and never wrong |
|
||||||
|
|
||||||
|
The one case `auto` gets wrong is **nginx reverse-proxying Apache**: PHP is talking to Apache, so
|
||||||
|
it picks PHP streaming, and downloads work but do not use the nginx in front. Set
|
||||||
|
`PROJECTSEND_FILE_DELIVERY=nginx` and make sure the front nginx serves `/protected-files/`. While
|
||||||
|
you are there, give the proxy some header headroom — the same headroom the reference configuration
|
||||||
|
in Step 6 gives PHP-FPM, in the directives a proxy uses instead:
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
proxy_buffer_size 32k;
|
proxy_buffer_size 32k;
|
||||||
@@ -84,11 +128,14 @@ Two ways out, if nginx really is impossible on your hosting:
|
|||||||
rather than as a misconfiguration. This applies to any proxy in front of ProjectSend, not just
|
rather than as a misconfiguration. This applies to any proxy in front of ProjectSend, not just
|
||||||
this one: Nginx Proxy Manager, Traefik and a hand-written nginx vhost all ship the same default.
|
this one: Nginx Proxy Manager, Traefik and a hand-written nginx vhost all ship the same default.
|
||||||
([#1664](https://github.com/projectsend/projectsend/issues/1664))
|
([#1664](https://github.com/projectsend/projectsend/issues/1664))
|
||||||
- Store your files in object storage instead — S3-compatible or Google Cloud Storage (see
|
|
||||||
|
#### Or take your server out of it entirely
|
||||||
|
|
||||||
|
Store your files in object storage — S3-compatible or Google Cloud Storage (see
|
||||||
[Storing files somewhere other than this server](#storing-files-somewhere-other-than-this-server)).
|
[Storing files somewhere other than this server](#storing-files-somewhere-other-than-this-server)).
|
||||||
Files kept there are never on your server's disk, so downloads become a signed, expiring redirect
|
Files kept there are never on your server's disk, so downloads become a signed, expiring redirect
|
||||||
to the storage provider and the web server is not involved at all. This is a genuine, supported
|
to the storage provider and the web server is not involved at all. Decide this before people start
|
||||||
path — just decide it before people start uploading, not after.
|
uploading, not after.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -216,10 +263,10 @@ FILES_WEB_SERVER_READABLE=true
|
|||||||
|
|
||||||
Uploaded files are written `0600` inside `0700` directories, readable only by the user that wrote
|
Uploaded files are written `0600` inside `0700` directories, readable only by the user that wrote
|
||||||
them. That is deliberate, and on a same-user server it is the safer setting. But a download is not
|
them. That is deliberate, and on a same-user server it is the safer setting. But a download is not
|
||||||
served by PHP: PHP checks permissions and then hands the web server the path with `X-Accel-Redirect`
|
served by PHP on nginx: PHP checks permissions and then hands the web server the path with
|
||||||
(see [Why nginx](#why-nginx)), so the web server has to open a file PHP owns. When it cannot, **the
|
`X-Accel-Redirect` (see [How downloads are sent](#how-downloads-are-sent)), so the web server has
|
||||||
whole site works and only downloads fail** — the browser reports `ERR_INVALID_RESPONSE` and the
|
to open a file PHP owns. When it cannot, **the whole site works and only downloads fail** — the
|
||||||
nginx error log says:
|
browser reports `ERR_INVALID_RESPONSE` and the nginx error log says:
|
||||||
|
|
||||||
```
|
```
|
||||||
open() ".../storage/app/files/..." failed (13: Permission denied)
|
open() ".../storage/app/files/..." failed (13: Permission denied)
|
||||||
@@ -277,8 +324,9 @@ like your logo reachable from the web.
|
|||||||
|
|
||||||
## Step 6 — Point your web server at it
|
## Step 6 — Point your web server at it
|
||||||
|
|
||||||
A complete nginx server block. Change `server_name`, and change `/var/www/projectsend` to wherever
|
A complete nginx server block below; [Apache is further down](#if-you-are-using-apache). Change
|
||||||
you unpacked the files (there are **three** places, including one inside `/protected-files/`):
|
`server_name`, and change `/var/www/projectsend` to wherever you unpacked the files (there are
|
||||||
|
**three** places, including one inside `/protected-files/`):
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
@@ -327,6 +375,38 @@ server {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### If you are using Apache
|
||||||
|
|
||||||
|
Two things matter, and both are easy to get wrong:
|
||||||
|
|
||||||
|
- **The document root is the `public/` directory**, not the directory you unpacked into. Everything
|
||||||
|
above `public/` — your `.env`, your uploaded files, the application code — has to stay out of
|
||||||
|
reach of any URL.
|
||||||
|
- **`AllowOverride All`, and `mod_rewrite` enabled** (`sudo a2enmod rewrite`). ProjectSend ships a
|
||||||
|
`public/.htaccess` that sends every address to the front controller. If Apache is told to ignore
|
||||||
|
it, every page except the home page is a 404.
|
||||||
|
|
||||||
|
```apache
|
||||||
|
<VirtualHost *:80>
|
||||||
|
ServerName files.example.com
|
||||||
|
DocumentRoot /var/www/projectsend/public
|
||||||
|
|
||||||
|
<Directory /var/www/projectsend/public>
|
||||||
|
AllowOverride All
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
ErrorLog ${APACHE_LOG_DIR}/projectsend-error.log
|
||||||
|
CustomLog ${APACHE_LOG_DIR}/projectsend-access.log combined
|
||||||
|
</VirtualHost>
|
||||||
|
```
|
||||||
|
|
||||||
|
Downloads work as they are: PHP sends the bytes. If that becomes a capacity problem, `mod_xsendfile`
|
||||||
|
hands the job to Apache — see [How downloads are sent](#how-downloads-are-sent).
|
||||||
|
|
||||||
|
On shared hosting you usually cannot edit any of this, and `public/.htaccess` is all you have. If
|
||||||
|
the site returns a 500 on every page, see [When something goes wrong](#when-something-goes-wrong).
|
||||||
|
|
||||||
Then check your PHP settings. Large uploads are sent in 20 MB pieces, so PHP never has to handle a
|
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`:
|
whole 5 GB file at once — but the pieces still need room. In your `php.ini`:
|
||||||
|
|
||||||
@@ -437,6 +517,36 @@ the place to configure it — the settings screen also has a "send test email" b
|
|||||||
save you a lot of guessing. The `MAIL_*` values in `.env` are only used until you fill that screen
|
save you a lot of guessing. The `MAIL_*` values in `.env` are only used until you fill that screen
|
||||||
in.
|
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
|
### Redis
|
||||||
|
|
||||||
If you have Redis available, it is faster than the database for sessions, cache and queues. Install
|
If you have Redis available, it is faster than the database for sessions, cache and queues. Install
|
||||||
@@ -534,6 +644,28 @@ names the exact command to run; do that, then reload.
|
|||||||
Look in `storage/logs/` — open the newest file, the real error is at the bottom. Nine times out of ten it is
|
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).
|
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".**
|
**"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.
|
`storage/` or `bootstrap/cache/` is not writable by the web server user. Step 4.
|
||||||
|
|
||||||
@@ -546,9 +678,15 @@ That is correct behaviour until the first administrator exists. Finish step 7. I
|
|||||||
created one and it still happens, ProjectSend cannot reach your database — check `storage/logs/`.
|
created one and it still happens, ProjectSend cannot reach your database — check `storage/logs/`.
|
||||||
|
|
||||||
**Pages load but downloads give a 404, or download a 0-byte file.**
|
**Pages load but downloads give a 404, or download a 0-byte file.**
|
||||||
The `/protected-files/` block is missing from your nginx config, or its `alias` path does not match
|
On nginx, the `/protected-files/` block is missing from your config, or its `alias` path does not
|
||||||
where you installed ProjectSend. It must point at `storage/app/files/` and end with a slash. If you
|
match where you installed ProjectSend. It must point at `storage/app/files/` and end with a slash.
|
||||||
are on Apache or LiteSpeed, no configuration will fix this — see [Why nginx](#why-nginx).
|
|
||||||
|
On any server, check the "Downloads sent by" line in the dashboard's System panel against the
|
||||||
|
server you are actually running. A 0-byte download means ProjectSend sent a header the server did
|
||||||
|
not act on — most often `PROJECTSEND_FILE_DELIVERY` set to `nginx` or `xsendfile` on a server that
|
||||||
|
is neither, or set to `xsendfile` without `XSendFilePath` allowing the storage directory. Setting
|
||||||
|
`PROJECTSEND_FILE_DELIVERY=php` always works and is the quickest way to confirm that is the
|
||||||
|
problem. See [How downloads are sent](#how-downloads-are-sent).
|
||||||
|
|
||||||
**Uploads fail partway through.**
|
**Uploads fail partway through.**
|
||||||
`client_max_body_size` in nginx, or `upload_max_filesize` / `post_max_size` in `php.ini`, is
|
`client_max_body_size` in nginx, or `upload_max_filesize` / `post_max_size` in `php.ini`, is
|
||||||
|
|||||||
+67
-4
@@ -107,10 +107,13 @@ section on its own.
|
|||||||
### If you installed from a release zip
|
### If you installed from a release zip
|
||||||
|
|
||||||
```sh
|
```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
|
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
|
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
|
already compiled and deliberately without the toolchain that compiled them, so there is no
|
||||||
`package.json` to build from.
|
`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 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 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
|
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
|
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** |
|
| `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 |
|
| `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**
|
If ProjectSend runs in Docker, Direct needs two things the container does not have by default: the
|
||||||
— bind-mount it there, and use the container's path, not the host's.
|
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
|
```sh
|
||||||
php artisan projectsend:migrate:reset --drop # also drops the tool's own tables
|
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
|
`--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
|
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.
|
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
|
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.
|
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
|
## What it does
|
||||||
|
|
||||||
**For the people you send to**
|
**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
|
- Privacy controls, including GDPR-grade account erasure with a grace period
|
||||||
- Local disk, S3-compatible storage, or Google Cloud Storage
|
- 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
|
## Getting started
|
||||||
|
|
||||||
**With Docker** — the quickest path, and the one we recommend. Nothing to build: the published
|
**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.
|
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
|
```sh
|
||||||
curl -O https://raw.githubusercontent.com/projectsend/projectsend/main/docker/production/compose.example.yaml
|
curl -o compose.yaml https://raw.githubusercontent.com/projectsend/projectsend/main/docker/production/compose.example.yaml
|
||||||
# edit the passwords and APP_URL in it, then:
|
# edit the passwords and APP_URL in compose.yaml, then:
|
||||||
docker compose -f compose.example.yaml up -d
|
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
|
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
|
account — or uncomment `ADMIN_EMAIL` and `ADMIN_PASSWORD` in the file first, with a password of
|
||||||
your own, and it is created for you.
|
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
|
needs Composer and npm before it runs. **[CONTRIBUTING.md](CONTRIBUTING.md)** has the sequence, and
|
||||||
it is short.
|
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?
|
## Coming from ProjectSend Legacy?
|
||||||
|
|
||||||
The previous generation of ProjectSend lives on at
|
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\Controllers\Controller;
|
||||||
use App\Http\Requests\Auth\LoginRequest;
|
use App\Http\Requests\Auth\LoginRequest;
|
||||||
|
use App\Modules\Identity\StartPages;
|
||||||
use App\Modules\Platform\Settings\Setting;
|
use App\Modules\Platform\Settings\Setting;
|
||||||
use App\Modules\Platform\Settings\Settings;
|
use App\Modules\Platform\Settings\Settings;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
@@ -30,7 +31,7 @@ class AuthenticatedSessionController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Handle an incoming authentication request.
|
* Handle an incoming authentication request.
|
||||||
*/
|
*/
|
||||||
public function store(LoginRequest $request): RedirectResponse
|
public function store(LoginRequest $request, StartPages $startPages): RedirectResponse
|
||||||
{
|
{
|
||||||
if ($request->authenticate()) {
|
if ($request->authenticate()) {
|
||||||
return redirect()->route('two-factor.challenge');
|
return redirect()->route('two-factor.challenge');
|
||||||
@@ -38,7 +39,10 @@ class AuthenticatedSessionController extends Controller
|
|||||||
|
|
||||||
$request->session()->regenerate();
|
$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;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Modules\Identity\AuthSource;
|
||||||
|
use App\Modules\Identity\PasswordVerification;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Http\Response as HttpResponse;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
@@ -15,23 +17,48 @@ class ConfirmablePasswordController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Show the confirm password page.
|
* Show the confirm password page.
|
||||||
*/
|
*/
|
||||||
public function show(): Response
|
public function show(Request $request): Response
|
||||||
{
|
|
||||||
return Inertia::render('auth/confirm-password');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Confirm the user's password.
|
|
||||||
*/
|
|
||||||
public function store(Request $request): RedirectResponse
|
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
assert($user !== null);
|
assert($user !== null);
|
||||||
|
|
||||||
if (! Auth::guard('web')->validate([
|
return Inertia::render('auth/confirm-password', [
|
||||||
'email' => $user->email,
|
// An account provisioned by a provider has no password to
|
||||||
'password' => $request->password,
|
// confirm with — its stored hash is a generated string nobody
|
||||||
])) {
|
// has seen. The screen offers to set one instead of asking for
|
||||||
|
// it, which is the only way past this for those accounts, and
|
||||||
|
// this screen stands in front of two-factor enrolment.
|
||||||
|
//
|
||||||
|
// Social, not "anything but Local": a directory account has a
|
||||||
|
// password -- the directory's -- and store() accepts it. Asking
|
||||||
|
// whether the account was Local told those accounts to set one
|
||||||
|
// here instead, which /settings/password refuses them, and left
|
||||||
|
// them no way past this screen at all.
|
||||||
|
'has_password' => $user->auth_source !== AuthSource::Social,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm the user's password.
|
||||||
|
*
|
||||||
|
* Through PasswordVerification, so this asks the same question the
|
||||||
|
* sign-in form asks: is this the account's password, from wherever
|
||||||
|
* that account's password lives. Checking only the local hash refused
|
||||||
|
* every directory-provisioned account the password it actually has --
|
||||||
|
* their local hash is a Str::password(64) nobody has ever seen -- and
|
||||||
|
* this screen stands in front of enrolling in two-factor, so those
|
||||||
|
* accounts could not enrol at all.
|
||||||
|
*
|
||||||
|
* Asked for JSON, it answers with a bare 204: that is the password
|
||||||
|
* dialog (RequirePasswordConfirmation), which stays on the page and
|
||||||
|
* sends the refused request again itself, so there is nowhere to go.
|
||||||
|
*/
|
||||||
|
public function store(Request $request, PasswordVerification $passwords): RedirectResponse|HttpResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
assert($user !== null);
|
||||||
|
|
||||||
|
if (! $passwords->verify($user, (string) $request->string('password'))) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'password' => __('auth.password'),
|
'password' => __('auth.password'),
|
||||||
]);
|
]);
|
||||||
@@ -39,6 +66,10 @@ class ConfirmablePasswordController extends Controller
|
|||||||
|
|
||||||
$request->session()->put('auth.password_confirmed_at', time());
|
$request->session()->put('auth.password_confirmed_at', time());
|
||||||
|
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()->intended(route('dashboard', absolute: false));
|
return redirect()->intended(route('dashboard', absolute: false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Modules\Identity\AuthSource;
|
||||||
|
use App\Modules\Identity\Ldap\LdapAuthenticator;
|
||||||
use Illuminate\Auth\Events\PasswordReset;
|
use Illuminate\Auth\Events\PasswordReset;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -16,6 +18,10 @@ use Inertia\Response;
|
|||||||
|
|
||||||
class NewPasswordController extends Controller
|
class NewPasswordController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly LdapAuthenticator $ldap,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show the password reset page.
|
* Show the password reset page.
|
||||||
*/
|
*/
|
||||||
@@ -24,9 +30,53 @@ class NewPasswordController extends Controller
|
|||||||
return Inertia::render('auth/reset-password', [
|
return Inertia::render('auth/reset-password', [
|
||||||
'email' => $request->email,
|
'email' => $request->email,
|
||||||
'token' => $request->route('token'),
|
'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.
|
* Handle an incoming new password request.
|
||||||
*
|
*
|
||||||
@@ -46,10 +96,55 @@ class NewPasswordController extends Controller
|
|||||||
$status = Password::reset(
|
$status = Password::reset(
|
||||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||||
function ($user) use ($request) {
|
function ($user) use ($request) {
|
||||||
$user->forceFill([
|
// A directory account's password lives in the directory and
|
||||||
|
// the local hash is not consulted at all, which is what
|
||||||
|
// isDirectoryAccount() means. Writing one here reported
|
||||||
|
// success and changed nothing anybody could use -- including
|
||||||
|
// when the directory it points at is gone, which is exactly
|
||||||
|
// when somebody reaches for a reset.
|
||||||
|
//
|
||||||
|
// Refused here rather than where the link is asked for: that
|
||||||
|
// endpoint answers "A reset link will be sent if the account
|
||||||
|
// exists" to everybody on purpose, and a refusal there would
|
||||||
|
// tell a stranger both that an address is an account and how
|
||||||
|
// it signs in. By this point the caller holds a token that
|
||||||
|
// was emailed to the address, so the explanation reaches the
|
||||||
|
// account holder and nobody else.
|
||||||
|
//
|
||||||
|
// Throwing before the write also leaves the token unspent:
|
||||||
|
// PasswordBroker deletes it after the callback returns, so
|
||||||
|
// the link still works if an administrator converts the
|
||||||
|
// account in the meantime.
|
||||||
|
if ($this->ldap->isDirectoryAccount($user)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => [__('This account signs in through your directory, so its password is not set here. Ask an administrator if you cannot sign in.')],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$attributes = [
|
||||||
'password' => Hash::make($request->password),
|
'password' => Hash::make($request->password),
|
||||||
'remember_token' => Str::random(60),
|
'remember_token' => Str::random(60),
|
||||||
])->save();
|
];
|
||||||
|
|
||||||
|
// `social` records that the account came into existence
|
||||||
|
// without anybody choosing a password, which AuthSource
|
||||||
|
// states outright -- along with "a social account may later
|
||||||
|
// set a real password". This is that moment, and nothing
|
||||||
|
// else in the application writes it: the Connected accounts
|
||||||
|
// screen reads `auth_source === Local` as
|
||||||
|
// `has_local_password`, so without this line its refusal
|
||||||
|
// goes on asking for a password that has just been set.
|
||||||
|
//
|
||||||
|
// The two branches of this method are the same rule read
|
||||||
|
// twice: `social` is where the account came from and the
|
||||||
|
// hash here is what signs it in, so choosing one settles it;
|
||||||
|
// `ldap` is the authentication path itself, so nothing
|
||||||
|
// chosen here settles anything.
|
||||||
|
if ($user->auth_source === AuthSource::Social) {
|
||||||
|
$attributes['auth_source'] = AuthSource::Local;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->forceFill($attributes)->save();
|
||||||
|
|
||||||
event(new PasswordReset($user));
|
event(new PasswordReset($user));
|
||||||
}
|
}
|
||||||
@@ -62,8 +157,21 @@ class NewPasswordController extends Controller
|
|||||||
return to_route('login')->with('status', __($status));
|
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([
|
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\Http\Controllers\Controller;
|
||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
|
use App\Modules\Identity\AuthSource;
|
||||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -21,9 +22,21 @@ class PasswordController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function edit(Request $request): Response
|
public function edit(Request $request): Response
|
||||||
{
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
assert($user !== null);
|
||||||
|
|
||||||
return Inertia::render('settings/password', [
|
return Inertia::render('settings/password', [
|
||||||
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
|
'mustVerifyEmail' => $user instanceof MustVerifyEmail,
|
||||||
'status' => $request->session()->get('status'),
|
'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
|
public function update(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
|
||||||
'current_password' => ['required', 'current_password'],
|
|
||||||
'password' => ['required', Password::defaults(), 'confirmed'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
assert($user !== null);
|
assert($user !== null);
|
||||||
|
|
||||||
$user->update([
|
// An LDAP account's password lives in the directory. Changing the
|
||||||
'password' => Hash::make($validated['password']),
|
// 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
|
// Changing a password is how someone reacts to a session they think
|
||||||
// is stolen, so it has to actually end that session. AuthenticateSession
|
// is stolen, so it has to actually end that session. AuthenticateSession
|
||||||
// (registered on the web group) compares each request's stored
|
// (registered on the web group) compares each request's stored
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ use App\Modules\Audit\Action;
|
|||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
use App\Modules\Clients\ClientFieldContext;
|
use App\Modules\Clients\ClientFieldContext;
|
||||||
use App\Modules\Clients\ClientPortalCustomFields;
|
use App\Modules\Clients\ClientPortalCustomFields;
|
||||||
|
use App\Modules\Files\DeletedAccountContent;
|
||||||
use App\Modules\Identity\Erasure\ErasureSchedule;
|
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\Localization\TimezoneRegistry;
|
||||||
use App\Modules\Platform\Settings\Setting;
|
use App\Modules\Platform\Settings\Setting;
|
||||||
use App\Modules\Platform\Settings\Settings;
|
use App\Modules\Platform\Settings\Settings;
|
||||||
@@ -16,6 +21,7 @@ use Illuminate\Contracts\Auth\MustVerifyEmail;
|
|||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
@@ -24,6 +30,8 @@ class ProfileController extends Controller
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ClientPortalCustomFields $customFields,
|
private readonly ClientPortalCustomFields $customFields,
|
||||||
private readonly TimezoneRegistry $timezones,
|
private readonly TimezoneRegistry $timezones,
|
||||||
|
private readonly StaffAccounts $accounts,
|
||||||
|
private readonly StartPages $startPages,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,6 +50,12 @@ class ProfileController extends Controller
|
|||||||
// browser was detected as, not something they ever chose.
|
// browser was detected as, not something they ever chose.
|
||||||
'timezone' => $this->timezones->resolve($user),
|
'timezone' => $this->timezones->resolve($user),
|
||||||
'timezones' => $this->timezones->options(),
|
'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_fields' => $user->isClient() ? $this->customFields->rows(ClientFieldContext::AccountEdit, $user) : [],
|
||||||
'custom_field_values' => $user->isClient() ? $this->customFields->values(ClientFieldContext::AccountEdit, $user) : [],
|
'custom_field_values' => $user->isClient() ? $this->customFields->values(ClientFieldContext::AccountEdit, $user) : [],
|
||||||
]);
|
]);
|
||||||
@@ -56,10 +70,20 @@ class ProfileController extends Controller
|
|||||||
* you scroll past on the way to saving your email address. The delete
|
* you scroll past on the way to saving your email address. The delete
|
||||||
* itself still goes to destroy() below.
|
* 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', [
|
return Inertia::render('settings/delete-account', [
|
||||||
'erasureGraceDays' => (int) app(Settings::class)->get(Setting::AccountErasureGraceDays),
|
'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(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,15 +128,45 @@ class ProfileController extends Controller
|
|||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
assert($user !== null);
|
assert($user !== null);
|
||||||
|
|
||||||
|
// The rule every other door into this already asks: Staff update(),
|
||||||
|
// guardDeletable(), and both role-conversion directions. This one
|
||||||
|
// did not, and self-deletion is the one door where the account
|
||||||
|
// being removed is certainly signed in — so the last active
|
||||||
|
// administrator could take themselves out, leaving no live staff
|
||||||
|
// row at all. EnsureSetupIsComplete then reopens first-run setup to
|
||||||
|
// anybody who asks, which is the other half of this and is closed
|
||||||
|
// below.
|
||||||
|
$this->accounts->guardLastAdministrator(
|
||||||
|
$user,
|
||||||
|
removesAdmin: $this->accounts->isAdministratorRole($user->role_id),
|
||||||
|
);
|
||||||
|
|
||||||
Auth::logout();
|
Auth::logout();
|
||||||
|
|
||||||
// Self-deletion: soft delete now, permanent GDPR erasure after
|
// Self-deletion: soft delete now, permanent GDPR erasure after
|
||||||
// the disclosed grace period (Setting::AccountErasureGraceDays).
|
// the disclosed grace period (Setting::AccountErasureGraceDays).
|
||||||
|
//
|
||||||
|
// 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);
|
app(ErasureSchedule::class)->apply($user);
|
||||||
$user->delete();
|
$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()->invalidate();
|
||||||
$request->session()->regenerateToken();
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ use App\Modules\Platform\Attribution\Attribution;
|
|||||||
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||||
use App\Modules\Platform\Captcha\Captcha;
|
use App\Modules\Platform\Captcha\Captcha;
|
||||||
use App\Modules\Platform\Installation\Installation;
|
use App\Modules\Platform\Installation\Installation;
|
||||||
|
use App\Modules\Files\Models\File;
|
||||||
use App\Modules\Files\Queue\StalledZipBuilds;
|
use App\Modules\Files\Queue\StalledZipBuilds;
|
||||||
|
use App\Modules\Files\Scanning\ScanStatus;
|
||||||
use App\Modules\Platform\Localization\LocaleRegistry;
|
use App\Modules\Platform\Localization\LocaleRegistry;
|
||||||
use App\Modules\Platform\Localization\TimezoneRegistry;
|
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||||
use App\Modules\Platform\OfficialLinks;
|
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\LatestReleaseInfo;
|
||||||
use App\Modules\Platform\Updates\RunningCodeState;
|
use App\Modules\Platform\Updates\RunningCodeState;
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
|
use App\Modules\Platform\Announcements\Events\ResolvingAnnouncement;
|
||||||
|
use App\Modules\Platform\Navigation\Events\ResolvingNavigationLinks;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
use Inertia\Middleware;
|
use Inertia\Middleware;
|
||||||
|
|
||||||
class HandleInertiaRequests extends Middleware
|
class HandleInertiaRequests extends Middleware
|
||||||
@@ -84,6 +89,19 @@ class HandleInertiaRequests extends Middleware
|
|||||||
// ignore this and always show it.
|
// ignore this and always show it.
|
||||||
'attribution' => app(Attribution::class)->visible(),
|
'attribution' => app(Attribution::class)->visible(),
|
||||||
'capabilities' => $capabilities->enabledKeys(),
|
'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,
|
// Shared rather than passed by each page: the sign-in buttons,
|
||||||
// the registration form and the Connected accounts nav entry
|
// the registration form and the Connected accounts nav entry
|
||||||
// all need the same list, and a nav entry to a screen with
|
// 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);
|
$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
|
// Unlike the counts above, every authenticated user (staff or
|
||||||
// client) has their own personal notifications — no permission
|
// client) has their own personal notifications — no permission
|
||||||
// gate here.
|
// gate here.
|
||||||
@@ -301,4 +331,48 @@ class HandleInertiaRequests extends Middleware
|
|||||||
/** @var array<string, string> */
|
/** @var array<string, string> */
|
||||||
return app('translator')->getLoader()->load($locale, '*', '*');
|
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,13 +3,13 @@
|
|||||||
namespace App\Http\Requests\Auth;
|
namespace App\Http\Requests\Auth;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Modules\Identity\Ldap\LdapAuthenticator;
|
use App\Modules\Identity\AccountLookup;
|
||||||
use App\Modules\Identity\Ldap\LdapProvisioner;
|
use App\Modules\Identity\Ldap\LdapProvisioner;
|
||||||
|
use App\Modules\Identity\PasswordVerification;
|
||||||
use App\Modules\Identity\SignIn;
|
use App\Modules\Identity\SignIn;
|
||||||
use App\Modules\Platform\Captcha\CaptchaForm;
|
use App\Modules\Platform\Captcha\CaptchaForm;
|
||||||
use App\Support\Rules;
|
use App\Support\Rules;
|
||||||
use Illuminate\Auth\Events\Lockout;
|
use Illuminate\Auth\Events\Lockout;
|
||||||
use Illuminate\Auth\SessionGuard;
|
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -72,7 +72,13 @@ class LoginRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
$this->ensureIsNotRateLimited();
|
$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
|
// A directory identity with no local account yet. Returns null
|
||||||
// unless LDAP is on, auto-provisioning is on, and the bind
|
// unless LDAP is on, auto-provisioning is on, and the bind
|
||||||
@@ -115,10 +121,9 @@ class LoginRequest extends FormRequest
|
|||||||
/**
|
/**
|
||||||
* The account whose password checks out, or null.
|
* The account whose password checks out, or null.
|
||||||
*
|
*
|
||||||
* The local hash is tried first and the directory only on failure, so
|
* The rule itself -- local hash first, directory when the credentials
|
||||||
* a login that succeeds locally never generates directory traffic.
|
* live there -- is PasswordVerification's, because this is no longer
|
||||||
* The exception is an account whose credentials are known to live in
|
* the only screen that has to ask it. See that class.
|
||||||
* the directory, where the local hash is a placeholder nobody holds.
|
|
||||||
*/
|
*/
|
||||||
private function verifyCredentials(?User $user): ?User
|
private function verifyCredentials(?User $user): ?User
|
||||||
{
|
{
|
||||||
@@ -126,67 +131,9 @@ class LoginRequest extends FormRequest
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ldap = app(LdapAuthenticator::class);
|
return app(PasswordVerification::class)->verify($user, (string) $this->string('password'))
|
||||||
|
? $user
|
||||||
if (! $ldap->isDirectoryAccount($user)
|
: null;
|
||||||
&& Auth::validate($this->only('email', 'password'))) {
|
|
||||||
$this->upgradeHashIfStale($user);
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
$identity = $ldap->attempt(
|
|
||||||
(string) $this->string('email'),
|
|
||||||
(string) $this->string('password'),
|
|
||||||
$user,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($identity === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$ldap->stamp($user, $identity);
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-hash a password stored under weaker settings than this
|
|
||||||
* installation now uses.
|
|
||||||
*
|
|
||||||
* Laravel does this for you inside SessionGuard::attempt(), but this
|
|
||||||
* form does not use attempt() — it verifies with Auth::validate() and
|
|
||||||
* hands the account to SignIn, which calls Auth::login(). Neither
|
|
||||||
* re-hashes, so without this an account keeps whatever cost it was
|
|
||||||
* created under forever, and raising BCRYPT_ROUNDS would quietly
|
|
||||||
* apply to new accounts only.
|
|
||||||
*
|
|
||||||
* That is not hypothetical: every account the v1 migration carries
|
|
||||||
* across arrives as `$2y$08$…`, because v1 hashed at cost 8, and
|
|
||||||
* would otherwise stay four times cheaper to attack than an account
|
|
||||||
* created here.
|
|
||||||
*
|
|
||||||
* **Only ever called on the local branch.** On the directory branch
|
|
||||||
* the submitted plaintext is the *LDAP* password and the local hash
|
|
||||||
* is a `Str::password(64)` placeholder nobody holds; writing the
|
|
||||||
* directory credential into it would mint a second way into the
|
|
||||||
* account that keeps working after LDAP is switched off.
|
|
||||||
*/
|
|
||||||
private function upgradeHashIfStale(User $user): void
|
|
||||||
{
|
|
||||||
$guard = Auth::guard('web');
|
|
||||||
|
|
||||||
// getProvider() is on SessionGuard rather than on the StatefulGuard
|
|
||||||
// contract. This guard is a SessionGuard in every configuration this
|
|
||||||
// application ships; the check is here so a custom driver degrades
|
|
||||||
// to "no re-hash" instead of a fatal on the login path.
|
|
||||||
if (! $guard instanceof SessionGuard) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No-ops unless the hasher says the stored digest needs it, so
|
|
||||||
// this costs an already-current account nothing.
|
|
||||||
$guard->getProvider()->rehashPasswordIfRequired($user, $this->only('password'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ namespace App\Http\Requests\Settings;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Modules\Clients\ClientFieldContext;
|
use App\Modules\Clients\ClientFieldContext;
|
||||||
use App\Modules\Clients\ClientPortalCustomFields;
|
use App\Modules\Clients\ClientPortalCustomFields;
|
||||||
|
use App\Modules\Identity\AuthSource;
|
||||||
|
use App\Modules\Identity\StartPages;
|
||||||
use App\Support\Rules;
|
use App\Support\Rules;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Closure;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class ProfileUpdateRequest extends FormRequest
|
class ProfileUpdateRequest extends FormRequest
|
||||||
@@ -31,6 +34,26 @@ class ProfileUpdateRequest extends FormRequest
|
|||||||
Rule::unique(User::class)->ignore($this->user()?->id),
|
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
|
// Saved with the rest of the profile so the screen keeps one
|
||||||
// Save button. `timezone` is fillable, so ProfileController's
|
// Save button. `timezone` is fillable, so ProfileController's
|
||||||
// fill() picks it up with no special handling.
|
// fill() picks it up with no special handling.
|
||||||
@@ -43,6 +66,28 @@ class ProfileUpdateRequest extends FormRequest
|
|||||||
];
|
];
|
||||||
|
|
||||||
$user = $this->user();
|
$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) {
|
if ($user?->isClient() === true) {
|
||||||
$rules = [
|
$rules = [
|
||||||
...$rules,
|
...$rules,
|
||||||
@@ -52,4 +97,29 @@ class ProfileUpdateRequest extends FormRequest
|
|||||||
|
|
||||||
return $rules;
|
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 bool $account_requested
|
||||||
* @property string|null $locale
|
* @property string|null $locale
|
||||||
* @property string|null $timezone
|
* @property string|null $timezone
|
||||||
|
* @property string|null $start_page a StartPage value; see StartPages
|
||||||
* @property int|null $dashboard_columns
|
* @property int|null $dashboard_columns
|
||||||
* @property int $storage_quota_mb
|
* @property int $storage_quota_mb
|
||||||
* @property Carbon|null $erase_after
|
* @property Carbon|null $erase_after
|
||||||
|
* @property \Carbon\Carbon|null $expires_at
|
||||||
* @property-read Role|null $role
|
* @property-read Role|null $role
|
||||||
*/
|
*/
|
||||||
class User extends Authenticatable implements HasLocalePreference
|
class User extends Authenticatable implements HasLocalePreference
|
||||||
@@ -54,6 +56,9 @@ class User extends Authenticatable implements HasLocalePreference
|
|||||||
'password',
|
'password',
|
||||||
'locale',
|
'locale',
|
||||||
'timezone',
|
'timezone',
|
||||||
|
// A personal preference, like timezone: the profile form fills it
|
||||||
|
// from its own validated request. See StartPages.
|
||||||
|
'start_page',
|
||||||
'dashboard_columns',
|
'dashboard_columns',
|
||||||
'storage_quota_mb',
|
'storage_quota_mb',
|
||||||
];
|
];
|
||||||
@@ -96,6 +101,32 @@ class User extends Authenticatable implements HasLocalePreference
|
|||||||
return $this->isStaff() && $this->role?->client_scoped === true;
|
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
|
public function hasTwoFactorEnabled(): bool
|
||||||
{
|
{
|
||||||
return $this->two_factor_confirmed_at !== null;
|
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
|
// credentials live is a security decision, not an attribute a
|
||||||
// form or an API payload may set. Written with forceFill by
|
// form or an API payload may set. Written with forceFill by
|
||||||
// the code that provisions the account.
|
// 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,
|
'auth_source' => AuthSource::class,
|
||||||
'ldap_synced_at' => 'datetime',
|
'ldap_synced_at' => 'datetime',
|
||||||
'active' => 'boolean',
|
'active' => 'boolean',
|
||||||
'account_requested' => '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',
|
'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',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
'two_factor_secret' => 'encrypted',
|
'two_factor_secret' => 'encrypted',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Models\User;
|
|||||||
use App\Modules\Api\Auth\ApiTokens;
|
use App\Modules\Api\Auth\ApiTokens;
|
||||||
use App\Modules\Api\Models\ApiRequestLog;
|
use App\Modules\Api\Models\ApiRequestLog;
|
||||||
use App\Modules\Audit\ActivityLog;
|
use App\Modules\Audit\ActivityLog;
|
||||||
|
use App\Modules\Audit\ActivityLogScope;
|
||||||
use App\Modules\Audit\ActivityOrigin;
|
use App\Modules\Audit\ActivityOrigin;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -27,6 +28,7 @@ class ApiUsage
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ApiUsageScope $scope,
|
private readonly ApiUsageScope $scope,
|
||||||
|
private readonly ActivityLogScope $activityLog,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -145,7 +147,23 @@ class ApiUsage
|
|||||||
*/
|
*/
|
||||||
public function recentActions(User $viewer, bool $installWide, int $limit = 15): array
|
public function recentActions(User $viewer, bool $installWide, int $limit = 15): array
|
||||||
{
|
{
|
||||||
$query = ActivityLog::query()->where('origin', ActivityOrigin::Api);
|
// Narrowed through ActivityLogScope, exactly as the activity page,
|
||||||
|
// the download history and the dashboard widget are.
|
||||||
|
// `view_actions_log` decides whether the install-wide view opens at
|
||||||
|
// all, but it is not the whole answer for a client-scoped viewer: a
|
||||||
|
// row carries the subject's name, so an unscoped feed reads out file
|
||||||
|
// and client names to somebody who gets a 403 on the files
|
||||||
|
// themselves. The Client Manager role ships with the permission, so
|
||||||
|
// this is the default configuration, not an exotic one.
|
||||||
|
//
|
||||||
|
// Applied on both sides of the branch rather than only in the
|
||||||
|
// install-wide one: the own-actor filter below already stays inside
|
||||||
|
// what the scope allows, and a boundary that only exists in one arm
|
||||||
|
// of an `if` is one refactor away from not existing.
|
||||||
|
$query = $this->activityLog->apply(
|
||||||
|
ActivityLog::query()->where('origin', ActivityOrigin::Api),
|
||||||
|
$viewer,
|
||||||
|
);
|
||||||
|
|
||||||
if (! $installWide) {
|
if (! $installWide) {
|
||||||
$query->where('actor_id', $viewer->id);
|
$query->where('actor_id', $viewer->id);
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The token twin of Identity's EnsureAccountIsActive: deactivating an
|
* The token twin of Identity's EnsureAccountIsActive: deactivating an
|
||||||
* account revokes its API access on the very next request, without anyone
|
* account, or its expiry date passing, revokes its API access on the very
|
||||||
* having to hunt down the tokens it minted.
|
* next request, without anyone having to hunt down the tokens it minted.
|
||||||
*
|
*
|
||||||
* Deleted accounts need no equivalent — users are soft-deleted and the
|
* Deleted accounts need no equivalent — users are soft-deleted and the
|
||||||
* default query scope means Sanctum simply fails to resolve the tokenable,
|
* default query scope means Sanctum simply fails to resolve the tokenable,
|
||||||
@@ -26,7 +26,7 @@ class EnsureApiAccountIsActive
|
|||||||
{
|
{
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|
||||||
if ($user !== null && ! $user->active) {
|
if ($user !== null && ! $user->maySignIn()) {
|
||||||
abort(401);
|
abort(401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Modules\Api\Support;
|
namespace App\Modules\Api\Support;
|
||||||
|
|
||||||
use App\Modules\Platform\Capabilities\CapabilityUnavailable;
|
use App\Modules\Platform\Capabilities\CapabilityUnavailable;
|
||||||
|
use App\Support\ApiSurface;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Auth\AuthenticationException;
|
use Illuminate\Auth\AuthenticationException;
|
||||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
@@ -16,7 +17,8 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RFC 7807 error bodies for /api/* only.
|
* RFC 7807 error bodies for the API surface only -- see ApiSurface, which
|
||||||
|
* is the same question the capability middleware asks.
|
||||||
*
|
*
|
||||||
* Two properties this class exists to guarantee:
|
* Two properties this class exists to guarantee:
|
||||||
*
|
*
|
||||||
@@ -55,7 +57,7 @@ class ProblemDetails
|
|||||||
|
|
||||||
public function shouldHandle(Request $request): bool
|
public function shouldHandle(Request $request): bool
|
||||||
{
|
{
|
||||||
return $request->is('api/*');
|
return ApiSurface::matches($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render(Request $request, Throwable $e): JsonResponse
|
public function render(Request $request, Throwable $e): JsonResponse
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ enum Action: string
|
|||||||
case SocialAccountUnlinked = 'social.account_unlinked';
|
case SocialAccountUnlinked = 'social.account_unlinked';
|
||||||
case ClientApproved = 'client.approved';
|
case ClientApproved = 'client.approved';
|
||||||
case ClientDenied = 'client.denied';
|
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
|
// Files
|
||||||
case FileUploaded = 'file.uploaded';
|
case FileUploaded = 'file.uploaded';
|
||||||
case FileUpdated = 'file.updated';
|
case FileUpdated = 'file.updated';
|
||||||
@@ -67,6 +75,21 @@ enum Action: string
|
|||||||
case FolderMadePrivate = 'folder.made_private';
|
case FolderMadePrivate = 'folder.made_private';
|
||||||
case UploadAborted = 'upload.aborted';
|
case UploadAborted = 'upload.aborted';
|
||||||
case FileImported = 'file.imported';
|
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 OrphanFileDeleted = 'orphan_file.deleted';
|
||||||
case OrphanFileAutoDeleted = 'orphan_file.auto_deleted';
|
case OrphanFileAutoDeleted = 'orphan_file.auto_deleted';
|
||||||
case ExpiredFileDeleted = 'file.expired_deleted';
|
case ExpiredFileDeleted = 'file.expired_deleted';
|
||||||
@@ -165,6 +188,11 @@ enum Action: string
|
|||||||
self::ClientSelfRegistered => 'Registered a new client account',
|
self::ClientSelfRegistered => 'Registered a new client account',
|
||||||
self::ClientApproved => 'Approved the account request of ":subject"',
|
self::ClientApproved => 'Approved the account request of ":subject"',
|
||||||
self::ClientDenied => 'Denied the account request of ":name"',
|
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::FileUploaded => 'Uploaded the file ":subject"',
|
||||||
self::FileUpdated => 'Updated the file ":subject"',
|
self::FileUpdated => 'Updated the file ":subject"',
|
||||||
self::FileDeleted => 'Deleted the file ":name"',
|
self::FileDeleted => 'Deleted the file ":name"',
|
||||||
@@ -199,6 +227,14 @@ enum Action: string
|
|||||||
self::CommentDeleted => 'Deleted a comment on the file ":subject"',
|
self::CommentDeleted => 'Deleted a comment on the file ":subject"',
|
||||||
self::CommentApproved => 'Approved a comment on the file ":subject"',
|
self::CommentApproved => 'Approved a comment on the file ":subject"',
|
||||||
self::FileImported => 'Imported the orphan 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::OrphanFileDeleted => 'Deleted the orphan file ":name"',
|
||||||
self::OrphanFileAutoDeleted => 'Deleted the orphan file ":name"',
|
self::OrphanFileAutoDeleted => 'Deleted the orphan file ":name"',
|
||||||
self::ExpiredFileDeleted => 'Deleted the expired file ":name"',
|
self::ExpiredFileDeleted => 'Deleted the expired file ":name"',
|
||||||
@@ -267,6 +303,11 @@ enum Action: string
|
|||||||
self::ClientSelfRegistered => 'A client registered an account',
|
self::ClientSelfRegistered => 'A client registered an account',
|
||||||
self::ClientApproved => 'A client account request was approved',
|
self::ClientApproved => 'A client account request was approved',
|
||||||
self::ClientDenied => 'A client account request was denied',
|
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::FileUploaded => 'A file was uploaded',
|
||||||
self::FileUpdated => 'A file was updated',
|
self::FileUpdated => 'A file was updated',
|
||||||
self::FileDeleted => 'A file was deleted',
|
self::FileDeleted => 'A file was deleted',
|
||||||
@@ -298,6 +339,10 @@ enum Action: string
|
|||||||
self::CommentDeleted => 'A comment was deleted',
|
self::CommentDeleted => 'A comment was deleted',
|
||||||
self::CommentApproved => 'A comment was approved',
|
self::CommentApproved => 'A comment was approved',
|
||||||
self::FileImported => 'An orphan file was imported',
|
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::OrphanFileDeleted => 'An orphan file was deleted',
|
||||||
self::OrphanFileAutoDeleted => 'An orphan file was automatically deleted after its retention grace period passed',
|
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',
|
self::ExpiredFileDeleted => 'An expired file was automatically deleted after its retention grace period passed',
|
||||||
|
|||||||
@@ -12,9 +12,14 @@ use App\Modules\Audit\ActivityLog;
|
|||||||
use App\Modules\Audit\ActivityLogScope;
|
use App\Modules\Audit\ActivityLogScope;
|
||||||
use App\Modules\Audit\ActivityPresenter;
|
use App\Modules\Audit\ActivityPresenter;
|
||||||
use App\Modules\Audit\DashboardWidgetPreferences;
|
use App\Modules\Audit\DashboardWidgetPreferences;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
use App\Modules\Clients\ClientStorageUsage;
|
use App\Modules\Clients\ClientStorageUsage;
|
||||||
use App\Modules\Files\Access\StaffLibraryScope;
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
|
use App\Modules\Files\Delivery\FileDelivery;
|
||||||
use App\Modules\Files\Models\File;
|
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\Groups\Models\Group;
|
||||||
use App\Modules\Identity\UserType;
|
use App\Modules\Identity\UserType;
|
||||||
use App\Modules\Platform\Capabilities\Capability;
|
use App\Modules\Platform\Capabilities\Capability;
|
||||||
@@ -25,6 +30,7 @@ use App\Modules\Platform\News\NewsItems;
|
|||||||
use App\Modules\Platform\Settings\Setting;
|
use App\Modules\Platform\Settings\Setting;
|
||||||
use App\Modules\Platform\Settings\Settings;
|
use App\Modules\Platform\Settings\Settings;
|
||||||
use App\Modules\Platform\Storage\StorageDurability;
|
use App\Modules\Platform\Storage\StorageDurability;
|
||||||
|
use App\Modules\Platform\Storage\StorageCapacity;
|
||||||
use App\Modules\Platform\System\SystemEnvironment;
|
use App\Modules\Platform\System\SystemEnvironment;
|
||||||
use App\Modules\Platform\Updates\LatestReleaseInfo;
|
use App\Modules\Platform\Updates\LatestReleaseInfo;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@@ -51,6 +57,8 @@ class DashboardController extends Controller
|
|||||||
private readonly Settings $settings,
|
private readonly Settings $settings,
|
||||||
private readonly ApiUsage $apiUsage,
|
private readonly ApiUsage $apiUsage,
|
||||||
private readonly StorageDurability $storageDurability,
|
private readonly StorageDurability $storageDurability,
|
||||||
|
private readonly StorageCapacity $storageCapacity,
|
||||||
|
private readonly FileDelivery $fileDelivery,
|
||||||
private readonly Installation $installation,
|
private readonly Installation $installation,
|
||||||
private readonly TimezoneRegistry $timezones,
|
private readonly TimezoneRegistry $timezones,
|
||||||
private readonly SystemEnvironment $environment,
|
private readonly SystemEnvironment $environment,
|
||||||
@@ -92,7 +100,7 @@ class DashboardController extends Controller
|
|||||||
: null,
|
: null,
|
||||||
'largest_files' => $canStatistics && $prefs->isEnabled($user, 'largest_files') ? $this->largestFiles($user) : null,
|
'largest_files' => $canStatistics && $prefs->isEnabled($user, 'largest_files') ? $this->largestFiles($user) : null,
|
||||||
'recent' => $canActionsLog && $prefs->isEnabled($user, 'recent') ? $this->recentActivity($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,
|
// Both editions — informational content, not an update action,
|
||||||
// so no Capability check alongside the permission (unlike
|
// so no Capability check alongside the permission (unlike
|
||||||
// 'system' above).
|
// 'system' above).
|
||||||
@@ -155,8 +163,12 @@ class DashboardController extends Controller
|
|||||||
*
|
*
|
||||||
* Every boundary is built in the viewer's zone, so "last week" ends
|
* Every boundary is built in the viewer's zone, so "last week" ends
|
||||||
* when their evening does and not at whatever hour UTC midnight falls
|
* when their evening does and not at whatever hour UTC midnight falls
|
||||||
* on for them. The returned instants are still absolute — only the
|
* on for them. The instants are absolute, but they carry that zone —
|
||||||
* day edges moved — so they compare against the UTC column directly.
|
* and a Carbon handed to the query builder is formatted in its own
|
||||||
|
* zone, offset discarded, so comparing one against a UTC column asks
|
||||||
|
* a question nine hours out for a viewer in Tokyo. transferSeries()
|
||||||
|
* converts before it compares; the day cursor there keeps them as
|
||||||
|
* they are, because that half really is about the viewer's calendar.
|
||||||
*
|
*
|
||||||
* @return array{0: Carbon, 1: Carbon, 2: string}
|
* @return array{0: Carbon, 1: Carbon, 2: string}
|
||||||
*/
|
*/
|
||||||
@@ -248,7 +260,13 @@ class DashboardController extends Controller
|
|||||||
|
|
||||||
$rows = ActivityLog::query()
|
$rows = ActivityLog::query()
|
||||||
->whereIn('action', [Action::FileUploaded->value, ...array_map(fn (Action $a): string => $a->value, $downloadActions)])
|
->whereIn('action', [Action::FileUploaded->value, ...array_map(fn (Action $a): string => $a->value, $downloadActions)])
|
||||||
->whereBetween('created_at', [$from, $to])
|
// In UTC, because that is what the column is. The query
|
||||||
|
// builder formats a Carbon in whatever zone the object holds
|
||||||
|
// and drops the offset, so passing the viewer's midnight
|
||||||
|
// straight in compares "2026-08-22 00:00:00" against a UTC
|
||||||
|
// column — nine hours of somebody else's day, at both ends,
|
||||||
|
// for a viewer in Tokyo.
|
||||||
|
->whereBetween('created_at', [$from->copy()->utc(), $to->copy()->utc()])
|
||||||
->get(['action', 'actor_type', 'created_at'])
|
->get(['action', 'actor_type', 'created_at'])
|
||||||
// Bucketed by the viewer's calendar day. Grouping on the UTC
|
// Bucketed by the viewer's calendar day. Grouping on the UTC
|
||||||
// one puts an evening upload from anywhere west of Greenwich
|
// one puts an evening upload from anywhere west of Greenwich
|
||||||
@@ -467,12 +485,10 @@ class DashboardController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string|int|bool|array<string, string|null>|null>
|
* @return array<string, array<string, bool|int|string|null>|bool|int|string|null>
|
||||||
*/
|
*/
|
||||||
private function systemInfo(): array
|
private function systemInfo(User $viewer): array
|
||||||
{
|
{
|
||||||
$freeBytes = @disk_free_space(storage_path('app/files'));
|
|
||||||
|
|
||||||
// Cached by CheckForUpdatesCommand (daily) — never a live HTTP
|
// Cached by CheckForUpdatesCommand (daily) — never a live HTTP
|
||||||
// call from the request path. null means either no successful
|
// call from the request path. null means either no successful
|
||||||
// check yet, or the current version is already the latest.
|
// check yet, or the current version is already the latest.
|
||||||
@@ -481,7 +497,7 @@ class DashboardController extends Controller
|
|||||||
return [
|
return [
|
||||||
...$this->environment->toArray(),
|
...$this->environment->toArray(),
|
||||||
'storage_used_bytes' => (int) File::query()->sum('size'),
|
'storage_used_bytes' => (int) File::query()->sum('size'),
|
||||||
'storage_free_bytes' => $freeBytes === false ? -1 : (int) $freeBytes,
|
...$this->storageCapacity->inspect($viewer),
|
||||||
'update_available' => $release !== null,
|
'update_available' => $release !== null,
|
||||||
'latest_version' => $release['version'] ?? null,
|
'latest_version' => $release['version'] ?? null,
|
||||||
'release_url' => $release['url'] ?? null,
|
'release_url' => $release['url'] ?? null,
|
||||||
@@ -492,28 +508,104 @@ class DashboardController extends Controller
|
|||||||
// Installation. Always present, unlike storage_durability, which
|
// Installation. Always present, unlike storage_durability, which
|
||||||
// is null whenever the durability question does not apply.
|
// is null whenever the durability question does not apply.
|
||||||
'install_kind' => $this->installation->kind()->value,
|
'install_kind' => $this->installation->kind()->value,
|
||||||
|
// How downloads leave the server, and whether that was
|
||||||
|
// detected or stated. Reported even when it is the fast path:
|
||||||
|
// "my downloads are handed to the web server" is worth being
|
||||||
|
// able to confirm at a glance, not only worth warning about
|
||||||
|
// when it is false — the same reasoning as storage_durability.
|
||||||
|
'file_delivery' => $this->fileDelivery->describe(),
|
||||||
|
// Always stated, like delivery and storage above it: "my
|
||||||
|
// uploads are checked by ClamAV" is worth confirming at a
|
||||||
|
// glance, not only worth mentioning when it is false. Null
|
||||||
|
// only where this installation does not connect its own
|
||||||
|
// scanner at all.
|
||||||
|
'scanning' => $this->scanningState(),
|
||||||
|
// Rows this installation lists and cannot produce. Zero is the
|
||||||
|
// ordinary answer and says nothing on screen; anything else is
|
||||||
|
// somebody's files gone, which is worth interrupting for.
|
||||||
|
'missing_files' => File::query()->where('scan_status', ScanStatus::Missing)->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where this installation stands with virus scanning.
|
||||||
|
*
|
||||||
|
* Reported whether or not anything is wrong: the System card states
|
||||||
|
* how downloads leave and where files are stored for the same reason,
|
||||||
|
* and "nothing is checking my uploads" is exactly the fact an
|
||||||
|
* administrator will not go looking for.
|
||||||
|
*
|
||||||
|
* Null only when this installation does not connect its own scanner —
|
||||||
|
* on a hosted one that is the platform's infrastructure, and a tenant
|
||||||
|
* reading about it could neither confirm nor fix it. See
|
||||||
|
* Capability::VirusScanningConnect.
|
||||||
|
*
|
||||||
|
* @return array{configured: bool, reachable: bool, engine: string|null, definitions_age_hours: int|null, let_through_24h: int, pending: int}|null
|
||||||
|
*/
|
||||||
|
private function scanningState(): ?array
|
||||||
|
{
|
||||||
|
if (! $this->capabilities->has(Capability::VirusScanningConnect)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = app(ScanningConfig::class);
|
||||||
|
|
||||||
|
if (! $config->enabled()) {
|
||||||
|
return [
|
||||||
|
'configured' => false,
|
||||||
|
'reachable' => false,
|
||||||
|
'engine' => null,
|
||||||
|
'definitions_age_hours' => null,
|
||||||
|
'let_through_24h' => 0,
|
||||||
|
'pending' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$scanner = app(VirusScanner::class)->status();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'configured' => true,
|
||||||
|
'reachable' => $scanner->reachable,
|
||||||
|
'engine' => $scanner->engine,
|
||||||
|
'definitions_age_hours' => $scanner->definitionsAgeHours(),
|
||||||
|
// Files let through unchecked in the last day that can still
|
||||||
|
// be downloaded. Zero is the only number that means
|
||||||
|
// "protected"; anything else is a scanner that was down, or
|
||||||
|
// files nobody could open. See File::scopeLetThrough().
|
||||||
|
'let_through_24h' => File::query()
|
||||||
|
->letThrough()
|
||||||
|
->where('scanned_at', '>=', now()->subDay())
|
||||||
|
->count(),
|
||||||
|
// Waiting more than an hour: on an installation set to hold,
|
||||||
|
// this is what an outage looks like.
|
||||||
|
'pending' => File::query()
|
||||||
|
->where('scan_status', ScanStatus::Pending)
|
||||||
|
->where('created_at', '<=', now()->subHour())
|
||||||
|
->count(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function clientDashboard(User $client): Response
|
private function clientDashboard(User $client): Response
|
||||||
{
|
{
|
||||||
$assignedFiles = File::query()->whereHas('assignments', function ($query) use ($client): void {
|
// File::scopeVisibleToClient is the single source of truth for
|
||||||
$query->where(function ($direct) use ($client): void {
|
// client file access, and this page has to agree with the portal it
|
||||||
$direct->where('assignable_type', User::class)->where('assignable_id', $client->id);
|
// introduces. Restating the assignment half here made it disagree
|
||||||
})->orWhere(function ($viaGroup) use ($client): void {
|
// in both directions: it counted expired files, which the scope
|
||||||
$viaGroup->where('assignable_type', Group::class)
|
// ends by excluding and /my-files therefore never shows, and it
|
||||||
->whereIn('assignable_id', $client->memberOfGroups()->pluck('groups.id'));
|
// missed everything that reaches a client another way — a file in a
|
||||||
});
|
// folder shared with them, their own portal upload, and a revision,
|
||||||
});
|
// which owns no assignment row and inherits its original's
|
||||||
|
// recipients.
|
||||||
|
$visibleFiles = File::query()->visibleToClient($client);
|
||||||
|
|
||||||
return Inertia::render('portal/dashboard', [
|
return Inertia::render('portal/dashboard', [
|
||||||
'files_count' => (clone $assignedFiles)->count(),
|
'files_count' => (clone $visibleFiles)->count(),
|
||||||
'groups_count' => $client->memberOfGroups()->where('public', true)->count(),
|
'groups_count' => $client->memberOfGroups()->where('public', true)->count(),
|
||||||
'storage' => [
|
'storage' => [
|
||||||
'used_bytes' => $this->storageUsage->usedBytes($client),
|
'used_bytes' => $this->storageUsage->usedBytes($client),
|
||||||
'quota_bytes' => $this->storageUsage->quotaBytes($client) ?: null,
|
'quota_bytes' => $this->storageUsage->quotaBytes($client) ?: null,
|
||||||
],
|
],
|
||||||
'latest_files' => $assignedFiles->orderByDesc('created_at')->limit(5)->get()
|
'latest_files' => $visibleFiles->orderByDesc('created_at')->limit(5)->get()
|
||||||
->map(fn (File $file): array => [
|
->map(fn (File $file): array => [
|
||||||
'id' => $file->id,
|
'id' => $file->id,
|
||||||
'name' => $file->name,
|
'name' => $file->name,
|
||||||
|
|||||||
@@ -45,8 +45,14 @@ class DashboardWidgetPreferencesController extends Controller
|
|||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'columns' => ['required', 'integer', 'between:1,4'],
|
'columns' => ['required', 'integer', 'between:1,4'],
|
||||||
'widgets' => ['required', 'array'],
|
// Bounded by the allowlist itself, and unique on the key. The
|
||||||
'widgets.*.widget_key' => ['required', 'string', Rule::in(self::WIDGET_KEYS)],
|
// Rule::in below checks each value; it says nothing about how
|
||||||
|
// many there are or whether they repeat, and the loop writes
|
||||||
|
// one row per element. A layout has at most one entry per
|
||||||
|
// widget, so anything longer than the registry is not a layout
|
||||||
|
// this screen could have produced.
|
||||||
|
'widgets' => ['required', 'array', 'max:'.count(self::WIDGET_KEYS)],
|
||||||
|
'widgets.*.widget_key' => ['required', 'string', 'distinct', Rule::in(self::WIDGET_KEYS)],
|
||||||
'widgets.*.enabled' => ['required', 'boolean'],
|
'widgets.*.enabled' => ['required', 'boolean'],
|
||||||
'widgets.*.column_index' => ['required', 'integer', 'between:0,3'],
|
'widgets.*.column_index' => ['required', 'integer', 'between:0,3'],
|
||||||
'widgets.*.position' => ['required', 'integer', 'min:0'],
|
'widgets.*.position' => ['required', 'integer', 'min:0'],
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Clients;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Modules\Audit\Action;
|
||||||
|
use App\Modules\Audit\ActivityLogger;
|
||||||
|
use App\Modules\Clients\Notifications\ClientWelcomeNotification;
|
||||||
|
use App\Modules\Identity\Models\Role;
|
||||||
|
use App\Modules\Identity\Permissions\SystemRole;
|
||||||
|
use App\Modules\Identity\UserType;
|
||||||
|
use App\Modules\Platform\Seats\SeatAllowance;
|
||||||
|
use App\Modules\Platform\Settings\Setting;
|
||||||
|
use App\Modules\Platform\Settings\Settings;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creating a client account — the rules and the side effects, shared by
|
||||||
|
* every surface that makes one.
|
||||||
|
*
|
||||||
|
* The same argument StaffAccounts makes for staff. What a client account
|
||||||
|
* *is* — its type, its role, an active flag, a quota where zero means
|
||||||
|
* "inherit the site default" rather than "none" — is a set of invariants,
|
||||||
|
* and an invariant enforced in one controller and re-implemented in
|
||||||
|
* another is one that will eventually hold in only one of them. There are
|
||||||
|
* three surfaces onto this now: the staff screens, `/api/v1/clients`, and
|
||||||
|
* the platform control plane in the private package, which reaches this
|
||||||
|
* by name because it cannot import a host class.
|
||||||
|
*
|
||||||
|
* What stays with the caller is what genuinely differs: the shape of the
|
||||||
|
* request, its validation rules, its response, and anything about *who is
|
||||||
|
* asking* — a client-scoped staff member gaining the client on their own
|
||||||
|
* roster is a fact about the creator, not about the account created.
|
||||||
|
*
|
||||||
|
* **Not to be confused with ClientProvisioning**, which sits beside it and
|
||||||
|
* handles the other half: an account that comes into existence without
|
||||||
|
* anybody deciding to create it — the public registration form, and a
|
||||||
|
* first successful LDAP sign-in. The policies genuinely differ rather than
|
||||||
|
* merely duplicating. An account made here is approved and verified by
|
||||||
|
* construction, because somebody who already knows who this is asked for
|
||||||
|
* it; one made there may wait for approval, joins a configured group, and
|
||||||
|
* tells the administrators it arrived.
|
||||||
|
*/
|
||||||
|
class ClientAccounts
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ActivityLogger $activity,
|
||||||
|
private readonly SeatAllowance $seats,
|
||||||
|
private readonly Settings $settings,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $storageQuotaMb 0 means no per-account quota and
|
||||||
|
* inherits the site default at
|
||||||
|
* enforcement time — see
|
||||||
|
* ClientStorageUsage::quotaMb(). It does
|
||||||
|
* not mean unlimited.
|
||||||
|
* @param Carbon|null $expiresAt when the account stops working;
|
||||||
|
* null for never. Must be in the
|
||||||
|
* future — see guardExpiry().
|
||||||
|
* @param bool $welcome whether this installation should email the
|
||||||
|
* new account. A caller that sends its own
|
||||||
|
* welcome passes false rather than having the
|
||||||
|
* customer receive two.
|
||||||
|
*/
|
||||||
|
public function create(
|
||||||
|
string $name,
|
||||||
|
string $email,
|
||||||
|
string $password,
|
||||||
|
int $storageQuotaMb = 0,
|
||||||
|
bool $welcome = true,
|
||||||
|
string $emailField = 'email',
|
||||||
|
?Carbon $expiresAt = null,
|
||||||
|
): User {
|
||||||
|
// Before anything is written, and deliberately not left to the
|
||||||
|
// caller. The platform sets this cap and the platform is also what
|
||||||
|
// calls the control plane — so enforcing it here is what stops a
|
||||||
|
// leaked control token minting accounts without limit. A guard
|
||||||
|
// that only ran on the surfaces that remembered it would not be a
|
||||||
|
// guard.
|
||||||
|
$this->seats->guardClient($emailField);
|
||||||
|
$this->guardExpiry($expiresAt, active: true);
|
||||||
|
|
||||||
|
$client = User::create([
|
||||||
|
'type' => UserType::Client,
|
||||||
|
'active' => true,
|
||||||
|
'account_requested' => false,
|
||||||
|
'role_id' => Role::query()->where('name', SystemRole::Client->value)->value('id'),
|
||||||
|
'name' => $name,
|
||||||
|
'email' => $email,
|
||||||
|
'password' => $password,
|
||||||
|
'storage_quota_mb' => $storageQuotaMb,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// forceFill, and not part of the create() array above: like
|
||||||
|
// StaffAccounts, email_verified_at is deliberately absent from
|
||||||
|
// User::$fillable — where an account stands is a security decision
|
||||||
|
// rather than an attribute — so mass assignment drops it in
|
||||||
|
// silence. Every client-creation path used to pass it in that
|
||||||
|
// array and lose it. The intent is real: an account created by
|
||||||
|
// somebody who already knows who this is has no address to
|
||||||
|
// confirm and nobody to confirm it to. (Inert today, since
|
||||||
|
// MustVerifyEmail is not enabled on the model, but the column is
|
||||||
|
// what a later switch would read.)
|
||||||
|
//
|
||||||
|
// expires_at is written the same way for its own reason: see the
|
||||||
|
// note on its cast in User.
|
||||||
|
$client->forceFill(['email_verified_at' => now(), 'expires_at' => $expiresAt])->save();
|
||||||
|
|
||||||
|
$this->activity->log(Action::UserCreated, subject: $client);
|
||||||
|
|
||||||
|
if ($welcome && $this->settings->get(Setting::EmailNotificationsEnabled) === true) {
|
||||||
|
$client->notify(new ClientWelcomeNotification);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An account cannot be both active and past its expiry date.
|
||||||
|
*
|
||||||
|
* Every surface that writes either value asks this before saving,
|
||||||
|
* because the combination is not a state anybody means: an account
|
||||||
|
* that looks switched on and refuses every sign-in, until the hourly
|
||||||
|
* sweep quietly switches it off again. Somebody reactivating an
|
||||||
|
* expired client has to give them a new date, or none.
|
||||||
|
*/
|
||||||
|
public function guardExpiry(?Carbon $expiresAt, bool $active, string $field = 'expires_at'): void
|
||||||
|
{
|
||||||
|
if ($active && $expiresAt !== null && $expiresAt->isPast()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
$field => __('This date has already passed. Choose a later date, or leave it empty for an account that never expires.'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -158,7 +158,23 @@ class ClientPortalCustomFields
|
|||||||
*/
|
*/
|
||||||
private function isLocked(ClientCustomField $field, BaseCollection $values): bool
|
private function isLocked(ClientCustomField $field, BaseCollection $values): bool
|
||||||
{
|
{
|
||||||
return $field->client_editability === ClientFieldEditability::EditableOnce
|
if ($field->client_editability !== ClientFieldEditability::EditableOnce) {
|
||||||
&& filled($values->get($field->id));
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stored = $values->get($field->id);
|
||||||
|
|
||||||
|
// A checkbox has a stored value from the first save onwards: an
|
||||||
|
// unticked box is written as '0', and filled('0') is true. Asking
|
||||||
|
// "is anything stored" therefore locked the field on the first save
|
||||||
|
// of the form it sits on, whatever the client had chosen — and a
|
||||||
|
// box they never ticked can then never be ticked. '0' is the
|
||||||
|
// absence of a decision, which is the state the other types express
|
||||||
|
// as null, so it is what an unlocked checkbox looks like.
|
||||||
|
if ($field->type === ClientCustomFieldType::Checkbox) {
|
||||||
|
return $stored === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
return filled($stored);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,12 @@ use App\Modules\Groups\Models\Group;
|
|||||||
use App\Modules\Identity\AuthSource;
|
use App\Modules\Identity\AuthSource;
|
||||||
use App\Modules\Identity\Models\Role;
|
use App\Modules\Identity\Models\Role;
|
||||||
use App\Modules\Identity\Permissions\SystemRole;
|
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\Identity\UserType;
|
||||||
use App\Modules\Platform\Settings\Setting;
|
use App\Modules\Notifications\Notifier;
|
||||||
use App\Modules\Platform\Seats\SeatAllowance;
|
use App\Modules\Platform\Seats\SeatAllowance;
|
||||||
|
use App\Modules\Platform\Settings\Setting;
|
||||||
use App\Modules\Platform\Settings\Settings;
|
use App\Modules\Platform\Settings\Settings;
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
|
|
||||||
@@ -38,6 +41,8 @@ class ClientProvisioning
|
|||||||
private readonly Settings $settings,
|
private readonly Settings $settings,
|
||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
private readonly SeatAllowance $seats,
|
private readonly SeatAllowance $seats,
|
||||||
|
private readonly Notifier $notifier,
|
||||||
|
private readonly PermissionChecker $permissions,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +53,22 @@ class ClientProvisioning
|
|||||||
return $this->settings->get(Setting::ClientsAutoApprove) === true;
|
return $this->settings->get(Setting::ClientsAutoApprove) === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an address is free for a new account.
|
||||||
|
*
|
||||||
|
* The unique index on `email` spans soft-deleted rows — AvailableEmailRule
|
||||||
|
* is built on exactly that, so a deleted account keeps its address until
|
||||||
|
* erasure takes the row away. The registration form learns this from
|
||||||
|
* validation. The machine paths have no form to validate: a directory or
|
||||||
|
* an identity provider hands over an address and provision() inserts it,
|
||||||
|
* so without asking first the insert raises a QueryException in the
|
||||||
|
* middle of somebody's sign-in.
|
||||||
|
*/
|
||||||
|
public function addressIsFree(string $email): bool
|
||||||
|
{
|
||||||
|
return ! User::withTrashed()->where('email', $email)->exists();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param bool|null $autoApprove Null asks Setting::ClientsAutoApprove,
|
* @param bool|null $autoApprove Null asks Setting::ClientsAutoApprove,
|
||||||
* which is the right question for the
|
* which is the right question for the
|
||||||
@@ -59,6 +80,14 @@ class ClientProvisioning
|
|||||||
* @param array<string, mixed> $context Placeholders for the action's
|
* @param array<string, mixed> $context Placeholders for the action's
|
||||||
* log template, e.g. which
|
* log template, e.g. which
|
||||||
* provider an account came from.
|
* 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(
|
public function provision(
|
||||||
string $name,
|
string $name,
|
||||||
@@ -69,6 +98,7 @@ class ClientProvisioning
|
|||||||
?string $ldapDn = null,
|
?string $ldapDn = null,
|
||||||
?bool $autoApprove = null,
|
?bool $autoApprove = null,
|
||||||
array $context = [],
|
array $context = [],
|
||||||
|
int $storageQuotaMb = 0,
|
||||||
): User {
|
): User {
|
||||||
$autoApprove ??= $this->autoApproves();
|
$autoApprove ??= $this->autoApproves();
|
||||||
|
|
||||||
@@ -89,6 +119,7 @@ class ClientProvisioning
|
|||||||
'name' => $name,
|
'name' => $name,
|
||||||
'email' => $email,
|
'email' => $email,
|
||||||
'password' => $password,
|
'password' => $password,
|
||||||
|
'storage_quota_mb' => $storageQuotaMb,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Not mass-assignable: where an account's credentials live is a
|
// Not mass-assignable: where an account's credentials live is a
|
||||||
@@ -105,6 +136,7 @@ class ClientProvisioning
|
|||||||
|
|
||||||
$this->joinAutoGroup($client);
|
$this->joinAutoGroup($client);
|
||||||
$this->notifyAdministrators($client, pending: ! $autoApprove);
|
$this->notifyAdministrators($client, pending: ! $autoApprove);
|
||||||
|
$this->notifyStaffInApp($client);
|
||||||
|
|
||||||
return $client;
|
return $client;
|
||||||
}
|
}
|
||||||
@@ -127,6 +159,37 @@ class ClientProvisioning
|
|||||||
$group?->members()->syncWithoutDetaching([$client->id]);
|
$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
|
private function notifyAdministrators(User $client, bool $pending): void
|
||||||
{
|
{
|
||||||
if ($this->settings->get(Setting::EmailNotificationsEnabled) !== true) {
|
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" —
|
* A client's own storage_quota_mb of 0 means "no custom quota set" —
|
||||||
* it inherits Setting::DefaultClientStorageQuotaMb instead of being
|
* it inherits the installation's default instead of being unlimited,
|
||||||
* unlimited, so a site-wide default (once set) also protects clients
|
* so a default (once set) also protects clients who never got an
|
||||||
* who never got an explicit quota, including self-registered ones.
|
* explicit quota, including self-registered ones.
|
||||||
* The site default itself being 0 is what actually means unlimited.
|
|
||||||
*
|
*
|
||||||
* @return int 0 means unlimited.
|
* @return int 0 means unlimited.
|
||||||
*/
|
*/
|
||||||
@@ -39,7 +38,46 @@ class ClientStorageUsage
|
|||||||
{
|
{
|
||||||
return $client->storage_quota_mb > 0
|
return $client->storage_quota_mb > 0
|
||||||
? $client->storage_quota_mb
|
? $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\Api\Support\PollingQuery;
|
||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
|
use App\Modules\Clients\ClientAccounts;
|
||||||
use App\Modules\Clients\ClientCustomFieldType;
|
use App\Modules\Clients\ClientCustomFieldType;
|
||||||
use App\Modules\Clients\ClientStorageUsage;
|
use App\Modules\Clients\ClientStorageUsage;
|
||||||
use App\Modules\Files\Access\StaffLibraryScope;
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
|
use App\Modules\Platform\Localization\DateInput;
|
||||||
use App\Modules\Platform\Seats\SeatAllowance;
|
use App\Modules\Platform\Seats\SeatAllowance;
|
||||||
use App\Modules\Clients\Http\Resources\Api\ClientResource;
|
use App\Modules\Clients\Http\Resources\Api\ClientResource;
|
||||||
use App\Modules\Clients\Models\ClientCustomField;
|
use App\Modules\Clients\Models\ClientCustomField;
|
||||||
@@ -22,13 +24,11 @@ use App\Modules\Files\DeletedAccountContent;
|
|||||||
use App\Modules\Identity\AccountContentDeletion;
|
use App\Modules\Identity\AccountContentDeletion;
|
||||||
use App\Modules\Identity\Erasure\AvailableEmailRule;
|
use App\Modules\Identity\Erasure\AvailableEmailRule;
|
||||||
use App\Modules\Identity\Erasure\ErasureSchedule;
|
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\TwoFactor\TwoFactorAdministration;
|
||||||
use App\Modules\Identity\UserType;
|
|
||||||
use App\Modules\Platform\Settings\Setting;
|
use App\Modules\Platform\Settings\Setting;
|
||||||
use App\Modules\Platform\Settings\Settings;
|
use App\Modules\Platform\Settings\Settings;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
@@ -61,7 +61,9 @@ class ClientsController extends Controller
|
|||||||
private readonly AccountContentDeletion $accountDeletion,
|
private readonly AccountContentDeletion $accountDeletion,
|
||||||
private readonly StaffLibraryScope $scope,
|
private readonly StaffLibraryScope $scope,
|
||||||
private readonly SeatAllowance $seats,
|
private readonly SeatAllowance $seats,
|
||||||
|
private readonly ClientAccounts $clients,
|
||||||
private readonly ErasureSchedule $erasure,
|
private readonly ErasureSchedule $erasure,
|
||||||
|
private readonly DateInput $dates,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): AnonymousResourceCollection
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
@@ -117,8 +119,6 @@ class ClientsController extends Controller
|
|||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$this->seats->guardClient();
|
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
|
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
|
||||||
@@ -132,29 +132,55 @@ class ClientsController extends Controller
|
|||||||
// installation may be refused on another.
|
// installation may be refused on another.
|
||||||
'password' => ['required', Password::defaults()],
|
'password' => ['required', Password::defaults()],
|
||||||
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
|
'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'],
|
'custom_field_values' => ['array'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$validated['custom_field_values'] = $this->validateCustomFieldValues($request);
|
$validated['custom_field_values'] = $this->validateCustomFieldValues($request);
|
||||||
|
|
||||||
$client = User::create([
|
$creator = $request->user();
|
||||||
'type' => UserType::Client,
|
assert($creator !== null);
|
||||||
'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);
|
// The invariants — the seat guard, the type, the role, the quota's
|
||||||
|
// "0 means inherit" — live in ClientAccounts, shared with the staff
|
||||||
|
// screens and with the platform control plane. What stays here is
|
||||||
|
// this surface's own business: its validation, its custom fields,
|
||||||
|
// and who the creator is.
|
||||||
|
$client = $this->clients->create(
|
||||||
|
name: $validated['name'],
|
||||||
|
email: $validated['email'],
|
||||||
|
password: $validated['password'],
|
||||||
|
// As on the staff screen, and for the same reason: the
|
||||||
|
// `integer` rule accepts a numeric string and does not convert
|
||||||
|
// it. A JSON number arrives as an int and was fine; a
|
||||||
|
// form-encoded body or a quoted JSON value is a string, and
|
||||||
|
// this file is strict_types.
|
||||||
|
storageQuotaMb: (int) ($validated['storage_quota_mb'] ?? 0),
|
||||||
|
welcome: false,
|
||||||
|
expiresAt: $this->dates->instant($validated['expires_at'] ?? null, $creator),
|
||||||
|
);
|
||||||
|
|
||||||
|
// A client-scoped creator would otherwise lose the client they just
|
||||||
|
// made. guardTarget() answers 404 for anything off their roster, so
|
||||||
|
// the record they created is not theirs to open, and
|
||||||
|
// StaffLibraryScope::clients() leaves it out of their list as well —
|
||||||
|
// the client exists, is welcomed by email, and is invisible to the
|
||||||
|
// person who made it. Their own roster is where a client they
|
||||||
|
// created belongs; an unscoped creator has no roster to add to.
|
||||||
|
if ($creator->isClientScoped()) {
|
||||||
|
$creator->assignedClients()->attach($client->id);
|
||||||
|
}
|
||||||
|
|
||||||
$this->saveCustomFieldValues($client, $validated['custom_field_values'] ?? []);
|
$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) {
|
if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) {
|
||||||
$client->notify(new ClientWelcomeNotification);
|
$client->notify(new ClientWelcomeNotification);
|
||||||
}
|
}
|
||||||
@@ -173,6 +199,11 @@ class ClientsController extends Controller
|
|||||||
'active' => ['sometimes', 'boolean'],
|
'active' => ['sometimes', 'boolean'],
|
||||||
'password' => ['sometimes', 'nullable', Password::defaults()],
|
'password' => ['sometimes', 'nullable', Password::defaults()],
|
||||||
'storage_quota_mb' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
'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'],
|
'custom_field_values' => ['sometimes', 'array'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -191,7 +222,31 @@ class ClientsController extends Controller
|
|||||||
$client->storage_quota_mb = $validated['storage_quota_mb'] ?? 0;
|
$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.
|
||||||
if (($validated['active'] ?? false) && $client->account_requested) {
|
if (($validated['active'] ?? false) && $client->account_requested) {
|
||||||
|
$this->seats->guardClient('active');
|
||||||
$client->account_requested = false;
|
$client->account_requested = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +257,7 @@ class ClientsController extends Controller
|
|||||||
$client->save();
|
$client->save();
|
||||||
|
|
||||||
if (array_key_exists('custom_field_values', $validated)) {
|
if (array_key_exists('custom_field_values', $validated)) {
|
||||||
$this->saveCustomFieldValues($client, $validated['custom_field_values']);
|
$this->patchCustomFieldValues($client, $validated['custom_field_values']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->activity->log(Action::UserUpdated, subject: $client);
|
$this->activity->log(Action::UserUpdated, subject: $client);
|
||||||
@@ -360,11 +415,43 @@ class ClientsController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Every field, whether or not the request named it — a new client has
|
||||||
|
* no values yet, and create() is not a partial update.
|
||||||
|
*
|
||||||
* @param array<int, mixed> $values field id => submitted value
|
* @param array<int, mixed> $values field id => submitted value
|
||||||
*/
|
*/
|
||||||
private function saveCustomFieldValues(User $client, array $values): void
|
private function saveCustomFieldValues(User $client, array $values): void
|
||||||
{
|
{
|
||||||
foreach (ClientCustomField::query()->get() as $field) {
|
$this->writeCustomFieldValues($client, ClientCustomField::query()->get(), $values);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only the fields the request actually named.
|
||||||
|
*
|
||||||
|
* PATCH semantics, the same rule update() applies to every other
|
||||||
|
* column: an absent key means "leave alone", not "clear". Sharing
|
||||||
|
* create()'s "write every field" pass here emptied every custom field
|
||||||
|
* the caller had not mentioned, which is silent data loss on a request
|
||||||
|
* that looked like it changed one thing.
|
||||||
|
*
|
||||||
|
* @param array<int, mixed> $values field id => submitted value
|
||||||
|
*/
|
||||||
|
private function patchCustomFieldValues(User $client, array $values): void
|
||||||
|
{
|
||||||
|
$this->writeCustomFieldValues(
|
||||||
|
$client,
|
||||||
|
ClientCustomField::query()->whereIn('id', array_keys($values))->get(),
|
||||||
|
$values,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, ClientCustomField> $fields
|
||||||
|
* @param array<int, mixed> $values field id => submitted value
|
||||||
|
*/
|
||||||
|
private function writeCustomFieldValues(User $client, Collection $fields, array $values): void
|
||||||
|
{
|
||||||
|
foreach ($fields as $field) {
|
||||||
$submitted = $values[$field->id] ?? null;
|
$submitted = $values[$field->id] ?? null;
|
||||||
$value = $field->type === ClientCustomFieldType::Checkbox
|
$value = $field->type === ClientCustomFieldType::Checkbox
|
||||||
? ($submitted ? '1' : '0')
|
? ($submitted ? '1' : '0')
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Modules\Clients\Http\Controllers;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
|
use App\Modules\Files\Folders\ClientHomeFolders;
|
||||||
use App\Modules\Groups\Models\Group;
|
use App\Modules\Groups\Models\Group;
|
||||||
use App\Modules\Platform\Settings\Setting;
|
use App\Modules\Platform\Settings\Setting;
|
||||||
use App\Modules\Platform\Settings\Settings;
|
use App\Modules\Platform\Settings\Settings;
|
||||||
@@ -21,6 +22,7 @@ class ClientSettingsController extends Controller
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Settings $settings,
|
private readonly Settings $settings,
|
||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
|
private readonly ClientHomeFolders $homeFolders,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function edit(): Response
|
public function edit(): Response
|
||||||
@@ -31,8 +33,14 @@ class ClientSettingsController extends Controller
|
|||||||
'clients_auto_group' => $this->settings->get(Setting::ClientsAutoGroup),
|
'clients_auto_group' => $this->settings->get(Setting::ClientsAutoGroup),
|
||||||
'clients_can_select_group' => $this->settings->get(Setting::ClientsCanSelectGroup),
|
'clients_can_select_group' => $this->settings->get(Setting::ClientsCanSelectGroup),
|
||||||
'clients_membership_deny_cooldown_days' => $this->settings->get(Setting::ClientsMembershipDenyCooldownDays),
|
'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),
|
'default_client_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
|
||||||
'clients_can_preview_files' => $this->settings->get(Setting::ClientsCanPreviewFiles),
|
'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()
|
'groups' => Group::query()->orderBy('name')->get()
|
||||||
->map(fn (Group $group): array => ['id' => $group->id, 'name' => $group->name])
|
->map(fn (Group $group): array => ['id' => $group->id, 'name' => $group->name])
|
||||||
->all(),
|
->all(),
|
||||||
@@ -47,8 +55,10 @@ class ClientSettingsController extends Controller
|
|||||||
'clients_auto_group' => ['required', 'integer', Rule::in([0, ...Group::query()->pluck('id')->all()])],
|
'clients_auto_group' => ['required', 'integer', Rule::in([0, ...Group::query()->pluck('id')->all()])],
|
||||||
'clients_can_select_group' => ['required', Rule::in(['none', 'public'])],
|
'clients_can_select_group' => ['required', Rule::in(['none', 'public'])],
|
||||||
'clients_membership_deny_cooldown_days' => ['required', 'integer', 'min:0', 'max:365'],
|
'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'],
|
'default_client_storage_quota_mb' => ['required', 'integer', 'min:0'],
|
||||||
'clients_can_preview_files' => ['required', 'boolean'],
|
'clients_can_preview_files' => ['required', 'boolean'],
|
||||||
|
'clients_home_folders' => ['required', 'boolean'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->settings->set(Setting::ClientsCanRegister, $validated['clients_can_register']);
|
$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::ClientsAutoGroup, (int) $validated['clients_auto_group']);
|
||||||
$this->settings->set(Setting::ClientsCanSelectGroup, $validated['clients_can_select_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::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::DefaultClientStorageQuotaMb, (int) $validated['default_client_storage_quota_mb']);
|
||||||
$this->settings->set(Setting::ClientsCanPreviewFiles, $validated['clients_can_preview_files']);
|
$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']);
|
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'clients']);
|
||||||
|
|
||||||
return back();
|
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\Models\User;
|
||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
|
use App\Modules\Clients\ClientAccounts;
|
||||||
use App\Modules\Clients\ClientCustomFieldType;
|
use App\Modules\Clients\ClientCustomFieldType;
|
||||||
use App\Modules\Clients\ClientStorageUsage;
|
use App\Modules\Clients\ClientStorageUsage;
|
||||||
use App\Modules\Files\Access\StaffLibraryScope;
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
|
use App\Modules\Platform\Localization\DateInput;
|
||||||
use App\Modules\Platform\Seats\SeatAllowance;
|
use App\Modules\Platform\Seats\SeatAllowance;
|
||||||
use App\Modules\Clients\Models\ClientCustomField;
|
use App\Modules\Clients\Models\ClientCustomField;
|
||||||
use App\Modules\Clients\Models\ClientCustomFieldValue;
|
use App\Modules\Clients\Models\ClientCustomFieldValue;
|
||||||
@@ -20,10 +22,7 @@ use App\Modules\Files\DeletedAccountContent;
|
|||||||
use App\Modules\Identity\AccountContentDeletion;
|
use App\Modules\Identity\AccountContentDeletion;
|
||||||
use App\Modules\Identity\Erasure\AvailableEmailRule;
|
use App\Modules\Identity\Erasure\AvailableEmailRule;
|
||||||
use App\Modules\Identity\Erasure\ErasureSchedule;
|
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\TwoFactor\TwoFactorAdministration;
|
||||||
use App\Modules\Identity\UserType;
|
|
||||||
use App\Modules\Platform\Settings\Setting;
|
use App\Modules\Platform\Settings\Setting;
|
||||||
use App\Modules\Platform\Settings\Settings;
|
use App\Modules\Platform\Settings\Settings;
|
||||||
use App\Support\Pagination;
|
use App\Support\Pagination;
|
||||||
@@ -51,7 +50,9 @@ class ClientsController extends Controller
|
|||||||
private readonly AccountContentDeletion $accountDeletion,
|
private readonly AccountContentDeletion $accountDeletion,
|
||||||
private readonly StaffLibraryScope $scope,
|
private readonly StaffLibraryScope $scope,
|
||||||
private readonly SeatAllowance $seats,
|
private readonly SeatAllowance $seats,
|
||||||
|
private readonly ClientAccounts $clients,
|
||||||
private readonly ErasureSchedule $erasure,
|
private readonly ErasureSchedule $erasure,
|
||||||
|
private readonly DateInput $dates,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): Response
|
public function index(Request $request): Response
|
||||||
@@ -90,6 +91,12 @@ class ClientsController extends Controller
|
|||||||
'email' => $client->email,
|
'email' => $client->email,
|
||||||
'active' => $client->active,
|
'active' => $client->active,
|
||||||
'account_requested' => $client->account_requested,
|
'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(),
|
'created_at' => $client->created_at?->toIso8601String(),
|
||||||
'content' => $content[$client->id] ?? ['files' => 0, 'folders' => 0],
|
'content' => $content[$client->id] ?? ['files' => 0, 'folders' => 0],
|
||||||
]);
|
]);
|
||||||
@@ -98,49 +105,92 @@ class ClientsController extends Controller
|
|||||||
'clients' => $clients->items(),
|
'clients' => $clients->items(),
|
||||||
'pagination' => Pagination::meta($clients),
|
'pagination' => Pagination::meta($clients),
|
||||||
'filters' => $filters,
|
'filters' => $filters,
|
||||||
'reassign_candidates' => $this->accountDeletion->candidates(),
|
// Only for somebody who may actually reassign: the picker is
|
||||||
|
// part of the delete dialog, and React filtering it out of the
|
||||||
|
// page is not the same as it never being on the page.
|
||||||
|
'reassign_candidates' => $viewer->can('delete_clients')
|
||||||
|
? $this->accountDeletion->candidates($viewer)
|
||||||
|
: [],
|
||||||
|
// Null on a self-hosted install: no limit, nothing to say.
|
||||||
|
'seats' => $this->seats->clientState(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(): Response
|
public function create(): RedirectResponse|Response
|
||||||
{
|
{
|
||||||
|
// The same courtesy UsersController::create() does: a full
|
||||||
|
// installation is an ordinary state on a managed plan, so say so
|
||||||
|
// before somebody fills in a form that cannot be submitted. The
|
||||||
|
// guard in store() is still the rule; this is only the door.
|
||||||
|
$seats = $this->seats->clientState();
|
||||||
|
|
||||||
|
if ($seats !== null && $seats['full']) {
|
||||||
|
return redirect()->route('clients.index')->with('error', $seats['message']);
|
||||||
|
}
|
||||||
|
|
||||||
return Inertia::render('clients/create', [
|
return Inertia::render('clients/create', [
|
||||||
'custom_fields' => $this->customFieldDefinitions(),
|
'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
|
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([
|
$validated = $request->validate(array_merge([
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
|
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
|
||||||
'password' => ['required', 'confirmed', Password::defaults()],
|
'password' => ['required', 'confirmed', Password::defaults()],
|
||||||
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
|
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'expires_at' => ['nullable', 'string', 'date'],
|
||||||
], $this->customFieldRules()));
|
], $this->customFieldRules()));
|
||||||
|
|
||||||
$client = User::create([
|
$creator = $request->user();
|
||||||
'type' => UserType::Client,
|
assert($creator !== null);
|
||||||
'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);
|
// The seat guard, the type, the role, and the quota's "0 means
|
||||||
|
// inherit the site default" all live in ClientAccounts, shared
|
||||||
|
// with the API and the control plane. A client created here is
|
||||||
|
// approved by construction, so it counts against the cap
|
||||||
|
// immediately — unlike a self-registration awaiting a decision.
|
||||||
|
// The welcome waits until the custom fields are saved below.
|
||||||
|
$client = $this->clients->create(
|
||||||
|
name: $validated['name'],
|
||||||
|
email: $validated['email'],
|
||||||
|
password: $validated['password'],
|
||||||
|
// Cast, because `integer` validates without converting:
|
||||||
|
// $request->validate() hands back the raw input, so a form
|
||||||
|
// field arrives as the string "2048" and this file is
|
||||||
|
// strict_types. Filling the quota in was a 500; leaving it
|
||||||
|
// blank went through null ?? 0 as an int, which is why it
|
||||||
|
// survived to the fleet.
|
||||||
|
storageQuotaMb: (int) ($validated['storage_quota_mb'] ?? 0),
|
||||||
|
welcome: false,
|
||||||
|
expiresAt: $this->dates->instant($validated['expires_at'] ?? null, $creator),
|
||||||
|
);
|
||||||
|
|
||||||
|
// A client-scoped creator would otherwise lose the client they just
|
||||||
|
// made. guardTarget() answers 404 for anything off their roster, so
|
||||||
|
// the record they created is not theirs to open, and
|
||||||
|
// StaffLibraryScope::clients() leaves it out of their list as well —
|
||||||
|
// the client exists, is welcomed by email, and is invisible to the
|
||||||
|
// person who made it. Their own roster is where a client they
|
||||||
|
// created belongs; an unscoped creator has no roster to add to.
|
||||||
|
if ($creator->isClientScoped()) {
|
||||||
|
$creator->assignedClients()->attach($client->id);
|
||||||
|
}
|
||||||
|
|
||||||
$this->saveCustomFieldValues($client, $validated['custom_field_values'] ?? []);
|
$this->saveCustomFieldValues($client, $validated['custom_field_values'] ?? []);
|
||||||
|
|
||||||
@@ -154,7 +204,7 @@ class ClientsController extends Controller
|
|||||||
// Fall back to the create form: it shares this route's own gate, so
|
// Fall back to the create form: it shares this route's own gate, so
|
||||||
// it is reachable by exactly whoever just created the record, and
|
// it is reachable by exactly whoever just created the record, and
|
||||||
// the success toast shows there.
|
// the success toast shows there.
|
||||||
$target = $request->user()?->can('edit_clients')
|
$target = $creator->can('edit_clients')
|
||||||
? redirect()->route('clients.edit', $client)
|
? redirect()->route('clients.edit', $client)
|
||||||
: redirect()->route('clients.create');
|
: redirect()->route('clients.create');
|
||||||
|
|
||||||
@@ -194,15 +244,22 @@ class ClientsController extends Controller
|
|||||||
'account_requested' => $client->account_requested,
|
'account_requested' => $client->account_requested,
|
||||||
'storage_quota_mb' => $client->storage_quota_mb,
|
'storage_quota_mb' => $client->storage_quota_mb,
|
||||||
'two_factor_enabled' => $client->hasTwoFactorEnabled(),
|
'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),
|
'storage_used_mb' => (int) ceil($this->storageUsage->usedBytes($client) / 1024 / 1024),
|
||||||
'custom_fields' => $this->customFieldDefinitions(),
|
'custom_fields' => $this->customFieldDefinitions(),
|
||||||
'custom_field_values' => ClientCustomFieldValue::query()
|
'custom_field_values' => ClientCustomFieldValue::query()
|
||||||
->where('user_id', $client->id)
|
->where('user_id', $client->id)
|
||||||
->pluck('value', 'client_custom_field_id'),
|
->pluck('value', 'client_custom_field_id'),
|
||||||
'content' => $this->accountContent->summarize($client),
|
'content' => $this->accountContent->summarize($client),
|
||||||
'reassign_candidates' => $this->accountDeletion->candidates($client->id),
|
'reassign_candidates' => $request->user()?->can('delete_clients') === true
|
||||||
|
? $this->accountDeletion->candidates($request->user(), $client->id)
|
||||||
|
: [],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,8 +274,27 @@ class ClientsController extends Controller
|
|||||||
'active' => ['required', 'boolean'],
|
'active' => ['required', 'boolean'],
|
||||||
'password' => ['nullable', 'confirmed', Password::defaults()],
|
'password' => ['nullable', 'confirmed', Password::defaults()],
|
||||||
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
|
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'expires_at' => ['nullable', 'string', 'date'],
|
||||||
], $this->customFieldRules()));
|
], $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;
|
$wasActive = $client->active;
|
||||||
$passwordChanged = is_string($validated['password'] ?? null) && $validated['password'] !== '';
|
$passwordChanged = is_string($validated['password'] ?? null) && $validated['password'] !== '';
|
||||||
|
|
||||||
@@ -233,9 +309,16 @@ class ClientsController extends Controller
|
|||||||
'storage_quota_mb' => $validated['storage_quota_mb'] ?? 0,
|
'storage_quota_mb' => $validated['storage_quota_mb'] ?? 0,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$client->expires_at = $expiresAt;
|
||||||
|
|
||||||
// Activating a pending account through the edit screen counts as
|
// Activating a pending account through the edit screen counts as
|
||||||
// approval and clears the request flag.
|
// approval and clears the request flag — which is the moment a
|
||||||
|
// seat is spent, so the cap is asked here for the same reason
|
||||||
|
// AccountRequestsController::approve() asks it one screen over.
|
||||||
|
// Inside the branch, not above it: an installation at its cap must
|
||||||
|
// still be able to rename a client it already has.
|
||||||
if ($client->account_requested && $validated['active']) {
|
if ($client->account_requested && $validated['active']) {
|
||||||
|
$this->seats->guardClient('active');
|
||||||
$client->account_requested = false;
|
$client->account_requested = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// caller needs to see before removing it. The secret and the
|
||||||
// recovery codes stay where they are.
|
// recovery codes stay where they are.
|
||||||
'two_factor_enabled' => $this->hasTwoFactorEnabled(),
|
'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(),
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
'updated_at' => $this->updated_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ use App\Modules\Comments\GuestCommentIdentity;
|
|||||||
use App\Modules\Comments\Models\FileComment;
|
use App\Modules\Comments\Models\FileComment;
|
||||||
use App\Modules\Files\Access\ShareTargets;
|
use App\Modules\Files\Access\ShareTargets;
|
||||||
use App\Modules\Files\Access\StaffLibraryScope;
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
|
use App\Modules\Files\Access\ViewableFileScope;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
use App\Modules\Files\Models\Folder;
|
use App\Modules\Files\Models\Folder;
|
||||||
use App\Modules\Identity\UserType;
|
use App\Modules\Identity\UserType;
|
||||||
@@ -51,6 +52,7 @@ class VisibleCommentScope
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly StaffLibraryScope $scope,
|
private readonly StaffLibraryScope $scope,
|
||||||
|
private readonly ViewableFileScope $viewable,
|
||||||
private readonly ShareTargets $shareTargets,
|
private readonly ShareTargets $shareTargets,
|
||||||
private readonly GuestCommentIdentity $guests,
|
private readonly GuestCommentIdentity $guests,
|
||||||
) {}
|
) {}
|
||||||
@@ -123,6 +125,13 @@ class VisibleCommentScope
|
|||||||
* way around the visibility model** — moderating means deciding about
|
* way around the visibility model** — moderating means deciding about
|
||||||
* comments you can already see.
|
* comments you can already see.
|
||||||
*
|
*
|
||||||
|
* Which is why the files come from ViewableFileScope rather than from
|
||||||
|
* StaffLibraryScope: FilePolicy::view() is a permission half AND a
|
||||||
|
* library half, and narrowing by the library alone would hand every
|
||||||
|
* comment in the installation to a role holding moderate_comments and
|
||||||
|
* none of the three file keys — somebody who gets a 403 on every file
|
||||||
|
* these comments are about.
|
||||||
|
*
|
||||||
* Staff only. A client has no cross-file view of comments and asking
|
* Staff only. A client has no cross-file view of comments and asking
|
||||||
* for one is a mistake rather than an empty result, but returning
|
* for one is a mistake rather than an empty result, but returning
|
||||||
* nothing is the safe way to be wrong.
|
* nothing is the safe way to be wrong.
|
||||||
@@ -136,7 +145,7 @@ class VisibleCommentScope
|
|||||||
}
|
}
|
||||||
|
|
||||||
return $this->applyVisibility(
|
return $this->applyVisibility(
|
||||||
FileComment::query()->whereIn('file_id', $this->scope->files($viewer)->select('files.id')),
|
FileComment::query()->whereIn('file_id', $this->viewable->for($viewer)->select('files.id')),
|
||||||
$viewer,
|
$viewer,
|
||||||
// Publicness is a property of each file, so it cannot be one
|
// Publicness is a property of each file, so it cannot be one
|
||||||
// value for a query spanning many. It does not have to be: the
|
// value for a query spanning many. It does not have to be: the
|
||||||
@@ -156,6 +165,12 @@ class VisibleCommentScope
|
|||||||
* than about what this viewer may read, and a moderator who cannot see
|
* than about what this viewer may read, and a moderator who cannot see
|
||||||
* a particular client's thread must still be told the file has
|
* a particular client's thread must still be told the file has
|
||||||
* something waiting.
|
* something waiting.
|
||||||
|
*
|
||||||
|
* The file boundary is still the same one, though. ViewableFileScope
|
||||||
|
* rather than StaffLibraryScope: which files is the part that varies
|
||||||
|
* per client, whether any is the part that does not, and a badge
|
||||||
|
* counting the whole installation for somebody who may open none of it
|
||||||
|
* is a number about other people's files.
|
||||||
*/
|
*/
|
||||||
public function pendingTotal(User $viewer): int
|
public function pendingTotal(User $viewer): int
|
||||||
{
|
{
|
||||||
@@ -165,7 +180,7 @@ class VisibleCommentScope
|
|||||||
|
|
||||||
return FileComment::query()
|
return FileComment::query()
|
||||||
->whereNull('approved_at')
|
->whereNull('approved_at')
|
||||||
->whereIn('file_id', $this->scope->files($viewer)->select('files.id'))
|
->whereIn('file_id', $this->viewable->for($viewer)->select('files.id'))
|
||||||
->count();
|
->count();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,14 +141,19 @@ class CommentPresenter
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asked of the column, not of the relation — the same rule
|
||||||
|
* isFromGuest() and authorName() follow. Since author() reads a
|
||||||
|
* deleted account too this would now answer correctly either way; it
|
||||||
|
* is written this way so the next reader does not re-derive "no
|
||||||
|
* author row means guest", which is what it used to mean here.
|
||||||
|
*/
|
||||||
private function authorType(FileComment $comment): string
|
private function authorType(FileComment $comment): string
|
||||||
{
|
{
|
||||||
$author = $comment->author;
|
if ($comment->isFromGuest()) {
|
||||||
|
|
||||||
if ($author === null) {
|
|
||||||
return 'guest';
|
return 'guest';
|
||||||
}
|
}
|
||||||
|
|
||||||
return $author->isStaff() ? 'staff' : 'client';
|
return $comment->author?->isStaff() === true ? 'staff' : 'client';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Models\User;
|
|||||||
use App\Modules\Comments\Access\VisibleCommentScope;
|
use App\Modules\Comments\Access\VisibleCommentScope;
|
||||||
use App\Modules\Comments\Models\FileComment;
|
use App\Modules\Comments\Models\FileComment;
|
||||||
use App\Modules\Files\Access\StaffLibraryScope;
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
|
use App\Modules\Files\Access\ViewableFileScope;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,6 +23,7 @@ class FileCommentPolicy
|
|||||||
private readonly VisibleCommentScope $scope,
|
private readonly VisibleCommentScope $scope,
|
||||||
private readonly CommentingRules $rules,
|
private readonly CommentingRules $rules,
|
||||||
private readonly StaffLibraryScope $library,
|
private readonly StaffLibraryScope $library,
|
||||||
|
private readonly ViewableFileScope $viewable,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function view(User $user, FileComment $comment): bool
|
public function view(User $user, FileComment $comment): bool
|
||||||
@@ -69,6 +71,15 @@ class FileCommentPolicy
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Moderating is deciding about comments you can already see, so the
|
||||||
|
// permission half of file reading is part of the answer in both
|
||||||
|
// forms. Without one of the three file keys this user gets a 403 on
|
||||||
|
// every file these comments are about, and approving one hands back
|
||||||
|
// its body — so this is a reading door, not only a writing one.
|
||||||
|
if (! $this->viewable->permitsAnyFile($user)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if ($comment === null || ! $user->isClientScoped()) {
|
if ($comment === null || ! $user->isClientScoped()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,10 +220,27 @@ class FileComments
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Asked of the column, not of the relation. client_context_id is
|
||||||
|
// cascadeOnDelete, but a user is soft-deleted, so the cascade
|
||||||
|
// never fires: the column goes on pointing at a row that is still
|
||||||
|
// there while the relation resolves to null. Branching on the
|
||||||
|
// relation therefore read "this is Alice's conversation" as "this
|
||||||
|
// has no conversation" — and a null context on a Clients comment
|
||||||
|
// is the branch every client on the file reads (see
|
||||||
|
// VisibleCommentScope's opening rule). A private reply became a
|
||||||
|
// circular, and canAssignClient below was skipped on the way.
|
||||||
|
if ($replyTo->client_context_id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$client = $replyTo->clientContext;
|
$client = $replyTo->clientContext;
|
||||||
|
|
||||||
if ($client === null) {
|
if ($client === null) {
|
||||||
return null;
|
// The column points at somebody, and that somebody is gone.
|
||||||
|
// There is nobody to answer, and the one outcome that must
|
||||||
|
// not follow from a filled column is the broadcast above, so
|
||||||
|
// this refuses rather than falling through to it.
|
||||||
|
throw new AuthorizationException('You cannot reply in this conversation.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $this->library->canAssignClient($author, $client)) {
|
if (! $this->library->canAssignClient($author, $client)) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Modules\Comments\FileComments;
|
use App\Modules\Comments\FileComments;
|
||||||
use App\Modules\Comments\Http\Resources\Api\FileCommentResource;
|
use App\Modules\Comments\Http\Resources\Api\FileCommentResource;
|
||||||
use App\Modules\Comments\Models\FileComment;
|
use App\Modules\Comments\Models\FileComment;
|
||||||
use App\Modules\Files\Access\StaffLibraryScope;
|
use App\Modules\Files\Access\ViewableFileScope;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
@@ -30,16 +30,17 @@ class CommentModerationController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly FileComments $comments,
|
private readonly FileComments $comments,
|
||||||
private readonly StaffLibraryScope $library,
|
private readonly ViewableFileScope $viewable,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List comments awaiting approval.
|
* List comments awaiting approval.
|
||||||
*
|
*
|
||||||
* Scoped by the same library boundary as everything else: a
|
* Scoped by the same file boundary as everything else — the whole of
|
||||||
* client-scoped token sees pending comments only on files its owner
|
* it, not just its library half: a client-scoped token sees pending
|
||||||
* could already open. Oldest first, so working through the list means
|
* comments only on files its owner could already open, and a token
|
||||||
* working through the backlog.
|
* whose owner holds no file key at all sees none. Oldest first, so
|
||||||
|
* working through the list means working through the backlog.
|
||||||
*/
|
*/
|
||||||
public function index(Request $request): AnonymousResourceCollection
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
{
|
{
|
||||||
@@ -49,7 +50,7 @@ class CommentModerationController extends Controller
|
|||||||
|
|
||||||
$pending = FileComment::query()
|
$pending = FileComment::query()
|
||||||
->whereNull('approved_at')
|
->whereNull('approved_at')
|
||||||
->whereIn('file_id', $this->library->files($viewer)->select('id'))
|
->whereIn('file_id', $this->viewable->for($viewer)->select('id'))
|
||||||
->with(['author', 'clientContext'])
|
->with(['author', 'clientContext'])
|
||||||
->orderBy('created_at')
|
->orderBy('created_at')
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
|
|||||||
@@ -147,15 +147,17 @@ class CommentsController extends Controller
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See CommentPresenter::authorType(): asked of the column, because
|
||||||
|
* that is what decides whether a comment is a guest's.
|
||||||
|
*/
|
||||||
private function authorType(FileComment $comment): string
|
private function authorType(FileComment $comment): string
|
||||||
{
|
{
|
||||||
$author = $comment->author;
|
if ($comment->isFromGuest()) {
|
||||||
|
|
||||||
if ($author === null) {
|
|
||||||
return 'guest';
|
return 'guest';
|
||||||
}
|
}
|
||||||
|
|
||||||
return $author->isStaff() ? 'staff' : 'client';
|
return $comment->author?->isStaff() === true ? 'staff' : 'client';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ class FileCommentsController extends Controller
|
|||||||
$viewer,
|
$viewer,
|
||||||
CommentVisibility::from($validated['visibility']),
|
CommentVisibility::from($validated['visibility']),
|
||||||
$validated['body'],
|
$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);
|
return response()->json($this->payload($viewer, $file), 201);
|
||||||
|
|||||||
@@ -125,7 +125,11 @@ class PublicFileCommentsController extends Controller
|
|||||||
private function guard(string $publicSlug, File $file): void
|
private function guard(string $publicSlug, File $file): void
|
||||||
{
|
{
|
||||||
abort_unless($this->settings->get(Setting::PublicListingSlug) === $publicSlug, 404);
|
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);
|
abort_unless($this->rules->enabled(), 404);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,11 +76,30 @@ class FileComment extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* The account that wrote this comment, deleted or not.
|
||||||
|
*
|
||||||
|
* `author_id` is cascadeOnDelete and the cascade never fires, because
|
||||||
|
* a user is soft-deleted: the row behind a deleted commenter is still
|
||||||
|
* there and the column still points at it. Handing back null for one
|
||||||
|
* left every caller to invent a meaning for the absence, and they
|
||||||
|
* invented different ones — the author type became "guest" on two
|
||||||
|
* screens and "client" in the API, while the name beside it stayed
|
||||||
|
* correct, and the author filter and the name search stopped matching
|
||||||
|
* the comment at all.
|
||||||
|
*
|
||||||
|
* Whether a comment is from a guest is decided by `author_id` alone.
|
||||||
|
* isFromGuest() and authorName() already say so; this makes the
|
||||||
|
* relation agree with them.
|
||||||
|
*
|
||||||
|
* Nothing that decides who may *read* a comment goes through here —
|
||||||
|
* VisibleCommentScope and FileCommentPolicy both compare `author_id`
|
||||||
|
* directly — so this widens no visibility.
|
||||||
|
*
|
||||||
* @return BelongsTo<User, $this>
|
* @return BelongsTo<User, $this>
|
||||||
*/
|
*/
|
||||||
public function author(): BelongsTo
|
public function author(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'author_id');
|
return $this->belongsTo(User::class, 'author_id')->withTrashed();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,18 +132,32 @@ class FileComment extends Model
|
|||||||
* The name to show. Snapshotted for guests at write time; read live
|
* The name to show. Snapshotted for guests at write time; read live
|
||||||
* for accounts so a rename is reflected everywhere at once.
|
* for accounts so a rename is reflected everywhere at once.
|
||||||
*
|
*
|
||||||
* author_id cascades on delete, so a row that has one always has the
|
* A deleted account is still read. author_id cascades on delete, but
|
||||||
* account behind it — there is no deleted-author case to snapshot
|
* a user is soft-deleted and the cascade never fires, so the row
|
||||||
* against, unlike the activity log's actor_name.
|
* behind a deleted commenter is still there — and reading it through
|
||||||
|
* the plain relation returned null, which sent a named client's
|
||||||
|
* comment out as "Anonymous". That is what a guest comment looks
|
||||||
|
* like, and a guest comment is governed by different rules; the two
|
||||||
|
* must not be able to look the same. Whether the author is a guest is
|
||||||
|
* decided by author_id alone, which is also what isFromGuest() asks.
|
||||||
*/
|
*/
|
||||||
public function authorName(): string
|
public function authorName(): string
|
||||||
{
|
{
|
||||||
|
if ($this->author_id === null) {
|
||||||
|
return $this->guest_name ?? (string) __('Anonymous');
|
||||||
|
}
|
||||||
|
|
||||||
$author = $this->author;
|
$author = $this->author;
|
||||||
|
|
||||||
if ($author !== null) {
|
if ($author !== null) {
|
||||||
return $author->name;
|
return $author->name;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->guest_name ?? (string) __('Anonymous');
|
// Trashed: the row is still there, the relation simply will not
|
||||||
|
// hand it over. Nothing comes back only once the grace-period
|
||||||
|
// erasure has removed the row for real.
|
||||||
|
$name = $this->author()->withTrashed()->value('name');
|
||||||
|
|
||||||
|
return is_string($name) ? $name : (string) __('Anonymous');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
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
|
* 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
|
* pairs. Neutral keys, so callers can nest it ('shares' on the details
|
||||||
* panel) or flatten it (the edit pages' assigned_* props).
|
* 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}>}
|
* @return array{clients: list<array{id: int, name: string}>, groups: list<array{id: int, name: string}>}
|
||||||
*/
|
*/
|
||||||
public function assigned(File|Folder $subject): array
|
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,
|
* The assigned lists plus everything still available to share with,
|
||||||
* narrowed to what this viewer is allowed to reach.
|
* narrowed to what this viewer is allowed to reach.
|
||||||
@@ -76,7 +99,12 @@ class ShareTargets
|
|||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get();
|
->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 [
|
return [
|
||||||
'assigned_clients' => $assigned['clients'],
|
'assigned_clients' => $assigned['clients'],
|
||||||
|
|||||||
@@ -159,15 +159,52 @@ class StaffLibraryScope
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$clientIds = $this->assignableClientIds($user) ?? [];
|
return array_values($this->groups($user)->pluck('id')->map(fn ($id): int => (int) $id)->all());
|
||||||
|
|
||||||
if ($clientIds === []) {
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_values(Group::query()
|
/**
|
||||||
->whereHas('members', fn (Builder $members) => $members->whereIn('users.id', $clientIds))
|
* Every group this staff member may be told about, as a query.
|
||||||
->pluck('id')->map(fn ($id): int => (int) $id)->all());
|
*
|
||||||
|
* 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 $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
|
public function canAssignClient(User $user, User $client): bool
|
||||||
@@ -249,7 +286,53 @@ class StaffLibraryScope
|
|||||||
*/
|
*/
|
||||||
public function allowsGroupChange(User $user, Group $group): bool
|
public function allowsGroupChange(User $user, Group $group): bool
|
||||||
{
|
{
|
||||||
return $this->groupReachesNoFurther($user, $group);
|
return $this->groupIsNotWhollySomebodyElses($user, $group)
|
||||||
|
&& $this->groupReachesNoFurther($user, $group);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this group is somebody else's entirely — every member
|
||||||
|
* outside the staff member's roster, and none of theirs in it.
|
||||||
|
*
|
||||||
|
* The half allowsGroupChange() was missing. Reach answers "what would
|
||||||
|
* this group hand somebody", which is the right question for putting a
|
||||||
|
* client *into* it; it says nothing about who is already there. So a
|
||||||
|
* group with nothing shared with it yet passed the reach check
|
||||||
|
* vacuously, and a scoped staff member could rename it, delete it, or
|
||||||
|
* publish it — a group made entirely of clients they had never been
|
||||||
|
* assigned (GHSA-r3hg-3fxw-rcmr).
|
||||||
|
*
|
||||||
|
* **Not "every member is mine", which is the obvious reading and is
|
||||||
|
* wrong.** A mixed group has to stay changeable: GHSA-whmp-p9hv-r7j7
|
||||||
|
* settled that a scoped staff member opens such a group's edit screen
|
||||||
|
* and is shown only their own clients in it, rather than being refused
|
||||||
|
* the screen. Requiring every member to be theirs turns that narrowing
|
||||||
|
* back into a 404 and undoes the earlier fix. What is left over — a
|
||||||
|
* mixed group whose shared content reaches past their library — is
|
||||||
|
* refused by groupReachesNoFurther() beside this, which is the check
|
||||||
|
* that has always covered it.
|
||||||
|
*
|
||||||
|
* **And deliberately not folded into groupReachesNoFurther() either.**
|
||||||
|
* That predicate is shared with allowsGroupMembership(), where a group
|
||||||
|
* nobody has joined must stay usable so its creator can put the first
|
||||||
|
* member in — the case that method's own docblock calls out.
|
||||||
|
*
|
||||||
|
* An empty group is nobody else's, so whoever just made it can still
|
||||||
|
* name it.
|
||||||
|
*/
|
||||||
|
private function groupIsNotWhollySomebodyElses(User $user, Group $group): bool
|
||||||
|
{
|
||||||
|
$clientIds = $this->assignableClientIds($user);
|
||||||
|
|
||||||
|
if ($clientIds === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $group->members()->exists()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $group->members()->whereIn('users.id', $clientIds)->exists();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -268,6 +351,15 @@ class StaffLibraryScope
|
|||||||
* row rather than from the assignment ignores the dead ones by
|
* row rather than from the assignment ignores the dead ones by
|
||||||
* construction, which is also the right answer: a deleted file is
|
* construction, which is also the right answer: a deleted file is
|
||||||
* not reach, because nobody can reach it.
|
* not reach, because nobody can reach it.
|
||||||
|
*
|
||||||
|
* An expired file is the same answer for the same reason. Membership
|
||||||
|
* in this group grants nobody access to it — File::scopeVisibleToClient
|
||||||
|
* ends in notExpired(), so it is gone from every member's /my-files and
|
||||||
|
* the download is refused — while its absence from files() otherwise
|
||||||
|
* reads as "outside my library" and locks the group exactly as a
|
||||||
|
* deleted file used to. Expiry is reversible where deletion is not, so
|
||||||
|
* the file counts as reach again the moment it does: this asks what is
|
||||||
|
* reachable now, at the moment somebody is added or removed.
|
||||||
*/
|
*/
|
||||||
private function groupReachesNoFurther(User $user, Group $group): bool
|
private function groupReachesNoFurther(User $user, Group $group): bool
|
||||||
{
|
{
|
||||||
@@ -282,6 +374,7 @@ class StaffLibraryScope
|
|||||||
|
|
||||||
$outside = File::query()
|
$outside = File::query()
|
||||||
->whereIn('id', $assignedFiles)
|
->whereIn('id', $assignedFiles)
|
||||||
|
->notExpired()
|
||||||
->whereNotIn('id', $this->files($user)->select('id'))
|
->whereNotIn('id', $this->files($user)->select('id'))
|
||||||
->exists();
|
->exists();
|
||||||
|
|
||||||
@@ -292,9 +385,54 @@ class StaffLibraryScope
|
|||||||
$assignedFolders = FolderAssignment::query()->select('folder_id')
|
$assignedFolders = FolderAssignment::query()->select('folder_id')
|
||||||
->where('assignable_type', $morph)->where('assignable_id', $group->id);
|
->where('assignable_type', $morph)->where('assignable_id', $group->id);
|
||||||
|
|
||||||
return ! Folder::query()
|
// The whole subtree, not the folder the assignment names. A folder
|
||||||
->whereIn('id', $assignedFolders)
|
// shared with a group hands its members everything inside it —
|
||||||
|
// File::scopeVisibleToClient matches on folder placement, and a
|
||||||
|
// folder is visible to a client when it or an ancestor is shared
|
||||||
|
// with them — so "is anything shared with this group outside my
|
||||||
|
// library" has to ask about the contents, which is what the
|
||||||
|
// docblock above already claims ("the folders whose subtrees it
|
||||||
|
// can browse").
|
||||||
|
//
|
||||||
|
// Measured: a scoped staff member's own folder, with a subfolder
|
||||||
|
// somebody else created inside it and somebody else's file in
|
||||||
|
// that. The folder is theirs, its contents are not, and adding
|
||||||
|
// their own client to a group holding the parent handed that
|
||||||
|
// client the file — which then enters the staff member's own
|
||||||
|
// library too, because files() is "everything my clients can
|
||||||
|
// see". That is the widening this guard exists to refuse, and the
|
||||||
|
// test above it says so in as many words.
|
||||||
|
$reachable = Folder::query()->whereIn('id', $assignedFolders)->get()
|
||||||
|
->flatMap(fn (Folder $folder): array => $folder->subtreeFolderIds())
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($reachable === []) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Folder::query()
|
||||||
|
->whereIn('id', $reachable)
|
||||||
->whereNotIn('id', $this->folders($user)->select('id'))
|
->whereNotIn('id', $this->folders($user)->select('id'))
|
||||||
|
->exists()
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the files sitting in them. A folder can be inside the
|
||||||
|
// library while a file in it is not: files() is own uploads plus
|
||||||
|
// what an assigned client may see, and neither covers somebody
|
||||||
|
// else's upload into a folder this staff member happens to own.
|
||||||
|
//
|
||||||
|
// notExpired() for the same reason the assignment half above skips
|
||||||
|
// deleted files: membership in this group grants nobody access to
|
||||||
|
// an expired file, because scopeVisibleToClient ends by excluding
|
||||||
|
// them, and something nobody can reach is not reach.
|
||||||
|
return ! File::query()
|
||||||
|
->whereIn('folder_id', $reachable)
|
||||||
|
->notExpired()
|
||||||
|
->whereNotIn('id', $this->files($user)->select('id'))
|
||||||
->exists();
|
->exists();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,15 +40,25 @@ class ViewableFileScope
|
|||||||
return File::query()->visibleToClient($user);
|
return File::query()->visibleToClient($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors FilePolicy::view()'s staff branch: the permission half is
|
if (! $this->permitsAnyFile($user)) {
|
||||||
// a property of the viewer, not the row, so it either opens the
|
|
||||||
// whole scope or closes it entirely.
|
|
||||||
$permitted = $user->can('upload') || $user->can('edit_files') || $user->can('edit_others_files');
|
|
||||||
|
|
||||||
if (! $permitted) {
|
|
||||||
return File::query()->whereRaw('1 = 0');
|
return File::query()->whereRaw('1 = 0');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->scope->files($user);
|
return $this->scope->files($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a staff member holds any of the three keys that open file
|
||||||
|
* reading at all — the permission half of FilePolicy::view()'s staff
|
||||||
|
* branch, named once because more than one module has to ask it.
|
||||||
|
*
|
||||||
|
* It is a property of the viewer rather than of a row, so it either
|
||||||
|
* opens the whole scope or closes it entirely. That is also why a
|
||||||
|
* query narrowed by StaffLibraryScope alone is only half the check:
|
||||||
|
* the library says *which* files, this says *whether any*.
|
||||||
|
*/
|
||||||
|
public function permitsAnyFile(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->can('upload') || $user->can('edit_files') || $user->can('edit_others_files');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Files\Console;
|
||||||
|
|
||||||
|
use App\Modules\Audit\Action;
|
||||||
|
use App\Modules\Audit\ActivityLogger;
|
||||||
|
use App\Modules\Files\Jobs\ScanFileJob;
|
||||||
|
use App\Modules\Files\MissingFileScanner;
|
||||||
|
use App\Modules\Files\Models\File;
|
||||||
|
use App\Modules\Files\Scanning\NotScannedReason;
|
||||||
|
use App\Modules\Files\Scanning\ScanningConfig;
|
||||||
|
use App\Modules\Files\Scanning\ScanStatus;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks daily that the files this installation lists are actually there.
|
||||||
|
*
|
||||||
|
* Its own command rather than part of scanning, because a missing file is
|
||||||
|
* not a virus question and an installation with no scanner has exactly
|
||||||
|
* the same problem. It was only ever noticed when something tried to read
|
||||||
|
* the bytes — a download, or a scan — which means the first person to
|
||||||
|
* find out was a client clicking a link.
|
||||||
|
*
|
||||||
|
* Recovery is part of the job: storage comes back, and a library that
|
||||||
|
* kept insisting every file was gone would be its own bug.
|
||||||
|
*/
|
||||||
|
class CheckMissingFilesCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'projectsend:check-missing-files';
|
||||||
|
|
||||||
|
protected $description = 'Check that every file in the database is still on disk (runs daily)';
|
||||||
|
|
||||||
|
public function handle(MissingFileScanner $scanner, ActivityLogger $activity, ScanningConfig $scanning): int
|
||||||
|
{
|
||||||
|
$gone = $scanner->scan();
|
||||||
|
$newlyGone = 0;
|
||||||
|
|
||||||
|
foreach (array_chunk($gone, 200) as $chunk) {
|
||||||
|
// Not a quarantined file. It is unavailable already, and
|
||||||
|
// marking it missing would wipe the threat name and then, when
|
||||||
|
// the bytes came back, send it round as a fresh upload — out
|
||||||
|
// of quarantine with nobody having released it.
|
||||||
|
$candidates = File::query()
|
||||||
|
->whereIn('id', $chunk)
|
||||||
|
->whereNotIn('scan_status', [
|
||||||
|
ScanStatus::Missing->value,
|
||||||
|
ScanStatus::Infected->value,
|
||||||
|
ScanStatus::UnscannableBlocked->value,
|
||||||
|
])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($candidates as $file) {
|
||||||
|
// Stamped like any other verdict: this is the moment the
|
||||||
|
// file was last looked at, and without it a missing file
|
||||||
|
// never appears in the Activity list — which is exactly
|
||||||
|
// where somebody watching would look for it.
|
||||||
|
$file->forceFill([
|
||||||
|
'scan_status' => ScanStatus::Missing,
|
||||||
|
'scan_note' => null,
|
||||||
|
'scanned_at' => now(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$activity->logSystem(Action::FileMissing, ['id' => $file->id, 'name' => $file->name]);
|
||||||
|
$newlyGone++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$back = $scanner->recovered();
|
||||||
|
|
||||||
|
foreach (array_chunk($back, 200) as $chunk) {
|
||||||
|
// Back to the start rather than to whatever it was before:
|
||||||
|
// nothing here knows what the scanner had decided about bytes
|
||||||
|
// that have since been away, and re-checking them is cheap
|
||||||
|
// next to trusting a verdict about a file that left.
|
||||||
|
File::query()->whereIn('id', $chunk)->update($scanning->enabled()
|
||||||
|
? ['scan_status' => ScanStatus::Pending->value, 'scan_note' => null]
|
||||||
|
: ['scan_status' => ScanStatus::NotScanned->value, 'scan_note' => NotScannedReason::BeforeScanning->value]);
|
||||||
|
|
||||||
|
// Straight to the scanner rather than left for the hourly
|
||||||
|
// sweep, which kept a file that had come back unavailable for
|
||||||
|
// up to an hour for no reason.
|
||||||
|
if ($scanning->enabled()) {
|
||||||
|
foreach ($chunk as $id) {
|
||||||
|
ScanFileJob::dispatch($id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'%d file(s) are missing from storage (%d newly), %d came back.',
|
||||||
|
count($gone),
|
||||||
|
$newlyGone,
|
||||||
|
count($back),
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Files\Console;
|
||||||
|
|
||||||
|
use App\Modules\Files\Jobs\ScanFileJob;
|
||||||
|
use App\Modules\Files\Models\File;
|
||||||
|
use App\Modules\Files\Scanning\NotScannedReason;
|
||||||
|
use App\Modules\Files\Scanning\ScanningConfig;
|
||||||
|
use App\Modules\Files\Scanning\ScanStatus;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends files back to the scanner: the ones still waiting, the ones that
|
||||||
|
* went through unscanned because it was down, and — when asked — the
|
||||||
|
* library that was already here before any of this existed.
|
||||||
|
*
|
||||||
|
* Hourly rather than daily. A file stuck pending is a file nobody can
|
||||||
|
* download, and an installation set to hold has no other way forward
|
||||||
|
* once its worker restarted and the job with it.
|
||||||
|
*/
|
||||||
|
class ScanFilesCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'projectsend:scan-files
|
||||||
|
{--existing : also work through files that were never scanned because scanning was off}
|
||||||
|
{--all : check every file again, whatever it said last}';
|
||||||
|
|
||||||
|
protected $description = 'Scan files that are waiting, were missed, or were never checked (runs hourly)';
|
||||||
|
|
||||||
|
public function handle(ScanningConfig $config): int
|
||||||
|
{
|
||||||
|
if (! $config->enabled()) {
|
||||||
|
$this->info('Virus scanning is switched off.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$waiting = $this->dispatchFor(File::query()->where('scan_status', ScanStatus::Pending));
|
||||||
|
|
||||||
|
// Allowed through while the scanner was unreachable. Now that it
|
||||||
|
// may be back, they are asked again — a file found infected at
|
||||||
|
// this point is quarantined like any other, and its quarantine
|
||||||
|
// notice says it was available in the meantime.
|
||||||
|
$missed = $this->dispatchFor(
|
||||||
|
File::query()
|
||||||
|
->where('scan_status', ScanStatus::NotScanned)
|
||||||
|
->where('scan_note', NotScannedReason::ScannerUnavailable->value),
|
||||||
|
rescan: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->info("Re-queued {$waiting} waiting file(s) and {$missed} that were missed while the scanner was down.");
|
||||||
|
|
||||||
|
if ($this->option('all')) {
|
||||||
|
// Every file somebody can have today. Not a file waiting for
|
||||||
|
// its first verdict, not one with no bytes, and not one in or
|
||||||
|
// released from quarantine — a scan is not how a file leaves
|
||||||
|
// quarantine, and a release is not undone by one. Files keep
|
||||||
|
// their current state, and stay downloadable, until a new
|
||||||
|
// verdict arrives.
|
||||||
|
$limit = $config->existingScanRatePerMinute() * 60;
|
||||||
|
|
||||||
|
$checked = $this->dispatchFor(
|
||||||
|
File::query()->whereIn('scan_status', ScanFileJob::rescannableValues()),
|
||||||
|
$limit,
|
||||||
|
rescan: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->info("Queued {$checked} file(s) to be checked again.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->option('existing')) {
|
||||||
|
// Paced, because this can be a whole library at once and the
|
||||||
|
// scanner is also serving today's uploads. An hour's worth per
|
||||||
|
// run, since that is how often this command runs.
|
||||||
|
$limit = $config->existingScanRatePerMinute() * 60;
|
||||||
|
|
||||||
|
$old = $this->dispatchFor(File::query()->neverScanned(), $limit, rescan: true);
|
||||||
|
|
||||||
|
$this->info("Queued {$old} file(s) that had never been scanned.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nothing here changes a file's state before the scanner has spoken.
|
||||||
|
*
|
||||||
|
* An earlier version marked each file pending first, which reads as
|
||||||
|
* tidy and is wrong twice over: pending means "withheld", so a
|
||||||
|
* backfill would have hidden an entire library from its clients for
|
||||||
|
* as long as it ran, and every file would then have been announced to
|
||||||
|
* its recipients a second time when it came back. The job knows which
|
||||||
|
* state it expects instead — see its $rescan.
|
||||||
|
*
|
||||||
|
* @param Builder<File> $query
|
||||||
|
*/
|
||||||
|
private function dispatchFor(Builder $query, ?int $limit = null, bool $rescan = false): int
|
||||||
|
{
|
||||||
|
if ($limit !== null) {
|
||||||
|
$query->limit($limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = $query->orderBy('id')->pluck('id');
|
||||||
|
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
ScanFileJob::dispatch((int) $id, $rescan);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ids->count();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Files\Delivery;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a file's bytes get from this server's disk to the visitor.
|
||||||
|
*
|
||||||
|
* Uploads live outside the web root, so every download passes through a
|
||||||
|
* permission check in PHP first. What differs is what happens after that
|
||||||
|
* check passes: PHP can read the file and write it out itself, or it can
|
||||||
|
* answer with an empty body and a header telling the web server to send
|
||||||
|
* the file instead.
|
||||||
|
*
|
||||||
|
* The header is the fast path and it is not portable — each server reads
|
||||||
|
* a different one, and a server reading none of them serves the empty
|
||||||
|
* body, which is how an installation ends up handing out 0-byte
|
||||||
|
* downloads while every other page works. ProjectSend v1 had this as a
|
||||||
|
* four-way setting with PHP as the default; v2 hard-coded nginx's
|
||||||
|
* spelling for its first releases, which is
|
||||||
|
* https://github.com/projectsend/projectsend/issues/1765.
|
||||||
|
*/
|
||||||
|
enum DeliveryMethod: string
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* nginx: `X-Accel-Redirect`, carrying a *URL path* that the
|
||||||
|
* `location /protected-files/` block maps back onto the storage
|
||||||
|
* directory. That block is marked `internal`, which is what stops a
|
||||||
|
* visitor requesting the path directly.
|
||||||
|
*/
|
||||||
|
case Nginx = 'nginx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apache with `mod_xsendfile`, and LiteSpeed, which reads the same
|
||||||
|
* header: `X-Sendfile`, carrying an *absolute filesystem path*.
|
||||||
|
*
|
||||||
|
* Never chosen automatically. The module also needs `XSendFilePath`
|
||||||
|
* to whitelist the storage directory, and there is no way to detect
|
||||||
|
* that from here — picking this on the strength of the module being
|
||||||
|
* loaded would trade one silent failure for another.
|
||||||
|
*/
|
||||||
|
case XSendFile = 'xsendfile';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PHP reads the file and streams it.
|
||||||
|
*
|
||||||
|
* Works on every server, and costs a worker process for the duration
|
||||||
|
* of each download — a handful of large concurrent downloads can
|
||||||
|
* occupy every worker while the CPU sits idle. That is why it is the
|
||||||
|
* fallback rather than the default, and why an installation using it
|
||||||
|
* says so on the dashboard rather than being quietly slow.
|
||||||
|
*/
|
||||||
|
case Php = 'php';
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Files\Delivery;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts a file that lives on this server's local disk on the wire.
|
||||||
|
*
|
||||||
|
* The single place that knows how the bytes travel. Four routes used to
|
||||||
|
* decide that for themselves and all four hard-coded nginx's header, so
|
||||||
|
* an Apache or LiteSpeed installation served four different flavours of
|
||||||
|
* empty response — uploads worked, thumbnails were broken images, and
|
||||||
|
* downloads arrived as 0 bytes. Callers now say *what* to send and this
|
||||||
|
* decides *how*.
|
||||||
|
*
|
||||||
|
* It authorizes nothing. Every caller has already done that its own way
|
||||||
|
* — a policy, a share token, a public-listing check — and the path it
|
||||||
|
* passes is always derived from a row it just authorized, never from the
|
||||||
|
* request. That is load-bearing: `serve()` will send any file under the
|
||||||
|
* storage root, so a caller that passed user input would have built a
|
||||||
|
* file-disclosure bug. The root check below is the backstop, not the
|
||||||
|
* rule.
|
||||||
|
*
|
||||||
|
* ### Choosing the method
|
||||||
|
*
|
||||||
|
* `PROJECTSEND_FILE_DELIVERY` picks one explicitly. Left at `auto` — the
|
||||||
|
* default — nginx gets its own fast path and everything else gets PHP
|
||||||
|
* streaming.
|
||||||
|
*
|
||||||
|
* Auto deliberately never chooses `xsendfile`. Apache's `mod_xsendfile`
|
||||||
|
* needs `XSendFilePath` to whitelist the storage directory as well as
|
||||||
|
* being loaded, and nothing here can see whether it does; choosing it
|
||||||
|
* because the module is present would swap a silent failure anybody can
|
||||||
|
* diagnose from the dashboard for one nobody can. So it stays something
|
||||||
|
* an operator turns on having configured it.
|
||||||
|
*
|
||||||
|
* A value that is not a method falls back to auto rather than throwing.
|
||||||
|
* A typo in an environment variable should cost speed, not every
|
||||||
|
* download on the installation.
|
||||||
|
*/
|
||||||
|
class FileDelivery
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The disk uploads live on. Named rather than injected because the
|
||||||
|
* whole class is about the local-disk case: a file on S3 never
|
||||||
|
* reaches here, it is a signed redirect from StoredFileResponse.
|
||||||
|
*/
|
||||||
|
private const DISK = 'files';
|
||||||
|
|
||||||
|
/** The internal nginx location that maps back onto the storage root. */
|
||||||
|
private const NGINX_LOCATION = '/protected-files/';
|
||||||
|
|
||||||
|
public function __construct(private readonly Request $request) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The method in force, and whether it was detected or stated.
|
||||||
|
*
|
||||||
|
* @return array{method: DeliveryMethod, detected: bool, observed: bool}
|
||||||
|
*/
|
||||||
|
public function resolve(): array
|
||||||
|
{
|
||||||
|
$configured = config('projectsend.file_delivery');
|
||||||
|
$explicit = is_string($configured) ? DeliveryMethod::tryFrom($configured) : null;
|
||||||
|
|
||||||
|
if ($explicit !== null) {
|
||||||
|
return ['method' => $explicit, 'detected' => false, 'observed' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether there was anything to detect *from*. detect() reads
|
||||||
|
// SERVER_SOFTWARE, which only exists inside a request — so a
|
||||||
|
// console process has nothing to look at and falls to the `php`
|
||||||
|
// default. That default is right for the console (no web server is
|
||||||
|
// handling this, so nothing could hand a file off), and wrong as a
|
||||||
|
// statement about the installation, which is how somebody reading
|
||||||
|
// it from `artisan tinker` will take it.
|
||||||
|
//
|
||||||
|
// Reported rather than papered over: a reader who runs
|
||||||
|
// `describe()` from a shell on a perfectly good nginx box was
|
||||||
|
// being told `php`, with `detected: true` vouching for it. That
|
||||||
|
// cost somebody an afternoon before it was recognised as an
|
||||||
|
// artefact of asking outside a request.
|
||||||
|
$observed = is_string($this->request->server('SERVER_SOFTWARE'));
|
||||||
|
|
||||||
|
return ['method' => $this->detect(), 'detected' => true, 'observed' => $observed];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function method(): DeliveryMethod
|
||||||
|
{
|
||||||
|
return $this->resolve()['method'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same answer as a plain array, for a screen or a probe.
|
||||||
|
*
|
||||||
|
* Spelled out rather than leaning on a backed enum encoding itself,
|
||||||
|
* because this shape is read by the dashboard and by whatever watches
|
||||||
|
* the installation from outside, and neither should change meaning if
|
||||||
|
* the enum ever grows a JsonSerializable of its own.
|
||||||
|
*
|
||||||
|
* `observed` is false only outside an HTTP request, where nothing can
|
||||||
|
* be detected and `method` is a default rather than a finding. Both
|
||||||
|
* screens that read this run in a request, so they always see true;
|
||||||
|
* it exists for whoever asks from a console.
|
||||||
|
*
|
||||||
|
* @return array{method: string, detected: bool, observed: bool}
|
||||||
|
*/
|
||||||
|
public function describe(): array
|
||||||
|
{
|
||||||
|
$resolved = $this->resolve();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'method' => $resolved['method']->value,
|
||||||
|
// True when nobody said which to use. The distinction matters
|
||||||
|
// to the reader: a detected `php` is an installation that
|
||||||
|
// could be faster, a stated one is somebody's decision.
|
||||||
|
'detected' => $resolved['detected'],
|
||||||
|
// And whether the detection had anything to work with.
|
||||||
|
'observed' => $resolved['observed'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the server says it is.
|
||||||
|
*
|
||||||
|
* `SERVER_SOFTWARE` is set by the web server itself through the
|
||||||
|
* FastCGI parameters, so it describes the process actually holding
|
||||||
|
* the connection to PHP. That is the right thing to ask: the header
|
||||||
|
* has to be understood by *that* server, not by whatever sits in
|
||||||
|
* front of it.
|
||||||
|
*
|
||||||
|
* The known-wrong case is nginx reverse-proxying Apache, which
|
||||||
|
* INSTALL.md offers as a way to keep an existing Apache. This reads
|
||||||
|
* Apache and picks PHP streaming, so downloads work and are slower
|
||||||
|
* than they need to be — the safe direction, and the reason the
|
||||||
|
* override exists.
|
||||||
|
*/
|
||||||
|
private function detect(): DeliveryMethod
|
||||||
|
{
|
||||||
|
$software = $this->request->server('SERVER_SOFTWARE');
|
||||||
|
$software = strtolower(is_string($software) ? $software : '');
|
||||||
|
|
||||||
|
return str_contains($software, 'nginx') ? DeliveryMethod::Nginx : DeliveryMethod::Php;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $path disk-relative, and always derived from an
|
||||||
|
* already-authorized row — never from the request
|
||||||
|
* @param int|null $length when the caller already knows it; PHP
|
||||||
|
* streaming ignores it and measures the file
|
||||||
|
*/
|
||||||
|
public function serve(string $path, string $mimeType, string $disposition, ?int $length = null): Response|BinaryFileResponse
|
||||||
|
{
|
||||||
|
$this->assertRelative($path);
|
||||||
|
|
||||||
|
$headers = array_filter([
|
||||||
|
'Content-Type' => $mimeType,
|
||||||
|
'Content-Disposition' => $disposition,
|
||||||
|
'Content-Length' => $length === null ? null : (string) $length,
|
||||||
|
], static fn (?string $value): bool => $value !== null);
|
||||||
|
|
||||||
|
return match ($this->method()) {
|
||||||
|
DeliveryMethod::Nginx => response('', 200, [
|
||||||
|
'X-Accel-Redirect' => self::NGINX_LOCATION.$path,
|
||||||
|
...$headers,
|
||||||
|
]),
|
||||||
|
DeliveryMethod::XSendFile => response('', 200, [
|
||||||
|
// An absolute filesystem path, unlike nginx's URL path.
|
||||||
|
// Renaming the header without changing the value is the
|
||||||
|
// obvious way to "add Apache support" and produces a
|
||||||
|
// second broken install.
|
||||||
|
'X-Sendfile' => $this->absolutePathWithin($path),
|
||||||
|
...$headers,
|
||||||
|
]),
|
||||||
|
DeliveryMethod::Php => $this->stream($this->absolutePathWithin($path), $headers),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $headers
|
||||||
|
*/
|
||||||
|
private function stream(string $absolute, array $headers): BinaryFileResponse
|
||||||
|
{
|
||||||
|
// A large download can outlive max_execution_time, and the visitor
|
||||||
|
// sees a truncated file rather than an error. The web server is
|
||||||
|
// not holding this one open for us.
|
||||||
|
if (function_exists('set_time_limit')) {
|
||||||
|
@set_time_limit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// BinaryFileResponse rather than a hand-written readfile loop: it
|
||||||
|
// answers Range requests, which is what makes seeking through a
|
||||||
|
// long video work. nginx does that for itself on the fast path, so
|
||||||
|
// rolling our own here would break preview scrubbing on exactly
|
||||||
|
// the installations this fallback exists for.
|
||||||
|
//
|
||||||
|
// Content-Length is deliberately dropped from the headers: the
|
||||||
|
// response sets its own from the file, and a caller's figure that
|
||||||
|
// disagrees — a stale `files.size`, or a range being served —
|
||||||
|
// truncates the download.
|
||||||
|
unset($headers['Content-Length']);
|
||||||
|
|
||||||
|
return new BinaryFileResponse($absolute, 200, $headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The path must stay a path *inside* the storage area.
|
||||||
|
*
|
||||||
|
* Checked for every method, and without touching the filesystem,
|
||||||
|
* because nginx resolves `..` in the URL it is handed just as
|
||||||
|
* happily as a filesystem call would -- and because every method
|
||||||
|
* puts this value into a response header. Callers pass paths from rows
|
||||||
|
* they authorized rather than from the request, so this is a
|
||||||
|
* backstop; it is here because the cost of being wrong about that,
|
||||||
|
* once, is handing over any file the web server can read.
|
||||||
|
*/
|
||||||
|
private function assertRelative(string $path): void
|
||||||
|
{
|
||||||
|
abort_if(
|
||||||
|
$path === ''
|
||||||
|
|| str_starts_with($path, '/')
|
||||||
|
|| preg_match('#(^|/)\.\.(/|$)#', $path) === 1
|
||||||
|
// A control character in the path is header injection, not
|
||||||
|
// traversal: this value is written into X-Accel-Redirect or
|
||||||
|
// X-Sendfile, and a CR or LF in a header value splits the
|
||||||
|
// response. PHP's header() refuses to emit one, so the real
|
||||||
|
// effect is a 500 on every download, preview and thumbnail
|
||||||
|
// of that file rather than a split -- a file permanently
|
||||||
|
// broken by its own name.
|
||||||
|
//
|
||||||
|
// Paths are `Y/m/{uuid}.{ext}` and generated here, so this
|
||||||
|
// should be unreachable. It is checked because the
|
||||||
|
// extension is not: it is taken from the uploader's
|
||||||
|
// filename, and on a migrated installation from a v1
|
||||||
|
// database, which is somebody else's data.
|
||||||
|
|| preg_match('/[\x00-\x1F\x7F]/', $path) === 1,
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The absolute path, proven to resolve inside the storage root.
|
||||||
|
*
|
||||||
|
* Only the two methods that hand over a *filesystem* path need this,
|
||||||
|
* and only they can afford it: it resolves symlinks, so it answers
|
||||||
|
* the question `assertRelative()` cannot — whether the file is really
|
||||||
|
* where the path says it is.
|
||||||
|
*
|
||||||
|
* It also requires the file to exist, which is why nginx does not go
|
||||||
|
* through it. On that path PHP never opens the file, and adding a
|
||||||
|
* stat to every download to discover something nginx is about to
|
||||||
|
* discover anyway would be a cost with no answer attached.
|
||||||
|
*/
|
||||||
|
private function absolutePathWithin(string $path): string
|
||||||
|
{
|
||||||
|
$disk = Storage::disk(self::DISK);
|
||||||
|
|
||||||
|
$absolute = realpath($disk->path($path));
|
||||||
|
$root = realpath($disk->path(''));
|
||||||
|
|
||||||
|
abort_if(
|
||||||
|
$absolute === false || $root === false || ! str_starts_with($absolute, rtrim($root, '/').'/'),
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $absolute;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ namespace App\Modules\Files\Delivery;
|
|||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
use App\Support\ContentDisposition;
|
use App\Support\ContentDisposition;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Response;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A stored file's own bytes, put on the wire for whichever disk it lives
|
* A stored file's own bytes, put on the wire for whichever disk it lives
|
||||||
@@ -20,15 +20,33 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
* asking. The one thing it knows is the thing each caller kept getting
|
* asking. The one thing it knows is the thing each caller kept getting
|
||||||
* wrong on its own: that `$file->disk` decides how the bytes travel.
|
* wrong on its own: that `$file->disk` decides how the bytes travel.
|
||||||
*
|
*
|
||||||
* Local disk: X-Accel-Redirect, so nginx streams the file and PHP never
|
* Local disk: handed to FileDelivery, which decides whether the web
|
||||||
* touches the bytes. Anything else — S3, GCS and friends — gets a
|
* server sends the bytes or PHP does. Anything else — S3, GCS and
|
||||||
* short-lived presigned URL carrying the disposition, which an object
|
* friends — gets a short-lived presigned URL carrying the disposition,
|
||||||
* store ranges just as well.
|
* which an object store ranges just as well.
|
||||||
*
|
*
|
||||||
* That distinction matters most for inline(): a <video> seeking through
|
* That distinction matters most for inline(): a <video> seeking through
|
||||||
* an hour of footage issues a long tail of Range requests, and nginx's
|
* an hour of footage issues a long tail of Range requests. Every local
|
||||||
* static handler answers those with 206s on its own, dropping the
|
* delivery method answers those — nginx's static handler on the fast
|
||||||
* Content-Length below in favour of the range it actually served.
|
* 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
|
* Callers of inline() must have established that the mime type is
|
||||||
* inline-safe first; PreviewKind is the allowlist, and the reason there
|
* inline-safe first; PreviewKind is the allowlist, and the reason there
|
||||||
@@ -36,35 +54,74 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
*/
|
*/
|
||||||
class StoredFileResponse
|
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. */
|
/** Shown in place — a preview. */
|
||||||
public function inline(File $file): Response|RedirectResponse
|
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. */
|
/** Handed over — a download. */
|
||||||
public function attachment(File $file): Response|RedirectResponse
|
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') {
|
if ($file->disk !== 'files') {
|
||||||
$url = Storage::disk($file->disk)->temporaryUrl(
|
$url = Storage::disk($this->signingDisk($file->disk))->temporaryUrl(
|
||||||
$file->path,
|
$file->path,
|
||||||
now()->addHour(),
|
now()->addSeconds($linkSeconds),
|
||||||
['ResponseContentDisposition' => $disposition],
|
['ResponseContentDisposition' => $disposition],
|
||||||
);
|
);
|
||||||
|
|
||||||
return redirect()->away($url);
|
return redirect()->away($url);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response('', 200, [
|
return $this->delivery->serve($file->path, $file->mime_type, $disposition, $file->size);
|
||||||
'X-Accel-Redirect' => '/protected-files/'.$file->path,
|
}
|
||||||
'Content-Type' => $file->mime_type,
|
|
||||||
'Content-Disposition' => $disposition,
|
/**
|
||||||
'Content-Length' => (string) $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,15 +24,37 @@ class FileDiskCleanup
|
|||||||
{
|
{
|
||||||
public function delete(File $file): void
|
public function delete(File $file): void
|
||||||
{
|
{
|
||||||
try {
|
$this->attempt($file, fn () => Storage::disk($file->disk)->delete($file->path));
|
||||||
Storage::disk($file->disk)->delete($file->path);
|
|
||||||
|
|
||||||
// Every rendition, for every audience — a deleted file's bytes
|
// Every rendition, for every audience — a deleted file's bytes must
|
||||||
// must not survive on disk because whoever wrote the cleanup
|
// not survive on disk because whoever wrote the cleanup only knew
|
||||||
// only knew about the one copy they had in mind.
|
// about the one copy they had in mind.
|
||||||
|
//
|
||||||
|
// Attempted separately from the original above, not because the two
|
||||||
|
// are unrelated but because they are on different disks: renditions
|
||||||
|
// are always local, and Storage::disk() throws outright for a name
|
||||||
|
// with no configured driver — which is exactly the state the
|
||||||
|
// original's disk is in when this fails at all. Sharing one `try`
|
||||||
|
// meant a file whose source disk had been removed kept every cached
|
||||||
|
// copy of itself, and nothing looks for those again:
|
||||||
|
// OrphanFileScanner skips the rendition directories on purpose.
|
||||||
|
$this->attempt($file, function () use ($file): void {
|
||||||
foreach (ThumbnailGenerator::pathsFor($file->id, $file->mime_type) as $renditionPath) {
|
foreach (ThumbnailGenerator::pathsFor($file->id, $file->mime_type) as $renditionPath) {
|
||||||
Storage::disk('files')->delete($renditionPath);
|
Storage::disk('files')->delete($renditionPath);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliberately tolerant, as the class docblock says: the warning is the
|
||||||
|
* whole report. Nothing else will find these bytes -- the row is
|
||||||
|
* soft-deleted, and OrphanFileScanner::knownPaths() counts a trashed
|
||||||
|
* row's path as claimed, so a scan never lists it.
|
||||||
|
*/
|
||||||
|
private function attempt(File $file, callable $work): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$work();
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
Log::warning('Could not remove disk bytes for deleted file '.$file->id.': '.$exception->getMessage());
|
Log::warning('Could not remove disk bytes for deleted file '.$file->id.': '.$exception->getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,15 @@ use App\Modules\Files\Models\File;
|
|||||||
/**
|
/**
|
||||||
* Ownership rules as policy methods (brief §6.13): "own" versus
|
* Ownership rules as policy methods (brief §6.13): "own" versus
|
||||||
* "others'" files map onto the v1 permission pairs. Clients may only
|
* "others'" files map onto the v1 permission pairs. Clients may only
|
||||||
* view/download what is assigned to them, directly or via a group. For
|
* view/download what is assigned to them, directly or via a group, and may
|
||||||
* client-scoped staff, every action is additionally gated by the
|
* edit or delete only what they uploaded themselves. For client-scoped
|
||||||
* StaffLibraryScope, so direct access can't reach out-of-scope files.
|
* 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
|
class FilePolicy
|
||||||
{
|
{
|
||||||
@@ -33,8 +39,25 @@ class FilePolicy
|
|||||||
|
|
||||||
public function update(User $user, File $file): bool
|
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()) {
|
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');
|
$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
|
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()) {
|
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');
|
$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;
|
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\Access\StaffLibraryScope;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
use App\Modules\Files\Models\Folder;
|
use App\Modules\Files\Models\Folder;
|
||||||
|
use App\Modules\Files\Jobs\ScanFileJob;
|
||||||
use App\Modules\Files\Notifications\FileShareDigestNotification;
|
use App\Modules\Files\Notifications\FileShareDigestNotification;
|
||||||
use App\Modules\Files\Notifications\FileSharedNotification;
|
use App\Modules\Files\Notifications\FileSharedNotification;
|
||||||
use App\Modules\Files\Notifications\NewVersionAvailableNotification;
|
use App\Modules\Files\Notifications\NewVersionAvailableNotification;
|
||||||
use App\Modules\Files\Notifications\NewVersionDigestNotification;
|
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\Events\ImageRenderingChanged;
|
||||||
use App\Modules\Files\Thumbnails\RenderedImageCache;
|
use App\Modules\Files\Thumbnails\RenderedImageCache;
|
||||||
use App\Modules\Notifications\NotificationTypeDefinition;
|
use App\Modules\Notifications\NotificationTypeDefinition;
|
||||||
@@ -30,6 +41,22 @@ class FilesServiceProvider extends ServiceProvider
|
|||||||
// reached twice. Scoped rather than a singleton so a long-lived
|
// reached twice. Scoped rather than a singleton so a long-lived
|
||||||
// queue worker starts each job with an empty memo.
|
// queue worker starts each job with an empty memo.
|
||||||
$this->app->scoped(StaffLibraryScope::class);
|
$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
|
public function boot(): void
|
||||||
@@ -37,6 +64,8 @@ class FilesServiceProvider extends ServiceProvider
|
|||||||
Gate::policy(File::class, FilePolicy::class);
|
Gate::policy(File::class, FilePolicy::class);
|
||||||
Gate::policy(Folder::class, FolderPolicy::class);
|
Gate::policy(Folder::class, FolderPolicy::class);
|
||||||
|
|
||||||
|
$this->keepClientHomeFolders();
|
||||||
|
|
||||||
// Cached renditions are written once and never revisited, so
|
// Cached renditions are written once and never revisited, so
|
||||||
// whoever changes how they render has to say so — otherwise the
|
// whoever changes how they render has to say so — otherwise the
|
||||||
// change is invisible on every file anyone has already looked at.
|
// 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'),
|
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()) {
|
if ($this->app->runningInConsole()) {
|
||||||
$this->commands([
|
$this->commands([
|
||||||
|
Console\ScanFilesCommand::class,
|
||||||
|
Console\CheckMissingFilesCommand::class,
|
||||||
Console\PurgeStaleUploadsCommand::class,
|
Console\PurgeStaleUploadsCommand::class,
|
||||||
Console\PurgeZipDownloadsCommand::class,
|
Console\PurgeZipDownloadsCommand::class,
|
||||||
Console\PurgeExpiredFilesCommand::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
|
public function update(User $user, Folder $folder): bool
|
||||||
{
|
{
|
||||||
if (! $user->isStaff()) {
|
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');
|
return $folder->isOwnedBy($user) && $user->can('create_own_folders');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +59,16 @@ class FolderPolicy
|
|||||||
|
|
||||||
public function delete(User $user, Folder $folder): bool
|
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()) {
|
if (! $user->isStaff()) {
|
||||||
return $folder->isOwnedBy($user) && $user->can('create_own_folders');
|
return $folder->isOwnedBy($user) && $user->can('create_own_folders');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Files\Folders;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Modules\Identity\UserType;
|
||||||
|
use App\Modules\Files\Models\Folder;
|
||||||
|
use App\Modules\Platform\Settings\Setting;
|
||||||
|
use App\Modules\Platform\Settings\Settings;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A folder per client, named after them, standing in for the root.
|
||||||
|
*
|
||||||
|
* ## What it is for
|
||||||
|
*
|
||||||
|
* Without it, a client who may create folders creates them at the top of
|
||||||
|
* the library, beside the ones staff made. Their uploads land at the root
|
||||||
|
* too. An administrator opening /files sees one flat pile with no clue
|
||||||
|
* which parts belong to whom. With it, each client gets one folder and
|
||||||
|
* everything of theirs goes inside, so /files reads as a list of clients.
|
||||||
|
*
|
||||||
|
* ## What it is NOT
|
||||||
|
*
|
||||||
|
* It is not a boundary, and this is the important sentence in the file. A
|
||||||
|
* folder staff shared with a client stays visible to that client, sitting
|
||||||
|
* beside their own — Folder::scopeVisibleToClient is untouched by any of
|
||||||
|
* this. Treating the home as a jail would silently revoke every share that
|
||||||
|
* already exists, which is a data-access change wearing the clothes of a
|
||||||
|
* tidying-up feature. "Root" here means *where new things go by default*,
|
||||||
|
* nothing more.
|
||||||
|
*
|
||||||
|
* ## Why created_by is the client
|
||||||
|
*
|
||||||
|
* scopeVisibleToClient grants a client their own folders through
|
||||||
|
* `created_by`. Creating the home as the client makes it theirs by the
|
||||||
|
* rule that already exists, rather than needing an assignment row that
|
||||||
|
* would then have to be kept in step with it. That is also why this writes
|
||||||
|
* the row itself instead of calling FolderService::create(), which takes
|
||||||
|
* `created_by` from `auth()->id()` — the creator here is whoever pressed a
|
||||||
|
* button, and the owner has to be the client.
|
||||||
|
*/
|
||||||
|
class ClientHomeFolders
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly Settings $settings,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function enabled(): bool
|
||||||
|
{
|
||||||
|
return (bool) $this->settings->get(Setting::ClientsHomeFolders);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This client's home, or null if they have none.
|
||||||
|
*
|
||||||
|
* Asked of the column and not of the setting: a home that exists keeps
|
||||||
|
* working after the switch is turned off again. The folder is real,
|
||||||
|
* it holds real files, and pretending it is not there would strand
|
||||||
|
* them somewhere no listing looks.
|
||||||
|
*/
|
||||||
|
public function for(?User $client): ?Folder
|
||||||
|
{
|
||||||
|
if ($client === null || ! $client->isClient()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Folder::query()->where('home_for_user_id', $client->id)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give this client a home if the installation wants them to have one.
|
||||||
|
*
|
||||||
|
* Idempotent, and safe to call on a client who already has one. Returns
|
||||||
|
* the folder either way, or null when the feature is off.
|
||||||
|
*/
|
||||||
|
public function ensureFor(User $client): ?Folder
|
||||||
|
{
|
||||||
|
if (! $client->isClient() || ! $this->enabled()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->create($client);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the row, or hand back the one that is already there.
|
||||||
|
*
|
||||||
|
* The unique index on home_for_user_id is what actually guarantees one
|
||||||
|
* home per client; this check only avoids raising on the ordinary
|
||||||
|
* second call. Two administrators pressing the backfill button at the
|
||||||
|
* same moment is exactly the race the index is there for.
|
||||||
|
*/
|
||||||
|
private function create(User $client): Folder
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($client): Folder {
|
||||||
|
$existing = $this->for($client);
|
||||||
|
|
||||||
|
if ($existing !== null) {
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Folder::query()->create([
|
||||||
|
'name' => $this->nameFor($client),
|
||||||
|
'parent_id' => null,
|
||||||
|
// Root, so an administrator sees it at the top of /files --
|
||||||
|
// which is the whole point of the feature.
|
||||||
|
'path' => '/',
|
||||||
|
'created_by' => $client->id,
|
||||||
|
'home_for_user_id' => $client->id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the folder's name in step with the client's.
|
||||||
|
*
|
||||||
|
* Always, including over a name somebody typed by hand. That was the
|
||||||
|
* product decision (2026-09-17) and it is the defensible one: the
|
||||||
|
* folder exists to say whose things these are, so a folder still
|
||||||
|
* called "Acme Ltd" after the account became "Acme Holdings" is
|
||||||
|
* actively misleading to the administrator the feature is for. A
|
||||||
|
* client cannot rename it anyway -- see FolderPolicy.
|
||||||
|
*/
|
||||||
|
public function syncName(User $client): void
|
||||||
|
{
|
||||||
|
$home = $this->for($client);
|
||||||
|
|
||||||
|
if ($home === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $this->nameFor($client);
|
||||||
|
|
||||||
|
if ($home->name !== $name) {
|
||||||
|
$home->update(['name' => $name]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many clients would get a folder if the button were pressed.
|
||||||
|
*
|
||||||
|
* A count and not the rows: the settings screen only needs the number,
|
||||||
|
* and an installation with thousands of clients should not load them
|
||||||
|
* all to render one sentence.
|
||||||
|
*/
|
||||||
|
public function pendingCount(): int
|
||||||
|
{
|
||||||
|
return User::query()
|
||||||
|
->where('type', UserType::Client)
|
||||||
|
->whereNotExists(fn ($q) => $q
|
||||||
|
->selectRaw('1')
|
||||||
|
->from('folders')
|
||||||
|
->whereColumn('folders.home_for_user_id', 'users.id')
|
||||||
|
->whereNull('folders.deleted_at'))
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every client without a home gets one.
|
||||||
|
*
|
||||||
|
* Deliberately a button rather than something switching the setting on
|
||||||
|
* does by itself: it writes a folder per client, and an administrator
|
||||||
|
* trying the feature out should be able to turn it on, look, and change
|
||||||
|
* their mind without having reorganised anything.
|
||||||
|
*
|
||||||
|
* Reports counts rather than staying quiet, because on an installation
|
||||||
|
* with hundreds of clients "it worked" is not a useful answer -- the
|
||||||
|
* administrator wants to know how many there were and how many are new.
|
||||||
|
*
|
||||||
|
* @return array{total: int, created: int, existing: int}
|
||||||
|
*/
|
||||||
|
public function backfill(): array
|
||||||
|
{
|
||||||
|
$clients = User::query()->where('type', UserType::Client)->orderBy('id')->get();
|
||||||
|
$created = 0;
|
||||||
|
$existing = 0;
|
||||||
|
|
||||||
|
foreach ($clients as $client) {
|
||||||
|
if ($this->for($client) !== null) {
|
||||||
|
$existing++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->create($client);
|
||||||
|
$created++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => $clients->count(),
|
||||||
|
'created' => $created,
|
||||||
|
'existing' => $existing,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A blank name would render as an unclickable sliver in the tree, so
|
||||||
|
* the address stands in -- every account has one, and it identifies
|
||||||
|
* the person as well as a name does.
|
||||||
|
*/
|
||||||
|
private function nameFor(User $client): string
|
||||||
|
{
|
||||||
|
$name = trim($client->name);
|
||||||
|
|
||||||
|
return $name !== '' ? $name : $client->email;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Files\Folders;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
|
use App\Modules\Files\Models\Folder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ancestors of a whole page of folders, in two queries however long the
|
||||||
|
* page is, trimmed to what the viewer may see.
|
||||||
|
*
|
||||||
|
* BreadcrumbBuilder answers this for one folder on a screen. A list
|
||||||
|
* endpoint needs it for every row, and a query per row is the cost a
|
||||||
|
* listing must not have.
|
||||||
|
*
|
||||||
|
* Trimmed the way BreadcrumbBuilder::visible() trims the client portal's
|
||||||
|
* trail: the list starts at the first ancestor the viewer can reach, since
|
||||||
|
* a client-scoped staff member holding a client's folder deep in somebody
|
||||||
|
* else's tree has no business reading the names of the folders above it.
|
||||||
|
* An unscoped staff member reaches every folder, so for them nothing is
|
||||||
|
* ever trimmed.
|
||||||
|
*/
|
||||||
|
class FolderTrails
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly StaffLibraryScope $scope,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<Folder> $folders
|
||||||
|
* @return array<int, list<array{id: int, name: string}>> folder id => its visible ancestors, root first, itself excluded
|
||||||
|
*/
|
||||||
|
public function ancestors(iterable $folders, User $viewer): array
|
||||||
|
{
|
||||||
|
$chains = [];
|
||||||
|
$allIds = [];
|
||||||
|
|
||||||
|
foreach ($folders as $folder) {
|
||||||
|
$ids = $folder->ancestorIds();
|
||||||
|
$chains[$folder->id] = $ids;
|
||||||
|
array_push($allIds, ...$ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
$allIds = array_values(array_unique($allIds));
|
||||||
|
|
||||||
|
if ($allIds === []) {
|
||||||
|
return array_map(fn (): array => [], $chains);
|
||||||
|
}
|
||||||
|
|
||||||
|
$names = Folder::query()->whereIn('id', $allIds)->pluck('name', 'id')->all();
|
||||||
|
|
||||||
|
$visible = $viewer->isClientScoped()
|
||||||
|
? array_flip($this->scope->folders($viewer)->whereIn('folders.id', $allIds)->pluck('folders.id')->all())
|
||||||
|
: array_flip($allIds);
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
foreach ($chains as $folderId => $ids) {
|
||||||
|
$trail = [];
|
||||||
|
$reached = false;
|
||||||
|
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$reached = $reached || isset($visible[$id]);
|
||||||
|
|
||||||
|
if ($reached && isset($names[$id])) {
|
||||||
|
$trail[] = ['id' => $id, 'name' => (string) $names[$id]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$out[$folderId] = $trail;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Files\Folders;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
|
use App\Modules\Files\Models\File;
|
||||||
|
use App\Modules\Files\Models\Folder;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many files in a folder's subtree a staff member may not delete.
|
||||||
|
*
|
||||||
|
* Deleting a folder cascades to every file in its subtree, and a File's
|
||||||
|
* `deleted` hook removes the bytes from disk — there is no restore.
|
||||||
|
* Authorizing the folder is not authorizing its contents: FilePolicy::delete
|
||||||
|
* asks for `delete_others_files` on somebody else's upload, and for the
|
||||||
|
* library boundary on top of that, and neither question is asked by
|
||||||
|
* FolderPolicy. Every staff path that deletes a folder asks this first, so
|
||||||
|
* the web screen and the API cannot disagree about what a cascade may take.
|
||||||
|
*
|
||||||
|
* Asked as one count rather than FilePolicy::delete per file: a folder can
|
||||||
|
* hold thousands, Gate resolves a fresh policy for every check, and a
|
||||||
|
* per-row policy check on a listing is the cost 0a8b609e went to some
|
||||||
|
* trouble to remove. The two halves of FilePolicy::delete are expressible
|
||||||
|
* in SQL — the permission half is constant for this viewer, and the
|
||||||
|
* library half is the query StaffLibraryScope already memoises per request.
|
||||||
|
*
|
||||||
|
* Somebody holding both delete permissions and no library scope can delete
|
||||||
|
* anything in the subtree by construction, so they never pay for the query
|
||||||
|
* at all.
|
||||||
|
*
|
||||||
|
* The client half of the same rule is MyFoldersController::destroy.
|
||||||
|
*/
|
||||||
|
class UndeletableFiles
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly StaffLibraryScope $scope,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function count(User $viewer, Folder $folder): int
|
||||||
|
{
|
||||||
|
$mayDeleteOwn = $viewer->can('delete_files');
|
||||||
|
$mayDeleteOthers = $viewer->can('delete_others_files');
|
||||||
|
$scoped = $viewer->isClientScoped();
|
||||||
|
|
||||||
|
if ($mayDeleteOwn && $mayDeleteOthers && ! $scoped) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return File::query()
|
||||||
|
->whereIn('folder_id', $folder->subtreeFolderIds())
|
||||||
|
->where(function (Builder $outer) use ($viewer, $mayDeleteOwn, $mayDeleteOthers, $scoped): void {
|
||||||
|
if (! $mayDeleteOwn) {
|
||||||
|
$outer->orWhere('uploaded_by', $viewer->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $mayDeleteOthers) {
|
||||||
|
$outer->orWhere(fn (Builder $others): Builder => $others
|
||||||
|
->whereNull('uploaded_by')->orWhere('uploaded_by', '!=', $viewer->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($scoped) {
|
||||||
|
$outer->orWhereNotIn('id', $this->scope->files($viewer)->select('id'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,13 +10,15 @@ use App\Modules\Api\Support\PollingQuery;
|
|||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
use App\Modules\Clients\ClientStorageUsage;
|
use App\Modules\Clients\ClientStorageUsage;
|
||||||
use App\Modules\Comments\CommentingRules;
|
use App\Modules\Files\Access\ClientIdentityScope;
|
||||||
use App\Modules\Comments\CommentScope;
|
|
||||||
use App\Modules\Files\Access\StaffLibraryScope;
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
use App\Modules\Files\Access\ViewableFileScope;
|
use App\Modules\Files\Access\ViewableFileScope;
|
||||||
use App\Modules\Files\DownloadLimitScope;
|
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\Http\Resources\Api\FileResource;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
|
use App\Modules\Files\Scanning\ScanStatus;
|
||||||
use App\Modules\Files\Models\Folder;
|
use App\Modules\Files\Models\Folder;
|
||||||
use App\Modules\Files\Storage\ResolvingUploadDisk;
|
use App\Modules\Files\Storage\ResolvingUploadDisk;
|
||||||
use App\Modules\Files\Uploads\StoreUploadedFile;
|
use App\Modules\Files\Uploads\StoreUploadedFile;
|
||||||
@@ -57,8 +59,10 @@ class FilesController extends Controller
|
|||||||
private readonly UploadExtensionPolicy $extensionPolicy,
|
private readonly UploadExtensionPolicy $extensionPolicy,
|
||||||
private readonly ClientStorageUsage $storageUsage,
|
private readonly ClientStorageUsage $storageUsage,
|
||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
private readonly CommentingRules $commenting,
|
|
||||||
private readonly StaffLibraryScope $scope,
|
private readonly StaffLibraryScope $scope,
|
||||||
|
private readonly ClientIdentityScope $identity,
|
||||||
|
private readonly ApplyFileEdits $fileEdits,
|
||||||
|
private readonly FileExpiry $expiry,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,7 +103,15 @@ class FilesController extends Controller
|
|||||||
'uploaded_by' => ['nullable', 'integer'],
|
'uploaded_by' => ['nullable', 'integer'],
|
||||||
'search' => ['nullable', 'string', 'max:255'],
|
'search' => ['nullable', 'string', 'max:255'],
|
||||||
'public' => ['nullable', 'boolean'],
|
'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'],
|
'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)
|
$query = $this->viewable->for($user)
|
||||||
@@ -110,6 +122,17 @@ class FilesController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (array_key_exists('uploaded_by', $filters) && $filters['uploaded_by'] !== null) {
|
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']);
|
$query->where('files.uploaded_by', $filters['uploaded_by']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,18 +148,73 @@ class FilesController extends Controller
|
|||||||
->orWhere('files.original_name', 'like', "%{$search}%"));
|
->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) {
|
if ($request->has('public') && ($filters['public'] ?? null) !== null) {
|
||||||
$query->where('files.public', $request->boolean('public'));
|
$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
|
// Expiry is a filter, not a default: staff see expired files in the
|
||||||
// UI too (that is how they notice and act on them). Only the client
|
// UI too (that is how they notice and act on them). Dropping them
|
||||||
// branch of the visibility rules drops them, and it does so inside
|
// is the client branch's rule, applied inside the visibility scopes
|
||||||
// ViewableFileScope where it belongs.
|
// where it belongs — which is also why a client-scoped caller does
|
||||||
|
// not get their clients' expired files back here whatever this
|
||||||
|
// filter says: their library is built on that same branch. See
|
||||||
|
// File::isExpired.
|
||||||
if ($request->has('expired') && ($filters['expired'] ?? null) !== null) {
|
if ($request->has('expired') && ($filters['expired'] ?? null) !== null) {
|
||||||
$request->boolean('expired') ? $query->expired() : $query->notExpired();
|
$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'));
|
return FileResource::collection($this->polling->paginate($request, $query, 'files'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +341,12 @@ class FilesController extends Controller
|
|||||||
* without the matching permission leaves that field untouched rather
|
* without the matching permission leaves that field untouched rather
|
||||||
* than failing the whole request, which mirrors the web interface.
|
* than failing the whole request, which mirrors the web interface.
|
||||||
*
|
*
|
||||||
|
* `expires_at` accepts either a calendar day (`2026-09-12`) or a full
|
||||||
|
* timestamp. A day means the end of that day in the caller's timezone,
|
||||||
|
* which is what the same value means on the web and what the file's
|
||||||
|
* own `expires_at` reads back as; a timestamp is taken as the instant
|
||||||
|
* it names.
|
||||||
|
*
|
||||||
* `commentable` only has an effect while the installation's comment
|
* `commentable` only has an effect while the installation's comment
|
||||||
* setting is "only files marked as commentable"; under any other
|
* setting is "only files marked as commentable"; under any other
|
||||||
* setting it is ignored, again rather than failing.
|
* setting it is ignored, again rather than failing.
|
||||||
@@ -283,14 +367,17 @@ class FilesController extends Controller
|
|||||||
'slug' => Rules::slug('files', $file->id),
|
'slug' => Rules::slug('files', $file->id),
|
||||||
'categories' => ['sometimes', 'array'],
|
'categories' => ['sometimes', 'array'],
|
||||||
'categories.*' => ['integer', 'exists:categories,id'],
|
'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' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
||||||
'download_limit_scope' => ['sometimes', Rule::enum(DownloadLimitScope::class)],
|
'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
|
// 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
|
// changes, so re-saving a file that already sits in an out-of-scope
|
||||||
// folder (reachable via a direct client share) still works. The
|
// folder (reachable via a direct client share) still works. The
|
||||||
// integer rule admits numeric strings, so cast before the strict
|
// integer rule admits numeric strings, so cast before the strict
|
||||||
@@ -299,48 +386,38 @@ class FilesController extends Controller
|
|||||||
$validated['folder_id'] = (int) $validated['folder_id'];
|
$validated['folder_id'] = (int) $validated['folder_id'];
|
||||||
|
|
||||||
if ($validated['folder_id'] !== $file->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')) {
|
// The one field that needs converting rather than passing along: a
|
||||||
$attributes['expires_at'] = $validated['expires_at'];
|
// 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')) {
|
$this->fileEdits->apply($user, $file, $changes);
|
||||||
$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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new FileResource($file->fresh()?->load(['folder', 'uploader', 'categories']) ?? $file);
|
return new FileResource($file->fresh()?->load(['folder', 'uploader', 'categories']) ?? $file);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Files\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
|
use App\Modules\Files\Folders\FolderTrails;
|
||||||
|
use App\Modules\Files\Http\Controllers\Concerns\ResolvesShareTargets;
|
||||||
|
use App\Modules\Files\Http\Resources\Api\FolderResource;
|
||||||
|
use App\Modules\Files\Models\Folder;
|
||||||
|
use App\Modules\Files\Sharing\FolderSharing;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sharing a folder with a client or a group: `{type: client|group, id}`.
|
||||||
|
*
|
||||||
|
* A client a folder is shared with sees everything inside it, including
|
||||||
|
* folders and files added later.
|
||||||
|
*
|
||||||
|
* Both the target resolution (ResolvesShareTargets) and the effects
|
||||||
|
* (FolderSharing — the row, the activity entry, the in-app notification,
|
||||||
|
* the digest email) are shared with the web controller, so the two surfaces
|
||||||
|
* cannot drift. "May share" is "may edit", as on the web.
|
||||||
|
*/
|
||||||
|
class FolderAssignmentsController extends Controller
|
||||||
|
{
|
||||||
|
use ResolvesShareTargets;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly StaffLibraryScope $scope,
|
||||||
|
private readonly FolderSharing $sharing,
|
||||||
|
private readonly FolderTrails $trails,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share a folder.
|
||||||
|
*
|
||||||
|
* Sharing it again with the same client or group leaves one share.
|
||||||
|
*/
|
||||||
|
public function store(Request $request, Folder $folder): FolderResource
|
||||||
|
{
|
||||||
|
Gate::authorize('update', $folder);
|
||||||
|
|
||||||
|
[$assignable, $targetName] = $this->resolveRequestedTarget(
|
||||||
|
$request,
|
||||||
|
__('Folders can only be shared with clients or groups.'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->sharing->assign($folder, $assignable, $targetName);
|
||||||
|
|
||||||
|
return $this->resource($request, $folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop sharing a folder.
|
||||||
|
*/
|
||||||
|
public function destroy(Request $request, Folder $folder): FolderResource
|
||||||
|
{
|
||||||
|
Gate::authorize('update', $folder);
|
||||||
|
|
||||||
|
[$assignable, $targetName] = $this->resolveRequestedTarget(
|
||||||
|
$request,
|
||||||
|
__('Folders can only be shared with clients or groups.'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->sharing->unassign($folder, $assignable, $targetName);
|
||||||
|
|
||||||
|
return $this->resource($request, $folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resource(Request $request, Folder $folder): FolderResource
|
||||||
|
{
|
||||||
|
$folder = $folder->fresh() ?? $folder;
|
||||||
|
$folder->load('assignments.assignable');
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if ($user !== null) {
|
||||||
|
$folder->setRelation('trail', collect($this->trails->ancestors([$folder], $user)[$folder->id] ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FolderResource($folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Files\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Modules\Api\Support\PollingQuery;
|
||||||
|
use App\Modules\Audit\Action;
|
||||||
|
use App\Modules\Audit\ActivityLogger;
|
||||||
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
|
use App\Modules\Files\Folders\FolderService;
|
||||||
|
use App\Modules\Files\Folders\FolderTrails;
|
||||||
|
use App\Modules\Files\Folders\UndeletableFiles;
|
||||||
|
use App\Modules\Files\Http\Resources\Api\FolderResource;
|
||||||
|
use App\Modules\Files\Models\File;
|
||||||
|
use App\Modules\Files\Models\Folder;
|
||||||
|
use App\Support\Rules;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The staff library's folders.
|
||||||
|
*
|
||||||
|
* Which folders a token sees is the same question the library screen
|
||||||
|
* answers, so a staff member limited to their assigned clients gets exactly
|
||||||
|
* the folders they see on the web. Every write goes through the same
|
||||||
|
* service, policy and placement rule as the web screen.
|
||||||
|
*/
|
||||||
|
class FoldersController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly StaffLibraryScope $scope,
|
||||||
|
private readonly PollingQuery $polling,
|
||||||
|
private readonly FolderService $folders,
|
||||||
|
private readonly FolderTrails $trails,
|
||||||
|
private readonly UndeletableFiles $undeletable,
|
||||||
|
private readonly ActivityLogger $activity,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List folders.
|
||||||
|
*
|
||||||
|
* Cursor paginated, like every list. Pass `updated_since` to poll for
|
||||||
|
* folders created, renamed or moved since a point in time. `parent_id`
|
||||||
|
* lists the folders directly inside one folder, and `top_level=1` the
|
||||||
|
* folders at the top of the library.
|
||||||
|
*
|
||||||
|
* Moving a folder updates the folder itself and every folder under it,
|
||||||
|
* so a poll sees the whole moved subtree.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
assert($user !== null);
|
||||||
|
|
||||||
|
$filters = $request->validate($this->polling->rules() + [
|
||||||
|
'parent_id' => ['nullable', 'integer'],
|
||||||
|
'top_level' => ['nullable', 'boolean'],
|
||||||
|
'search' => ['nullable', 'string', 'max:255'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$query = $this->scope->folders($user);
|
||||||
|
|
||||||
|
if (($filters['parent_id'] ?? null) !== null) {
|
||||||
|
$query->where('folders.parent_id', (int) $filters['parent_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->boolean('top_level')) {
|
||||||
|
$query->whereNull('folders.parent_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($filters['search'] ?? null) !== null) {
|
||||||
|
$query->where('folders.name', 'like', '%'.$filters['search'].'%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$page = $this->polling->paginate($request, $query, 'folders');
|
||||||
|
|
||||||
|
/** @var Collection<int, Folder> $items */
|
||||||
|
$items = collect($page->items());
|
||||||
|
$this->attachTrails($items, $user);
|
||||||
|
|
||||||
|
return FolderResource::collection($page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a folder, with the clients and groups it is shared with.
|
||||||
|
*/
|
||||||
|
public function show(Request $request, Folder $folder): FolderResource
|
||||||
|
{
|
||||||
|
Gate::authorize('view', $folder);
|
||||||
|
|
||||||
|
return $this->resource($folder, $request->user());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a folder.
|
||||||
|
*
|
||||||
|
* At the top of the library, or inside `parent_id`. Requires the
|
||||||
|
* `create_own_folders` ability, and `upload` with it.
|
||||||
|
*
|
||||||
|
* If a folder with the same name already exists in the same place, that
|
||||||
|
* folder is returned with a 200 instead of a second one being made, so
|
||||||
|
* retrying a request is safe. A new folder answers 201.
|
||||||
|
*/
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
assert($user !== null);
|
||||||
|
|
||||||
|
// The same pair FoldersController::store asks on the web: a folder
|
||||||
|
// nobody can put anything into is no use.
|
||||||
|
abort_unless($user->can('create_own_folders') && $user->can('upload'), 403);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'parent_id' => Rules::folderId(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$parent = $this->resolveParent($user, $validated['parent_id'] ?? null);
|
||||||
|
|
||||||
|
// A folder inside a public one is public, so creating one there is
|
||||||
|
// placing content into it (Folder::uploadableBy).
|
||||||
|
abort_unless(Folder::uploadableBy($user, $parent), 403);
|
||||||
|
|
||||||
|
$existing = $this->scope->folders($user)
|
||||||
|
->where('folders.parent_id', $parent?->id)
|
||||||
|
->where('folders.name', $validated['name'])
|
||||||
|
->orderBy('folders.id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing instanceof Folder) {
|
||||||
|
return $this->resource($existing, $user)->response()->setStatusCode(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
$folder = $this->folders->create($validated['name'], $parent);
|
||||||
|
|
||||||
|
$this->activity->log(Action::FolderCreated, subject: $folder);
|
||||||
|
|
||||||
|
return $this->resource($folder, $user)->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename or move a folder.
|
||||||
|
*
|
||||||
|
* Only the fields you send change. `parent_id: null` moves the folder to
|
||||||
|
* the top of the library. A folder moves with everything inside it, and
|
||||||
|
* cannot be moved into itself or one of its own subfolders.
|
||||||
|
*/
|
||||||
|
public function update(Request $request, Folder $folder): FolderResource
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
assert($user !== null);
|
||||||
|
|
||||||
|
Gate::authorize('update', $folder);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['sometimes', 'required', 'string', 'max:255'],
|
||||||
|
'parent_id' => ['sometimes', ...Rules::folderId()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (array_key_exists('name', $validated) && $validated['name'] !== $folder->name) {
|
||||||
|
$folder->update(['name' => $validated['name']]);
|
||||||
|
$this->activity->log(Action::FolderRenamed, subject: $folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('parent_id', $validated)) {
|
||||||
|
$newParentId = $validated['parent_id'] === null ? null : (int) $validated['parent_id'];
|
||||||
|
|
||||||
|
if ($newParentId !== $folder->parent_id) {
|
||||||
|
$newParent = $this->resolveParent($user, $newParentId);
|
||||||
|
|
||||||
|
// Dropping a folder into a public parent publishes its whole
|
||||||
|
// subtree, the act FoldersController::move refuses without
|
||||||
|
// `upload_public` (GHSA-rxf8-wh8v-jm9j).
|
||||||
|
abort_unless(Folder::uploadableBy($user, $newParent), 403);
|
||||||
|
|
||||||
|
$this->folders->move($folder, $newParent);
|
||||||
|
$this->activity->log(Action::FolderMoved, subject: $folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->resource($folder->fresh() ?? $folder, $user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a folder.
|
||||||
|
*
|
||||||
|
* An empty folder is deleted straight away. A folder holding files or
|
||||||
|
* other folders answers 409 unless you send
|
||||||
|
* `content_action=cascade_delete`, which deletes the folder, every folder
|
||||||
|
* under it and every file inside them, as the web screen does. There is
|
||||||
|
* no restore.
|
||||||
|
*
|
||||||
|
* A cascade is refused with 403 if the folder holds any file this token
|
||||||
|
* may not delete itself.
|
||||||
|
*/
|
||||||
|
public function destroy(Request $request, Folder $folder): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
assert($user !== null);
|
||||||
|
|
||||||
|
Gate::authorize('delete', $folder);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'content_action' => ['nullable', Rule::in(['cascade_delete'])],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$subtree = $folder->subtreeFolderIds();
|
||||||
|
$hasContent = count($subtree) > 1
|
||||||
|
|| File::query()->whereIn('folder_id', $subtree)->exists();
|
||||||
|
|
||||||
|
// A sync job with a bug in it must not be one request away from
|
||||||
|
// emptying a client's folder: the cascade has to be asked for.
|
||||||
|
abort_if(
|
||||||
|
$hasContent && ($validated['content_action'] ?? null) !== 'cascade_delete',
|
||||||
|
409,
|
||||||
|
__('This folder is not empty. Send content_action=cascade_delete to delete it with everything inside it.'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$blocked = $this->undeletable->count($user, $folder);
|
||||||
|
|
||||||
|
abort_if($blocked > 0, 403, trans_choice(
|
||||||
|
'This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.',
|
||||||
|
$blocked,
|
||||||
|
['count' => (string) $blocked],
|
||||||
|
));
|
||||||
|
|
||||||
|
$name = $folder->name;
|
||||||
|
|
||||||
|
$this->folders->delete($folder);
|
||||||
|
|
||||||
|
$this->activity->log(Action::FolderDeleted, context: ['name' => $name]);
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resource(Folder $folder, ?User $user): FolderResource
|
||||||
|
{
|
||||||
|
$folder->load('assignments.assignable');
|
||||||
|
|
||||||
|
if ($user !== null) {
|
||||||
|
$this->attachTrails(collect([$folder]), $user);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FolderResource($folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, Folder> $folders
|
||||||
|
*/
|
||||||
|
private function attachTrails(Collection $folders, User $user): void
|
||||||
|
{
|
||||||
|
$trails = $this->trails->ancestors($folders, $user);
|
||||||
|
|
||||||
|
foreach ($folders as $folder) {
|
||||||
|
$folder->setRelation('trail', collect($trails[$folder->id] ?? []));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parent must be a folder this caller's library shows them — the
|
||||||
|
* same lookup the web screen makes, answering 404 otherwise.
|
||||||
|
*/
|
||||||
|
private function resolveParent(User $user, ?int $parentId): ?Folder
|
||||||
|
{
|
||||||
|
if ($parentId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Builder<Folder> $folders */
|
||||||
|
$folders = $this->scope->folders($user);
|
||||||
|
|
||||||
|
return $folders->findOrFail($parentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ use App\Modules\Audit\Action;
|
|||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
use App\Modules\Clients\ClientStorageUsage;
|
use App\Modules\Clients\ClientStorageUsage;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
|
use App\Modules\Files\Folders\ClientHomeFolders;
|
||||||
use App\Modules\Files\Models\Folder;
|
use App\Modules\Files\Models\Folder;
|
||||||
use App\Modules\Files\Notifications\AdminClientUploadedNotification;
|
use App\Modules\Files\Notifications\AdminClientUploadedNotification;
|
||||||
use App\Modules\Files\Uploads\LocalPartStore;
|
use App\Modules\Files\Uploads\LocalPartStore;
|
||||||
@@ -26,6 +27,7 @@ use App\Modules\Platform\Settings\Setting;
|
|||||||
use App\Modules\Platform\Settings\Settings;
|
use App\Modules\Platform\Settings\Settings;
|
||||||
use App\Support\Rules;
|
use App\Support\Rules;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -56,6 +58,7 @@ class ChunkedUploadsController extends Controller
|
|||||||
private readonly Notifier $notifier,
|
private readonly Notifier $notifier,
|
||||||
private readonly PermissionChecker $permissions,
|
private readonly PermissionChecker $permissions,
|
||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
|
private readonly ClientHomeFolders $homeFolders,
|
||||||
private readonly FileVersions $versions,
|
private readonly FileVersions $versions,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -89,16 +92,60 @@ class ChunkedUploadsController extends Controller
|
|||||||
assert($user !== null);
|
assert($user !== null);
|
||||||
|
|
||||||
$folder = isset($validated['folder_id']) ? Folder::query()->whereKey($validated['folder_id'])->first() : 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);
|
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
|
// The declared size here is client-supplied and unverified until
|
||||||
// complete()'s real assembled byte count — re-checked there too.
|
// complete()'s real assembled byte count — re-checked there too.
|
||||||
if ($user->isClient()) {
|
if ($user->isClient()) {
|
||||||
$quotaBytes = $this->storageUsage->quotaBytes($user);
|
$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([
|
throw ValidationException::withMessages([
|
||||||
'size' => __('This upload would exceed your storage quota of :quota MB.', ['quota' => (string) $user->storage_quota_mb]),
|
'size' => __('This upload would exceed your storage quota of :quota MB.', [
|
||||||
|
// The resolved quota, not the column: a client who
|
||||||
|
// was never given one of their own carries 0 there
|
||||||
|
// and inherits the site default, so printing the
|
||||||
|
// column reads "your storage quota of 0 MB" at the
|
||||||
|
// moment somebody is asking what their limit is.
|
||||||
|
'quota' => (string) $this->storageUsage->quotaMb($user),
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,13 +227,7 @@ class ChunkedUploadsController extends Controller
|
|||||||
// ownership of the session is still enforced below.
|
// ownership of the session is still enforced below.
|
||||||
$this->authorizeSession($request, $session);
|
$this->authorizeSession($request, $session);
|
||||||
|
|
||||||
// signPart() bounds the part number; bound the part body too, or a
|
// signPart() bounds the part number; bound the part body too.
|
||||||
// session can absorb unlimited bytes. The quota is only enforceable
|
|
||||||
// at complete(), against the assembled size — until then nothing
|
|
||||||
// stops a client declaring a 1-byte upload and streaming gigabytes
|
|
||||||
// of parts, which never becomes a File row and so never counts
|
|
||||||
// against anything. Stale sessions are purged daily, so without a
|
|
||||||
// cap here the exposure is a day's worth of disk.
|
|
||||||
abort_unless($part >= 1 && $part <= 10000, 422);
|
abort_unless($part >= 1 && $part <= 10000, 422);
|
||||||
|
|
||||||
$maxPartBytes = max(1, (int) config('projectsend.upload_part_size_mb')) * 1024 * 1024;
|
$maxPartBytes = max(1, (int) config('projectsend.upload_part_size_mb')) * 1024 * 1024;
|
||||||
@@ -194,20 +235,61 @@ class ChunkedUploadsController extends Controller
|
|||||||
// chooses its own chunking and only the last part is short.
|
// chooses its own chunking and only the last part is short.
|
||||||
$limit = $maxPartBytes * 2;
|
$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);
|
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 {
|
try {
|
||||||
$etag = $this->parts->storePart($session, $part, $stream, $limit);
|
$stream = $request->getContent(true);
|
||||||
|
$etag = $this->parts->storePart($session, $part, $stream, $reserve);
|
||||||
} catch (PartTooLargeException) {
|
} catch (PartTooLargeException) {
|
||||||
abort(413);
|
abort(413);
|
||||||
} finally {
|
} finally {
|
||||||
if (is_resource($stream)) {
|
if (is_resource($stream)) {
|
||||||
fclose($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, [
|
return response('', 200, [
|
||||||
@@ -224,6 +306,52 @@ class ChunkedUploadsController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim room for one part, returning how many bytes were claimed — 0
|
||||||
|
* when the session has none left.
|
||||||
|
*
|
||||||
|
* Read-then-claim, under a lock held for the two statements and not
|
||||||
|
* for the transfer. The protocol sends parts in parallel and how many
|
||||||
|
* is the client's choice, so without it every part in flight reads the
|
||||||
|
* same "room left" and they all claim it; and making the claim alone
|
||||||
|
* atomic is no better, because then the honest parallel upload is the
|
||||||
|
* one that gets refused. The lock is the same per-session shape
|
||||||
|
* complete() already uses, and it is released before a byte is read.
|
||||||
|
*/
|
||||||
|
private function reservePartRoom(UploadSession $session, int $part, int $limit): int
|
||||||
|
{
|
||||||
|
$lock = Cache::lock('upload-part:'.$session->id, 30);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$lock->block(15);
|
||||||
|
} catch (LockTimeoutException) {
|
||||||
|
// Nothing is wrong with the upload — the queue for this one
|
||||||
|
// session just did not clear. 503 with Retry-After is what the
|
||||||
|
// client's own backoff is for.
|
||||||
|
abort(503, headers: ['Retry-After' => '5']);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$existing = $this->parts->partSize($session, $part);
|
||||||
|
|
||||||
|
$session->refresh();
|
||||||
|
|
||||||
|
// Re-sending a part replaces it rather than adding to it, so
|
||||||
|
// what it already holds is room this request may spend again.
|
||||||
|
// That is an ordinary resume.
|
||||||
|
$room = max(0, $session->size - ($session->staged_bytes - $existing));
|
||||||
|
$reserve = min($limit, $room);
|
||||||
|
|
||||||
|
if ($reserve > 0 && ! $session->reserveStaged($reserve, $existing)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $reserve;
|
||||||
|
} finally {
|
||||||
|
$lock->release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List the parts already received, so an interrupted upload can resume
|
* List the parts already received, so an interrupted upload can resume
|
||||||
* rather than start again.
|
* rather than start again.
|
||||||
@@ -306,7 +434,9 @@ class ChunkedUploadsController extends Controller
|
|||||||
$session->delete();
|
$session->delete();
|
||||||
|
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'size' => __('This upload would exceed your storage quota of :quota MB.', ['quota' => (string) $user->storage_quota_mb]),
|
'size' => __('This upload would exceed your storage quota of :quota MB.', [
|
||||||
|
'quota' => (string) $this->storageUsage->quotaMb($user),
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Modules\Files\Http\Controllers;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Modules\Files\Access\ClientIdentityScope;
|
||||||
use App\Modules\Files\Access\StaffLibraryScope;
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
use App\Modules\Files\Models\Category;
|
use App\Modules\Files\Models\Category;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
@@ -28,6 +29,7 @@ class ClientFilesController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly StaffLibraryScope $scope,
|
private readonly StaffLibraryScope $scope,
|
||||||
|
private readonly ClientIdentityScope $identity,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request, User $client): Response
|
public function index(Request $request, User $client): Response
|
||||||
@@ -66,7 +68,11 @@ class ClientFilesController extends Controller
|
|||||||
'size' => $file->size,
|
'size' => $file->size,
|
||||||
'created_at' => $file->created_at?->toIso8601String(),
|
'created_at' => $file->created_at?->toIso8601String(),
|
||||||
'uploaded_by_client' => $file->uploaded_by === $client->id,
|
'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,
|
'downloads_count' => $file->downloads_count,
|
||||||
'can_download' => Gate::forUser($viewer)->allows('view', $file),
|
'can_download' => Gate::forUser($viewer)->allows('view', $file),
|
||||||
'categories' => $file->categories->map(fn (Category $category): array => [
|
'categories' => $file->categories->map(fn (Category $category): array => [
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Modules\Files\Http\Controllers;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
|
use App\Modules\Files\Delivery\FileDelivery;
|
||||||
use App\Modules\Platform\Settings\Setting;
|
use App\Modules\Platform\Settings\Setting;
|
||||||
use App\Modules\Platform\Settings\Settings;
|
use App\Modules\Platform\Settings\Settings;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
@@ -24,12 +25,21 @@ class DownloadSettingsController extends Controller
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Settings $settings,
|
private readonly Settings $settings,
|
||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
|
private readonly FileDelivery $delivery,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function edit(): Response
|
public function edit(): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('system/settings/downloads', [
|
return Inertia::render('system/settings/downloads', [
|
||||||
'max_zip_download_size_mb' => $this->settings->get(Setting::MaxZipDownloadSizeMb),
|
'max_zip_download_size_mb' => $this->settings->get(Setting::MaxZipDownloadSizeMb),
|
||||||
|
// Not a setting, and shown here because this is where somebody
|
||||||
|
// coming from v1 looks for one: v1 had a "Download method"
|
||||||
|
// dropdown on its uploads options screen. It is an environment
|
||||||
|
// variable now rather than a stored setting, because it
|
||||||
|
// describes the server the installation is running on rather
|
||||||
|
// than a preference — a value in the database can be restored
|
||||||
|
// onto a different server and be wrong there.
|
||||||
|
'file_delivery' => $this->delivery->describe(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Modules\Audit\ActivityLog;
|
|||||||
use App\Modules\Audit\ActivityPresenter;
|
use App\Modules\Audit\ActivityPresenter;
|
||||||
use App\Modules\Audit\DownloadPresenter;
|
use App\Modules\Audit\DownloadPresenter;
|
||||||
use App\Modules\Comments\CommentingRules;
|
use App\Modules\Comments\CommentingRules;
|
||||||
|
use App\Modules\Files\Access\ClientIdentityScope;
|
||||||
use App\Modules\Files\Access\DownloadAllowance;
|
use App\Modules\Files\Access\DownloadAllowance;
|
||||||
use App\Modules\Files\Access\ShareTargets;
|
use App\Modules\Files\Access\ShareTargets;
|
||||||
use App\Modules\Files\DownloadLimitScope;
|
use App\Modules\Files\DownloadLimitScope;
|
||||||
@@ -79,6 +80,7 @@ class FileDetailsController extends Controller
|
|||||||
private readonly ActivityPresenter $presenter,
|
private readonly ActivityPresenter $presenter,
|
||||||
private readonly DownloadPresenter $downloadPresenter,
|
private readonly DownloadPresenter $downloadPresenter,
|
||||||
private readonly ShareTargets $shareTargets,
|
private readonly ShareTargets $shareTargets,
|
||||||
|
private readonly ClientIdentityScope $identity,
|
||||||
private readonly CommentingRules $commenting,
|
private readonly CommentingRules $commenting,
|
||||||
private readonly FileVersionLinks $versionLinks,
|
private readonly FileVersionLinks $versionLinks,
|
||||||
private readonly DownloadAllowance $allowance,
|
private readonly DownloadAllowance $allowance,
|
||||||
@@ -100,7 +102,10 @@ class FileDetailsController extends Controller
|
|||||||
'size' => $file->size,
|
'size' => $file->size,
|
||||||
'mime_type' => $file->mime_type,
|
'mime_type' => $file->mime_type,
|
||||||
'checksum' => $file->checksum,
|
'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'),
|
'folder' => $file->folder?->only('id', 'name'),
|
||||||
'categories' => $file->categories()->orderBy('name')->get()
|
'categories' => $file->categories()->orderBy('name')->get()
|
||||||
->map(fn (Category $category): array => ['id' => $category->id, 'name' => $category->name, 'color' => $category->color])
|
->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
|
// Resolved from the chain root for a revision (ShareTargets
|
||||||
// does that), so this names who really has the file. The panel
|
// does that), so this names who really has the file. The panel
|
||||||
// says where those recipients are set.
|
// says where those recipients are set.
|
||||||
'shares' => $this->shareTargets->assigned($file),
|
'shares' => $this->shareTargets->assignedFor($file, $viewer),
|
||||||
'sharing_root' => $file->isRevision()
|
'sharing_root' => $file->isRevision()
|
||||||
? File::query()->find($file->sharingOwnerId())?->only('id', 'name')
|
? File::query()->find($file->sharingOwnerId())?->only('id', 'name')
|
||||||
: null,
|
: null,
|
||||||
@@ -368,7 +373,7 @@ class FileDetailsController extends Controller
|
|||||||
'name' => $folder->name,
|
'name' => $folder->name,
|
||||||
'files_count' => $folder->files()->count(),
|
'files_count' => $folder->files()->count(),
|
||||||
'children_count' => $folder->children()->count(),
|
'children_count' => $folder->children()->count(),
|
||||||
'creator' => $folder->creator?->name,
|
'creator' => $this->identity->nameOf($viewer, $folder->creator),
|
||||||
'created_at' => $folder->created_at?->toIso8601String(),
|
'created_at' => $folder->created_at?->toIso8601String(),
|
||||||
'open_url' => route('files.index', ['folder' => $folder->id], false),
|
'open_url' => route('files.index', ['folder' => $folder->id], false),
|
||||||
// Read-only here, same as a file's shares — sharing (and every
|
// 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),
|
'edit_url' => route('folders.share', $folder, false),
|
||||||
'can_update' => Gate::forUser($viewer)->allows('update', $folder),
|
'can_update' => Gate::forUser($viewer)->allows('update', $folder),
|
||||||
'can_view_activity' => $viewer->can('view_actions_log'),
|
'can_view_activity' => $viewer->can('view_actions_log'),
|
||||||
'shares' => $this->shareTargets->assigned($folder),
|
'shares' => $this->shareTargets->assignedFor($folder, $viewer),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,17 +10,19 @@ use App\Modules\Audit\ActivityLogger;
|
|||||||
use App\Modules\Files\Access\DownloadAllowance;
|
use App\Modules\Files\Access\DownloadAllowance;
|
||||||
use App\Modules\Files\Delivery\StoredFileResponse;
|
use App\Modules\Files\Delivery\StoredFileResponse;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
|
use App\Modules\Files\Scanning\FileAvailability;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authorized downloads without the bytes ever traversing PHP: the app
|
* Authorized downloads: the app checks the policy, and StoredFileResponse
|
||||||
* checks the policy, and StoredFileResponse answers with either an
|
* decides how the bytes travel — a presigned URL when the file lives on
|
||||||
* X-Accel-Redirect for nginx to stream from the protected location
|
* external storage, and otherwise whichever local delivery method this
|
||||||
* (brief §3) or a presigned URL when the file lives on external storage,
|
* installation's web server understands (see FileDelivery). On nginx that
|
||||||
* since nginx has no way to serve bytes it doesn't have on disk.
|
* is an X-Accel-Redirect and the bytes never traverse PHP at all; on a
|
||||||
|
* server with no such header PHP streams them, which is slower and works.
|
||||||
*/
|
*/
|
||||||
class FileDownloadController extends Controller
|
class FileDownloadController extends Controller
|
||||||
{
|
{
|
||||||
@@ -28,12 +30,18 @@ class FileDownloadController extends Controller
|
|||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
private readonly DownloadAllowance $allowance,
|
private readonly DownloadAllowance $allowance,
|
||||||
private readonly StoredFileResponse $bytes,
|
private readonly StoredFileResponse $bytes,
|
||||||
|
private readonly FileAvailability $availability,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function __invoke(Request $request, File $file): Response|RedirectResponse
|
public function __invoke(Request $request, File $file): Response|RedirectResponse
|
||||||
{
|
{
|
||||||
Gate::authorize('view', $file);
|
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
|
// Separate from the policy on purpose: a spent download limit is
|
||||||
// not "you may not see this file" — the file stays listed, and
|
// not "you may not see this file" — the file stays listed, and
|
||||||
// the same person may still open its details. It is only the
|
// the same person may still open its details. It is only the
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ namespace App\Modules\Files\Http\Controllers;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLogger;
|
|
||||||
use App\Modules\Files\Access\DownloadAllowance;
|
use App\Modules\Files\Access\DownloadAllowance;
|
||||||
|
use App\Modules\Files\Delivery\FileDelivery;
|
||||||
use App\Modules\Files\Delivery\StoredFileResponse;
|
use App\Modules\Files\Delivery\StoredFileResponse;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
|
use App\Modules\Files\Scanning\FileAvailability;
|
||||||
use App\Modules\Files\Preview\PreviewKind;
|
use App\Modules\Files\Preview\PreviewKind;
|
||||||
|
use App\Modules\Files\Preview\PreviewLog;
|
||||||
use App\Modules\Files\Thumbnails\Events\ResolvingImageRendering;
|
use App\Modules\Files\Thumbnails\Events\ResolvingImageRendering;
|
||||||
use App\Modules\Files\Thumbnails\ImageAudience;
|
use App\Modules\Files\Thumbnails\ImageAudience;
|
||||||
use App\Modules\Files\Thumbnails\ImageRendition;
|
use App\Modules\Files\Thumbnails\ImageRendition;
|
||||||
@@ -21,15 +23,14 @@ use App\Modules\Platform\Settings\Settings;
|
|||||||
use App\Support\ContentDisposition;
|
use App\Support\ContentDisposition;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Two inline (never `attachment`) views of a file, same X-Accel-Redirect
|
* Two inline (never `attachment`) views of a file, delivered the same way
|
||||||
* pattern as FileDownloadController: a bounded thumbnail for listing rows,
|
* FileDownloadController delivers one: a bounded thumbnail for listing rows,
|
||||||
* and a larger view opened in a new tab when a thumbnail is clicked.
|
* and a larger view opened in a new tab when a thumbnail is clicked.
|
||||||
* `thumbnail()` stays unlogged — it fires automatically as an `<img src>`
|
* `thumbnail()` stays unlogged — it fires automatically as an `<img src>`
|
||||||
* for every row on every listing render, not a deliberate action, and
|
* for every row on every listing render, not a deliberate action, and
|
||||||
@@ -72,17 +73,24 @@ class FileThumbnailController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ThumbnailGenerator $thumbnails,
|
private readonly ThumbnailGenerator $thumbnails,
|
||||||
private readonly ActivityLogger $activity,
|
private readonly PreviewLog $previews,
|
||||||
private readonly DownloadAllowance $allowance,
|
private readonly DownloadAllowance $allowance,
|
||||||
private readonly StoredFileResponse $bytes,
|
private readonly StoredFileResponse $bytes,
|
||||||
private readonly LocalSourceFile $source,
|
private readonly LocalSourceFile $source,
|
||||||
private readonly Settings $settings,
|
private readonly Settings $settings,
|
||||||
|
private readonly FileDelivery $delivery,
|
||||||
|
private readonly FileAvailability $availability,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function thumbnail(Request $request, File $file): Response
|
public function thumbnail(Request $request, File $file): Response
|
||||||
{
|
{
|
||||||
Gate::authorize('view', $file);
|
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
|
// 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
|
// 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
|
// and a staff member looking at the same file get different cached
|
||||||
@@ -118,6 +126,8 @@ class FileThumbnailController extends Controller
|
|||||||
{
|
{
|
||||||
Gate::authorize('view', $file);
|
Gate::authorize('view', $file);
|
||||||
|
|
||||||
|
$this->availability->guardDelivery($file);
|
||||||
|
|
||||||
// The inline allowlist. See the class docblock and PreviewKind —
|
// The inline allowlist. See the class docblock and PreviewKind —
|
||||||
// the stored mime type is sniffed from the bytes, so an allowed
|
// the stored mime type is sniffed from the bytes, so an allowed
|
||||||
// extension is not evidence of a safe-to-render payload.
|
// extension is not evidence of a safe-to-render payload.
|
||||||
@@ -144,7 +154,10 @@ class FileThumbnailController extends Controller
|
|||||||
// file.
|
// file.
|
||||||
abort_unless($this->allowance->allows($file, $request->user()), 403);
|
abort_unless($this->allowance->allows($file, $request->user()), 403);
|
||||||
|
|
||||||
$this->logPreview($file, $request);
|
// Debounced, because a browser turns one video into dozens of
|
||||||
|
// Range requests — see PreviewLog, which the anonymous twin in
|
||||||
|
// PublicGroupsController::preview shares.
|
||||||
|
$this->previews->record(Action::FilePreviewed, $file, $request->user());
|
||||||
|
|
||||||
if ($kind === PreviewKind::Image) {
|
if ($kind === PreviewKind::Image) {
|
||||||
$audience = ImageAudience::forViewer($request->user());
|
$audience = ImageAudience::forViewer($request->user());
|
||||||
@@ -164,29 +177,6 @@ class FileThumbnailController extends Controller
|
|||||||
return $this->bytes->inline($file);
|
return $this->bytes->inline($file);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* One log row per viewer per file per five minutes.
|
|
||||||
*
|
|
||||||
* Watching a video is a single deliberate act that the browser turns
|
|
||||||
* into dozens of Range requests against this route, and each one
|
|
||||||
* arrives here indistinguishable from someone clicking preview again.
|
|
||||||
* Cache::add is the whole mechanism: it writes only if the key is
|
|
||||||
* absent, so the first request through the window logs and the rest
|
|
||||||
* are silent, without a read-then-write race between two of them.
|
|
||||||
*
|
|
||||||
* Keyed by viewer, so one client's playback never suppresses another
|
|
||||||
* person's preview of the same file. Anonymous viewers do not reach
|
|
||||||
* this route at all — see PublicGroupsController::preview.
|
|
||||||
*/
|
|
||||||
private function logPreview(File $file, Request $request): void
|
|
||||||
{
|
|
||||||
$key = 'file-preview-logged:'.$file->id.':'.($request->user()->id ?? 'guest');
|
|
||||||
|
|
||||||
if (Cache::add($key, true, now()->addMinutes(5))) {
|
|
||||||
$this->activity->log(Action::FilePreviewed, subject: $file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The cached rendition's path on the local disk, generating it first
|
* The cached rendition's path on the local disk, generating it first
|
||||||
* if this is the first time anyone has asked for it. Null only when
|
* if this is the first time anyone has asked for it. Null only when
|
||||||
@@ -202,10 +192,21 @@ class FileThumbnailController extends Controller
|
|||||||
|
|
||||||
$disk = Storage::disk('files');
|
$disk = Storage::disk('files');
|
||||||
|
|
||||||
|
// Existence is the cache, and an empty file is not a rendition: it
|
||||||
|
// is what a render that died before writing anything leaves behind,
|
||||||
|
// and serving it hands the viewer a broken image for as long as the
|
||||||
|
// file lives — nothing invalidates a rendition once it is there.
|
||||||
|
// ThumbnailGenerator writes through a temporary file now, so this
|
||||||
|
// state can no longer be created here; it can still be inherited
|
||||||
|
// from an installation that ran an older version.
|
||||||
if ($disk->exists($path)) {
|
if ($disk->exists($path)) {
|
||||||
|
if ($disk->size($path) > 0) {
|
||||||
return $path;
|
return $path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$disk->delete($path);
|
||||||
|
}
|
||||||
|
|
||||||
$disk->makeDirectory(dirname($path));
|
$disk->makeDirectory(dirname($path));
|
||||||
|
|
||||||
$this->source->use($file, fn (string $sourcePath) => $this->thumbnails->generate(
|
$this->source->use($file, fn (string $sourcePath) => $this->thumbnails->generate(
|
||||||
@@ -221,10 +222,12 @@ class FileThumbnailController extends Controller
|
|||||||
|
|
||||||
private function serve(File $file, string $path): Response
|
private function serve(File $file, string $path): Response
|
||||||
{
|
{
|
||||||
return response('', 200, [
|
// No Content-Length: this is the rendition's size, not the
|
||||||
'X-Accel-Redirect' => '/protected-files/'.$path,
|
// original file's, and $file->size is the wrong number for it.
|
||||||
'Content-Type' => $file->mime_type,
|
return $this->delivery->serve(
|
||||||
'Content-Disposition' => ContentDisposition::inline($file->original_name),
|
$path,
|
||||||
]);
|
$file->mime_type,
|
||||||
|
ContentDisposition::inline($file->original_name),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ use App\Modules\Audit\Action;
|
|||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
use App\Modules\Comments\CommentingRules;
|
use App\Modules\Comments\CommentingRules;
|
||||||
use App\Modules\Comments\CommentScope;
|
use App\Modules\Comments\CommentScope;
|
||||||
|
use App\Modules\Files\Access\ClientIdentityScope;
|
||||||
use App\Modules\Files\Access\ShareTargets;
|
use App\Modules\Files\Access\ShareTargets;
|
||||||
use App\Modules\Files\Access\StaffLibraryScope;
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
use App\Modules\Files\DownloadLimitScope;
|
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\Category;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
use App\Modules\Files\Models\Folder;
|
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\Uploads\UploadExtensionPolicy;
|
||||||
use App\Modules\Files\Versions\FileVersionLinks;
|
use App\Modules\Files\Versions\FileVersionLinks;
|
||||||
use App\Modules\Files\Versions\FileVersions;
|
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\Setting;
|
||||||
use App\Modules\Platform\Settings\Settings;
|
use App\Modules\Platform\Settings\Settings;
|
||||||
use App\Support\PublicUrl;
|
use App\Support\PublicUrl;
|
||||||
use App\Support\Rules;
|
use App\Support\Rules;
|
||||||
use Carbon\Carbon;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
@@ -49,10 +49,12 @@ class FilesController extends Controller
|
|||||||
private readonly StaffLibraryScope $scope,
|
private readonly StaffLibraryScope $scope,
|
||||||
private readonly PublicUrl $publicUrl,
|
private readonly PublicUrl $publicUrl,
|
||||||
private readonly ShareTargets $shareTargets,
|
private readonly ShareTargets $shareTargets,
|
||||||
|
private readonly ClientIdentityScope $identity,
|
||||||
private readonly CommentingRules $commenting,
|
private readonly CommentingRules $commenting,
|
||||||
private readonly FileVersions $versions,
|
private readonly FileVersions $versions,
|
||||||
private readonly FileVersionLinks $versionLinks,
|
private readonly FileVersionLinks $versionLinks,
|
||||||
private readonly TimezoneRegistry $timezones,
|
private readonly ApplyFileEdits $fileEdits,
|
||||||
|
private readonly FileExpiry $expiry,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function create(Request $request): Response
|
public function create(Request $request): Response
|
||||||
@@ -60,7 +62,22 @@ class FilesController extends Controller
|
|||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
assert($user !== null);
|
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', [
|
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),
|
'max_file_size_mb' => app(Settings::class)->get(Setting::MaxFileSizeMb),
|
||||||
'part_size_mb' => (int) config('projectsend.upload_part_size_mb'),
|
'part_size_mb' => (int) config('projectsend.upload_part_size_mb'),
|
||||||
'allowed_extensions' => app(UploadExtensionPolicy::class)->hintFor($user),
|
'allowed_extensions' => app(UploadExtensionPolicy::class)->hintFor($user),
|
||||||
@@ -164,7 +181,7 @@ class FilesController extends Controller
|
|||||||
'original_name' => $file->original_name,
|
'original_name' => $file->original_name,
|
||||||
'size' => $file->size,
|
'size' => $file->size,
|
||||||
'mime_type' => $file->mime_type,
|
'mime_type' => $file->mime_type,
|
||||||
'uploader' => $file->uploader?->name,
|
'uploader' => $this->identity->nameOf($viewer, $file->uploader),
|
||||||
'folder_id' => $file->folder_id,
|
'folder_id' => $file->folder_id,
|
||||||
'public' => $file->public,
|
'public' => $file->public,
|
||||||
'commentable' => $file->commentable,
|
'commentable' => $file->commentable,
|
||||||
@@ -173,8 +190,20 @@ class FilesController extends Controller
|
|||||||
// calendar date the editor typed — read back in their
|
// calendar date the editor typed — read back in their
|
||||||
// zone, not the server's, or a file set to expire on the
|
// zone, not the server's, or a file set to expire on the
|
||||||
// 12th reopens showing the 11th.
|
// 12th reopens showing the 11th.
|
||||||
'expires_at' => $file->expires_at?->copy()->setTimezone($this->timezones->resolve($request->user()))->toDateString(),
|
'expires_at' => $this->expiry->asShown($file, $request->user()),
|
||||||
'expired' => $file->isExpired(),
|
'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' => $file->download_limit,
|
||||||
'download_limit_scope' => ($file->download_limit_scope ?? DownloadLimitScope::Total)->value,
|
'download_limit_scope' => ($file->download_limit_scope ?? DownloadLimitScope::Total)->value,
|
||||||
// The file's total downloads, so the editor can see what
|
// 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),
|
'slug' => Rules::slug('files', $file->id),
|
||||||
'categories' => ['array'],
|
'categories' => ['array'],
|
||||||
'categories.*' => ['integer', 'exists:categories,id'],
|
'categories.*' => ['integer', 'exists:categories,id'],
|
||||||
'expires_at' => ['nullable', 'date'],
|
'expires_at' => ['nullable', 'string', 'date'],
|
||||||
'download_limit' => ['nullable', 'integer', 'min:1'],
|
'download_limit' => ['nullable', 'integer', 'min:1'],
|
||||||
'download_limit_scope' => ['nullable', Rule::enum(DownloadLimitScope::class)],
|
'download_limit_scope' => ['nullable', Rule::enum(DownloadLimitScope::class)],
|
||||||
]);
|
]);
|
||||||
@@ -274,6 +303,8 @@ class FilesController extends Controller
|
|||||||
// change comparison below matches the model's int.
|
// change comparison below matches the model's int.
|
||||||
$folderId = isset($validated['folder_id']) ? (int) $validated['folder_id'] : null;
|
$folderId = isset($validated['folder_id']) ? (int) $validated['folder_id'] : null;
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
// Gate::authorize above cannot pass without one.
|
||||||
|
assert($user !== null);
|
||||||
|
|
||||||
// Reparenting through update() is the same privileged write as
|
// Reparenting through update() is the same privileged write as
|
||||||
// move()/bulkUpdate(), so it needs the same guard: the destination
|
// move()/bulkUpdate(), so it needs the same guard: the destination
|
||||||
@@ -281,68 +312,49 @@ class FilesController extends Controller
|
|||||||
// folder actually changes, so re-saving a file that already sits in
|
// folder actually changes, so re-saving a file that already sits in
|
||||||
// an out-of-scope folder (reachable via a direct client share) still
|
// an out-of-scope folder (reachable via a direct client share) still
|
||||||
// works.
|
// works.
|
||||||
if ($folderId !== null && $folderId !== $file->folder_id && $user !== null) {
|
if ($folderId !== null && $folderId !== $file->folder_id) {
|
||||||
$this->scope->folders($user)->findOrFail($folderId);
|
$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'],
|
'name' => $validated['name'],
|
||||||
'description' => $validated['description'] ?? null,
|
'description' => $validated['description'] ?? null,
|
||||||
'folder_id' => $folderId,
|
'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
|
// The one field that is conditionally *present* rather than
|
||||||
// offered by the page then — but a request reaching here directly
|
// conditionally honoured, and the reason it cannot move into
|
||||||
// must not be able to set a flag the UI is currently hiding, the
|
// ApplyFileEdits: the form was rendered with the stored instant
|
||||||
// same shape as the upload_public gate below.
|
// read back as a date in this viewer's zone, and posts it again
|
||||||
if ($this->commenting->scope() === CommentScope::SelectedFiles) {
|
// untouched with every other edit. Re-deriving it unconditionally
|
||||||
$attributes['commentable'] = $validated['commentable'] ?? $file->commentable;
|
// 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
|
$this->fileEdits->apply($user, $file, $changes);
|
||||||
// own expiry — same "leave it alone if you lack the permission"
|
|
||||||
// rule as the upload_public gate below.
|
|
||||||
if ($request->user()?->can('set_file_expiration_date') === true) {
|
|
||||||
$attributes['expires_at'] = $this->expiryInstant($validated['expires_at'] ?? null, $request->user());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same rule again for the download cap, behind its own
|
|
||||||
// permission — the one that already gates a share link's
|
|
||||||
// max_downloads, since both are the same question asked about
|
|
||||||
// different objects.
|
|
||||||
if ($request->user()?->can('limit_downloads') === true) {
|
|
||||||
$attributes['download_limit'] = $validated['download_limit'] ?? null;
|
|
||||||
$attributes['download_limit_scope'] = $validated['download_limit_scope'] ?? DownloadLimitScope::Total->value;
|
|
||||||
}
|
|
||||||
|
|
||||||
$wasPublic = $file->public;
|
|
||||||
|
|
||||||
// Only a user who can manage public state may change it — a user
|
|
||||||
// who can edit a file but lacks upload_public leaves its public
|
|
||||||
// state exactly as it was, same rule as FoldersController::update.
|
|
||||||
if ($request->user()?->can('upload_public') === true) {
|
|
||||||
$attributes['public'] = $validated['public'] ?? $file->public;
|
|
||||||
// Omitting the field on an update leaves the current slug
|
|
||||||
// alone — it must not silently change just because the name
|
|
||||||
// did.
|
|
||||||
$attributes['slug'] = ($validated['slug'] ?? '') ?: ($file->slug ?: File::uniqueSlugFrom($validated['name'], $file->id));
|
|
||||||
}
|
|
||||||
|
|
||||||
$file->update($attributes);
|
|
||||||
|
|
||||||
// Categories are gated by their own permission; leave them untouched
|
|
||||||
// for a user who can edit the file but not set categories.
|
|
||||||
if ($request->user()?->can('set_file_categories') === true) {
|
|
||||||
$file->categories()->sync($validated['categories'] ?? []);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->activity->log(Action::FileUpdated, subject: $file);
|
|
||||||
|
|
||||||
if (! $wasPublic && $file->public) {
|
|
||||||
$this->activity->log(Action::FileMadePublic, subject: $file, context: ['slug' => $file->slug]);
|
|
||||||
} elseif ($wasPublic && ! $file->public) {
|
|
||||||
$this->activity->log(Action::FileMadePrivate, subject: $file);
|
|
||||||
}
|
|
||||||
|
|
||||||
return back()->with('success', __('File updated.'));
|
return back()->with('success', __('File updated.'));
|
||||||
}
|
}
|
||||||
@@ -363,9 +375,18 @@ class FilesController extends Controller
|
|||||||
$folderId = $validated['folder_id'] ?? null;
|
$folderId = $validated['folder_id'] ?? null;
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|
||||||
// The target folder must be one the mover can actually see.
|
// The target folder must be one the mover can actually see, and one
|
||||||
if ($folderId !== null && $user !== null) {
|
// they are allowed to put content into. Those are two questions:
|
||||||
$this->scope->folders($user)->findOrFail($folderId);
|
// 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]);
|
$file->update(['folder_id' => $folderId]);
|
||||||
@@ -399,7 +420,7 @@ class FilesController extends Controller
|
|||||||
'description' => ['nullable', 'string', 'max:2000'],
|
'description' => ['nullable', 'string', 'max:2000'],
|
||||||
|
|
||||||
'expiration_action' => ['required', Rule::in(['no_change', 'set', 'clear'])],
|
'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:
|
// `sometimes` rather than `required` like the fields above:
|
||||||
// a browser still running the previous build would start
|
// a browser still running the previous build would start
|
||||||
@@ -422,13 +443,19 @@ class FilesController extends Controller
|
|||||||
&& ($validated['remove_category_ids'] ?? []) === [];
|
&& ($validated['remove_category_ids'] ?? []) === [];
|
||||||
abort_if($touchesNothing, 422, __('Change at least one field before applying a bulk edit.'));
|
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
|
// The target folder must be one this user can actually see, and one
|
||||||
// rule move() already applies to a single file's target.
|
// 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;
|
$targetFolderId = null;
|
||||||
if ($validated['folder_action'] === 'move') {
|
if ($validated['folder_action'] === 'move') {
|
||||||
$targetFolderId = $validated['folder_id'] ?? null;
|
$targetFolderId = $validated['folder_id'] ?? null;
|
||||||
if ($targetFolderId !== null) {
|
if ($targetFolderId !== null) {
|
||||||
$this->scope->folders($user)->findOrFail($targetFolderId);
|
$destination = $this->scope->folders($user)->whereKey($targetFolderId)->firstOrFail();
|
||||||
|
|
||||||
|
abort_unless(Folder::uploadableBy($user, $destination), 403);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,7 +490,7 @@ class FilesController extends Controller
|
|||||||
// update()'s expires_at handling.
|
// update()'s expires_at handling.
|
||||||
if ($validated['expiration_action'] !== 'no_change' && $canSetExpiration) {
|
if ($validated['expiration_action'] !== 'no_change' && $canSetExpiration) {
|
||||||
$attributes['expires_at'] = $validated['expiration_action'] === 'set'
|
$attributes['expires_at'] = $validated['expiration_action'] === 'set'
|
||||||
? $this->expiryInstant($validated['expires_at'], $user)
|
? $this->expiry->instant($validated['expires_at'], $user)
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,9 +531,23 @@ class FilesController extends Controller
|
|||||||
});
|
});
|
||||||
|
|
||||||
$requested = count($validated['file_ids']);
|
$requested = count($validated['file_ids']);
|
||||||
$message = $updated < $requested
|
|
||||||
? __(':updated of :requested selected files were updated. The rest were skipped because you don\'t have permission to edit them.', ['updated' => $updated, 'requested' => $requested])
|
// Two different reasons a selected file can go unchanged, and they
|
||||||
: trans_choice(':count file updated.|:count files updated.', $updated, ['count' => $updated]);
|
// are not the same sentence. Files dropped by the Gate::allows
|
||||||
|
// filter above are ones this user may not edit at all. A file that
|
||||||
|
// survived the filter and still changed nothing was editable --
|
||||||
|
// every field they asked to change was one their role does not let
|
||||||
|
// them set, which is the case the single-file editor states
|
||||||
|
// separately too. Reporting the first reason for the second told a
|
||||||
|
// staff member with edit_files but without set_file_expiration_date
|
||||||
|
// that three files they own are not theirs to edit.
|
||||||
|
$unreachable = $requested - $files->count();
|
||||||
|
|
||||||
|
$message = match (true) {
|
||||||
|
$updated === $requested => trans_choice(':count file updated.|:count files updated.', $updated, ['count' => $updated]),
|
||||||
|
$updated + $unreachable === $requested => __(':updated of :requested selected files were updated. The rest were skipped because you don\'t have permission to edit them.', ['updated' => $updated, 'requested' => $requested]),
|
||||||
|
default => __(':updated of :requested selected files were updated. The rest were skipped because you don\'t have permission to make those changes.', ['updated' => $updated, 'requested' => $requested]),
|
||||||
|
};
|
||||||
|
|
||||||
return back()->with('success', $message);
|
return back()->with('success', $message);
|
||||||
}
|
}
|
||||||
@@ -516,8 +557,13 @@ class FilesController extends Controller
|
|||||||
Gate::authorize('delete', $file);
|
Gate::authorize('delete', $file);
|
||||||
|
|
||||||
$name = $file->name;
|
$name = $file->name;
|
||||||
// Soft delete; the bytes stay on disk until a purge policy
|
// Soft delete of the row — but not of the bytes. File::booted()'s
|
||||||
// lands with the retention work.
|
// `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();
|
$file->delete();
|
||||||
|
|
||||||
$this->activity->log(Action::FileDeleted, context: ['name' => $name]);
|
$this->activity->log(Action::FileDeleted, context: ['name' => $name]);
|
||||||
@@ -526,18 +572,39 @@ class FilesController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The instant a `<input type="date">` expiry actually falls on.
|
* Delete several files at once, from the staff selection bar (#1800).
|
||||||
*
|
*
|
||||||
* The form posts a bare `YYYY-MM-DD`, which Eloquent would otherwise
|
* Each file is asked exactly what destroy() asks, through the same
|
||||||
* store as midnight UTC — so "expires on the 12th" would cut the file
|
* policy, and gets the same soft delete and the same activity entry: a
|
||||||
* off partway through the 11th for anyone in the Americas, and give
|
* batch is a shorthand for single deletes, never a way around one. A
|
||||||
* anyone east of Greenwich most of a day they were not promised. It
|
* file the person may not delete is dropped from the batch rather than
|
||||||
* means the end of the 12th where the person setting it lives.
|
* 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
|
$user = $request->user();
|
||||||
? null
|
assert($user !== null);
|
||||||
: LocalDay::end($date, $this->timezones->resolve($setter));
|
|
||||||
|
$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;
|
namespace App\Modules\Files\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
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\Access\StaffLibraryScope;
|
||||||
use App\Modules\Files\Http\Controllers\Concerns\ResolvesShareTargets;
|
use App\Modules\Files\Http\Controllers\Concerns\ResolvesShareTargets;
|
||||||
use App\Modules\Files\Models\Folder;
|
use App\Modules\Files\Models\Folder;
|
||||||
use App\Modules\Files\Models\FolderAssignment;
|
use App\Modules\Files\Sharing\FolderSharing;
|
||||||
use App\Modules\Notifications\NotificationDigester;
|
|
||||||
use App\Modules\Notifications\Notifier;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sharing a folder with a client or group grants live access to its
|
* 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
|
class FolderAssignmentsController extends Controller
|
||||||
{
|
{
|
||||||
use ResolvesShareTargets;
|
use ResolvesShareTargets;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ActivityLogger $activity,
|
|
||||||
private readonly StaffLibraryScope $scope,
|
private readonly StaffLibraryScope $scope,
|
||||||
private readonly NotificationDigester $digester,
|
private readonly FolderSharing $sharing,
|
||||||
private readonly Notifier $notifier,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function store(Request $request, Folder $folder): RedirectResponse
|
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.'),
|
__('Folders can only be shared with clients or groups.'),
|
||||||
);
|
);
|
||||||
|
|
||||||
FolderAssignment::query()->firstOrCreate([
|
$this->sharing->assign($folder, $assignable, $targetName);
|
||||||
'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]);
|
|
||||||
|
|
||||||
return back();
|
return back();
|
||||||
}
|
}
|
||||||
@@ -68,15 +50,7 @@ class FolderAssignmentsController extends Controller
|
|||||||
__('Folders can only be shared with clients or groups.'),
|
__('Folders can only be shared with clients or groups.'),
|
||||||
);
|
);
|
||||||
|
|
||||||
$deleted = FolderAssignment::query()
|
$this->sharing->unassign($folder, $assignable, $targetName);
|
||||||
->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]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return back();
|
return back();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,16 +10,22 @@ use App\Modules\Audit\Action;
|
|||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
use App\Modules\Comments\Access\VisibleCommentScope;
|
use App\Modules\Comments\Access\VisibleCommentScope;
|
||||||
use App\Modules\Comments\CommentingRules;
|
use App\Modules\Comments\CommentingRules;
|
||||||
|
use App\Modules\Files\Access\ClientIdentityScope;
|
||||||
use App\Modules\Files\Access\DownloadAllowance;
|
use App\Modules\Files\Access\DownloadAllowance;
|
||||||
use App\Modules\Files\Access\ShareTargets;
|
use App\Modules\Files\Access\ShareTargets;
|
||||||
use App\Modules\Files\Access\StaffLibraryScope;
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
use App\Modules\Files\Folders\BreadcrumbBuilder;
|
use App\Modules\Files\Folders\BreadcrumbBuilder;
|
||||||
use App\Modules\Files\Folders\FolderService;
|
use App\Modules\Files\Folders\FolderService;
|
||||||
|
use App\Modules\Files\Folders\UndeletableFiles;
|
||||||
use App\Modules\Files\Models\Category;
|
use App\Modules\Files\Models\Category;
|
||||||
use App\Modules\Files\Models\File;
|
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\Models\Folder;
|
||||||
use App\Modules\Files\Versions\FileVersionLinks;
|
use App\Modules\Files\Versions\FileVersionLinks;
|
||||||
use App\Modules\Groups\Models\Group;
|
use App\Modules\Groups\Models\Group;
|
||||||
|
use App\Modules\Identity\Models\Role;
|
||||||
use App\Support\ConcatenatedPagination;
|
use App\Support\ConcatenatedPagination;
|
||||||
use App\Support\Pagination;
|
use App\Support\Pagination;
|
||||||
use App\Support\PublicUrl;
|
use App\Support\PublicUrl;
|
||||||
@@ -54,11 +60,13 @@ class FoldersController extends Controller
|
|||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
private readonly PublicUrl $publicUrl,
|
private readonly PublicUrl $publicUrl,
|
||||||
private readonly ShareTargets $shareTargets,
|
private readonly ShareTargets $shareTargets,
|
||||||
|
private readonly ClientIdentityScope $identity,
|
||||||
private readonly BreadcrumbBuilder $breadcrumbs,
|
private readonly BreadcrumbBuilder $breadcrumbs,
|
||||||
private readonly CommentingRules $commenting,
|
private readonly CommentingRules $commenting,
|
||||||
private readonly VisibleCommentScope $comments,
|
private readonly VisibleCommentScope $comments,
|
||||||
private readonly FileVersionLinks $versionLinks,
|
private readonly FileVersionLinks $versionLinks,
|
||||||
private readonly DownloadAllowance $allowance,
|
private readonly DownloadAllowance $allowance,
|
||||||
|
private readonly UndeletableFiles $undeletable,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,6 +90,15 @@ class FoldersController extends Controller
|
|||||||
'search' => ['nullable', 'string', 'max:255'],
|
'search' => ['nullable', 'string', 'max:255'],
|
||||||
'folder' => ['nullable', 'integer'],
|
'folder' => ['nullable', 'integer'],
|
||||||
'category' => ['nullable', 'integer', 'exists:categories,id'],
|
'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',
|
// Not a 'boolean' rule: that only accepts true/false/0/1/'0'/'1',
|
||||||
// rejecting the literal "true"/"" the frontend checkbox sends.
|
// rejecting the literal "true"/"" the frontend checkbox sends.
|
||||||
// $request->boolean() below coerces any of those safely, so
|
// $request->boolean() below coerces any of those safely, so
|
||||||
@@ -90,12 +107,25 @@ class FoldersController extends Controller
|
|||||||
$search = trim($validated['search'] ?? '');
|
$search = trim($validated['search'] ?? '');
|
||||||
$searching = $search !== '';
|
$searching = $search !== '';
|
||||||
$categoryId = $validated['category'] ?? null;
|
$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');
|
$expired = $request->boolean('expired');
|
||||||
|
|
||||||
// A search term, a category filter, or the expired-only filter all
|
// A search term or any filter switches to a flat view across the
|
||||||
// switch to a flat view across the whole visible library;
|
// whole visible library; otherwise it's folder browsing. Every
|
||||||
// otherwise it's folder browsing.
|
// filter here is a property of a *file*, so in flat mode the folder
|
||||||
$flat = $searching || $categoryId !== null || $expired;
|
// 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']);
|
$folderQuery = $this->scope->folders($user)->withCount(['children', 'files']);
|
||||||
// `downloads` unconditionally — the library has always shown a
|
// `downloads` unconditionally — the library has always shown a
|
||||||
@@ -103,7 +133,18 @@ class FoldersController extends Controller
|
|||||||
// something on this install is actually limited.
|
// something on this install is actually limited.
|
||||||
$fileQuery = $this->allowance->withOwnCount(
|
$fileQuery = $this->allowance->withOwnCount(
|
||||||
$this->scope->files($user)->with('uploader.role', 'categories', 'folder')
|
$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,
|
$user,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -118,6 +159,29 @@ class FoldersController extends Controller
|
|||||||
->when($categoryId !== null, fn (Builder $q) => $q
|
->when($categoryId !== null, fn (Builder $q) => $q
|
||||||
->whereHas('categories', fn (Builder $c) => $c->where('categories.id', $categoryId)))
|
->whereHas('categories', fn (Builder $c) => $c->where('categories.id', $categoryId)))
|
||||||
->when($expired, fn (Builder $q) => $q->expired())
|
->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');
|
->orderBy('name');
|
||||||
} else {
|
} else {
|
||||||
$current = $request->integer('folder') > 0
|
$current = $request->integer('folder') > 0
|
||||||
@@ -160,6 +224,11 @@ class FoldersController extends Controller
|
|||||||
'search' => $search !== '' ? $search : null,
|
'search' => $search !== '' ? $search : null,
|
||||||
'folder' => $current?->id,
|
'folder' => $current?->id,
|
||||||
'category' => $categoryId,
|
'category' => $categoryId,
|
||||||
|
'uploader' => $uploaderId,
|
||||||
|
'visibility' => $visibility,
|
||||||
|
'downloads' => $downloads,
|
||||||
|
'role' => $roleId,
|
||||||
|
'version' => $version,
|
||||||
'expired' => $expired ? 'true' : null,
|
'expired' => $expired ? 'true' : null,
|
||||||
'page' => Pagination::redirectPage($sliced['paginator']),
|
'page' => Pagination::redirectPage($sliced['paginator']),
|
||||||
]));
|
]));
|
||||||
@@ -174,15 +243,29 @@ class FoldersController extends Controller
|
|||||||
// as the comment counts above.
|
// as the comment counts above.
|
||||||
$versions = $this->versionLinks->forMany($fileRows, $user, fn (File $other): string => route('files.edit', $other, false));
|
$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', [
|
return Inertia::render('files/index', [
|
||||||
'folder' => $current === null ? null : ['id' => $current->id, 'name' => $current->name],
|
'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(),
|
'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(),
|
'files' => $fileRows->map(fn (File $file): array => $this->fileRow($user, $file, $commentCounts, $pendingCounts, $versions))->all(),
|
||||||
'pagination' => Pagination::meta($sliced['paginator']),
|
'pagination' => Pagination::meta($sliced['paginator']),
|
||||||
'search' => $search,
|
'search' => $search,
|
||||||
'searching' => $flat,
|
'searching' => $flat,
|
||||||
'category' => $categoryId,
|
'category' => $categoryId,
|
||||||
|
'uploader' => $uploaderId,
|
||||||
|
'visibility' => $visibility,
|
||||||
|
'downloads' => $downloads,
|
||||||
|
'role' => $roleId,
|
||||||
|
'version' => $version,
|
||||||
'expired' => $expired,
|
'expired' => $expired,
|
||||||
'categories' => Category::query()->orderBy('name')->get(['id', 'name', 'color'])
|
'categories' => Category::query()->orderBy('name')->get(['id', 'name', 'color'])
|
||||||
->map(fn (Category $category): array => ['id' => $category->id, 'name' => $category->name, 'color' => $category->color])->all(),
|
->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.
|
// every folder name and id on the installation.
|
||||||
'folder_options' => $this->scope->folders($user)->orderBy('path')->orderBy('name')->get()
|
'folder_options' => $this->scope->folders($user)->orderBy('path')->orderBy('name')->get()
|
||||||
->map(fn (Folder $folder): array => ['id' => $folder->id, 'name' => $folder->name])->all(),
|
->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_create_folders' => $user->can('create_own_folders'),
|
||||||
'can_upload' => $user->can('upload'),
|
'can_upload' => $user->can('upload'),
|
||||||
'can_manage_public' => $user->can('upload_public'),
|
'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>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@@ -240,13 +368,20 @@ class FoldersController extends Controller
|
|||||||
'original_name' => $file->original_name,
|
'original_name' => $file->original_name,
|
||||||
'mime_type' => $file->mime_type,
|
'mime_type' => $file->mime_type,
|
||||||
'size' => $file->size,
|
'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,
|
'name' => $file->uploader->name,
|
||||||
'type' => $file->uploader->type->value,
|
'type' => $file->uploader->type->value,
|
||||||
'role' => $file->uploader->role?->name,
|
'role' => $file->uploader->role?->name,
|
||||||
] : null,
|
] : null,
|
||||||
'public' => $file->isEffectivelyPublic(),
|
'public' => $file->isEffectivelyPublic(),
|
||||||
'expired' => $file->isExpired(),
|
'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
|
// No link at all once expired — the public route 404s past
|
||||||
// expiry too (see File::scopeNotExpired's callers), so there's
|
// expiry too (see File::scopeNotExpired's callers), so there's
|
||||||
// no point offering a button that leads to a dead page.
|
// no point offering a button that leads to a dead page.
|
||||||
@@ -293,7 +428,7 @@ class FoldersController extends Controller
|
|||||||
'public_url' => $folder->public
|
'public_url' => $folder->public
|
||||||
? $this->publicUrl->for($folder)
|
? $this->publicUrl->for($folder)
|
||||||
: null,
|
: null,
|
||||||
'breadcrumb' => $this->breadcrumbs->for($folder),
|
'breadcrumb' => $this->breadcrumb($user, $folder),
|
||||||
'can_update' => Gate::forUser($user)->allows('update', $folder),
|
'can_update' => Gate::forUser($user)->allows('update', $folder),
|
||||||
'can_manage_public' => $user->can('upload_public'),
|
'can_manage_public' => $user->can('upload_public'),
|
||||||
...$this->shareTargets->forSubject($folder, $user),
|
...$this->shareTargets->forSubject($folder, $user),
|
||||||
@@ -317,6 +452,11 @@ class FoldersController extends Controller
|
|||||||
|
|
||||||
$parent = $this->resolveParent($user, $validated['parent_id'] ?? null);
|
$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);
|
$folder = $this->folders->create($validated['name'], $parent);
|
||||||
|
|
||||||
// Only a user who can manage public state may set it on create —
|
// 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(),
|
'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);
|
$this->folders->move($folder, $newParent);
|
||||||
|
|
||||||
@@ -405,10 +558,25 @@ class FoldersController extends Controller
|
|||||||
return back();
|
return back();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Folder $folder): RedirectResponse
|
public function destroy(Request $request, Folder $folder): RedirectResponse
|
||||||
{
|
{
|
||||||
Gate::authorize('delete', $folder);
|
Gate::authorize('delete', $folder);
|
||||||
|
|
||||||
|
$viewer = $request->user();
|
||||||
|
assert($viewer !== null);
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
'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;
|
$name = $folder->name;
|
||||||
$parentId = $folder->parent_id;
|
$parentId = $folder->parent_id;
|
||||||
|
|
||||||
@@ -419,6 +587,31 @@ class FoldersController extends Controller
|
|||||||
return redirect()->route('files.index', $parentId !== null ? ['folder' => $parentId] : [])->with('success', __('Folder deleted.'));
|
return redirect()->route('files.index', $parentId !== null ? ['folder' => $parentId] : [])->with('success', __('Folder deleted.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* @return list<array{id: int, name: string}>
|
||||||
|
*/
|
||||||
|
private function breadcrumb(User $user, ?Folder $folder): array
|
||||||
|
{
|
||||||
|
if ($folder === null || ! $user->isClientScoped()) {
|
||||||
|
return $this->breadcrumbs->for($folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
$visibleIds = array_values(array_map(
|
||||||
|
'intval',
|
||||||
|
$this->scope->folders($user)
|
||||||
|
->whereIn('folders.id', [...$folder->ancestorIds(), $folder->id])
|
||||||
|
->pluck('folders.id')
|
||||||
|
->all(),
|
||||||
|
));
|
||||||
|
|
||||||
|
return $this->breadcrumbs->visible($folder, $visibleIds);
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveParent(?User $user, ?int $parentId): ?Folder
|
private function resolveParent(?User $user, ?int $parentId): ?Folder
|
||||||
{
|
{
|
||||||
if ($user === null || $parentId === null) {
|
if ($user === null || $parentId === null) {
|
||||||
|
|||||||
@@ -5,14 +5,25 @@ declare(strict_types=1);
|
|||||||
namespace App\Modules\Files\Http\Controllers;
|
namespace App\Modules\Files\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Modules\Audit\Action;
|
||||||
|
use App\Modules\Audit\ActivityLogger;
|
||||||
use App\Modules\Clients\ClientStorageUsage;
|
use App\Modules\Clients\ClientStorageUsage;
|
||||||
use App\Modules\Comments\Access\VisibleCommentScope;
|
use App\Modules\Comments\Access\VisibleCommentScope;
|
||||||
use App\Modules\Comments\CommentingRules;
|
use App\Modules\Comments\CommentingRules;
|
||||||
|
use App\Modules\Comments\CommentScope;
|
||||||
use App\Modules\Files\Access\DownloadAllowance;
|
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\BreadcrumbBuilder;
|
||||||
|
use App\Modules\Files\Folders\ClientHomeFolders;
|
||||||
use App\Modules\Files\Models\Category;
|
use App\Modules\Files\Models\Category;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
use App\Modules\Files\Models\Folder;
|
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\Uploads\UploadExtensionPolicy;
|
||||||
use App\Modules\Files\Versions\FileVersionLinks;
|
use App\Modules\Files\Versions\FileVersionLinks;
|
||||||
use App\Modules\Files\Versions\FileVersions;
|
use App\Modules\Files\Versions\FileVersions;
|
||||||
@@ -22,6 +33,7 @@ use App\Modules\Platform\Settings\Settings;
|
|||||||
use App\Modules\Platform\Theming\PublicThemeRegistry;
|
use App\Modules\Platform\Theming\PublicThemeRegistry;
|
||||||
use App\Support\ConcatenatedPagination;
|
use App\Support\ConcatenatedPagination;
|
||||||
use App\Support\Pagination;
|
use App\Support\Pagination;
|
||||||
|
use App\Support\Rules;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -61,11 +73,17 @@ class MyFilesController extends Controller
|
|||||||
private readonly PublicThemeRegistry $themes,
|
private readonly PublicThemeRegistry $themes,
|
||||||
private readonly CapabilityRegistry $capabilities,
|
private readonly CapabilityRegistry $capabilities,
|
||||||
private readonly BreadcrumbBuilder $breadcrumbs,
|
private readonly BreadcrumbBuilder $breadcrumbs,
|
||||||
|
private readonly ClientHomeFolders $homeFolders,
|
||||||
private readonly CommentingRules $commenting,
|
private readonly CommentingRules $commenting,
|
||||||
private readonly VisibleCommentScope $comments,
|
private readonly VisibleCommentScope $comments,
|
||||||
private readonly DownloadAllowance $allowance,
|
private readonly DownloadAllowance $allowance,
|
||||||
private readonly FileVersions $versions,
|
private readonly FileVersions $versions,
|
||||||
private readonly FileVersionLinks $versionLinks,
|
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
|
public function index(Request $request): Response|RedirectResponse
|
||||||
@@ -92,6 +110,17 @@ class MyFilesController extends Controller
|
|||||||
// folder they created themselves, anywhere in that visible tree.
|
// 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());
|
$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 search term, a category filter, or an owner filter all switch to
|
||||||
// a flat, global view across everything the client may see — same
|
// a flat, global view across everything the client may see — same
|
||||||
// convention as the staff library (FoldersController) uses for
|
// convention as the staff library (FoldersController) uses for
|
||||||
@@ -132,7 +161,24 @@ class MyFilesController extends Controller
|
|||||||
if ($current === null) {
|
if ($current === null) {
|
||||||
$folders = Folder::query()
|
$folders = Folder::query()
|
||||||
->whereIn('id', $visibleIds)
|
->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');
|
->orderBy('name');
|
||||||
} else {
|
} else {
|
||||||
$folders = Folder::query()
|
$folders = Folder::query()
|
||||||
@@ -147,7 +193,12 @@ class MyFilesController extends Controller
|
|||||||
// own listing) show here with no folder context.
|
// own listing) show here with no folder context.
|
||||||
$filesQuery = File::query()->visibleToClient($client);
|
$filesQuery = File::query()->visibleToClient($client);
|
||||||
if ($current === null) {
|
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 {
|
} else {
|
||||||
$filesQuery->where('folder_id', $current->id);
|
$filesQuery->where('folder_id', $current->id);
|
||||||
}
|
}
|
||||||
@@ -207,15 +258,28 @@ class MyFilesController extends Controller
|
|||||||
$fileRows = $sliced['items']['files'];
|
$fileRows = $sliced['items']['files'];
|
||||||
|
|
||||||
$commentCounts = $this->comments->countsFor($client, $fileRows);
|
$commentCounts = $this->comments->countsFor($client, $fileRows);
|
||||||
// Two queries for the page, not two per row. No URL resolver: the
|
// Two queries for the page, not two per row. Still no URL resolver:
|
||||||
// portal has no per-file page to link to, so a counterpart is named
|
// the portal's per-file page is an *editor* for a client's own
|
||||||
// and not linked (see docs/theming-files-checklist.md).
|
// 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);
|
$versions = $this->versionLinks->forMany($fileRows, $client);
|
||||||
$unreadComments = $this->comments->unreadCountsFor($client, array_values(array_map(intval(...), $fileRows->pluck('id')->all())));
|
$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", [
|
return Inertia::render("portal/themes/{$this->themeKey()}/my-files", [
|
||||||
'folder' => $current === null ? null : ['id' => $current->id, 'name' => $current->name],
|
'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 => [
|
'folders' => $folderRows->map(fn (Folder $folder): array => [
|
||||||
'id' => $folder->id,
|
'id' => $folder->id,
|
||||||
'name' => $folder->name,
|
'name' => $folder->name,
|
||||||
@@ -236,7 +300,19 @@ class MyFilesController extends Controller
|
|||||||
'mime_type' => $file->mime_type,
|
'mime_type' => $file->mime_type,
|
||||||
'size' => $file->size,
|
'size' => $file->size,
|
||||||
'created_at' => $file->created_at?->toIso8601String(),
|
'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,
|
'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
|
// Effective status (own flag or inherited from a public
|
||||||
// folder) — same "will visitors on the public site see
|
// folder) — same "will visitors on the public site see
|
||||||
// this" badge as the staff library shows.
|
// 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
|
// counterpart they were not given is null, not hidden by
|
||||||
// the theme. A theme must never filter this itself.
|
// the theme. A theme must never filter this itself.
|
||||||
'version' => $versions[$file->id] ?? ['previous' => null, 'next' => null],
|
'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 => [
|
'categories' => $file->categories->map(fn (Category $category): array => [
|
||||||
'id' => $category->id, 'name' => $category->name, 'color' => $category->color,
|
'id' => $category->id, 'name' => $category->name, 'color' => $category->color,
|
||||||
])->values()->all(),
|
])->values()->all(),
|
||||||
@@ -291,7 +380,13 @@ class MyFilesController extends Controller
|
|||||||
abort_unless(Folder::uploadableBy($client, $folder), 403);
|
abort_unless(Folder::uploadableBy($client, $folder), 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$notice = new ResolvingUploadNotice($client);
|
||||||
|
event($notice);
|
||||||
|
|
||||||
return Inertia::render('portal/upload', [
|
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),
|
'allowed_extensions' => $this->extensionPolicy->hintFor($client),
|
||||||
'max_file_size_mb' => (int) $this->settings->get(Setting::MaxFileSizeMb),
|
'max_file_size_mb' => (int) $this->settings->get(Setting::MaxFileSizeMb),
|
||||||
'part_size_mb' => (int) config('projectsend.upload_part_size_mb'),
|
'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
|
* Files this client may name as the previous version of what they are
|
||||||
* uploading — THEIR OWN UPLOADS ONLY.
|
* uploading — THEIR OWN UPLOADS ONLY.
|
||||||
@@ -339,4 +654,23 @@ class MyFilesController extends Controller
|
|||||||
|
|
||||||
return $this->themes->resolve(is_string($value) ? $value : 'default', $this->capabilities);
|
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\Http\Controllers\Controller;
|
||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
|
use App\Modules\Files\Folders\ClientHomeFolders;
|
||||||
use App\Modules\Files\Folders\FolderService;
|
use App\Modules\Files\Folders\FolderService;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
use App\Modules\Files\Models\Folder;
|
use App\Modules\Files\Models\Folder;
|
||||||
@@ -30,6 +31,7 @@ class MyFoldersController extends Controller
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly FolderService $folders,
|
private readonly FolderService $folders,
|
||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
|
private readonly ClientHomeFolders $homeFolders,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function store(Request $request): RedirectResponse
|
public function store(Request $request): RedirectResponse
|
||||||
@@ -52,6 +54,13 @@ class MyFoldersController extends Controller
|
|||||||
$parent = Folder::query()->visibleToClient($client)->whereKey($validated['parent_id'])->firstOrFail();
|
$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);
|
$folder = $this->folders->create($validated['name'], $parent);
|
||||||
|
|
||||||
$this->activity->log(Action::FolderCreated, subject: $folder);
|
$this->activity->log(Action::FolderCreated, subject: $folder);
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ namespace App\Modules\Files\Http\Controllers;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
|
use App\Modules\Files\Models\File;
|
||||||
use App\Modules\Files\OrphanFileScanner;
|
use App\Modules\Files\OrphanFileScanner;
|
||||||
|
use App\Modules\Files\Scanning\ScanStatus;
|
||||||
use App\Modules\Files\Uploads\StoreUploadedFile;
|
use App\Modules\Files\Uploads\StoreUploadedFile;
|
||||||
use App\Support\Pagination;
|
use App\Support\Pagination;
|
||||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||||
@@ -45,6 +47,14 @@ class OrphanFilesController extends Controller
|
|||||||
$validated = $request->validate(['search' => ['nullable', 'string', 'max:255']]);
|
$validated = $request->validate(['search' => ['nullable', 'string', 'max:255']]);
|
||||||
$search = trim($validated['search'] ?? '');
|
$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
|
// A full disk scan (potentially thousands of entries, across
|
||||||
// every scanned disk) happens once per request regardless of
|
// every scanned disk) happens once per request regardless of
|
||||||
// page — Storage::allFiles() has no server-side paging of its
|
// page — Storage::allFiles() has no server-side paging of its
|
||||||
@@ -75,10 +85,51 @@ class OrphanFilesController extends Controller
|
|||||||
);
|
);
|
||||||
|
|
||||||
return Inertia::render('files/orphans', [
|
return Inertia::render('files/orphans', [
|
||||||
|
'tab' => 'orphans',
|
||||||
'orphans' => $paginator->items(),
|
'orphans' => $paginator->items(),
|
||||||
'pagination' => Pagination::meta($paginator),
|
'pagination' => Pagination::meta($paginator),
|
||||||
'search' => $search,
|
'search' => $search,
|
||||||
'scanned_disks' => $this->scanner->scannedDisks(),
|
'scanned_disks' => $this->scanner->scannedDisks(),
|
||||||
|
'missing_count' => File::query()->where('scan_status', ScanStatus::Missing)->count(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Files this installation lists and cannot produce.
|
||||||
|
*
|
||||||
|
* Read from the rows rather than from the disk: the daily check
|
||||||
|
* (projectsend:check-missing-files) has already done the comparing,
|
||||||
|
* and repeating a full disk listing on every page load would make
|
||||||
|
* this screen slower the worse the problem is.
|
||||||
|
*/
|
||||||
|
private function missing(Request $request): Response
|
||||||
|
{
|
||||||
|
$missing = File::query()
|
||||||
|
->where('scan_status', ScanStatus::Missing)
|
||||||
|
->with('uploader')
|
||||||
|
->orderBy('name')
|
||||||
|
->paginate(self::PER_PAGE)
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
$missing->through(fn (File $file): array => [
|
||||||
|
'id' => $file->id,
|
||||||
|
'name' => $file->name,
|
||||||
|
'original_name' => $file->original_name,
|
||||||
|
'size' => $file->size,
|
||||||
|
'disk' => $file->disk,
|
||||||
|
'path' => $file->path,
|
||||||
|
'uploader' => $file->uploader?->name,
|
||||||
|
'created_at' => $file->created_at?->toIso8601String(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Inertia::render('files/orphans', [
|
||||||
|
'tab' => 'missing',
|
||||||
|
'orphans' => [],
|
||||||
|
'pagination' => Pagination::meta($missing),
|
||||||
|
'search' => '',
|
||||||
|
'scanned_disks' => $this->scanner->scannedDisks(),
|
||||||
|
'missing' => $missing->items(),
|
||||||
|
'missing_count' => $missing->total(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,14 @@ use App\Modules\Files\Access\DownloadAllowance;
|
|||||||
use App\Modules\Files\Delivery\StoredFileResponse;
|
use App\Modules\Files\Delivery\StoredFileResponse;
|
||||||
use App\Modules\Files\Models\Category;
|
use App\Modules\Files\Models\Category;
|
||||||
use App\Modules\Files\Models\File;
|
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 App\Modules\Files\Models\ShareLink;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Response;
|
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response as InertiaResponse;
|
use Inertia\Response as InertiaResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The public, unauthenticated side of a share link: no Gate/policy is
|
* The public, unauthenticated side of a share link: no Gate/policy is
|
||||||
@@ -29,6 +32,8 @@ class PublicShareController extends Controller
|
|||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
private readonly DownloadAllowance $allowance,
|
private readonly DownloadAllowance $allowance,
|
||||||
private readonly StoredFileResponse $bytes,
|
private readonly StoredFileResponse $bytes,
|
||||||
|
private readonly FileAvailability $availability,
|
||||||
|
private readonly ScanningConfig $scanning,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function show(string $token): InertiaResponse
|
public function show(string $token): InertiaResponse
|
||||||
@@ -36,7 +41,12 @@ class PublicShareController extends Controller
|
|||||||
$shareLink = ShareLink::query()->where('token', $token)->first();
|
$shareLink = ShareLink::query()->where('token', $token)->first();
|
||||||
$file = $shareLink?->shareable;
|
$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']);
|
return Inertia::render('share/show', ['status' => 'not_found']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +57,16 @@ class PublicShareController extends Controller
|
|||||||
return Inertia::render('share/show', ['status' => 'expired']);
|
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
|
// Two separate caps reach the same page: the link's own
|
||||||
// max_downloads, and the file's. A visitor here has no account,
|
// max_downloads, and the file's. A visitor here has no account,
|
||||||
// so the file's limit is measured against the whole file — see
|
// so the file's limit is measured against the whole file — see
|
||||||
@@ -71,6 +91,11 @@ class PublicShareController extends Controller
|
|||||||
])->values()->all(),
|
])->values()->all(),
|
||||||
],
|
],
|
||||||
'download_url' => route('share.download', $token),
|
'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();
|
$shareLink = ShareLink::query()->where('token', $token)->first();
|
||||||
$file = $shareLink?->shareable;
|
$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);
|
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\Audit\ActivityLogger;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
use App\Modules\Files\Models\ShareLink;
|
use App\Modules\Files\Models\ShareLink;
|
||||||
|
use App\Modules\Files\Sharing\CreateShareLink;
|
||||||
use App\Modules\Platform\Localization\LocalDay;
|
use App\Modules\Platform\Localization\LocalDay;
|
||||||
use App\Modules\Platform\Localization\TimezoneRegistry;
|
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
@@ -30,19 +31,29 @@ class ShareLinksController extends Controller
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
private readonly TimezoneRegistry $timezones,
|
private readonly TimezoneRegistry $timezones,
|
||||||
|
private readonly CreateShareLink $links,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function store(Request $request, File $file): RedirectResponse
|
public function store(Request $request, File $file): RedirectResponse
|
||||||
{
|
{
|
||||||
Gate::authorize('update', $file);
|
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([
|
$validated = $request->validate([
|
||||||
// Deliberately not `after:now`: that rule reads the bare
|
// Deliberately not `after:now`: that rule reads the bare
|
||||||
// YYYY-MM-DD the picker posts as midnight UTC, so a creator
|
// 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
|
// far enough east would be told today's date is in the past
|
||||||
// while it is plainly still today where they are. The check
|
// while it is plainly still today where they are. The check
|
||||||
// moves below, onto the instant the date actually resolves to.
|
// 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'],
|
'max_downloads' => ['nullable', 'integer', 'min:1'],
|
||||||
// A custom token is optional — leave blank for a random one,
|
// A custom token is optional — leave blank for a random one,
|
||||||
// same as before. Must not collide with the file's own
|
// same as before. Must not collide with the file's own
|
||||||
@@ -80,16 +91,27 @@ class ShareLinksController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
ShareLink::query()->create([
|
// The permission gates stay here, where the request is: whether
|
||||||
'shareable_type' => $file->getMorphClass(),
|
// this person may set an expiry or a cap is a fact about them,
|
||||||
'shareable_id' => $file->id,
|
// not about link creation, and the action has no viewer to ask.
|
||||||
'token' => $validated['token'] ?? Str::random(32),
|
$this->links->for(
|
||||||
'created_by' => $user->id,
|
file: $file,
|
||||||
'expires_at' => $user->can('set_file_expiration_date') ? $expiresAt : null,
|
creator: $user,
|
||||||
'max_downloads' => $user->can('limit_downloads') ? $validated['max_downloads'] ?? null : null,
|
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
|
||||||
$this->activity->log(Action::ShareLinkCreated, subject: $file);
|
// `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.'));
|
return back()->with('success', __('Public link created.'));
|
||||||
}
|
}
|
||||||
@@ -99,6 +121,9 @@ class ShareLinksController extends Controller
|
|||||||
$file = $shareLink->shareable;
|
$file = $shareLink->shareable;
|
||||||
abort_unless($file instanceof File, 404);
|
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);
|
Gate::authorize('update', $file);
|
||||||
|
|
||||||
$shareLink->delete();
|
$shareLink->delete();
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Files\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Modules\Audit\Action;
|
||||||
|
use App\Modules\Audit\ActivityLogger;
|
||||||
|
use App\Modules\Files\Jobs\ScanFileJob;
|
||||||
|
use App\Modules\Files\Models\File;
|
||||||
|
use App\Modules\Files\Scanning\NotScannedReason;
|
||||||
|
use App\Modules\Files\Scanning\ScannerAddress;
|
||||||
|
use App\Modules\Files\Scanning\ScanningConfig;
|
||||||
|
use App\Modules\Files\Scanning\ScanOutcome;
|
||||||
|
use App\Modules\Files\Scanning\ScanStatus;
|
||||||
|
use App\Modules\Files\Scanning\VirusScanner;
|
||||||
|
use App\Modules\Platform\Capabilities\Capability;
|
||||||
|
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||||
|
use App\Modules\Platform\Settings\Setting;
|
||||||
|
use App\Modules\Platform\Settings\Settings;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The virus scanning screen.
|
||||||
|
*
|
||||||
|
* Two of these settings decide what happens when the scanner cannot
|
||||||
|
* answer, and both default to letting files through. That is a
|
||||||
|
* deliberate choice (see docs/feature-virus-scanning.md) and it is the
|
||||||
|
* reason this screen states the count of files currently allowed through
|
||||||
|
* unscanned rather than leaving it to be discovered: a scanner that has
|
||||||
|
* quietly stopped protecting anything looks exactly like one that is
|
||||||
|
* working.
|
||||||
|
*
|
||||||
|
* Where a managed configuration names a scanner, the connection is not
|
||||||
|
* this screen's to change and scanning cannot be switched off — the
|
||||||
|
* policies still are. Same shape as the CAPTCHA screen under managed
|
||||||
|
* keys.
|
||||||
|
*/
|
||||||
|
class VirusScanningSettingsController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* A zip holding check.txt ("ProjectSend checks that the scanner
|
||||||
|
* reports encrypted archives."), encrypted with the password
|
||||||
|
* "projectsend". Made with `zip -P`.
|
||||||
|
*/
|
||||||
|
private const ENCRYPTED_ARCHIVE = 'UEsDBBQACQAIAACon1toMefdSgAAAEAAAAAJAAAAY2hlY2sudHh0prvUiniNGfEwEalXOcDbsYylfm2yAcyjplSfHJqk2sSxcVWFx0omz5AvASvSRdDbfeSQ+CC2qu6JEP/NYbBKy+g5t2nJr4swpv9QSwcIaDHn3UoAAABAAAAAUEsBAh4DFAAJAAgAAKifW2gx591KAAAAQAAAAAkAAAAAAAAAAQAAALSBAAAAAGNoZWNrLnR4dFBLBQYAAAAAAQABADcAAACBAAAAAAA=';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly Settings $settings,
|
||||||
|
private readonly ScanningConfig $config,
|
||||||
|
private readonly ActivityLogger $activity,
|
||||||
|
private readonly CapabilityRegistry $capabilities,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function edit(Request $request): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('system/settings/virus-scanning', [
|
||||||
|
// Which half of the screen is open. The connection and the
|
||||||
|
// policies are two different jobs — one is done once when the
|
||||||
|
// scanner is set up, the other is revisited — and a single
|
||||||
|
// column of fields with two Save buttons reads as one form
|
||||||
|
// that saves half of itself.
|
||||||
|
'tab' => in_array($request->query('tab'), ['options', 'activity'], true)
|
||||||
|
? (string) $request->query('tab')
|
||||||
|
: 'scanner',
|
||||||
|
// Read from the session here rather than shared as a flash
|
||||||
|
// prop: HandleInertiaRequests shares `success` and `error` and
|
||||||
|
// nothing else, which is why the Test button appeared to do
|
||||||
|
// nothing at all. Same shape the CAPTCHA screen uses.
|
||||||
|
'test_result' => $request->session()->get('scanner_test_result'),
|
||||||
|
'enabled' => $this->config->enabled(),
|
||||||
|
// Two different reasons the connection is not this screen's to
|
||||||
|
// change: a managed configuration names the scanner, or this
|
||||||
|
// edition does not connect scanners at all. The screen says
|
||||||
|
// the same thing for both, since to the person reading it
|
||||||
|
// they are the same fact.
|
||||||
|
'managed' => $this->config->isManaged() || ! $this->canConnect(),
|
||||||
|
// Distinct from `managed`, which covers two different reasons
|
||||||
|
// the address is not editable. A managed installation still
|
||||||
|
// has a scanner worth testing; one that does not connect
|
||||||
|
// scanners at all has nothing to test, and the endpoint says
|
||||||
|
// so with a 403.
|
||||||
|
'can_test' => $this->canConnect(),
|
||||||
|
'address' => $this->config->isManaged() ? '' : $this->settings->get(Setting::VirusScannerAddress),
|
||||||
|
'max_size_mb' => $this->settings->get(Setting::VirusScanMaxSizeMb),
|
||||||
|
'unscannable_policy' => $this->settings->get(Setting::VirusUnscannablePolicy),
|
||||||
|
'scanner_down_policy' => $this->settings->get(Setting::VirusScannerDownPolicy),
|
||||||
|
'wait_minutes' => $this->settings->get(Setting::VirusScannerWaitMinutes),
|
||||||
|
'existing_rate_per_minute' => $this->settings->get(Setting::VirusScanExistingRatePerMinute),
|
||||||
|
'counts' => $this->counts(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'enabled' => ['required', 'boolean'],
|
||||||
|
'address' => ['nullable', 'string', 'max:255'],
|
||||||
|
'max_size_mb' => ['required', 'integer', 'min:0', 'max:4096'],
|
||||||
|
'unscannable_policy' => ['required', Rule::in(['allow', 'block'])],
|
||||||
|
'scanner_down_policy' => ['required', Rule::in(['allow', 'hold'])],
|
||||||
|
'wait_minutes' => ['required', 'integer', 'min:1', 'max:1440'],
|
||||||
|
'existing_rate_per_minute' => ['required', 'integer', 'min:1', 'max:6000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// A managed installation may still choose its policies. The
|
||||||
|
// connection and the switch are not on the screen there, and a
|
||||||
|
// request that sends them anyway changes nothing.
|
||||||
|
if (! $this->config->isManaged() && $this->canConnect()) {
|
||||||
|
$address = trim((string) ($validated['address'] ?? ''));
|
||||||
|
|
||||||
|
// Refused rather than saved and quietly inert: switching this
|
||||||
|
// on with nowhere to send files would leave every upload
|
||||||
|
// waiting for a scanner that does not exist.
|
||||||
|
if ($request->boolean('enabled') && $address === '') {
|
||||||
|
return back()->withErrors(['address' => __('Enter the address of your scanner first.')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checked here rather than left to the socket, which accepts
|
||||||
|
// more than it should — see ScannerAddress.
|
||||||
|
if ($address !== '' && ! ScannerAddress::isValid($address)) {
|
||||||
|
return back()->withErrors(['address' => __(ScannerAddress::message())]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->settings->set(Setting::VirusScannerAddress, $address);
|
||||||
|
$this->settings->set(Setting::VirusScanningEnabled, $request->boolean('enabled'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->settings->set(Setting::VirusScanMaxSizeMb, (int) $validated['max_size_mb']);
|
||||||
|
$this->settings->set(Setting::VirusUnscannablePolicy, $validated['unscannable_policy']);
|
||||||
|
$this->settings->set(Setting::VirusScannerDownPolicy, $validated['scanner_down_policy']);
|
||||||
|
$this->settings->set(Setting::VirusScannerWaitMinutes, (int) $validated['wait_minutes']);
|
||||||
|
$this->settings->set(Setting::VirusScanExistingRatePerMinute, (int) $validated['existing_rate_per_minute']);
|
||||||
|
|
||||||
|
// The scans worker is the one process that acts on every setting
|
||||||
|
// above, and it holds them in memory from the job it started on.
|
||||||
|
// Without this, switching scanning off or pointing it at another
|
||||||
|
// scanner changed the screen and nothing else until somebody
|
||||||
|
// restarted the worker.
|
||||||
|
Artisan::call('queue:restart');
|
||||||
|
|
||||||
|
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'virus_scanning']);
|
||||||
|
|
||||||
|
return back();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prove the scanner is there, and that it is actually detecting.
|
||||||
|
*
|
||||||
|
* Three steps, reported separately, because "cannot connect" and
|
||||||
|
* "connects and finds nothing" are different problems and the second
|
||||||
|
* is the one that looks fine from the outside. The third sends the
|
||||||
|
* EICAR test string — a harmless sequence every engine recognises by
|
||||||
|
* agreement — so the answer is "it detected something" rather than
|
||||||
|
* "it did not complain".
|
||||||
|
*/
|
||||||
|
public function test(Request $request, VirusScanner $scanner): RedirectResponse
|
||||||
|
{
|
||||||
|
// Nothing to test where the connection is not this installation's
|
||||||
|
// to make.
|
||||||
|
abort_unless($this->canConnect(), 403);
|
||||||
|
|
||||||
|
$typed = trim((string) $request->input('address', ''));
|
||||||
|
|
||||||
|
// What the button is for: the address on screen, which on a first
|
||||||
|
// attempt has never been saved. Falls back to the stored one when
|
||||||
|
// the field is empty, so the button still answers on a screen
|
||||||
|
// somebody has not touched.
|
||||||
|
if ($typed !== '') {
|
||||||
|
if (! ScannerAddress::isValid($typed)) {
|
||||||
|
// Answered as a test result rather than as a field error:
|
||||||
|
// the person pressed Test, and this is what the test
|
||||||
|
// found. Nothing is dialled.
|
||||||
|
return back()->with('scanner_test_result', [
|
||||||
|
'ok' => false,
|
||||||
|
'message' => __(ScannerAddress::message()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->config->preview($typed);
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = $scanner->status();
|
||||||
|
|
||||||
|
if (! $status->reachable) {
|
||||||
|
return back()->with('scanner_test_result', [
|
||||||
|
'ok' => false,
|
||||||
|
'message' => $status->error ?? __('The scanner could not be reached.'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stream = fopen('php://temp', 'r+');
|
||||||
|
assert($stream !== false);
|
||||||
|
fwrite($stream, $this->eicar());
|
||||||
|
rewind($stream);
|
||||||
|
|
||||||
|
$verdict = $scanner->scan($stream, strlen($this->eicar()));
|
||||||
|
fclose($stream);
|
||||||
|
|
||||||
|
if ($verdict->outcome === ScanOutcome::Infected) {
|
||||||
|
// Detecting is half of it. A clamd left on its own defaults
|
||||||
|
// answers "OK" for an archive it could not open, and every
|
||||||
|
// encrypted zip would be recorded as clean — while this test
|
||||||
|
// passed. So ask it about one.
|
||||||
|
if ($this->passesEncryptedArchives($scanner)) {
|
||||||
|
return back()->with('scanner_test_result', [
|
||||||
|
'ok' => false,
|
||||||
|
'message' => __(':engine detects viruses, but reports encrypted archives as clean, so a password-protected zip would get through unchecked. Add AlertEncrypted, AlertEncryptedArchive, AlertEncryptedDoc and AlertExceedsMax, each set to yes, to its clamd.conf and restart it.', [
|
||||||
|
'engine' => $status->engine ?? __('The scanner'),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('scanner_test_result', [
|
||||||
|
'ok' => true,
|
||||||
|
'message' => __('Working. :engine detected the EICAR test file as ":threat". EICAR is a harmless file made only for testing, and every antivirus recognises it.', [
|
||||||
|
'engine' => $status->engine ?? __('The scanner'),
|
||||||
|
'threat' => $verdict->detail ?? '',
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reachable, and did not recognise a file every engine is supposed
|
||||||
|
// to. Almost always empty or broken virus definitions, which is
|
||||||
|
// exactly the failure nothing else would show.
|
||||||
|
return back()->with('scanner_test_result', [
|
||||||
|
'ok' => false,
|
||||||
|
'message' => __(':engine answered but did not detect the EICAR test file, a harmless file made only for testing. Check that its virus definitions are installed and up to date.', [
|
||||||
|
'engine' => $status->engine ?? __('The scanner'),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check every file people can download again.
|
||||||
|
*
|
||||||
|
* The work itself is the command's, so this button does not hold a
|
||||||
|
* request open for a library of any size, and the pace is the setting
|
||||||
|
* above rather than "as fast as the queue will go".
|
||||||
|
*/
|
||||||
|
public function scanExisting(): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($this->config->enabled(), 422);
|
||||||
|
|
||||||
|
// The screen disables the button while a scan is working through
|
||||||
|
// the queue. Refused here too, because each press queues the whole
|
||||||
|
// library again, and the throttle alone allows six a minute.
|
||||||
|
if ($this->scansInQueue() > 0) {
|
||||||
|
return redirect()
|
||||||
|
->route('system-settings.virus-scanning.edit', ['tab' => 'activity'])
|
||||||
|
->with('error', __('A scan is already running. Wait for it to finish.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --all rather than --existing: this is "New scan", and on a
|
||||||
|
// library already scanned once --existing finds nothing to do.
|
||||||
|
Artisan::queue('projectsend:scan-files', ['--all' => true]);
|
||||||
|
|
||||||
|
// Onto the tab that shows it happening rather than back where they
|
||||||
|
// were: somebody who just started a scan wants to watch it, and a
|
||||||
|
// screen that looks unchanged reads as a button that did nothing.
|
||||||
|
return redirect()
|
||||||
|
->route('system-settings.virus-scanning.edit', ['tab' => 'activity'])
|
||||||
|
->with('success', __('The scan has started.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the scanner is doing right now, and what it last decided.
|
||||||
|
*
|
||||||
|
* Polled by the Activity tab rather than rendered with the page: a
|
||||||
|
* backfill takes minutes to hours, and a screen that only tells you
|
||||||
|
* where things stood when you opened it is the screen somebody
|
||||||
|
* reloads repeatedly instead of watching.
|
||||||
|
*
|
||||||
|
* JSON rather than an Inertia partial, the way the notification bell
|
||||||
|
* and the zip builder already poll — see use-notification-poll.ts.
|
||||||
|
*/
|
||||||
|
public function activity(): JsonResponse
|
||||||
|
{
|
||||||
|
$recent = File::query()
|
||||||
|
->whereNotNull('scanned_at')
|
||||||
|
->orderByDesc('scanned_at')
|
||||||
|
->limit(20)
|
||||||
|
->get(['id', 'name', 'scan_status', 'scan_note', 'scanned_at', 'scan_engine']);
|
||||||
|
|
||||||
|
$waiting = File::query()->where('scan_status', ScanStatus::Pending)->count();
|
||||||
|
|
||||||
|
// Counted as well as the files above, and this is the half that
|
||||||
|
// makes a backfill visible: re-scanning a file that already went
|
||||||
|
// out unchecked deliberately leaves it available, so it is not
|
||||||
|
// "pending" and a screen watching only that count says nothing is
|
||||||
|
// happening while the queue works through a whole library.
|
||||||
|
$queued = $this->scansInQueue();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
// "Something is happening" is the one thing a person watching
|
||||||
|
// this screen wants to know, and it is worth being explicit
|
||||||
|
// about rather than left to be inferred from a count.
|
||||||
|
'running' => $waiting > 0 || $queued > 0,
|
||||||
|
'waiting' => $waiting,
|
||||||
|
'queued' => $queued,
|
||||||
|
'checked_last_hour' => File::query()->where('scanned_at', '>=', now()->subHour())->count(),
|
||||||
|
'last_scanned_at' => $recent->first()?->scanned_at?->toIso8601String(),
|
||||||
|
'never_scanned' => File::query()->neverScanned()->count(),
|
||||||
|
'quarantined' => File::query()->whereIn('scan_status', [
|
||||||
|
ScanStatus::Infected->value,
|
||||||
|
ScanStatus::UnscannableBlocked->value,
|
||||||
|
])->count(),
|
||||||
|
'recent' => $recent->map(fn (File $file): array => [
|
||||||
|
'id' => $file->id,
|
||||||
|
'name' => $file->name,
|
||||||
|
'status' => $file->scan_status->value,
|
||||||
|
// A reason is a key and is translated; a threat name is
|
||||||
|
// the scanner's own words and is passed through.
|
||||||
|
'note' => $this->noteFor($file),
|
||||||
|
'scanned_at' => $file->scanned_at?->toIso8601String(),
|
||||||
|
'engine' => $file->scan_engine,
|
||||||
|
])->all(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan jobs waiting to run or running now.
|
||||||
|
*
|
||||||
|
* Not size(), which counts delayed jobs too. A file held while the
|
||||||
|
* scanner was down leaves a retry scheduled for up to five minutes
|
||||||
|
* after the scanner is back and the file already checked, and for
|
||||||
|
* that long the screen said "Scanning now" and refused a new scan.
|
||||||
|
*/
|
||||||
|
private function scansInQueue(): int
|
||||||
|
{
|
||||||
|
$queue = Queue::connection();
|
||||||
|
|
||||||
|
if (method_exists($queue, 'pendingSize') && method_exists($queue, 'reservedSize')) {
|
||||||
|
return (int) $queue->pendingSize('scans') + (int) $queue->reservedSize('scans');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $queue->size('scans');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this installation connects its own scanner.
|
||||||
|
*
|
||||||
|
* Community only, through the registry rather than an edition check —
|
||||||
|
* see Capability::VirusScanningConnect for the division.
|
||||||
|
*/
|
||||||
|
private function canConnect(): bool
|
||||||
|
{
|
||||||
|
return $this->capabilities->has(Capability::VirusScanningConnect);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function noteFor(File $file): ?string
|
||||||
|
{
|
||||||
|
$note = $file->scan_note;
|
||||||
|
|
||||||
|
if ($note === null) {
|
||||||
|
return $file->scan_status === ScanStatus::NotScanned
|
||||||
|
? (string) __(NotScannedReason::BeforeScanning->label())
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reason = NotScannedReason::tryFrom($note);
|
||||||
|
|
||||||
|
return $reason === null ? $note : (string) __($reason->label());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
private function counts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'pending' => File::query()->where('scan_status', ScanStatus::Pending)->count(),
|
||||||
|
'quarantined' => File::query()->whereIn('scan_status', [
|
||||||
|
ScanStatus::Infected->value,
|
||||||
|
ScanStatus::UnscannableBlocked->value,
|
||||||
|
])->count(),
|
||||||
|
'never_scanned' => File::query()->neverScanned()->count(),
|
||||||
|
'let_through' => File::query()->letThrough()->count(),
|
||||||
|
// What a New scan would actually check — see
|
||||||
|
// ScanFileJob::rescannableValues().
|
||||||
|
'scannable' => File::query()
|
||||||
|
->whereIn('scan_status', ScanFileJob::rescannableValues())
|
||||||
|
->count(),
|
||||||
|
// So the New scan button can refuse a second scan while one is
|
||||||
|
// still working through the queue.
|
||||||
|
'queued' => $this->scansInQueue(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the scanner calls a password-protected zip clean.
|
||||||
|
*
|
||||||
|
* The archive holds one line of text and nothing else; what matters
|
||||||
|
* is only that it cannot be opened without the password. A scanner
|
||||||
|
* set up as documented answers "encrypted".
|
||||||
|
*/
|
||||||
|
private function passesEncryptedArchives(VirusScanner $scanner): bool
|
||||||
|
{
|
||||||
|
$archive = (string) base64_decode(self::ENCRYPTED_ARCHIVE, true);
|
||||||
|
|
||||||
|
$stream = fopen('php://temp', 'r+');
|
||||||
|
assert($stream !== false);
|
||||||
|
fwrite($stream, $archive);
|
||||||
|
rewind($stream);
|
||||||
|
|
||||||
|
$verdict = $scanner->scan($stream, strlen($archive));
|
||||||
|
fclose($stream);
|
||||||
|
|
||||||
|
return $verdict->outcome === ScanOutcome::Clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function eicar(): string
|
||||||
|
{
|
||||||
|
// Assembled rather than written out, so the repository itself
|
||||||
|
// never contains the literal string: antivirus software on a
|
||||||
|
// developer's machine quarantines files that do, and a checkout
|
||||||
|
// that deletes its own test fixtures is a bad afternoon.
|
||||||
|
return 'X5O!P%@AP[4\\PZX54(P^)7CC)7}$'.'EICAR-STANDARD-'.'ANTIVIRUS-TEST-FILE!'.'$H+H*';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,10 +8,13 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
|
use App\Modules\Files\Delivery\FileDelivery;
|
||||||
use App\Modules\Files\Access\DownloadAllowance;
|
use App\Modules\Files\Access\DownloadAllowance;
|
||||||
use App\Modules\Files\Access\ViewableFileScope;
|
use App\Modules\Files\Access\ViewableFileScope;
|
||||||
use App\Modules\Files\Jobs\BuildZipDownloadJob;
|
use App\Modules\Files\Jobs\BuildZipDownloadJob;
|
||||||
use App\Modules\Files\Models\File;
|
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\Folder;
|
||||||
use App\Modules\Files\Models\ZipDownload;
|
use App\Modules\Files\Models\ZipDownload;
|
||||||
use App\Modules\Files\Uploads\StoreUploadedFile;
|
use App\Modules\Files\Uploads\StoreUploadedFile;
|
||||||
@@ -21,10 +24,10 @@ use App\Support\ContentDisposition;
|
|||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Number;
|
use Illuminate\Support\Number;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A folder's "Download as zip" button and the file listing's multi-select
|
* A folder's "Download as zip" button and the file listing's multi-select
|
||||||
@@ -44,7 +47,9 @@ class ZipDownloadsController extends Controller
|
|||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
private readonly ViewableFileScope $viewable,
|
private readonly ViewableFileScope $viewable,
|
||||||
private readonly DownloadAllowance $allowance,
|
private readonly DownloadAllowance $allowance,
|
||||||
|
private readonly FileAvailability $availability,
|
||||||
private readonly Settings $settings,
|
private readonly Settings $settings,
|
||||||
|
private readonly FileDelivery $delivery,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
@@ -100,7 +105,11 @@ class ZipDownloadsController extends Controller
|
|||||||
// as many times as they were meant to is the whole point of not
|
// as many times as they were meant to is the whole point of not
|
||||||
// hiding exhausted files.
|
// hiding exhausted files.
|
||||||
$selected = $files->count();
|
$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(
|
abort_if(
|
||||||
$files->isEmpty() && $folders->isEmpty() && $selected > 0,
|
$files->isEmpty() && $folders->isEmpty() && $selected > 0,
|
||||||
@@ -178,6 +187,21 @@ class ZipDownloadsController extends Controller
|
|||||||
$path = $zipDownload->path;
|
$path = $zipDownload->path;
|
||||||
abort_unless($zipDownload->status === ZipDownload::STATUS_READY && $path !== null, 404);
|
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
|
// Only the first time. Re-fetching one prepared archive is the
|
||||||
// same delivery, not a fresh download of everything inside it.
|
// same delivery, not a fresh download of everything inside it.
|
||||||
if ($zipDownload->delivered_at === null) {
|
if ($zipDownload->delivered_at === null) {
|
||||||
@@ -186,12 +210,12 @@ class ZipDownloadsController extends Controller
|
|||||||
|
|
||||||
$size = Storage::disk('files')->size($path);
|
$size = Storage::disk('files')->size($path);
|
||||||
|
|
||||||
return response('', 200, [
|
return $this->delivery->serve(
|
||||||
'X-Accel-Redirect' => '/protected-files/'.$path,
|
$path,
|
||||||
'Content-Type' => 'application/zip',
|
'application/zip',
|
||||||
'Content-Disposition' => ContentDisposition::attachment($this->filenameFor($zipDownload)),
|
ContentDisposition::attachment($this->filenameFor($zipDownload)),
|
||||||
'Content-Length' => (string) $size,
|
$size,
|
||||||
]);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Modules\Files\Http\Resources\Api;
|
namespace App\Modules\Files\Http\Resources\Api;
|
||||||
|
|
||||||
|
use App\Modules\Files\Access\ClientIdentityScope;
|
||||||
use App\Modules\Files\DownloadLimitScope;
|
use App\Modules\Files\DownloadLimitScope;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
use App\Modules\Files\Models\FileAssignment;
|
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
|
* - `checksum` is included deliberately, since verifying an integration's
|
||||||
* own download is a real use case, and it reveals nothing about
|
* own download is a real use case, and it reveals nothing about
|
||||||
* location.
|
* 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
|
class FileResource extends JsonResource
|
||||||
{
|
{
|
||||||
@@ -34,6 +52,15 @@ class FileResource extends JsonResource
|
|||||||
*/
|
*/
|
||||||
public function toArray(Request $request): array
|
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 [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
@@ -51,6 +78,18 @@ class FileResource extends JsonResource
|
|||||||
'expires_at' => $this->expires_at?->toIso8601String(),
|
'expires_at' => $this->expires_at?->toIso8601String(),
|
||||||
'expired' => $this->isExpired(),
|
'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.
|
// Null when the file may be downloaded any number of times.
|
||||||
// `download_limit_scope` says what the number counts —
|
// `download_limit_scope` says what the number counts —
|
||||||
// "total" across everyone, or "per_user" for each person
|
// "total" across everyone, or "per_user" for each person
|
||||||
@@ -90,17 +129,24 @@ class FileResource extends JsonResource
|
|||||||
'name' => $this->nextVersion->name,
|
'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 : [
|
'folder' => $this->whenLoaded('folder', fn (): ?array => $this->folder === null ? null : [
|
||||||
'id' => $this->folder->id,
|
'id' => $this->folder->id,
|
||||||
'name' => $this->folder->name,
|
'name' => $this->folder->name,
|
||||||
|
'parent_id' => $this->folder->parent_id,
|
||||||
]),
|
]),
|
||||||
|
|
||||||
// Name only. The uploader is a user record; their email address
|
// Name only. The uploader is a user record; their email address
|
||||||
// is not part of what "this file exists" needs to say.
|
// is not part of what "this file exists" needs to say. Null
|
||||||
'uploaded_by' => $this->whenLoaded('uploader', fn (): ?array => $this->uploader === null ? 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,
|
'id' => $this->uploader->id,
|
||||||
'name' => $this->uploader->name,
|
'name' => $this->uploader->name,
|
||||||
]),
|
] : null,
|
||||||
|
),
|
||||||
|
|
||||||
'categories' => $this->whenLoaded('categories', fn (): array => $this->categories
|
'categories' => $this->whenLoaded('categories', fn (): array => $this->categories
|
||||||
->map(fn ($category): array => [
|
->map(fn ($category): array => [
|
||||||
@@ -109,15 +155,22 @@ class FileResource extends JsonResource
|
|||||||
])
|
])
|
||||||
->all()),
|
->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
|
'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 => [
|
->map(fn (FileAssignment $assignment): array => [
|
||||||
'type' => $assignment->assignable_type === Group::class ? 'group' : 'client',
|
'type' => $assignment->assignable_type === $groupMorph ? 'group' : 'client',
|
||||||
'id' => $assignment->assignable_id,
|
'id' => $assignment->assignable_id,
|
||||||
// getAttribute() rather than ->name: the relation is a
|
// getAttribute() rather than ->name: the relation is a
|
||||||
// MorphTo over User|Group, so the property is only
|
// MorphTo over User|Group, so the property is only
|
||||||
// knowable at runtime. Both targets carry a name.
|
// knowable at runtime. Both targets carry a name.
|
||||||
'name' => $assignment->assignable?->getAttribute('name'),
|
'name' => $assignment->assignable?->getAttribute('name'),
|
||||||
])
|
])
|
||||||
|
->values()
|
||||||
->all()),
|
->all()),
|
||||||
|
|
||||||
'links' => [
|
'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\DownloadAllowance;
|
||||||
use App\Modules\Files\Access\ViewableFileScope;
|
use App\Modules\Files\Access\ViewableFileScope;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
|
use App\Modules\Files\Scanning\FileAvailability;
|
||||||
use App\Modules\Files\Models\Folder;
|
use App\Modules\Files\Models\Folder;
|
||||||
use App\Modules\Files\Models\ZipDownload;
|
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\Setting;
|
||||||
use App\Modules\Platform\Settings\Settings;
|
use App\Modules\Platform\Settings\Settings;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
@@ -86,6 +89,23 @@ class BuildZipDownloadJob implements ShouldQueue
|
|||||||
return;
|
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
|
// Stamped before any of the work, because the only thing this is
|
||||||
// for is telling "a worker has this in hand" apart from "nobody
|
// for is telling "a worker has this in hand" apart from "nobody
|
||||||
// is listening to the zips queue". A build that waits and never
|
// 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);
|
$visible = app(ViewableFileScope::class)->for($requester);
|
||||||
$allowance = app(DownloadAllowance::class);
|
$allowance = app(DownloadAllowance::class);
|
||||||
|
$availability = app(FileAvailability::class);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$relativePath = 'zips/'.$zipDownload->id.'.zip';
|
$relativePath = 'zips/'.$zipDownload->id.'.zip';
|
||||||
@@ -135,13 +156,24 @@ class BuildZipDownloadJob implements ShouldQueue
|
|||||||
// count, is what lets the download action log exactly what it
|
// count, is what lets the download action log exactly what it
|
||||||
// hands over instead of resolving the selection a second time
|
// hands over instead of resolving the selection a second time
|
||||||
// against a scope that may have moved since.
|
// against a scope that may have moved since.
|
||||||
$addedIds = [];
|
//
|
||||||
|
// Keyed by id rather than appended to a list, because it is
|
||||||
|
// also what keeps a file out of the archive twice. The loose
|
||||||
|
// selection cannot repeat itself — one whereIn on the primary
|
||||||
|
// key — but a selected folder can hold a file that was also
|
||||||
|
// named loosely, and the cap is 10000 sources, so the check
|
||||||
|
// has to be a lookup rather than a scan.
|
||||||
|
$added = [];
|
||||||
|
|
||||||
foreach ((clone $visible)->whereIn('id', $zipDownload->file_ids)->get() as $file) {
|
foreach ((clone $visible)->whereIn('id', $zipDownload->file_ids)->get() as $file) {
|
||||||
// Re-checked here for the same reason visibility is: the
|
// Re-checked here for the same reason visibility is: the
|
||||||
// archive is built some time after it was asked for, and
|
// archive is built some time after it was asked for, and
|
||||||
// the allowance may have been spent in between.
|
// 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];
|
$skipped[] = ['id' => $file->id, 'name' => $file->name];
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@@ -150,11 +182,11 @@ class BuildZipDownloadJob implements ShouldQueue
|
|||||||
$entryName = $this->dedupeName($usedNames, $this->entrySegment($file->original_name));
|
$entryName = $this->dedupeName($usedNames, $this->entrySegment($file->original_name));
|
||||||
$zip->addFile($this->localPathFor($file, $tempFiles), $entryName);
|
$zip->addFile($this->localPathFor($file, $tempFiles), $entryName);
|
||||||
$totalSize += $file->size;
|
$totalSize += $file->size;
|
||||||
$addedIds[] = $file->id;
|
$added[$file->id] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (Folder::query()->whereIn('id', $zipDownload->folder_ids)->get() as $folder) {
|
foreach ($this->outermostFolders($zipDownload->folder_ids) as $folder) {
|
||||||
$totalSize += $this->addFolder($zip, $folder, $requester, $usedNames, $tempFiles, $visible, $skipped, $addedIds);
|
$totalSize += $this->addFolder($zip, $folder, $requester, $usedNames, $tempFiles, $visible, $skipped, $added);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-checked here, not only in ZipDownloadsController: the
|
// Re-checked here, not only in ZipDownloadsController: the
|
||||||
@@ -197,7 +229,7 @@ class BuildZipDownloadJob implements ShouldQueue
|
|||||||
@unlink($tempFile);
|
@unlink($tempFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($written !== true || $addedIds === []) {
|
if ($written !== true || $added === []) {
|
||||||
if ($written !== true) {
|
if ($written !== true) {
|
||||||
// What the requester sees stays generic: a libzip
|
// What the requester sees stays generic: a libzip
|
||||||
// string means nothing to them and can name a server
|
// string means nothing to them and can name a server
|
||||||
@@ -229,8 +261,8 @@ class BuildZipDownloadJob implements ShouldQueue
|
|||||||
'status' => ZipDownload::STATUS_READY,
|
'status' => ZipDownload::STATUS_READY,
|
||||||
'path' => $relativePath,
|
'path' => $relativePath,
|
||||||
'total_size' => $totalSize,
|
'total_size' => $totalSize,
|
||||||
'file_count' => count($addedIds),
|
'file_count' => count($added),
|
||||||
'contained_file_ids' => $addedIds,
|
'contained_file_ids' => array_keys($added),
|
||||||
'skipped_files' => $skipped === [] ? null : $skipped,
|
'skipped_files' => $skipped === [] ? null : $skipped,
|
||||||
]);
|
]);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
@@ -238,9 +270,21 @@ class BuildZipDownloadJob implements ShouldQueue
|
|||||||
@unlink($tempFile);
|
@unlink($tempFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same division as the write failure above: the reason is the
|
||||||
|
// operator's, the sentence is the requester's. An exception
|
||||||
|
// message here has already named a disk in practice — "Disk
|
||||||
|
// [x] does not have a configured driver." — and can name a
|
||||||
|
// server path, and this column is shown to whoever asked for
|
||||||
|
// the archive, including clients.
|
||||||
|
Log::error('A zip download could not be built.', [
|
||||||
|
'zip_download_id' => $zipDownload->id,
|
||||||
|
'exception' => $e::class,
|
||||||
|
'reason' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
$zipDownload->update([
|
$zipDownload->update([
|
||||||
'status' => ZipDownload::STATUS_FAILED,
|
'status' => ZipDownload::STATUS_FAILED,
|
||||||
'error' => $e->getMessage(),
|
'error' => 'The zip archive could not be built.',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -305,21 +349,50 @@ class BuildZipDownloadJob implements ShouldQueue
|
|||||||
throw new \RuntimeException('Could not create a temp file for '.$file->original_name);
|
throw new \RuntimeException('Could not create a temp file for '.$file->original_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Registered before anything else can fail. tempnam() has already
|
||||||
|
// created the file, and the caller's cleanup only knows the paths
|
||||||
|
// it was told about — so every throw between here and the end of
|
||||||
|
// the copy used to leave a zip-src- file behind for good.
|
||||||
|
$tempFiles[] = $tempPath;
|
||||||
|
|
||||||
$stream = Storage::disk($file->disk)->readStream($file->path);
|
$stream = Storage::disk($file->disk)->readStream($file->path);
|
||||||
$out = fopen($tempPath, 'wb');
|
$out = fopen($tempPath, 'wb');
|
||||||
|
|
||||||
if ($stream === null || $out === false) {
|
if ($stream === null || $out === false) {
|
||||||
throw new \RuntimeException('Could not read '.$file->original_name.' from its storage disk.');
|
|
||||||
}
|
|
||||||
|
|
||||||
stream_copy_to_stream($stream, $out);
|
|
||||||
fclose($out);
|
|
||||||
|
|
||||||
if (is_resource($stream)) {
|
if (is_resource($stream)) {
|
||||||
fclose($stream);
|
fclose($stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
$tempFiles[] = $tempPath;
|
if ($out !== false) {
|
||||||
|
fclose($out);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \RuntimeException('Could not read '.$file->original_name.' from its storage disk.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// A copy that stops early is a truncated member added to the
|
||||||
|
// archive as though it were the file: the build reports ready,
|
||||||
|
// and the recipient gets something that opens and is wrong.
|
||||||
|
// fclose is checked for the same reason it is in
|
||||||
|
// LocalPartStore: it flushes, so a volume that filled on the
|
||||||
|
// last buffer fails there rather than here.
|
||||||
|
$copied = stream_copy_to_stream($stream, $out);
|
||||||
|
$flushed = fclose($out);
|
||||||
|
$out = false;
|
||||||
|
|
||||||
|
if ($copied === false || ! $flushed) {
|
||||||
|
throw new \RuntimeException('Could not copy '.$file->original_name.' from its storage disk.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if ($out !== false) {
|
||||||
|
fclose($out);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_resource($stream)) {
|
||||||
|
fclose($stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $tempPath;
|
return $tempPath;
|
||||||
}
|
}
|
||||||
@@ -329,11 +402,12 @@ class BuildZipDownloadJob implements ShouldQueue
|
|||||||
* @param array<int, string> $tempFiles
|
* @param array<int, string> $tempFiles
|
||||||
* @param Builder<File> $visible every file the requester may read
|
* @param Builder<File> $visible every file the requester may read
|
||||||
* @param list<array{id: int, name: string}> $skipped
|
* @param list<array{id: int, name: string}> $skipped
|
||||||
* @param list<int> $addedIds every file really written into the archive
|
* @param array<int, true> $added every file really written into the archive, keyed by id
|
||||||
*/
|
*/
|
||||||
private function addFolder(ZipArchive $zip, Folder $folder, User $requester, array &$usedNames, array &$tempFiles, Builder $visible, array &$skipped, array &$addedIds): int
|
private function addFolder(ZipArchive $zip, Folder $folder, User $requester, array &$usedNames, array &$tempFiles, Builder $visible, array &$skipped, array &$added): int
|
||||||
{
|
{
|
||||||
$allowance = app(DownloadAllowance::class);
|
$allowance = app(DownloadAllowance::class);
|
||||||
|
$availability = app(FileAvailability::class);
|
||||||
|
|
||||||
$subtreeIds = $folder->subtreeFolderIds();
|
$subtreeIds = $folder->subtreeFolderIds();
|
||||||
/** @var Collection<int, Folder> $foldersById */
|
/** @var Collection<int, Folder> $foldersById */
|
||||||
@@ -342,11 +416,20 @@ class BuildZipDownloadJob implements ShouldQueue
|
|||||||
$totalSize = 0;
|
$totalSize = 0;
|
||||||
|
|
||||||
foreach ((clone $visible)->whereIn('folder_id', $subtreeIds)->get() as $file) {
|
foreach ((clone $visible)->whereIn('folder_id', $subtreeIds)->get() as $file) {
|
||||||
|
// Already in the archive under another part of the selection —
|
||||||
|
// named loosely, or inside a folder selected before this one.
|
||||||
|
// Skipped rather than added again: a second entry is a second
|
||||||
|
// copy of the same bytes, and delivery charges one download
|
||||||
|
// however many copies went out.
|
||||||
|
if (isset($added[$file->id])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Holding the folder does not entitle the requester to a file
|
// Holding the folder does not entitle the requester to a file
|
||||||
// inside it whose own allowance is spent — same reason the
|
// inside it whose own allowance is spent — same reason the
|
||||||
// per-file visibility filter is re-derived rather than
|
// per-file visibility filter is re-derived rather than
|
||||||
// inherited from the folder.
|
// inherited from the folder.
|
||||||
if (! $allowance->allows($file, $requester)) {
|
if (! $availability->isAvailable($file) || ! $allowance->allows($file, $requester)) {
|
||||||
$skipped[] = ['id' => $file->id, 'name' => $file->name];
|
$skipped[] = ['id' => $file->id, 'name' => $file->name];
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@@ -357,12 +440,38 @@ class BuildZipDownloadJob implements ShouldQueue
|
|||||||
$entryPath = $this->dedupeName($usedNames, $entryPath);
|
$entryPath = $this->dedupeName($usedNames, $entryPath);
|
||||||
$zip->addFile($this->localPathFor($file, $tempFiles), $entryPath);
|
$zip->addFile($this->localPathFor($file, $tempFiles), $entryPath);
|
||||||
$totalSize += $file->size;
|
$totalSize += $file->size;
|
||||||
$addedIds[] = $file->id;
|
$added[$file->id] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $totalSize;
|
return $totalSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The selected folders with the redundant ones dropped: one that sits
|
||||||
|
* inside another selected folder is already covered by it.
|
||||||
|
*
|
||||||
|
* Zipping both would reach the same file twice, and which of the two
|
||||||
|
* paths the surviving entry ended up under would be decided by
|
||||||
|
* whatever order the database returned the rows in. Keeping the outer
|
||||||
|
* folder keeps the fuller path — Reports/Q1/report.pdf rather than
|
||||||
|
* Q1/report.pdf — and gives the same archive on every run.
|
||||||
|
*
|
||||||
|
* @param list<int> $folderIds
|
||||||
|
* @return Collection<int, Folder>
|
||||||
|
*/
|
||||||
|
private function outermostFolders(array $folderIds): Collection
|
||||||
|
{
|
||||||
|
/** @var Collection<int, Folder> $folders */
|
||||||
|
$folders = Folder::query()->whereIn('id', $folderIds)->orderBy('id')->get();
|
||||||
|
|
||||||
|
return $folders
|
||||||
|
->reject(fn (Folder $folder): bool => $folders->contains(
|
||||||
|
fn (Folder $other): bool => $other->id !== $folder->id
|
||||||
|
&& str_starts_with($folder->path, $other->subtreePathPrefix()),
|
||||||
|
))
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Collection<int, Folder> $foldersById Every folder in the root's subtree, keyed by id.
|
* @param Collection<int, Folder> $foldersById Every folder in the root's subtree, keyed by id.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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\Access\SharingIdentity;
|
||||||
use App\Modules\Files\DownloadLimitScope;
|
use App\Modules\Files\DownloadLimitScope;
|
||||||
use App\Modules\Files\FileDiskCleanup;
|
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\Files\Versions\FileVersions;
|
||||||
use App\Modules\Groups\Models\Group;
|
use App\Modules\Groups\Models\Group;
|
||||||
|
use App\Modules\Identity\Erasure\SelfDeletion;
|
||||||
use App\Support\Concerns\HasUniqueSlug;
|
use App\Support\Concerns\HasUniqueSlug;
|
||||||
use Database\Factories\FileFactory;
|
use Database\Factories\FileFactory;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@@ -40,6 +43,14 @@ use Illuminate\Support\Carbon;
|
|||||||
* @property string $mime_type
|
* @property string $mime_type
|
||||||
* @property int $size
|
* @property int $size
|
||||||
* @property string $checksum
|
* @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 bool $public
|
||||||
* @property Carbon|null $expires_at
|
* @property Carbon|null $expires_at
|
||||||
* @property int|null $download_limit
|
* @property int|null $download_limit
|
||||||
@@ -74,6 +85,14 @@ class File extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'public' => 'boolean',
|
'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',
|
'commentable' => 'boolean',
|
||||||
'expires_at' => 'datetime',
|
'expires_at' => 'datetime',
|
||||||
'download_limit' => 'integer',
|
'download_limit' => 'integer',
|
||||||
@@ -110,8 +129,12 @@ class File extends Model
|
|||||||
// an account's content — deletes many rows in one transaction,
|
// an account's content — deletes many rows in one transaction,
|
||||||
// and anything that rolls it back afterwards puts every row
|
// and anything that rolls it back afterwards puts every row
|
||||||
// back while the bytes are already gone: a loss nothing can
|
// back while the bytes are already gone: a loss nothing can
|
||||||
// undo. Deferred, the worst case is bytes left on disk with no
|
// undo. Deferred, the worst case is bytes left on disk with a
|
||||||
// row, which OrphanFileScanner already finds and reports.
|
// row that is only trashed, and a scan will not offer those:
|
||||||
|
// OrphanFileScanner::knownPaths() counts a trashed row's path
|
||||||
|
// as claimed, on purpose, so nothing double-adopts a file still
|
||||||
|
// inside its erasure grace period. FileDiskCleanup's warning is
|
||||||
|
// therefore the only record that it happened.
|
||||||
//
|
//
|
||||||
// Outside a transaction the callback runs immediately, so
|
// Outside a transaction the callback runs immediately, so
|
||||||
// deleting one file is unchanged. Nested transactions only fire
|
// deleting one file is unchanged. Nested transactions only fire
|
||||||
@@ -247,14 +270,120 @@ class File extends Model
|
|||||||
/**
|
/**
|
||||||
* A file's own expiration date — independent of any share link's.
|
* A file's own expiration date — independent of any share link's.
|
||||||
* Null means never expires. Once past, the file is hidden from
|
* Null means never expires. Once past, the file is hidden from
|
||||||
* clients and the public site (see scopeNotExpired) but staff keep
|
* clients and the public site (see scopeNotExpired) and staff keep
|
||||||
* full access to view, download, and manage it.
|
* full access to view, download, and manage it — with one boundary
|
||||||
|
* this used to leave out.
|
||||||
|
*
|
||||||
|
* A client-scoped staff member's library is their own uploads ∪ what
|
||||||
|
* each assigned client may see (StaffLibraryScope::buildFiles), and
|
||||||
|
* that second half is scopeVisibleToClient, which ends in
|
||||||
|
* notExpired(). So an expired file they held only through a client
|
||||||
|
* leaves their library too, while their own expired upload stays.
|
||||||
|
* That is deliberate: c8078f65 weighed widening it and left the
|
||||||
|
* boundary where it is, because scopeVisibleToClient is the single
|
||||||
|
* source of truth for client file access, and relabelled the
|
||||||
|
* expired-files widget instead. ExpiredFileStaffAccessTest pins both
|
||||||
|
* halves so the sentence above cannot drift from the code again.
|
||||||
*/
|
*/
|
||||||
public function isExpired(): bool
|
public function isExpired(): bool
|
||||||
{
|
{
|
||||||
return $this->expires_at !== null && $this->expires_at->isPast();
|
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
|
* @param Builder<File> $query
|
||||||
*/
|
*/
|
||||||
@@ -263,6 +392,36 @@ class File extends Model
|
|||||||
$query->where(fn (Builder $q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()));
|
$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
|
* Whether a cap has been set on how many times this may be
|
||||||
* downloaded. Unlike expiry, reaching it does not hide the file:
|
* downloaded. Unlike expiry, reaching it does not hide the file:
|
||||||
@@ -300,6 +459,41 @@ class File extends Model
|
|||||||
return $this->public || ($this->folder?->isEffectivelyPublic() ?? false);
|
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
|
* 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
|
* via a group, that sits in a folder shared with them (self or
|
||||||
@@ -336,7 +530,9 @@ class File extends Model
|
|||||||
$outer->orWhere('uploaded_by', $client->id);
|
$outer->orWhere('uploaded_by', $client->id);
|
||||||
});
|
});
|
||||||
|
|
||||||
$query->notExpired();
|
// Withdrawn last, beside expiry, because it is the same kind of
|
||||||
|
// rule: not "who may see this" but "may anybody besides staff".
|
||||||
|
$query->notExpired()->notWithdrawn()->available($client);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -377,7 +573,7 @@ class File extends Model
|
|||||||
$outer->orWhereIn('folder_id', $subtreeFolderIds);
|
$outer->orWhereIn('folder_id', $subtreeFolderIds);
|
||||||
});
|
});
|
||||||
|
|
||||||
$query->notExpired();
|
$query->notExpired()->notWithdrawn()->available();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -391,7 +587,7 @@ class File extends Model
|
|||||||
*/
|
*/
|
||||||
public function scopePubliclyVisibleForFolder(Builder $query, Folder $folder): void
|
public function scopePubliclyVisibleForFolder(Builder $query, Folder $folder): void
|
||||||
{
|
{
|
||||||
$query->whereIn('folder_id', $folder->subtreeFolderIds())->notExpired();
|
$query->whereIn('folder_id', $folder->subtreeFolderIds())->notExpired()->notWithdrawn()->available();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -442,6 +638,8 @@ class File extends Model
|
|||||||
->where(function (Builder $folder) use ($publicFolderSubtreeIds): void {
|
->where(function (Builder $folder) use ($publicFolderSubtreeIds): void {
|
||||||
$folder->whereNull('folder_id')->orWhereNotIn('folder_id', $publicFolderSubtreeIds);
|
$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;
|
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
|
* Self or any ancestor is public — the inheritance every file in this
|
||||||
* folder's subtree relies on (File::isEffectivelyPublic()), and what
|
* 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 =
|
* Whether $user may put content into $folder (null = loose at the
|
||||||
* loose at the root, always allowed).
|
* 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
|
* Staff are held to the library boundary they are held to everywhere
|
||||||
* else: an unscoped staff member may use any folder, a client-scoped
|
* else: an unscoped staff member may use any folder, a client-scoped
|
||||||
* one only the folders StaffLibraryScope already shows them. This is
|
* one only the folders StaffLibraryScope already shows them. Callers
|
||||||
* the only place that decides it: every upload path — the web form,
|
* that have already resolved the destination through
|
||||||
* the API and the chunked flow the browser actually posts to — comes
|
* StaffLibraryScope::folders() have answered that half — the two are
|
||||||
* through here rather than checking folder_id for itself.
|
* the same query — and call this for the publication half.
|
||||||
*
|
*
|
||||||
* For a client this is unchanged, and is still the whole of the
|
* 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
|
* 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()) {
|
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)
|
return $folder->isOwnedBy($user)
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Files\Preview;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Modules\Audit\Action;
|
||||||
|
use App\Modules\Audit\ActivityLogger;
|
||||||
|
use App\Modules\Files\Models\File;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One log row per viewer per file per five minutes, for both preview
|
||||||
|
* routes — FileThumbnailController::preview (signed in) and
|
||||||
|
* PublicGroupsController::preview (anonymous).
|
||||||
|
*
|
||||||
|
* Watching a video is a single deliberate act that the browser turns into
|
||||||
|
* dozens of Range requests, each arriving indistinguishable from someone
|
||||||
|
* clicking preview again. Cache::add is the whole mechanism: it writes
|
||||||
|
* only if the key is absent, so the first request through the window logs
|
||||||
|
* and the rest are silent, without a read-then-write race between two of
|
||||||
|
* them.
|
||||||
|
*
|
||||||
|
* Keyed by viewer, so one person's playback never suppresses another's
|
||||||
|
* view of the same file. An anonymous visitor has no account to key on,
|
||||||
|
* so the request IP stands in — the same substitute the API's rate
|
||||||
|
* limiter makes for an unauthenticated caller. It is a cache key with a
|
||||||
|
* five-minute life and never reaches the log, which keeps its own
|
||||||
|
* decision about recording an IP (see ActivityLogger::shouldRecordIp and
|
||||||
|
* Setting::DownloadIpLogging).
|
||||||
|
*
|
||||||
|
* Shared rather than restated, because the window is the rule: two copies
|
||||||
|
* of "five minutes" are two things to change and one to forget.
|
||||||
|
*/
|
||||||
|
class PreviewLog
|
||||||
|
{
|
||||||
|
private const WINDOW_MINUTES = 5;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ActivityLogger $activity,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function record(Action $action, File $file, ?User $viewer): void
|
||||||
|
{
|
||||||
|
$viewerKey = $viewer !== null ? (string) $viewer->id : 'ip:'.request()->ip();
|
||||||
|
|
||||||
|
if (Cache::add('file-preview-logged:'.$file->id.':'.$viewerKey, true, now()->addMinutes(self::WINDOW_MINUTES))) {
|
||||||
|
$this->activity->log($action, subject: $file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace App\Modules\Files\Queue;
|
namespace App\Modules\Files\Queue;
|
||||||
|
|
||||||
use App\Modules\Files\Models\ZipDownload;
|
use App\Modules\Files\Models\ZipDownload;
|
||||||
|
use App\Modules\Platform\Capabilities\Capability;
|
||||||
|
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +58,15 @@ class StalledZipBuilds
|
|||||||
*/
|
*/
|
||||||
public function oldestUnstarted(): ?Carbon
|
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()) {
|
if ($this->buildInHand()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user