mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-04 13:33:22 +00:00
Compare commits
207 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,22 @@ 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: 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+375
-395
@@ -6,12 +6,363 @@ 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.4.1 — 11 September 2026
|
||||||
|
|
||||||
This section collects changes as they land; the release process turns it into a numbered entry when
|
### ⚠️ Important — do these yourself
|
||||||
a version is cut.
|
|
||||||
|
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 +428,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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+162
-54
@@ -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,66 +29,113 @@ A few notes on that list:
|
|||||||
- **`ldap` is required even if you never use LDAP.** One of the libraries ProjectSend depends on
|
- **`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 |
|
||||||
|
|
||||||
```nginx
|
**The dashboard tells you which one is in use.** The System panel has a "Downloads sent by" line,
|
||||||
proxy_buffer_size 32k;
|
with a warning icon and an explanation whenever PHP is doing the sending. You do not have to
|
||||||
proxy_buffers 8 32k;
|
remember to check this file.
|
||||||
proxy_busy_buffers_size 64k;
|
|
||||||
```
|
|
||||||
|
|
||||||
nginx buffers a response's headers into a single block that defaults to one memory page — 4 KB on
|
#### Enabling X-Sendfile on Apache or LiteSpeed
|
||||||
most systems — and answers `502 Bad Gateway` with `upstream sent too big header` when they do not
|
|
||||||
fit. The page that goes over is not always the same one, so it presents as an intermittent fault
|
Apache needs [`mod_xsendfile`](https://github.com/nmaier/mod_xsendfile) installed and enabled, and
|
||||||
rather than as a misconfiguration. This applies to any proxy in front of ProjectSend, not just
|
a directive allowing it to serve your storage directory:
|
||||||
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))
|
```apache
|
||||||
- Store your files in object storage instead — S3-compatible or Google Cloud Storage (see
|
XSendFile On
|
||||||
[Storing files somewhere other than this server](#storing-files-somewhere-other-than-this-server)).
|
XSendFilePath /home/projectsend/storage/app/files
|
||||||
Files kept there are never on your server's disk, so downloads become a signed, expiring redirect
|
```
|
||||||
to the storage provider and the web server is not involved at all. This is a genuine, supported
|
|
||||||
path — just decide it before people start uploading, not after.
|
LiteSpeed and OpenLiteSpeed read the same header without an extra module; enable it in the server
|
||||||
|
configuration.
|
||||||
|
|
||||||
|
Then tell ProjectSend to use it, in `.env`:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
PROJECTSEND_FILE_DELIVERY=xsendfile
|
||||||
|
```
|
||||||
|
|
||||||
|
**ProjectSend will not switch this on by itself**, even when it can see the module is loaded,
|
||||||
|
because it cannot see whether `XSendFilePath` allows the storage directory. Guessing wrong there
|
||||||
|
produces empty downloads rather than slow ones, and an empty download is a much worse failure than
|
||||||
|
a slow one — so this stays something you turn on having configured it.
|
||||||
|
|
||||||
|
#### Choosing explicitly
|
||||||
|
|
||||||
|
`PROJECTSEND_FILE_DELIVERY` accepts:
|
||||||
|
|
||||||
|
| Value | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `auto` | The default. nginx if the server says it is nginx, PHP otherwise |
|
||||||
|
| `nginx` | Always `X-Accel-Redirect`. Use this if nginx is proxying another server |
|
||||||
|
| `xsendfile` | Always `X-Sendfile`, for Apache with `mod_xsendfile`, or LiteSpeed |
|
||||||
|
| `php` | Always PHP. Correct and slow, and never wrong |
|
||||||
|
|
||||||
|
The one case `auto` gets wrong is **nginx reverse-proxying Apache**: PHP is talking to Apache, so
|
||||||
|
it picks PHP streaming, and downloads work but do not use the nginx in front. Set
|
||||||
|
`PROJECTSEND_FILE_DELIVERY=nginx` and make sure the front nginx serves `/protected-files/`. While
|
||||||
|
you are there, give the proxy some header headroom — the same headroom the reference configuration
|
||||||
|
in Step 6 gives PHP-FPM, in the directives a proxy uses instead:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
proxy_buffer_size 32k;
|
||||||
|
proxy_buffers 8 32k;
|
||||||
|
proxy_busy_buffers_size 64k;
|
||||||
|
```
|
||||||
|
|
||||||
|
nginx buffers a response's headers into a single block that defaults to one memory page — 4 KB on
|
||||||
|
most systems — and answers `502 Bad Gateway` with `upstream sent too big header` when they do not
|
||||||
|
fit. The page that goes over is not always the same one, so it presents as an intermittent fault
|
||||||
|
rather than as a misconfiguration. This applies to any proxy in front of ProjectSend, not just
|
||||||
|
this one: Nginx Proxy Manager, Traefik and a hand-written nginx vhost all ship the same default.
|
||||||
|
([#1664](https://github.com/projectsend/projectsend/issues/1664))
|
||||||
|
|
||||||
|
#### Or take your server out of it entirely
|
||||||
|
|
||||||
|
Store your files in object storage — S3-compatible or Google Cloud Storage (see
|
||||||
|
[Storing files somewhere other than this server](#storing-files-somewhere-other-than-this-server)).
|
||||||
|
Files kept there are never on your server's disk, so downloads become a signed, expiring redirect
|
||||||
|
to the storage provider and the web server is not involved at all. Decide this before people start
|
||||||
|
uploading, not after.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -216,10 +263,10 @@ FILES_WEB_SERVER_READABLE=true
|
|||||||
|
|
||||||
Uploaded files are written `0600` inside `0700` directories, readable only by the user that wrote
|
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`:
|
||||||
|
|
||||||
@@ -534,6 +614,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 +648,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
|
||||||
|
|||||||
@@ -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**
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
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\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
@@ -22,16 +22,21 @@ class ConfirmablePasswordController extends Controller
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Confirm the user's password.
|
* 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.
|
||||||
*/
|
*/
|
||||||
public function store(Request $request): RedirectResponse
|
public function store(Request $request, PasswordVerification $passwords): RedirectResponse
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
assert($user !== null);
|
assert($user !== null);
|
||||||
|
|
||||||
if (! Auth::guard('web')->validate([
|
if (! $passwords->verify($user, (string) $request->string('password'))) {
|
||||||
'email' => $user->email,
|
|
||||||
'password' => $request->password,
|
|
||||||
])) {
|
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'password' => __('auth.password'),
|
'password' => __('auth.password'),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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.')],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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\Identity\Erasure\ErasureSchedule;
|
use App\Modules\Identity\Erasure\ErasureSchedule;
|
||||||
|
use App\Modules\Identity\StaffAccounts;
|
||||||
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;
|
||||||
@@ -24,6 +25,7 @@ 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,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,6 +106,19 @@ 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
|
||||||
|
|||||||
@@ -24,7 +24,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 +87,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
|
||||||
@@ -301,4 +317,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,11 @@ 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\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 +33,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 +65,21 @@ class ProfileUpdateRequest extends FormRequest
|
|||||||
];
|
];
|
||||||
|
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
|
|
||||||
|
// 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 +89,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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,16 @@ 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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ 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\Groups\Models\Group;
|
use App\Modules\Groups\Models\Group;
|
||||||
use App\Modules\Identity\UserType;
|
use App\Modules\Identity\UserType;
|
||||||
@@ -51,6 +53,7 @@ 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 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,
|
||||||
@@ -155,8 +158,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 +255,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,7 +480,7 @@ class DashboardController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string|int|bool|array<string, string|null>|null>
|
* @return array<string, array<string, bool|string|null>|bool|int|string|null>
|
||||||
*/
|
*/
|
||||||
private function systemInfo(): array
|
private function systemInfo(): array
|
||||||
{
|
{
|
||||||
@@ -492,28 +505,36 @@ 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(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
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,111 @@
|
|||||||
|
<?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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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',
|
||||||
|
): 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);
|
||||||
|
|
||||||
|
$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.)
|
||||||
|
$client->forceFill(['email_verified_at' => now()])->save();
|
||||||
|
|
||||||
|
$this->activity->log(Action::UserCreated, subject: $client);
|
||||||
|
|
||||||
|
if ($welcome && $this->settings->get(Setting::EmailNotificationsEnabled) === true) {
|
||||||
|
$client->notify(new ClientWelcomeNotification);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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;
|
||||||
@@ -22,13 +23,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,6 +60,7 @@ 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,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -117,8 +117,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],
|
||||||
@@ -137,24 +135,39 @@ class ClientsController extends Controller
|
|||||||
|
|
||||||
$validated['custom_field_values'] = $this->validateCustomFieldValues($request);
|
$validated['custom_field_values'] = $this->validateCustomFieldValues($request);
|
||||||
|
|
||||||
$client = User::create([
|
// The invariants — the seat guard, the type, the role, the quota's
|
||||||
'type' => UserType::Client,
|
// "0 means inherit" — live in ClientAccounts, shared with the staff
|
||||||
'active' => true,
|
// screens and with the platform control plane. What stays here is
|
||||||
'account_requested' => false,
|
// this surface's own business: its validation, its custom fields,
|
||||||
'role_id' => Role::query()->where('name', SystemRole::Client->value)->value('id'),
|
// and who the creator is.
|
||||||
'name' => $validated['name'],
|
$client = $this->clients->create(
|
||||||
'email' => $validated['email'],
|
name: $validated['name'],
|
||||||
'password' => $validated['password'],
|
email: $validated['email'],
|
||||||
// 0 means "no custom quota" and inherits the site default at
|
password: $validated['password'],
|
||||||
// enforcement time — see ClientStorageUsage::quotaMb().
|
storageQuotaMb: $validated['storage_quota_mb'] ?? 0,
|
||||||
'storage_quota_mb' => $validated['storage_quota_mb'] ?? 0,
|
welcome: false,
|
||||||
'email_verified_at' => now(),
|
);
|
||||||
]);
|
|
||||||
|
|
||||||
$this->activity->log(Action::UserCreated, subject: $client);
|
$creator = $request->user();
|
||||||
|
assert($creator !== null);
|
||||||
|
|
||||||
|
// A client-scoped creator would otherwise lose the client they just
|
||||||
|
// made. guardTarget() answers 404 for anything off their roster, so
|
||||||
|
// the record they created is not theirs to open, and
|
||||||
|
// 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);
|
||||||
}
|
}
|
||||||
@@ -191,7 +204,11 @@ class ClientsController extends Controller
|
|||||||
$client->storage_quota_mb = $validated['storage_quota_mb'] ?? 0;
|
$client->storage_quota_mb = $validated['storage_quota_mb'] ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 +219,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 +377,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')
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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;
|
||||||
@@ -20,10 +21,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,6 +49,7 @@ 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,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -98,24 +97,50 @@ 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],
|
||||||
@@ -123,24 +148,33 @@ class ClientsController extends Controller
|
|||||||
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
|
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
|
||||||
], $this->customFieldRules()));
|
], $this->customFieldRules()));
|
||||||
|
|
||||||
$client = User::create([
|
// The seat guard, the type, the role, and the quota's "0 means
|
||||||
'type' => UserType::Client,
|
// inherit the site default" all live in ClientAccounts, shared
|
||||||
'active' => true,
|
// with the API and the control plane. A client created here is
|
||||||
'account_requested' => false,
|
// approved by construction, so it counts against the cap
|
||||||
'role_id' => Role::query()->where('name', SystemRole::Client->value)->value('id'),
|
// immediately — unlike a self-registration awaiting a decision.
|
||||||
'name' => $validated['name'],
|
// The welcome waits until the custom fields are saved below.
|
||||||
'email' => $validated['email'],
|
$client = $this->clients->create(
|
||||||
'password' => $validated['password'],
|
name: $validated['name'],
|
||||||
// 0 (including an omitted field) means "no custom quota" —
|
email: $validated['email'],
|
||||||
// it inherits Setting::DefaultClientStorageQuotaMb at
|
password: $validated['password'],
|
||||||
// enforcement time (see ClientStorageUsage::quotaMb()), not
|
storageQuotaMb: $validated['storage_quota_mb'] ?? 0,
|
||||||
// baked in here, so a later change to the site default
|
welcome: false,
|
||||||
// keeps applying to this client automatically.
|
);
|
||||||
'storage_quota_mb' => $validated['storage_quota_mb'] ?? 0,
|
|
||||||
'email_verified_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->activity->log(Action::UserCreated, subject: $client);
|
$creator = $request->user();
|
||||||
|
assert($creator !== null);
|
||||||
|
|
||||||
|
// 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 +188,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');
|
||||||
|
|
||||||
@@ -195,14 +229,17 @@ class ClientsController extends Controller
|
|||||||
'storage_quota_mb' => $client->storage_quota_mb,
|
'storage_quota_mb' => $client->storage_quota_mb,
|
||||||
'two_factor_enabled' => $client->hasTwoFactorEnabled(),
|
'two_factor_enabled' => $client->hasTwoFactorEnabled(),
|
||||||
],
|
],
|
||||||
'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)
|
||||||
|
: [],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,8 +271,13 @@ class ClientsController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,37 @@ 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 [];
|
* Every group this staff member may be told about, as a query.
|
||||||
|
*
|
||||||
|
* The listing half of assignableGroupIds(), and the same rule: a
|
||||||
|
* group counts as theirs because one of their clients is in it. The
|
||||||
|
* two were not the same code, and the listing simply had none — so
|
||||||
|
* `/groups` and `/api/v1/groups` showed a scoped staff member every
|
||||||
|
* group on the installation, name, description and member count,
|
||||||
|
* including groups whose every member was somebody else's client
|
||||||
|
* (GHSA-r3hg-3fxw-rcmr).
|
||||||
|
*
|
||||||
|
* Deliberately the *sharing* rule rather than the change rule below.
|
||||||
|
* A scoped staff member may already share a file with a mixed group,
|
||||||
|
* so its existence is not news to them; what they may not do is
|
||||||
|
* rename, publish or delete it.
|
||||||
|
*
|
||||||
|
* @return Builder<Group>
|
||||||
|
*/
|
||||||
|
public function groups(User $user): Builder
|
||||||
|
{
|
||||||
|
$query = Group::query();
|
||||||
|
$clientIds = $this->assignableClientIds($user);
|
||||||
|
|
||||||
|
if ($clientIds === null) {
|
||||||
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_values(Group::query()
|
return $query->whereHas('members', fn (Builder $members) => $members->whereIn('users.id', $clientIds));
|
||||||
->whereHas('members', fn (Builder $members) => $members->whereIn('users.id', $clientIds))
|
|
||||||
->pluck('id')->map(fn ($id): int => (int) $id)->all());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function canAssignClient(User $user, User $client): bool
|
public function canAssignClient(User $user, User $client): bool
|
||||||
@@ -249,7 +271,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 +336,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 +359,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 +370,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,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,48 @@ 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($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,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,67 @@
|
|||||||
|
<?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\LocalDay;
|
||||||
|
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reading and writing a file's expiry in the zone of whoever is looking.
|
||||||
|
*
|
||||||
|
* The stored value is an instant. What a person sets is a calendar day,
|
||||||
|
* and "the 12th" means the end of the 12th where *they* live — otherwise a
|
||||||
|
* file asked to expire on the 12th dies partway through the 11th for
|
||||||
|
* anyone west of Greenwich, and gives anyone east of it most of a day
|
||||||
|
* nobody promised.
|
||||||
|
*
|
||||||
|
* The two halves have to agree, which is the whole reason they sit
|
||||||
|
* together: a form is rendered with asShown() and posts the same string
|
||||||
|
* back untouched with every other edit, so a caller compares against
|
||||||
|
* asShown() to tell "the editor changed the date" from "the editor renamed
|
||||||
|
* the file and the date came along for the ride". Re-deriving on every
|
||||||
|
* save instead moves the expiry by the difference between two people's
|
||||||
|
* zones each time somebody edits anything.
|
||||||
|
*
|
||||||
|
* Was three private copies — the staff editor, the API, and now the client
|
||||||
|
* portal — of which the API's was the only one that could read a
|
||||||
|
* timestamp.
|
||||||
|
*/
|
||||||
|
class FileExpiry
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly TimezoneRegistry $timezones,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 $file->expires_at?->copy()->setTimezone($this->timezones->resolve($viewer))->toDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The instant a submitted value actually names.
|
||||||
|
*
|
||||||
|
* A bare `YYYY-MM-DD` is a calendar day and means the end of it where
|
||||||
|
* the setter is — what every date input posts. Anything carrying a
|
||||||
|
* time is an instant somebody named on purpose and is stored as it
|
||||||
|
* arrives: the API can express a moment, and a date input cannot.
|
||||||
|
*/
|
||||||
|
public function instant(?string $value, ?User $setter): ?Carbon
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1
|
||||||
|
? LocalDay::end($value, $this->timezones->resolve($setter))
|
||||||
|
: Carbon::parse($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Modules\Files;
|
namespace App\Modules\Files;
|
||||||
|
|
||||||
|
use App\Modules\Files\Access\ClientIdentityScope;
|
||||||
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;
|
||||||
@@ -30,6 +31,10 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ 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\Models\Folder;
|
use App\Modules\Files\Models\Folder;
|
||||||
@@ -57,8 +58,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,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -110,6 +113,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']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,9 +144,12 @@ class FilesController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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();
|
||||||
}
|
}
|
||||||
@@ -263,6 +280,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.
|
||||||
@@ -288,9 +311,12 @@ class FilesController extends Controller
|
|||||||
'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 +325,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,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;
|
||||||
@@ -91,14 +92,45 @@ class ChunkedUploadsController extends Controller
|
|||||||
$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;
|
||||||
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 +212,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;
|
||||||
@@ -198,16 +224,48 @@ class ChunkedUploadsController extends Controller
|
|||||||
abort(413);
|
abort(413);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bounding one request bounds one request, and nothing else. Ten
|
||||||
|
// thousand part numbers at twice a 20 MB part is about 400 GB per
|
||||||
|
// session, sessions were not counted against anything, and none of
|
||||||
|
// it becomes a File row — so a client with a 1 MB quota could
|
||||||
|
// declare a one-byte upload and fill the temporary volume, then do
|
||||||
|
// it again. The session needs a ceiling of its own, and the room
|
||||||
|
// for a part has to be claimed before the part is read: a body's
|
||||||
|
// length is not known until it has arrived, and by then it is on
|
||||||
|
// the disk this is protecting.
|
||||||
|
//
|
||||||
|
// The ceiling is the size the session declared, which store() has
|
||||||
|
// already weighed against the file-size limit and the quota. So
|
||||||
|
// what a part gets is whatever the session has left, and the write
|
||||||
|
// is then capped at exactly that — an over-long body is cut off
|
||||||
|
// mid-stream as it always was, just against a smaller number.
|
||||||
|
$reserve = $this->reservePartRoom($session, $part, $limit);
|
||||||
|
|
||||||
|
if ($reserve < 1) {
|
||||||
|
// 413 rather than 422: this is about the size of what is being
|
||||||
|
// sent, and a client's resume logic already understands it. The
|
||||||
|
// session survives — the parts it holds are untouched, and it
|
||||||
|
// can still be completed or aborted.
|
||||||
|
abort(413);
|
||||||
|
}
|
||||||
|
|
||||||
$stream = $request->getContent(true);
|
$stream = $request->getContent(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$etag = $this->parts->storePart($session, $part, $stream, $limit);
|
$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 +282,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 +410,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),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,15 +12,16 @@ use App\Modules\Files\Delivery\StoredFileResponse;
|
|||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ 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\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 +22,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,11 +72,12 @@ 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,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function thumbnail(Request $request, File $file): Response
|
public function thumbnail(Request $request, File $file): Response
|
||||||
@@ -144,7 +145,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 +168,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,8 +183,19 @@ 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)) {
|
||||||
return $path;
|
if ($disk->size($path) > 0) {
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
$disk->delete($path);
|
||||||
}
|
}
|
||||||
|
|
||||||
$disk->makeDirectory(dirname($path));
|
$disk->makeDirectory(dirname($path));
|
||||||
@@ -221,10 +213,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
|
||||||
@@ -164,7 +166,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,7 +175,7 @@ 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(),
|
||||||
'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,
|
||||||
@@ -274,6 +276,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 +285,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 +348,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]);
|
||||||
@@ -422,13 +416,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 +463,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 +504,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,28 +530,17 @@ 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]);
|
||||||
|
|
||||||
return redirect()->route('files.index')->with('success', __('File deleted.'));
|
return redirect()->route('files.index')->with('success', __('File deleted.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The instant a `<input type="date">` expiry actually falls on.
|
|
||||||
*
|
|
||||||
* The form posts a bare `YYYY-MM-DD`, which Eloquent would otherwise
|
|
||||||
* store as midnight UTC — so "expires on the 12th" would cut the file
|
|
||||||
* off partway through the 11th for anyone in the Americas, and give
|
|
||||||
* anyone east of Greenwich most of a day they were not promised. It
|
|
||||||
* means the end of the 12th where the person setting it lives.
|
|
||||||
*/
|
|
||||||
private function expiryInstant(?string $date, ?User $setter): ?Carbon
|
|
||||||
{
|
|
||||||
return $date === null
|
|
||||||
? null
|
|
||||||
: LocalDay::end($date, $this->timezones->resolve($setter));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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;
|
||||||
@@ -54,6 +55,7 @@ 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,
|
||||||
@@ -240,7 +242,11 @@ 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,
|
||||||
@@ -396,7 +402,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 +424,32 @@ 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);
|
||||||
|
|
||||||
|
// Deleting a folder cascades to every file in its subtree, and a
|
||||||
|
// File's `deleted` hook removes the bytes from disk — there is no
|
||||||
|
// restore. Authorizing the folder is not authorizing its contents:
|
||||||
|
// FilePolicy::delete asks for `delete_others_files` on somebody
|
||||||
|
// else's upload, and for the library boundary on top of that, and
|
||||||
|
// neither question is asked anywhere on this path.
|
||||||
|
//
|
||||||
|
// MyFoldersController::destroy already refuses for the client half
|
||||||
|
// of the same cascade, in the same words. This is the staff half.
|
||||||
|
$blocked = $this->undeletableFileCount($viewer, $folder);
|
||||||
|
|
||||||
|
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 +460,50 @@ 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.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many files in this folder's subtree the viewer may not delete.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
private function undeletableFileCount(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();
|
||||||
|
}
|
||||||
|
|
||||||
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,22 @@ 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\Folders\BreadcrumbBuilder;
|
use App\Modules\Files\Folders\BreadcrumbBuilder;
|
||||||
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\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 +30,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;
|
||||||
@@ -66,6 +75,11 @@ class MyFilesController extends Controller
|
|||||||
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
|
||||||
@@ -207,11 +221,21 @@ 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],
|
||||||
@@ -237,6 +261,14 @@ class MyFilesController extends Controller
|
|||||||
'size' => $file->size,
|
'size' => $file->size,
|
||||||
'created_at' => $file->created_at?->toIso8601String(),
|
'created_at' => $file->created_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 +279,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(),
|
||||||
@@ -306,6 +351,200 @@ 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),
|
||||||
|
'can_publish' => $client->can('upload_public'),
|
||||||
|
'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', '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;
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ use App\Modules\Files\Models\Category;
|
|||||||
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 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
|
||||||
|
|||||||
@@ -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,6 +31,7 @@ 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
|
||||||
@@ -80,16 +82,16 @@ 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,
|
||||||
]);
|
maxDownloads: $user->can('limit_downloads') ? $validated['max_downloads'] ?? null : null,
|
||||||
|
token: $validated['token'] ?? null,
|
||||||
$this->activity->log(Action::ShareLinkCreated, subject: $file);
|
);
|
||||||
|
|
||||||
return back()->with('success', __('Public link created.'));
|
return back()->with('success', __('Public link created.'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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;
|
||||||
@@ -21,10 +22,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
|
||||||
@@ -45,6 +46,7 @@ class ZipDownloadsController extends Controller
|
|||||||
private readonly ViewableFileScope $viewable,
|
private readonly ViewableFileScope $viewable,
|
||||||
private readonly DownloadAllowance $allowance,
|
private readonly DownloadAllowance $allowance,
|
||||||
private readonly Settings $settings,
|
private readonly Settings $settings,
|
||||||
|
private readonly FileDelivery $delivery,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
@@ -186,12 +188,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,
|
||||||
@@ -96,11 +123,16 @@ class FileResource extends JsonResource
|
|||||||
]),
|
]),
|
||||||
|
|
||||||
// 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
|
||||||
'id' => $this->uploader->id,
|
// scoped to; an unscoped account always gets the name.
|
||||||
'name' => $this->uploader->name,
|
'uploaded_by' => $this->whenLoaded(
|
||||||
]),
|
'uploader',
|
||||||
|
fn (): ?array => $identity->permits($viewer, $this->uploader) && $this->uploader !== null ? [
|
||||||
|
'id' => $this->uploader->id,
|
||||||
|
'name' => $this->uploader->name,
|
||||||
|
] : null,
|
||||||
|
),
|
||||||
|
|
||||||
'categories' => $this->whenLoaded('categories', fn (): array => $this->categories
|
'categories' => $this->whenLoaded('categories', fn (): array => $this->categories
|
||||||
->map(fn ($category): array => [
|
->map(fn ($category): array => [
|
||||||
@@ -109,15 +141,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' => [
|
||||||
|
|||||||
@@ -135,7 +135,14 @@ 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
|
||||||
@@ -150,11 +157,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 +204,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 +236,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 +245,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,22 +324,51 @@ 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) {
|
||||||
|
if (is_resource($stream)) {
|
||||||
|
fclose($stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($out !== false) {
|
||||||
|
fclose($out);
|
||||||
|
}
|
||||||
|
|
||||||
throw new \RuntimeException('Could not read '.$file->original_name.' from its storage disk.');
|
throw new \RuntimeException('Could not read '.$file->original_name.' from its storage disk.');
|
||||||
}
|
}
|
||||||
|
|
||||||
stream_copy_to_stream($stream, $out);
|
try {
|
||||||
fclose($out);
|
// A copy that stops early is a truncated member added to the
|
||||||
|
// archive as though it were the file: the build reports ready,
|
||||||
|
// and the recipient gets something that opens and is wrong.
|
||||||
|
// fclose is checked for the same reason it is in
|
||||||
|
// LocalPartStore: it flushes, so a volume that filled on the
|
||||||
|
// last buffer fails there rather than here.
|
||||||
|
$copied = stream_copy_to_stream($stream, $out);
|
||||||
|
$flushed = fclose($out);
|
||||||
|
$out = false;
|
||||||
|
|
||||||
if (is_resource($stream)) {
|
if ($copied === false || ! $flushed) {
|
||||||
fclose($stream);
|
throw new \RuntimeException('Could not copy '.$file->original_name.' from its storage disk.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if ($out !== false) {
|
||||||
|
fclose($out);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_resource($stream)) {
|
||||||
|
fclose($stream);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$tempFiles[] = $tempPath;
|
|
||||||
|
|
||||||
return $tempPath;
|
return $tempPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,9 +377,9 @@ 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);
|
||||||
|
|
||||||
@@ -342,6 +390,15 @@ 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
|
||||||
@@ -357,12 +414,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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -110,8 +110,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,8 +251,20 @@ 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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -152,15 +152,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 +185,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Files\Sharing;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Modules\Files\Models\File;
|
||||||
|
use App\Modules\Files\Models\ShareLink;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public URLs a client may be shown for their own files.
|
||||||
|
*
|
||||||
|
* A client's portal lists two kinds of file side by side: what they
|
||||||
|
* uploaded, and what somebody shared with them. Both can carry share
|
||||||
|
* links, and only one kind of link is theirs to see — a link a staff
|
||||||
|
* member minted for a file they were given is that staff member's
|
||||||
|
* decision about who may reach it, and handing the recipient the URL
|
||||||
|
* would turn "you may download this" into "you may pass this on to
|
||||||
|
* anyone".
|
||||||
|
*
|
||||||
|
* So the rule is narrow and stated once: **a link this client created, on
|
||||||
|
* a file this client uploaded.** Both halves, not either. Neither is
|
||||||
|
* redundant — a client-created link on a file they no longer own would
|
||||||
|
* outlive a reassignment, and a staff link on their own upload is still
|
||||||
|
* not theirs to hand out.
|
||||||
|
*
|
||||||
|
* Inactive links are left out rather than shown greyed: the only thing a
|
||||||
|
* client can do with this is copy it, and a URL that answers "this link
|
||||||
|
* has expired" is worse than no URL at all.
|
||||||
|
*
|
||||||
|
* Resolved for a whole page at a time. One query for the listing, not one
|
||||||
|
* per row.
|
||||||
|
*/
|
||||||
|
class ClientShareLinks
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param Collection<int, File> $files
|
||||||
|
* @return array<int, string> file id => URL, for the files that have one
|
||||||
|
*/
|
||||||
|
public function forMany(Collection $files, User $client): array
|
||||||
|
{
|
||||||
|
$own = $files
|
||||||
|
->filter(fn (File $file): bool => $file->uploaded_by === $client->id)
|
||||||
|
->pluck('id')
|
||||||
|
->map(fn ($id): int => (int) $id)
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($own === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$links = ShareLink::query()
|
||||||
|
->where('shareable_type', (new File)->getMorphClass())
|
||||||
|
->whereIn('shareable_id', $own)
|
||||||
|
->where('created_by', $client->id)
|
||||||
|
// Oldest first, so a file that somehow carries two is
|
||||||
|
// described by the one the client has already been given
|
||||||
|
// rather than by whichever the database returned today.
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$urls = [];
|
||||||
|
|
||||||
|
foreach ($links as $link) {
|
||||||
|
$fileId = (int) $link->shareable_id;
|
||||||
|
|
||||||
|
if (isset($urls[$fileId]) || ! $link->isActive()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$urls[$fileId] = route('share.show', $link->token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $urls;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Files\Sharing;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Modules\Audit\Action;
|
||||||
|
use App\Modules\Audit\ActivityLogger;
|
||||||
|
use App\Modules\Files\Models\File;
|
||||||
|
use App\Modules\Files\Models\ShareLink;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minting a public link for a file, in one place.
|
||||||
|
*
|
||||||
|
* Extracted from ShareLinksController rather than invented: the
|
||||||
|
* controller is an HTTP handler behind `staff` middleware, and a link now
|
||||||
|
* needs creating from outside a request as well. Two copies of "make a
|
||||||
|
* token, write the row, log it" would drift, and the half most likely to
|
||||||
|
* drift is the token.
|
||||||
|
*
|
||||||
|
* **The token is the whole authorization.** There is nothing behind
|
||||||
|
* /s/{token} — no session, no second factor — so its only defence is
|
||||||
|
* being unguessable. Str::random(32) is about 190 bits, which is more
|
||||||
|
* than a UUID's 122; anything minted here gets that and never a chosen
|
||||||
|
* value. A caller that wants a chosen token is a person typing one into a
|
||||||
|
* form, and that path stays in the controller where its minimum length
|
||||||
|
* can be argued about in a validation rule.
|
||||||
|
*
|
||||||
|
* Expiry and download caps are the caller's to decide and are passed in
|
||||||
|
* already resolved, because "the end of the 12th" depends on whose zone
|
||||||
|
* you are in and this class has no viewer.
|
||||||
|
*/
|
||||||
|
class CreateShareLink
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ActivityLogger $activity,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function for(
|
||||||
|
File $file,
|
||||||
|
User $creator,
|
||||||
|
?CarbonInterface $expiresAt = null,
|
||||||
|
?int $maxDownloads = null,
|
||||||
|
?string $token = null,
|
||||||
|
): ShareLink {
|
||||||
|
$link = ShareLink::query()->create([
|
||||||
|
'shareable_type' => $file->getMorphClass(),
|
||||||
|
'shareable_id' => $file->id,
|
||||||
|
'token' => $token ?? Str::random(32),
|
||||||
|
'created_by' => $creator->id,
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
'max_downloads' => $maxDownloads,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->activity->log(Action::ShareLinkCreated, subject: $file);
|
||||||
|
|
||||||
|
return $link;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,9 +14,10 @@ use App\Modules\Files\Thumbnails\ImageRendition;
|
|||||||
*
|
*
|
||||||
* A thumbnail never asks: it is a rendering by definition, nothing else
|
* A thumbnail never asks: it is a rendering by definition, nothing else
|
||||||
* would fit in a listing row. A preview is the case with two valid
|
* would fit in a listing row. A preview is the case with two valid
|
||||||
* answers. Serving the stored file is far cheaper — an X-Accel-Redirect
|
* answers. Serving the stored file is far cheaper — handed to the web
|
||||||
* with no PHP in the path at all, or a redirect straight to external
|
* server with no PHP in the path at all where that is possible, or a
|
||||||
* storage — and it is what this app has always done. Decoding and
|
* redirect straight to external storage — and it is what this app has
|
||||||
|
* always done. Decoding and
|
||||||
* re-encoding a full-size photograph instead is only worth it when
|
* re-encoding a full-size photograph instead is only worth it when
|
||||||
* something actually intends to change what the viewer sees.
|
* something actually intends to change what the viewer sees.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ namespace App\Modules\Files\Thumbnails;
|
|||||||
|
|
||||||
use App\Modules\Files\Thumbnails\Events\RenderingImage;
|
use App\Modules\Files\Thumbnails\Events\RenderingImage;
|
||||||
use claviska\SimpleImage;
|
use claviska\SimpleImage;
|
||||||
|
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
@@ -102,12 +104,111 @@ class ThumbnailGenerator
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a render may hold the lock before another request is
|
||||||
|
* entitled to assume it died. Generous: a 40-megapixel decode is a
|
||||||
|
* second or two, and an external source is copied local first.
|
||||||
|
*/
|
||||||
|
private const LOCK_SECONDS = 120;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long to wait for the request that got there first.
|
||||||
|
*
|
||||||
|
* Waiting costs an idle worker — about 35 MB. Rendering costs that
|
||||||
|
* plus four bytes per source pixel, up to 160 MB at the megapixel
|
||||||
|
* ceiling. Waiting is the cheap option by an order of magnitude,
|
||||||
|
* which is the whole reason this exists.
|
||||||
|
*
|
||||||
|
* Configurable because the right number depends on how long a decode
|
||||||
|
* takes here, and that is a property of the machine rather than of
|
||||||
|
* the application: a small VPS reading a large source off a slow disk
|
||||||
|
* wants longer than this, and nothing in the code can know that.
|
||||||
|
*/
|
||||||
|
private const DEFAULT_LOCK_WAIT_SECONDS = 15;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render one image, once, however many requests ask at the same time.
|
||||||
|
*
|
||||||
|
* **Why the lock.** Renditions are generated on demand and cached by
|
||||||
|
* existence, and nothing between the callers stopped two requests
|
||||||
|
* rendering the same image at once. The atomic rename below settles
|
||||||
|
* which file survives — it never stopped both from decoding. So N
|
||||||
|
* concurrent requests for one cold rendition were N full-size decodes,
|
||||||
|
* each holding four bytes per source pixel.
|
||||||
|
*
|
||||||
|
* That is not an attack. A public listing emits a thumbnail URL per
|
||||||
|
* file, a browser opens six or more connections at once, and the first
|
||||||
|
* visit to a gallery of ordinary camera images is six simultaneous
|
||||||
|
* decodes on a container sized for one. It kills the container, and
|
||||||
|
* because a killed render writes nothing, the cache never warms: the
|
||||||
|
* page dies again on the next visit. `PublicGroupsController` reaches
|
||||||
|
* here with no account at all.
|
||||||
|
*
|
||||||
|
* **Why waiting rather than refusing.** The request that waits holds
|
||||||
|
* an idle worker. The request that renders holds a worker plus the
|
||||||
|
* whole source bitmap. Six waiters cost what one renderer costs, so
|
||||||
|
* blocking is the cheap answer even when it looks like the slow one.
|
||||||
|
*
|
||||||
|
* **Why the re-check after acquiring.** The winner has finished by the
|
||||||
|
* time a waiter gets in, so the file it was waiting for is already
|
||||||
|
* there. Re-reading is what turns a wait into a cache hit rather than
|
||||||
|
* a second render of the same image.
|
||||||
|
*/
|
||||||
public function generate(
|
public function generate(
|
||||||
string $sourcePath,
|
string $sourcePath,
|
||||||
string $destinationPath,
|
string $destinationPath,
|
||||||
string $mimeType,
|
string $mimeType,
|
||||||
ImageAudience $audience,
|
ImageAudience $audience,
|
||||||
ImageRendition $rendition,
|
ImageRendition $rendition,
|
||||||
|
): void {
|
||||||
|
// Keyed on the destination, which already encodes the file, the
|
||||||
|
// audience and the rendition — two requests collide here exactly
|
||||||
|
// when they would have written the same path.
|
||||||
|
$lock = Cache::lock('rendition:'.sha1($destinationPath), self::LOCK_SECONDS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$lock->block($this->lockWaitSeconds());
|
||||||
|
} catch (LockTimeoutException) {
|
||||||
|
// Deliberately not rendering anyway. Falling through on
|
||||||
|
// timeout would reinstate exactly the pile-on this exists to
|
||||||
|
// stop, at the moment the system is already struggling — one
|
||||||
|
// failed thumbnail is a better outcome than a container that
|
||||||
|
// dies and takes the warm cache with it.
|
||||||
|
throw new RuntimeException('Timed out waiting for another request to render this image.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Somebody else rendered it while we waited. An empty file is
|
||||||
|
// not a rendition — same rule the callers apply, and the same
|
||||||
|
// reason: nothing invalidates one once it is cached.
|
||||||
|
if (is_file($destinationPath) && filesize($destinationPath) > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->render($sourcePath, $destinationPath, $mimeType, $audience, $rendition);
|
||||||
|
} finally {
|
||||||
|
$lock->release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamped to at least a second: a zero would make every concurrent
|
||||||
|
* request fail instead of waiting, which is the opposite of the point
|
||||||
|
* and exactly what a stray empty environment variable produces.
|
||||||
|
*/
|
||||||
|
private function lockWaitSeconds(): int
|
||||||
|
{
|
||||||
|
$configured = config('projectsend.rendition_lock_wait_seconds');
|
||||||
|
|
||||||
|
return max(1, is_numeric($configured) ? (int) $configured : self::DEFAULT_LOCK_WAIT_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function render(
|
||||||
|
string $sourcePath,
|
||||||
|
string $destinationPath,
|
||||||
|
string $mimeType,
|
||||||
|
ImageAudience $audience,
|
||||||
|
ImageRendition $rendition,
|
||||||
): void {
|
): void {
|
||||||
$dimensions = @getimagesize($sourcePath);
|
$dimensions = @getimagesize($sourcePath);
|
||||||
|
|
||||||
@@ -134,6 +235,31 @@ class ThumbnailGenerator
|
|||||||
// listening the image is written exactly as produced above.
|
// listening the image is written exactly as produced above.
|
||||||
Event::dispatch(new RenderingImage($image, $mimeType, $audience, $rendition));
|
Event::dispatch(new RenderingImage($image, $mimeType, $audience, $rendition));
|
||||||
|
|
||||||
$image->toFile($destinationPath, $mimeType);
|
// Written beside the destination and renamed into place, so the
|
||||||
|
// cached path never exists half-finished. Both callers test only
|
||||||
|
// that the path exists and then serve whatever is there
|
||||||
|
// (FileThumbnailController::render, PublicGroupsController::
|
||||||
|
// thumbnail), and nothing ever invalidates a rendition —
|
||||||
|
// RenderedImageCache::flush() runs on an event no core code raises.
|
||||||
|
// A render that died partway would therefore be served as the
|
||||||
|
// rendition from then on.
|
||||||
|
//
|
||||||
|
// It also settles the race: two requests rendering the same file at
|
||||||
|
// once used to encode into one path together. rename() within a
|
||||||
|
// directory is atomic and replaces what is there, so now the loser
|
||||||
|
// leaves a complete rendition behind rather than a mixture of two.
|
||||||
|
$temporaryPath = $destinationPath.'.'.bin2hex(random_bytes(8)).'.partial';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$image->toFile($temporaryPath, $mimeType);
|
||||||
|
|
||||||
|
if (! rename($temporaryPath, $destinationPath)) {
|
||||||
|
throw new RuntimeException('Could not move the rendered image into place.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (is_file($temporaryPath)) {
|
||||||
|
@unlink($temporaryPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ use Throwable;
|
|||||||
*/
|
*/
|
||||||
class LocalPartStore
|
class LocalPartStore
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Said twice, because a full temp volume can announce itself in the
|
||||||
|
* middle of the copy or only when the last buffer is flushed.
|
||||||
|
*/
|
||||||
|
private const WRITE_FAILED = 'Could not assemble the upload: writing to the temporary directory failed.';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The route name is a parameter because the same flow is mounted twice:
|
* The route name is a parameter because the same flow is mounted twice:
|
||||||
* once on the session-authenticated web routes for the browser, once on
|
* once on the session-authenticated web routes for the browser, once on
|
||||||
@@ -112,6 +118,25 @@ class LocalPartStore
|
|||||||
return md5_file($path) ?: '';
|
return md5_file($path) ?: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What one part number currently weighs on disk, 0 if it has never
|
||||||
|
* arrived. Read before and after a part is received, so the session's
|
||||||
|
* reservation can be settled against what is really there rather than
|
||||||
|
* against what the request claimed it would send.
|
||||||
|
*/
|
||||||
|
public function partSize(UploadSession $session, int $partNumber): int
|
||||||
|
{
|
||||||
|
$path = $this->partPath($session, $partNumber);
|
||||||
|
|
||||||
|
if (! is_file($path)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearstatcache(true, $path);
|
||||||
|
|
||||||
|
return (int) (filesize($path) ?: 0);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<array{PartNumber: int, Size: int, ETag: string}>
|
* @return list<array{PartNumber: int, Size: int, ETag: string}>
|
||||||
*/
|
*/
|
||||||
@@ -140,9 +165,21 @@ class LocalPartStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream-append parts in order onto the files disk, hashing as we
|
* Stream-append parts in order onto the files disk, hashing as we go.
|
||||||
* go. Peak temp usage ≈ file size + one part (parts are unlinked
|
*
|
||||||
* as they are consumed).
|
* The parts stay on disk until the assembled bytes are safely on the
|
||||||
|
* target disk. ChunkedUploadsController's completion lock promises that
|
||||||
|
* "a later retry still works", and everything that can fail after the
|
||||||
|
* concatenation — reopening the copy, a disk refusing the write, the
|
||||||
|
* File row itself — happens while the client has nothing but this
|
||||||
|
* session to retry with. Unlinking each part as it was consumed left
|
||||||
|
* listParts() empty, so every later complete() answered "Upload is
|
||||||
|
* incomplete: missing parts" for good.
|
||||||
|
*
|
||||||
|
* The cost is temp space: peak usage is the whole file twice over
|
||||||
|
* (every part, plus the assembled copy) rather than the file plus one
|
||||||
|
* part. Both are freed by the abort() below the moment the write lands,
|
||||||
|
* and by the failure path the moment it does not.
|
||||||
*
|
*
|
||||||
* @return array{path: string, disk: string, size: int, checksum: string}
|
* @return array{path: string, disk: string, size: int, checksum: string}
|
||||||
*/
|
*/
|
||||||
@@ -158,6 +195,93 @@ class LocalPartStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
$assembledPath = $this->directory($session).'/assembled';
|
$assembledPath = $this->directory($session).'/assembled';
|
||||||
|
|
||||||
|
try {
|
||||||
|
[$size, $checksum] = $this->concatenate($session, $parts, $assembledPath);
|
||||||
|
|
||||||
|
$readStream = fopen($assembledPath, 'rb');
|
||||||
|
|
||||||
|
if ($readStream === false) {
|
||||||
|
throw new RuntimeException('Could not reopen assembled file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$diskEvent = new ResolvingUploadDisk($session->user);
|
||||||
|
Event::dispatch($diskEvent);
|
||||||
|
$disk = $diskEvent->disk;
|
||||||
|
|
||||||
|
$written = Storage::disk($disk)->writeStream($targetPath, $readStream);
|
||||||
|
|
||||||
|
if (is_resource($readStream)) {
|
||||||
|
fclose($readStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The disks are configured with 'throw' => false, so a refused
|
||||||
|
// write is a `false` return rather than an exception — and the
|
||||||
|
// caller goes on to record a File row for bytes that were never
|
||||||
|
// stored. Losing an upload silently is worse than failing it, and
|
||||||
|
// this is the only place that can tell the difference: a real
|
||||||
|
// instance of it was a GCS bucket rejecting the adapter's ACL,
|
||||||
|
// which looked exactly like a successful upload.
|
||||||
|
if ($written === false) {
|
||||||
|
// The reason is lost by the time it gets here — 'throw' => false
|
||||||
|
// means Flysystem swallowed the exception rather than passing it
|
||||||
|
// on — so log what was attempted. Which bucket it was is the
|
||||||
|
// difference between reading this as "my credentials expired"
|
||||||
|
// and "I typed the wrong bucket name", and only the log can say
|
||||||
|
// it: the message below is shown to whoever was uploading, which
|
||||||
|
// includes clients, and a bucket name is not theirs to see.
|
||||||
|
Log::error('Upload could not be written to storage.', [
|
||||||
|
'disk' => $disk,
|
||||||
|
'bucket' => config('filesystems.disks.'.$disk.'.bucket'),
|
||||||
|
'driver' => config('filesystems.disks.'.$disk.'.driver'),
|
||||||
|
'path' => $targetPath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw new RuntimeException(
|
||||||
|
'Could not write the assembled upload to the "'.$disk.'" disk. '
|
||||||
|
.'Check the storage backend is reachable and its credentials are still valid.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (Throwable $failure) {
|
||||||
|
// The half-written copy belongs to this attempt and the next one
|
||||||
|
// makes its own; the parts belong to the client, and they are
|
||||||
|
// what a retry needs. Deleting the copy here is also the only
|
||||||
|
// thing that removes it at all on this path — it used to sit in
|
||||||
|
// the session directory until the sweeper came round.
|
||||||
|
FileSystem::delete($assembledPath);
|
||||||
|
|
||||||
|
throw $failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->abort($session);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'path' => $targetPath,
|
||||||
|
'disk' => $disk,
|
||||||
|
'size' => $size,
|
||||||
|
'checksum' => $checksum,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concatenate the parts into $assembledPath, returning the byte count
|
||||||
|
* and the sha256 of what was written.
|
||||||
|
*
|
||||||
|
* Every read and every write is checked. They were not, and while a
|
||||||
|
* failing fwrite on a full volume is loud in practice — Laravel's
|
||||||
|
* error handler turns the warning into an ErrorException — loud there
|
||||||
|
* means a 500 carrying a PHP message, where the disk-refused-the-write
|
||||||
|
* case a few lines above becomes a sentence the person uploading can
|
||||||
|
* act on. A short write arriving without a warning would be worse
|
||||||
|
* still: $size and the hash describe the buffer that was read, so an
|
||||||
|
* unchecked one yields a truncated file with a checksum matching bytes
|
||||||
|
* that were never stored.
|
||||||
|
*
|
||||||
|
* @param list<array{PartNumber: int, Size: int, ETag: string}> $parts
|
||||||
|
* @return array{0: int, 1: string}
|
||||||
|
*/
|
||||||
|
private function concatenate(UploadSession $session, array $parts, string $assembledPath): array
|
||||||
|
{
|
||||||
$out = fopen($assembledPath, 'wb');
|
$out = fopen($assembledPath, 'wb');
|
||||||
|
|
||||||
if ($out === false) {
|
if ($out === false) {
|
||||||
@@ -167,85 +291,46 @@ class LocalPartStore
|
|||||||
$hash = hash_init('sha256');
|
$hash = hash_init('sha256');
|
||||||
$size = 0;
|
$size = 0;
|
||||||
|
|
||||||
foreach ($parts as $part) {
|
try {
|
||||||
$partPath = $this->partPath($session, $part['PartNumber']);
|
foreach ($parts as $part) {
|
||||||
$in = fopen($partPath, 'rb');
|
$in = fopen($this->partPath($session, $part['PartNumber']), 'rb');
|
||||||
|
|
||||||
if ($in === false) {
|
if ($in === false) {
|
||||||
fclose($out);
|
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
|
||||||
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
|
|
||||||
}
|
|
||||||
|
|
||||||
while (! feof($in)) {
|
|
||||||
$buffer = fread($in, 1024 * 1024);
|
|
||||||
|
|
||||||
if ($buffer === false) {
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fwrite($out, $buffer);
|
try {
|
||||||
hash_update($hash, $buffer);
|
while (! feof($in)) {
|
||||||
$size += strlen($buffer);
|
$buffer = fread($in, 1024 * 1024);
|
||||||
|
|
||||||
|
if ($buffer === false) {
|
||||||
|
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($buffer !== '' && @fwrite($out, $buffer) !== strlen($buffer)) {
|
||||||
|
throw new RuntimeException(self::WRITE_FAILED);
|
||||||
|
}
|
||||||
|
|
||||||
|
hash_update($hash, $buffer);
|
||||||
|
$size += strlen($buffer);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fclose($in);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (Throwable $failure) {
|
||||||
|
fclose($out);
|
||||||
|
|
||||||
fclose($in);
|
throw $failure;
|
||||||
unlink($partPath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fclose($out);
|
// fclose flushes, so a volume that filled up on the last buffer
|
||||||
|
// fails here rather than in the loop.
|
||||||
$readStream = fopen($assembledPath, 'rb');
|
if (! fclose($out)) {
|
||||||
|
throw new RuntimeException(self::WRITE_FAILED);
|
||||||
if ($readStream === false) {
|
|
||||||
throw new RuntimeException('Could not reopen assembled file.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$diskEvent = new ResolvingUploadDisk($session->user);
|
return [$size, hash_final($hash)];
|
||||||
Event::dispatch($diskEvent);
|
|
||||||
$disk = $diskEvent->disk;
|
|
||||||
|
|
||||||
$written = Storage::disk($disk)->writeStream($targetPath, $readStream);
|
|
||||||
|
|
||||||
if (is_resource($readStream)) {
|
|
||||||
fclose($readStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The disks are configured with 'throw' => false, so a refused
|
|
||||||
// write is a `false` return rather than an exception — and the
|
|
||||||
// caller goes on to record a File row for bytes that were never
|
|
||||||
// stored. Losing an upload silently is worse than failing it, and
|
|
||||||
// this is the only place that can tell the difference: a real
|
|
||||||
// instance of it was a GCS bucket rejecting the adapter's ACL,
|
|
||||||
// which looked exactly like a successful upload.
|
|
||||||
if ($written === false) {
|
|
||||||
// The reason is lost by the time it gets here — 'throw' => false
|
|
||||||
// means Flysystem swallowed the exception rather than passing it
|
|
||||||
// on — so log what was attempted. Which bucket it was is the
|
|
||||||
// difference between reading this as "my credentials expired"
|
|
||||||
// and "I typed the wrong bucket name", and only the log can say
|
|
||||||
// it: the message below is shown to whoever was uploading, which
|
|
||||||
// includes clients, and a bucket name is not theirs to see.
|
|
||||||
Log::error('Upload could not be written to storage.', [
|
|
||||||
'disk' => $disk,
|
|
||||||
'bucket' => config('filesystems.disks.'.$disk.'.bucket'),
|
|
||||||
'driver' => config('filesystems.disks.'.$disk.'.driver'),
|
|
||||||
'path' => $targetPath,
|
|
||||||
]);
|
|
||||||
|
|
||||||
throw new RuntimeException(
|
|
||||||
'Could not write the assembled upload to the "'.$disk.'" disk. '
|
|
||||||
.'Check the storage backend is reachable and its credentials are still valid.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->abort($session);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'path' => $targetPath,
|
|
||||||
'disk' => $disk,
|
|
||||||
'size' => $size,
|
|
||||||
'checksum' => hash_final($hash),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function abort(UploadSession $session): void
|
public function abort(UploadSession $session): void
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace App\Modules\Files\Uploads;
|
namespace App\Modules\Files\Uploads;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Modules\Files\Events\FileWasStored;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
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\Models\File;
|
||||||
@@ -52,6 +54,13 @@ class StoreUploadedFile
|
|||||||
|
|
||||||
$this->activity->log($action, $uploader, $file);
|
$this->activity->log($action, $uploader, $file);
|
||||||
|
|
||||||
|
// Every upload path converges here — the chunked flow staff and
|
||||||
|
// clients share, and the synchronous POST beside it — so a
|
||||||
|
// listener sees each upload once without knowing which route
|
||||||
|
// produced it. Dispatched after the row exists, so what it
|
||||||
|
// receives is a complete File.
|
||||||
|
Event::dispatch(new FileWasStored($file, $uploader));
|
||||||
|
|
||||||
return $file;
|
return $file;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Modules\Files\Models\Folder;
|
|||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A resumable chunked upload in progress. Parts live on disk under the
|
* A resumable chunked upload in progress. Parts live on disk under the
|
||||||
@@ -20,6 +21,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
* @property int|null $previous_file_id
|
* @property int|null $previous_file_id
|
||||||
* @property string $original_name
|
* @property string $original_name
|
||||||
* @property int $size
|
* @property int $size
|
||||||
|
* @property int $staged_bytes
|
||||||
* @property string|null $mime_type
|
* @property string|null $mime_type
|
||||||
* @property string|null $description
|
* @property string|null $description
|
||||||
* @property string $status
|
* @property string $status
|
||||||
@@ -53,4 +55,71 @@ class UploadSession extends Model
|
|||||||
{
|
{
|
||||||
return $this->user_id === $user->id;
|
return $this->user_id === $user->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim room on the temporary volume for a part that is about to
|
||||||
|
* arrive, returning false if the session has no room left.
|
||||||
|
*
|
||||||
|
* The claim is made before the bytes are read, and it is one
|
||||||
|
* statement, because neither weaker version holds. Checking the part
|
||||||
|
* directory and then writing leaves a gap that every other part
|
||||||
|
* currently in flight fits through — and the protocol sends parts in
|
||||||
|
* parallel, so the number of them is the caller's choice, not ours.
|
||||||
|
* Charging the real size afterwards is the same gap by another name.
|
||||||
|
*
|
||||||
|
* $replacing is what the part number already holds, since re-sending a
|
||||||
|
* part overwrites it rather than adding to it. That is an ordinary
|
||||||
|
* resume, not an attack.
|
||||||
|
*
|
||||||
|
* The ceiling is the size the session declared. A client cannot stage
|
||||||
|
* more than it said it was sending, which is the invariant the whole
|
||||||
|
* fix rests on: store() has already measured that declaration against
|
||||||
|
* the file-size limit and the storage quota, so bounding staged bytes
|
||||||
|
* by it puts temporary bytes under the same limits as stored ones.
|
||||||
|
*/
|
||||||
|
public function reserveStaged(int $bytes, int $replacing = 0): bool
|
||||||
|
{
|
||||||
|
$delta = $bytes - $replacing;
|
||||||
|
|
||||||
|
$query = static::query()->whereKey($this->getKey());
|
||||||
|
|
||||||
|
// Both bounds are rearranged so that the column is never part of a
|
||||||
|
// subtraction. `staged_bytes + :delta BETWEEN 0 AND size` reads
|
||||||
|
// naturally and is wrong: staged_bytes is BIGINT UNSIGNED, and on
|
||||||
|
// MySQL a negative delta makes that expression underflow and raise
|
||||||
|
// SQLSTATE 22003 — in the comparison, before any row is chosen, so
|
||||||
|
// the bound meant to prevent it is the thing that trips over it.
|
||||||
|
// SQLite has no unsigned integers, so the suite cannot see this at
|
||||||
|
// all; UploadSessionStagedBytesMysqlTest is what covers it.
|
||||||
|
if ($delta >= 0) {
|
||||||
|
if ($delta > $this->size) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->where('staged_bytes', '<=', $this->size - $delta);
|
||||||
|
} else {
|
||||||
|
// Never give back more than is held.
|
||||||
|
$query->where('staged_bytes', '>=', -$delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->update(['staged_bytes' => DB::raw(sprintf('staged_bytes + (%d)', $delta))]) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace a reservation with what the part actually weighs.
|
||||||
|
*
|
||||||
|
* Always called, whatever happened to the part: a body shorter than
|
||||||
|
* its Content-Length, a client that hung up mid-transfer, a part
|
||||||
|
* refused for being too long and deleted. Whatever is on disk now is
|
||||||
|
* the truth, and the difference goes back to the session — otherwise
|
||||||
|
* a client's own retries would slowly exhaust their room.
|
||||||
|
*/
|
||||||
|
public function settleStaged(int $reserved, int $actual): void
|
||||||
|
{
|
||||||
|
if ($reserved === $actual) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->reserveStaged($actual, $reserved);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,8 +96,9 @@ class FileVersions
|
|||||||
DB::transaction(function () use ($file, $previous, $root, $actor): void {
|
DB::transaction(function () use ($file, $previous, $root, $actor): void {
|
||||||
// Move, never drop: a revision holds no recipients of its
|
// Move, never drop: a revision holds no recipients of its
|
||||||
// own, but the people who already had this file must not
|
// own, but the people who already had this file must not
|
||||||
// lose it. Through FileSharing so each target still gets
|
// lose it. Through FileSharing, so a target the root does
|
||||||
// its activity entry, notification and digest.
|
// not hold yet still gets its activity entry, notification
|
||||||
|
// and digest — and only such a target, see below.
|
||||||
$this->moveAssignmentsToRoot($file, $root);
|
$this->moveAssignmentsToRoot($file, $root);
|
||||||
|
|
||||||
$file->update([
|
$file->update([
|
||||||
@@ -544,8 +545,27 @@ class FileVersions
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// firstOrCreate inside, so a target the root already has is a
|
// A target the root already holds gains nothing here, so it
|
||||||
// no-op rather than a duplicate notification.
|
// is skipped rather than handed to FileSharing::assign().
|
||||||
|
// That method's firstOrCreate makes the assignment row
|
||||||
|
// idempotent but not the three side effects under it, so such
|
||||||
|
// a target was told a file had been shared with it about a
|
||||||
|
// file it already had — on top of the file_new_version it
|
||||||
|
// gets from sharedAudience(), which is exactly the two
|
||||||
|
// notifications for one action link() resolves that audience
|
||||||
|
// early to avoid. copyAssignmentsFrom() below states the rule
|
||||||
|
// outright for its own case: nobody is gaining access, so the
|
||||||
|
// notification would be a lie.
|
||||||
|
$alreadyOnRoot = FileAssignment::query()
|
||||||
|
->where('file_id', $root->id)
|
||||||
|
->where('assignable_type', $target->getMorphClass())
|
||||||
|
->where('assignable_id', $target->getKey())
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($alreadyOnRoot) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$this->sharing->assign($root, $target, $target->name);
|
$this->sharing->assign($root, $target, $target->name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Modules\Audit\ActivityLogger;
|
|||||||
use App\Modules\Files\Access\StaffLibraryScope;
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
use App\Modules\Groups\Http\Resources\Api\GroupResource;
|
use App\Modules\Groups\Http\Resources\Api\GroupResource;
|
||||||
use App\Modules\Groups\Models\Group;
|
use App\Modules\Groups\Models\Group;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ class GroupMembersController extends Controller
|
|||||||
|
|
||||||
$this->activity->log(Action::GroupMemberAdded, subject: $group, context: ['member' => $client->name]);
|
$this->activity->log(Action::GroupMemberAdded, subject: $group, context: ['member' => $client->name]);
|
||||||
|
|
||||||
return new GroupResource($group->loadCount('members')->load('members'));
|
return $this->response($group, $actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Request $request, Group $group, User $member): GroupResource
|
public function destroy(Request $request, Group $group, User $member): GroupResource
|
||||||
@@ -72,6 +73,28 @@ class GroupMembersController extends Controller
|
|||||||
|
|
||||||
$this->activity->log(Action::GroupMemberRemoved, subject: $group, context: ['member' => $member->name]);
|
$this->activity->log(Action::GroupMemberRemoved, subject: $group, context: ['member' => $member->name]);
|
||||||
|
|
||||||
return new GroupResource($group->loadCount('members')->load('members'));
|
return $this->response($group, $actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The group as this actor may see it.
|
||||||
|
*
|
||||||
|
* GroupResource carries a name and an email per member, and its own
|
||||||
|
* docblock puts the boundary here: "the controller loading this
|
||||||
|
* relation is where that narrowing is applied". Api\GroupsController
|
||||||
|
* ::show() applies it for the read of the same group; changing the
|
||||||
|
* membership is not a reason to be told more than reading it, so both
|
||||||
|
* halves narrow by the same query.
|
||||||
|
*
|
||||||
|
* The count is deliberately not narrowed. members_count is the size of
|
||||||
|
* the group, which is a fact about the group rather than about who is
|
||||||
|
* in it, and the web screen shows the same total.
|
||||||
|
*/
|
||||||
|
private function response(Group $group, User $actor): GroupResource
|
||||||
|
{
|
||||||
|
return new GroupResource($group->loadCount('members')->load([
|
||||||
|
'members' => fn (BelongsToMany $members) => $members
|
||||||
|
->whereIn('users.id', $this->scope->clients($actor)->select('id')),
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use App\Modules\Files\Access\StaffLibraryScope;
|
|||||||
use App\Modules\Groups\Models\Group;
|
use App\Modules\Groups\Models\Group;
|
||||||
use App\Support\Rules;
|
use App\Support\Rules;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
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;
|
||||||
@@ -40,7 +41,13 @@ class GroupsController extends Controller
|
|||||||
'visibility' => ['nullable', Rule::in(['public', 'private'])],
|
'visibility' => ['nullable', Rule::in(['public', 'private'])],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$query = Group::query()->withCount('members');
|
$viewer = $request->user();
|
||||||
|
assert($viewer !== null);
|
||||||
|
|
||||||
|
// The API twin of the web listing's narrowing, and it has to be
|
||||||
|
// here rather than only there: the same disclosure through a token
|
||||||
|
// is the same disclosure (GHSA-r3hg-3fxw-rcmr).
|
||||||
|
$query = $this->scope->groups($viewer)->withCount('members');
|
||||||
|
|
||||||
if (($filters['search'] ?? null) !== null) {
|
if (($filters['search'] ?? null) !== null) {
|
||||||
$search = $filters['search'];
|
$search = $filters['search'];
|
||||||
@@ -56,9 +63,20 @@ class GroupsController extends Controller
|
|||||||
return GroupResource::collection($this->polling->paginate($request, $query, 'groups'));
|
return GroupResource::collection($this->polling->paginate($request, $query, 'groups'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Group $group): GroupResource
|
public function show(Request $request, Group $group): GroupResource
|
||||||
{
|
{
|
||||||
return new GroupResource($group->loadCount('members')->load('members'));
|
$viewer = $request->user();
|
||||||
|
assert($viewer !== null);
|
||||||
|
|
||||||
|
// The web edit screen's boundary, on its API twin: this is the read
|
||||||
|
// half of the group that update() and destroy() below already refuse
|
||||||
|
// to touch, and it hands back the membership with addresses.
|
||||||
|
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
|
||||||
|
|
||||||
|
return new GroupResource($group->loadCount('members')->load([
|
||||||
|
'members' => fn (BelongsToMany $members) => $members
|
||||||
|
->whereIn('users.id', $this->scope->clients($viewer)->select('id')),
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ use App\Modules\Audit\Action;
|
|||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
use App\Modules\Files\Access\StaffLibraryScope;
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
use App\Modules\Groups\Models\Group;
|
use App\Modules\Groups\Models\Group;
|
||||||
use App\Modules\Identity\UserType;
|
|
||||||
use App\Support\Pagination;
|
use App\Support\Pagination;
|
||||||
use App\Support\PublicUrl;
|
use App\Support\PublicUrl;
|
||||||
use App\Support\Rules;
|
use App\Support\Rules;
|
||||||
@@ -41,7 +40,14 @@ class GroupsController extends Controller
|
|||||||
'visibility' => $validated['visibility'] ?? null,
|
'visibility' => $validated['visibility'] ?? null,
|
||||||
];
|
];
|
||||||
|
|
||||||
$groups = Group::query()
|
$viewer = $request->user();
|
||||||
|
assert($viewer !== null);
|
||||||
|
|
||||||
|
// Scoped, not Group::query(): a client-scoped staff member is told
|
||||||
|
// about a group because one of their clients is in it. Without
|
||||||
|
// this the listing showed every group on the installation, to a
|
||||||
|
// viewer who could reach nothing of theirs (GHSA-r3hg-3fxw-rcmr).
|
||||||
|
$groups = $this->scope->groups($viewer)
|
||||||
->withCount('members')
|
->withCount('members')
|
||||||
->when($filters['search'], fn (Builder $query, string $search) => $query->where(fn (Builder $q) => $q
|
->when($filters['search'], fn (Builder $query, string $search) => $query->where(fn (Builder $q) => $q
|
||||||
->where('name', 'like', "%{$search}%")
|
->where('name', 'like', "%{$search}%")
|
||||||
@@ -102,8 +108,18 @@ class GroupsController extends Controller
|
|||||||
return $target->with('success', __('Group created.'));
|
return $target->with('success', __('Group created.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function edit(Group $group): Response
|
public function edit(Request $request, Group $group): Response
|
||||||
{
|
{
|
||||||
|
$viewer = $request->user();
|
||||||
|
assert($viewer !== null);
|
||||||
|
|
||||||
|
// The same reach question update() and destroy() ask, asked one
|
||||||
|
// step earlier. Without it this was the one group route holding no
|
||||||
|
// library boundary at all: a scoped staff member could open a group
|
||||||
|
// whose contents they cannot see, read its membership off the
|
||||||
|
// screen, and only be refused on save.
|
||||||
|
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
|
||||||
|
|
||||||
return Inertia::render('groups/edit', [
|
return Inertia::render('groups/edit', [
|
||||||
'group' => [
|
'group' => [
|
||||||
'id' => $group->id,
|
'id' => $group->id,
|
||||||
@@ -112,14 +128,24 @@ class GroupsController extends Controller
|
|||||||
'description' => $group->description,
|
'description' => $group->description,
|
||||||
'public' => $group->public,
|
'public' => $group->public,
|
||||||
],
|
],
|
||||||
'members' => $group->members()->orderBy('name')->get()
|
// Both lists narrow through StaffLibraryScope::clients(), which
|
||||||
|
// is the listing half of the rule this screen's buttons are
|
||||||
|
// already guarded with: a member outside the roster cannot be
|
||||||
|
// removed here (allowsGroupMembership refuses it), and a client
|
||||||
|
// outside it cannot be added. Naming them anyway, with their
|
||||||
|
// address, was the same mistake the client list made before
|
||||||
|
// that method existed. An unscoped viewer sees everything,
|
||||||
|
// unchanged.
|
||||||
|
'members' => $group->members()
|
||||||
|
->whereIn('users.id', $this->scope->clients($viewer)->select('id'))
|
||||||
|
->orderBy('name')
|
||||||
|
->get()
|
||||||
->map(fn (User $member): array => [
|
->map(fn (User $member): array => [
|
||||||
'id' => $member->id,
|
'id' => $member->id,
|
||||||
'name' => $member->name,
|
'name' => $member->name,
|
||||||
'email' => $member->email,
|
'email' => $member->email,
|
||||||
])->all(),
|
])->all(),
|
||||||
'available_clients' => User::query()
|
'available_clients' => $this->scope->clients($viewer)
|
||||||
->where('type', UserType::Client)
|
|
||||||
->whereNotIn('id', $group->members()->pluck('users.id'))
|
->whereNotIn('id', $group->members()->pluck('users.id'))
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get()
|
->get()
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ 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\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\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\Preview\PreviewKind;
|
use App\Modules\Files\Preview\PreviewKind;
|
||||||
|
use App\Modules\Files\Preview\PreviewLog;
|
||||||
use App\Modules\Files\Thumbnails\ImageAudience;
|
use App\Modules\Files\Thumbnails\ImageAudience;
|
||||||
use App\Modules\Files\Thumbnails\ImageRendition;
|
use App\Modules\Files\Thumbnails\ImageRendition;
|
||||||
use App\Modules\Files\Thumbnails\LocalSourceFile;
|
use App\Modules\Files\Thumbnails\LocalSourceFile;
|
||||||
@@ -32,12 +34,12 @@ use Illuminate\Database\Eloquent\Builder;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
|
||||||
use Illuminate\Pagination\Paginator;
|
use Illuminate\Pagination\Paginator;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response as InertiaResponse;
|
use Inertia\Response as InertiaResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The guest-facing side of a public group: no Gate/policy involved (same
|
* The guest-facing side of a public group: no Gate/policy involved (same
|
||||||
@@ -77,6 +79,7 @@ class PublicGroupsController 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 PreviewLog $previews,
|
||||||
private readonly DownloadAllowance $allowance,
|
private readonly DownloadAllowance $allowance,
|
||||||
private readonly ThumbnailGenerator $thumbnails,
|
private readonly ThumbnailGenerator $thumbnails,
|
||||||
private readonly PublicThemeRegistry $themes,
|
private readonly PublicThemeRegistry $themes,
|
||||||
@@ -84,6 +87,7 @@ class PublicGroupsController extends Controller
|
|||||||
private readonly CommentingRules $commenting,
|
private readonly CommentingRules $commenting,
|
||||||
private readonly StoredFileResponse $bytes,
|
private readonly StoredFileResponse $bytes,
|
||||||
private readonly LocalSourceFile $source,
|
private readonly LocalSourceFile $source,
|
||||||
|
private readonly FileDelivery $delivery,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request, string $publicSlug): InertiaResponse|RedirectResponse
|
public function index(Request $request, string $publicSlug): InertiaResponse|RedirectResponse
|
||||||
@@ -251,6 +255,13 @@ class PublicGroupsController extends Controller
|
|||||||
|
|
||||||
$disk = Storage::disk('files');
|
$disk = Storage::disk('files');
|
||||||
|
|
||||||
|
// An empty file is not a rendition — same rule as the signed-in
|
||||||
|
// twin in FileThumbnailController::render(), and the same reason:
|
||||||
|
// nothing invalidates one once it is cached.
|
||||||
|
if ($disk->exists($thumbnailPath) && $disk->size($thumbnailPath) === 0) {
|
||||||
|
$disk->delete($thumbnailPath);
|
||||||
|
}
|
||||||
|
|
||||||
if (! $disk->exists($thumbnailPath)) {
|
if (! $disk->exists($thumbnailPath)) {
|
||||||
$disk->makeDirectory(dirname($thumbnailPath));
|
$disk->makeDirectory(dirname($thumbnailPath));
|
||||||
|
|
||||||
@@ -267,11 +278,11 @@ class PublicGroupsController extends Controller
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
return response('', 200, [
|
return $this->delivery->serve(
|
||||||
'X-Accel-Redirect' => '/protected-files/'.$thumbnailPath,
|
$thumbnailPath,
|
||||||
'Content-Type' => $file->mime_type,
|
$file->mime_type,
|
||||||
'Content-Disposition' => ContentDisposition::inline($file->original_name),
|
ContentDisposition::inline($file->original_name),
|
||||||
]);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -298,7 +309,11 @@ class PublicGroupsController extends Controller
|
|||||||
// nothing.
|
// nothing.
|
||||||
abort_unless($this->allowance->allows($file, null), 403);
|
abort_unless($this->allowance->allows($file, null), 403);
|
||||||
|
|
||||||
$this->activity->log(Action::PublicFilePreviewed, subject: $file);
|
// Debounced exactly as the signed-in twin is, and for the same
|
||||||
|
// reason: a single visitor watching one video arrives here dozens
|
||||||
|
// of times. Without a viewer to key on, PreviewLog keys on the
|
||||||
|
// request IP.
|
||||||
|
$this->previews->record(Action::PublicFilePreviewed, $file, null);
|
||||||
|
|
||||||
return $this->bytes->inline($file);
|
return $this->bytes->inline($file);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ use Illuminate\Http\Resources\Json\JsonResource;
|
|||||||
* @mixin Group
|
* @mixin Group
|
||||||
*
|
*
|
||||||
* Members carry a name and an email, which is what the group edit screen
|
* Members carry a name and an email, which is what the group edit screen
|
||||||
* already shows to anyone holding `edit_groups`. They are attached only
|
* shows the same viewer. That is a claim about the screen, so it holds
|
||||||
* when explicitly loaded, so a listing of groups does not become a bulk
|
* only for as long as the screen does: both narrow the list to the
|
||||||
* export of every client's address.
|
* clients the viewer may act on, and the controller loading this relation
|
||||||
|
* is where that narrowing is applied. They are attached only when
|
||||||
|
* explicitly loaded, so a listing of groups does not become a bulk export
|
||||||
|
* of every client's address.
|
||||||
*/
|
*/
|
||||||
class GroupResource extends JsonResource
|
class GroupResource extends JsonResource
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ namespace App\Modules\Identity;
|
|||||||
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\Access\StaffLibraryScope;
|
||||||
use App\Modules\Files\DeletedAccountContent;
|
use App\Modules\Files\DeletedAccountContent;
|
||||||
use App\Modules\Identity\Models\Role;
|
use App\Modules\Identity\Models\Role;
|
||||||
|
use Closure;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
@@ -32,21 +34,36 @@ class AccountContentDeletion
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly DeletedAccountContent $content,
|
private readonly DeletedAccountContent $content,
|
||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
|
private readonly StaffLibraryScope $scope,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every other active account, for the reassignment-target picker.
|
* Every other active account this viewer may be shown, for the
|
||||||
* $excludeId is omitted on index pages, where one candidate list is
|
* reassignment-target picker. $excludeId is omitted on index pages,
|
||||||
* shared across every row and each row's own id is filtered out
|
* where one candidate list is shared across every row and each row's
|
||||||
* client-side instead.
|
* own id is filtered out client-side instead.
|
||||||
|
*
|
||||||
|
* The client half is narrowed by StaffLibraryScope, the same rule that
|
||||||
|
* narrows the list this picker sits next to: a client-scoped staff
|
||||||
|
* member is not shown the name of somebody they can reach nothing of,
|
||||||
|
* and a picker is no more a reason to hand one over than a listing is.
|
||||||
|
* Staff accounts are not narrowed anywhere in the application and are
|
||||||
|
* not narrowed here.
|
||||||
|
*
|
||||||
|
* An unscoped viewer's list is unchanged — StaffLibraryScope::clients()
|
||||||
|
* returns every client for them.
|
||||||
|
*
|
||||||
|
* $viewer is null only where the picker is about the installation
|
||||||
|
* rather than about a screen: the erasure default in privacy settings
|
||||||
|
* is stored once for everybody, behind edit_settings, so narrowing it
|
||||||
|
* by whoever happens to be editing would store the wrong answer.
|
||||||
*
|
*
|
||||||
* @return array<int, array{id: int, name: string, role: string}>
|
* @return array<int, array{id: int, name: string, role: string}>
|
||||||
*/
|
*/
|
||||||
public function candidates(?int $excludeId = null): array
|
public function candidates(?User $viewer, ?int $excludeId = null): array
|
||||||
{
|
{
|
||||||
return User::query()
|
return $this->reachableTargets($viewer)
|
||||||
->when($excludeId, fn (Builder $query, int $id) => $query->whereKeyNot($id))
|
->when($excludeId, fn (Builder $query, int $id) => $query->whereKeyNot($id))
|
||||||
->where('active', true)
|
|
||||||
->with('role')
|
->with('role')
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get()
|
->get()
|
||||||
@@ -79,17 +96,62 @@ class AccountContentDeletion
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$viewer = $request->user();
|
||||||
|
|
||||||
return $request->validate([
|
return $request->validate([
|
||||||
'content_action' => ['required', Rule::in(['cascade_delete', 'reassign'])],
|
'content_action' => ['required', Rule::in(['cascade_delete', 'reassign'])],
|
||||||
'reassign_to_id' => [
|
'reassign_to_id' => [
|
||||||
'required_if:content_action,reassign',
|
'required_if:content_action,reassign',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('users', 'id')->where('active', true),
|
|
||||||
Rule::notIn([$target->id]),
|
Rule::notIn([$target->id]),
|
||||||
|
// The same question the picker asks, asked again of what
|
||||||
|
// came back from it. It used to be "exists, and is active",
|
||||||
|
// which is not the boundary the picker documents two
|
||||||
|
// methods up: a client-scoped staff member was shown their
|
||||||
|
// own roster and could name anybody, so deleting a roster
|
||||||
|
// client could hand that client's files and folders to a
|
||||||
|
// client on somebody else's roster — who then reads, edits
|
||||||
|
// and deletes them under the own-upload rules
|
||||||
|
// (GHSA-w29w-pj29-x7ww).
|
||||||
|
//
|
||||||
|
// One predicate for both, rather than a matching pair: a
|
||||||
|
// picker that promises a boundary the write does not keep
|
||||||
|
// is exactly what this was.
|
||||||
|
function (string $attribute, mixed $value, Closure $fail) use ($viewer): void {
|
||||||
|
if (! $this->reachableTargets($viewer)->whereKey($value)->exists()) {
|
||||||
|
// Deliberately the message an id that does not
|
||||||
|
// exist at all would get. "Not yours" and "not
|
||||||
|
// there" have to read the same, or refusing is how
|
||||||
|
// a scoped staff member enumerates the accounts
|
||||||
|
// outside their roster.
|
||||||
|
$fail('validation.exists')->translate();
|
||||||
|
}
|
||||||
|
},
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every active account $viewer may hand content to: staff, who are
|
||||||
|
* narrowed nowhere in the application, plus the clients
|
||||||
|
* StaffLibraryScope shows them. An unscoped viewer gets everybody,
|
||||||
|
* because clients() returns everybody for them.
|
||||||
|
*
|
||||||
|
* $viewer is null only where the question is about the installation
|
||||||
|
* rather than about a screen — the erasure default in privacy
|
||||||
|
* settings, which is stored once for everybody.
|
||||||
|
*
|
||||||
|
* @return Builder<User>
|
||||||
|
*/
|
||||||
|
private function reachableTargets(?User $viewer): Builder
|
||||||
|
{
|
||||||
|
return User::query()
|
||||||
|
->when($viewer, fn (Builder $query, User $for) => $query->where(fn (Builder $reachable) => $reachable
|
||||||
|
->where('type', UserType::Staff)
|
||||||
|
->orWhereIn('id', $this->scope->clients($for)->select('users.id'))))
|
||||||
|
->where('active', true);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{content_action?: string, reassign_to_id?: int} $validated
|
* @param array{content_action?: string, reassign_to_id?: int} $validated
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Identity;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finding the account that holds an address, exactly.
|
||||||
|
*
|
||||||
|
* `where('email', $address)` is not an exact match. It is whatever the
|
||||||
|
* database's collation says equality means, and the documented one here —
|
||||||
|
* `utf8mb4_unicode_ci`, in INSTALL.md and in config/database.php — folds
|
||||||
|
* accents:
|
||||||
|
*
|
||||||
|
* administrator@example.com = administrator@éxample.com -> 1
|
||||||
|
*
|
||||||
|
* Those are two different domains. `éxample.com` is `xn--xample-9ua.com`,
|
||||||
|
* a name somebody else can own and prove they own. So an attacker could
|
||||||
|
* register the second at an OIDC provider, verify it honestly, sign in,
|
||||||
|
* and be handed the first account — no password, no interaction from its
|
||||||
|
* owner, and an administrator session if that account was one
|
||||||
|
* (GHSA-wgxf-v8cr-37mj).
|
||||||
|
*
|
||||||
|
* ### Loose is right when refusing and wrong when selecting
|
||||||
|
*
|
||||||
|
* The same looseness protects elsewhere and is deliberately left alone.
|
||||||
|
* `AvailableEmailRule` and `ClientProvisioning::emailIsAvailable()` ask
|
||||||
|
* "is this address free?", and a collation that answers "no" to a
|
||||||
|
* near-miss refuses *more* registrations, which is the safe direction.
|
||||||
|
* This class is for the other question — "which account is this?" — where
|
||||||
|
* matching more than you meant hands somebody an account.
|
||||||
|
*
|
||||||
|
* ### Why the filtering is in PHP
|
||||||
|
*
|
||||||
|
* A `COLLATE utf8mb4_bin` in the query would work on MySQL and break
|
||||||
|
* everywhere else, and the test suite runs on SQLite, which is byte-exact
|
||||||
|
* and would never have shown the bug in the first place. Comparing here
|
||||||
|
* gives one answer on every driver, and it is the answer that does not
|
||||||
|
* depend on how somebody created their database.
|
||||||
|
*
|
||||||
|
* Case is still folded, because that is a real requirement rather than an
|
||||||
|
* accident: addresses are stored lowercased and a provider may send any
|
||||||
|
* case. `mb_strtolower` folds case without folding accents, which is
|
||||||
|
* exactly the line to draw.
|
||||||
|
*/
|
||||||
|
class AccountLookup
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The account whose address is exactly this one, or null.
|
||||||
|
*
|
||||||
|
* @param bool $withTrashed include soft-deleted accounts — a
|
||||||
|
* deleted account still holds its address
|
||||||
|
* until erasure
|
||||||
|
*/
|
||||||
|
public function byEmail(string $email, bool $withTrashed = false): ?User
|
||||||
|
{
|
||||||
|
$query = $withTrashed ? User::withTrashed() : User::query();
|
||||||
|
|
||||||
|
// The database narrows, this decides. A collation that matches too
|
||||||
|
// much returns extra rows here and they are dropped; one that
|
||||||
|
// matches too little was never going to return the right row at
|
||||||
|
// all, which is a different bug and not one anybody has.
|
||||||
|
return $query->where('email', $email)->get()
|
||||||
|
->first(fn (User $user): bool => $this->isSameAddress($user->email, $email));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether two strings name the same mailbox: case-insensitively, and
|
||||||
|
* byte-exact about everything else.
|
||||||
|
*/
|
||||||
|
public function isSameAddress(?string $stored, ?string $given): bool
|
||||||
|
{
|
||||||
|
if ($stored === null || $given === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mb_strtolower(trim($stored), 'UTF-8') === mb_strtolower(trim($given), 'UTF-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ 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\Identity\Erasure\AvailableEmailRule;
|
use App\Modules\Identity\Erasure\AvailableEmailRule;
|
||||||
|
use App\Modules\Identity\FirstAdministrator;
|
||||||
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\UserType;
|
use App\Modules\Identity\UserType;
|
||||||
@@ -30,7 +31,14 @@ class CreateAdminCommand extends Command
|
|||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
if ($this->option('if-none') && User::query()->where('type', UserType::Staff)->exists()) {
|
$ifNone = (bool) $this->option('if-none');
|
||||||
|
|
||||||
|
// Asked early so an unattended boot does not prompt for a name and
|
||||||
|
// a password it is about to throw away. It is asked again below,
|
||||||
|
// under a lock, because this read on its own has the same hole the
|
||||||
|
// setup screen had: two containers coming up against one database
|
||||||
|
// both see no staff and both create an administrator.
|
||||||
|
if ($ifNone && $this->staffExists()) {
|
||||||
$this->info('A staff user already exists; nothing to do.');
|
$this->info('A staff user already exists; nothing to do.');
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
@@ -57,15 +65,36 @@ class CreateAdminCommand extends Command
|
|||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = User::create([
|
$create = function () use ($name, $email, $password): User {
|
||||||
'type' => UserType::Staff,
|
$user = User::create([
|
||||||
'active' => true,
|
'type' => UserType::Staff,
|
||||||
'role_id' => Role::query()->where('name', SystemRole::SystemAdministrator->value)->value('id'),
|
'active' => true,
|
||||||
'name' => $name,
|
'role_id' => Role::query()->where('name', SystemRole::SystemAdministrator->value)->value('id'),
|
||||||
'email' => $email,
|
'name' => $name,
|
||||||
'password' => $password,
|
'email' => $email,
|
||||||
'email_verified_at' => now(),
|
'password' => $password,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// forceFill, for the reason SetupController gives beside it:
|
||||||
|
// email_verified_at is not in User::$fillable, so passing it
|
||||||
|
// into create() lost it without a word. Whoever provisioned
|
||||||
|
// this container supplied the address themselves.
|
||||||
|
$user->forceFill(['email_verified_at' => now()])->save();
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Without --if-none an operator is asking for an administrator
|
||||||
|
// outright, whoever else exists, so there is nothing to claim.
|
||||||
|
$user = $ifNone
|
||||||
|
? FirstAdministrator::claim(fn (): bool => ! $this->staffExists(), $create)
|
||||||
|
: $create();
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
$this->info('A staff user already exists; nothing to do.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
app(ActivityLogger::class)->log(Action::UserCreated, null, $user);
|
app(ActivityLogger::class)->log(Action::UserCreated, null, $user);
|
||||||
|
|
||||||
@@ -84,4 +113,12 @@ class CreateAdminCommand extends Command
|
|||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @phpstan-impure another process can create one between two calls
|
||||||
|
*/
|
||||||
|
private function staffExists(): bool
|
||||||
|
{
|
||||||
|
return User::query()->where('type', UserType::Staff)->exists();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Modules\Identity\Console;
|
namespace App\Modules\Identity\Console;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Modules\Identity\AccountLookup;
|
||||||
use App\Modules\Identity\Erasure\AccountEraser;
|
use App\Modules\Identity\Erasure\AccountEraser;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
@@ -25,7 +26,10 @@ class EraseAccountCommand extends Command
|
|||||||
{
|
{
|
||||||
$email = (string) $this->argument('email');
|
$email = (string) $this->argument('email');
|
||||||
|
|
||||||
$user = User::withTrashed()->where('email', $email)->first();
|
// Exact: this deletes somebody permanently, and a collation that
|
||||||
|
// folds accents could hand it a different account than the one an
|
||||||
|
// operator typed. See AccountLookup.
|
||||||
|
$user = app(AccountLookup::class)->byEmail($email, withTrashed: true);
|
||||||
|
|
||||||
if ($user === null) {
|
if ($user === null) {
|
||||||
$this->error("No account found for {$email}.");
|
$this->error("No account found for {$email}.");
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Modules\Audit\Action;
|
|||||||
use App\Modules\Audit\ActivityLog;
|
use App\Modules\Audit\ActivityLog;
|
||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
use App\Modules\Files\DeletedAccountContent;
|
use App\Modules\Files\DeletedAccountContent;
|
||||||
|
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\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -102,6 +103,24 @@ class AccountEraser
|
|||||||
$this->activity->logSystem(Action::AccountContentCascadeDeleted, ['name' => $user->name, ...$result]);
|
$this->activity->logSystem(Action::AccountContentCascadeDeleted, ['name' => $user->name, ...$result]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account configured to inherit erased content, or null when
|
||||||
|
* there is nobody valid to hand it to — in which case handleContent()
|
||||||
|
* cascades, because orphaning is never the answer.
|
||||||
|
*
|
||||||
|
* **A staff member's content may only go to staff.** The target is one
|
||||||
|
* installation-wide id used for every erasure, and the picker offers
|
||||||
|
* clients on purpose: erasing a client and handing their files to
|
||||||
|
* another client is what the setting is for. Applied to a *staff*
|
||||||
|
* account the same id means something else entirely — a staff library
|
||||||
|
* is usually the whole installation's, and a client named there would
|
||||||
|
* inherit all of it, in one unattended scheduled job.
|
||||||
|
*
|
||||||
|
* The check cannot live in the settings validation, which is where it
|
||||||
|
* would otherwise belong: that runs when the target is chosen, and
|
||||||
|
* whose account will be erased later is not knowable then. So it is
|
||||||
|
* asked here, where both halves are in hand.
|
||||||
|
*/
|
||||||
private function fallbackFor(User $user): ?User
|
private function fallbackFor(User $user): ?User
|
||||||
{
|
{
|
||||||
$id = (int) $this->settings->get(Setting::AccountErasureReassignTo);
|
$id = (int) $this->settings->get(Setting::AccountErasureReassignTo);
|
||||||
@@ -113,6 +132,7 @@ class AccountEraser
|
|||||||
return User::query()
|
return User::query()
|
||||||
->where('active', true)
|
->where('active', true)
|
||||||
->whereKeyNot($user->id)
|
->whereKeyNot($user->id)
|
||||||
|
->when($user->isStaff(), fn ($query) => $query->where('type', UserType::Staff))
|
||||||
->find($id);
|
->find($id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Identity;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Modules\Identity\Models\Role;
|
||||||
|
use App\Modules\Identity\Permissions\EnsureSystemRoles;
|
||||||
|
use App\Modules\Identity\Permissions\SystemRole;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creating the very first administrator is a claim, not a check followed
|
||||||
|
* by an insert.
|
||||||
|
*
|
||||||
|
* "Has this installation been set up" is answered by asking whether any
|
||||||
|
* staff row exists, and an empty result has nothing in it to lock. So two
|
||||||
|
* unauthenticated setup requests arriving together both read "no staff",
|
||||||
|
* both spend a quarter of a second hashing a password, and both insert a
|
||||||
|
* System Administrator. The operator's own setup succeeds and looks
|
||||||
|
* entirely normal, which is the point: a stranger walks away with a
|
||||||
|
* second, permanent administrator account and nothing says so.
|
||||||
|
* (GHSA-w3w9-prpw-qx77, reported by @ry2811.)
|
||||||
|
*
|
||||||
|
* What gets locked is the System Administrator role row. It is the thing
|
||||||
|
* being claimed; it is written by the roles migration and rewritten on
|
||||||
|
* every boot, so unlike the staff rows it is always there to be locked.
|
||||||
|
* The second caller waits on it, and by the time it has the lock the
|
||||||
|
* first caller's user row is committed and visible — so its own re-check,
|
||||||
|
* asked inside the claim this time, sees an installation that is already
|
||||||
|
* set up and creates nothing.
|
||||||
|
*
|
||||||
|
* Locking the staff query itself would not do. There are no matching rows
|
||||||
|
* on a fresh install, and a lock over nothing serialises nothing.
|
||||||
|
*/
|
||||||
|
final class FirstAdministrator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create the initial administrator, or nothing if somebody else got
|
||||||
|
* there first.
|
||||||
|
*
|
||||||
|
* @param callable(): bool $stillNeeded asked again with the claim held
|
||||||
|
* @param callable(): User $create runs only if it is still needed
|
||||||
|
* @return User|null null when the claim was lost
|
||||||
|
*/
|
||||||
|
public static function claim(callable $stillNeeded, callable $create): ?User
|
||||||
|
{
|
||||||
|
// The lock needs a row to bite on. This is idempotent and already
|
||||||
|
// runs on every boot; asking again costs one query on the one
|
||||||
|
// request in the life of an installation that comes through here,
|
||||||
|
// and means a database somehow missing its roles gets them back
|
||||||
|
// rather than quietly racing.
|
||||||
|
(new EnsureSystemRoles)->ensure();
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($stillNeeded, $create): ?User {
|
||||||
|
Role::query()
|
||||||
|
->where('name', SystemRole::SystemAdministrator->value)
|
||||||
|
->lockForUpdate()
|
||||||
|
->value('id');
|
||||||
|
|
||||||
|
return $stillNeeded() ? $create() : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ namespace App\Modules\Identity\Http\Controllers;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Modules\Api\Auth\ApiTokens;
|
use App\Modules\Api\Auth\ApiTokens;
|
||||||
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
use App\Modules\Files\DeletedAccountContent;
|
use App\Modules\Files\DeletedAccountContent;
|
||||||
use App\Modules\Identity\AccountConversion;
|
use App\Modules\Identity\AccountConversion;
|
||||||
use App\Modules\Identity\Models\Role;
|
use App\Modules\Identity\Models\Role;
|
||||||
@@ -26,10 +27,12 @@ use Inertia\Response;
|
|||||||
/**
|
/**
|
||||||
* Moving an account between staff and clients.
|
* Moving an account between staff and clients.
|
||||||
*
|
*
|
||||||
* Community edition only, by the same route group as every other
|
* Both editions since 2.2.0, by the same route group as every other
|
||||||
* staff-account screen — managed installations create staff accounts
|
* staff-account screen: whoever may create a staff account may promote
|
||||||
* outside the application, so a converter there would be a second,
|
* one, and a managed installation limits that by seats rather than by
|
||||||
* unmanaged way to create one.
|
* closing the screen — AccountConversion asks SeatAllowance on both
|
||||||
|
* directions, because a promotion spends a staff seat and a demotion
|
||||||
|
* spends a client one.
|
||||||
*
|
*
|
||||||
* The rules live in AccountConversion, which calls StaffAccounts for the
|
* The rules live in AccountConversion, which calls StaffAccounts for the
|
||||||
* authority questions. This controller is the request shape and the
|
* authority questions. This controller is the request shape and the
|
||||||
@@ -44,6 +47,7 @@ class AccountConversionController extends Controller
|
|||||||
private readonly StaffAccounts $accounts,
|
private readonly StaffAccounts $accounts,
|
||||||
private readonly DeletedAccountContent $accountContent,
|
private readonly DeletedAccountContent $accountContent,
|
||||||
private readonly ApiTokens $apiTokens,
|
private readonly ApiTokens $apiTokens,
|
||||||
|
private readonly StaffLibraryScope $library,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): Response
|
public function index(Request $request): Response
|
||||||
@@ -57,8 +61,18 @@ class AccountConversionController extends Controller
|
|||||||
$search = $validated['search'] ?? null;
|
$search = $validated['search'] ?? null;
|
||||||
$actor = $this->actor($request);
|
$actor = $this->actor($request);
|
||||||
|
|
||||||
$accounts = User::query()
|
// Narrowed for the direction that lists clients, by the same rule
|
||||||
->where('type', $direction === 'to_client' ? UserType::Staff : UserType::Client)
|
// store() refuses one with: AccountConversion::guardToStaff() aborts
|
||||||
|
// 404 unless StaffLibraryScope::canAssignClient() allows the target,
|
||||||
|
// and clients() is that same method's listing half. Without this a
|
||||||
|
// client-scoped staff member was refused the promotion and then
|
||||||
|
// shown the person's name and address in the list it was refused
|
||||||
|
// from. Staff are not narrowed: whoever may demote a staff member
|
||||||
|
// may see the staff roster, which is what assignableRoles() and
|
||||||
|
// guardTarget() already decide on the write side.
|
||||||
|
$accounts = ($direction === 'to_client'
|
||||||
|
? User::query()->where('type', UserType::Staff)
|
||||||
|
: $this->library->clients($actor))
|
||||||
->with('role')
|
->with('role')
|
||||||
->when($search, fn (Builder $query, string $term) => $query->where(fn (Builder $inner) => $inner
|
->when($search, fn (Builder $query, string $term) => $query->where(fn (Builder $inner) => $inner
|
||||||
->where('name', 'like', "%{$term}%")
|
->where('name', 'like', "%{$term}%")
|
||||||
|
|||||||
@@ -26,12 +26,18 @@ use Illuminate\Validation\ValidationException;
|
|||||||
/**
|
/**
|
||||||
* Staff accounts over the API — the API twin of the /users screens.
|
* Staff accounts over the API — the API twin of the /users screens.
|
||||||
*
|
*
|
||||||
* **Community only.** Every route is behind `capability:users.manage`, so
|
* Both editions since 2.2.0. Every route is behind
|
||||||
* a cloud install answers 403 `capability_unavailable`: managed
|
* `capability:users.manage`, which cloud installations now hold as well:
|
||||||
* installations create staff accounts outside the application, and an API
|
* a platform sells staff seats and the tenant fills them, so an API that
|
||||||
* that could mint them there would be a second, unmanaged door into the
|
* creates one is the same door the screen is, not a second unmanaged one
|
||||||
* same thing.
|
* (see Capability::UsersManage). How many it may create is
|
||||||
* The routes are still registered in every edition so the committed
|
* SeatAllowance's question, asked here through StaffAccounts, and an
|
||||||
|
* installation at its limit answers 422 rather than 403.
|
||||||
|
*
|
||||||
|
* The capability stays in front of the routes rather than being dropped:
|
||||||
|
* it is the seam an edition difference would have to travel through, and
|
||||||
|
* an installation without it answers 403 `capability_unavailable`. The
|
||||||
|
* routes are registered in every edition either way, so the committed
|
||||||
* OpenAPI document is identical everywhere — the middleware refuses, the
|
* OpenAPI document is identical everywhere — the middleware refuses, the
|
||||||
* route table does not lie.
|
* route table does not lie.
|
||||||
*
|
*
|
||||||
@@ -176,15 +182,27 @@ class UsersController extends Controller
|
|||||||
'assigned_clients.*' => ['integer', Rule::in($this->accounts->assignableClientIds($actor))],
|
'assigned_clients.*' => ['integer', Rule::in($this->accounts->assignableClientIds($actor))],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Read through Request::boolean() rather than off the validated
|
||||||
|
// array, for the reason RolesController::guardScopeRemoval spells
|
||||||
|
// out: the `boolean` rule accepts 0 and "0" as well as false but
|
||||||
|
// does not cast, so a strict comparison lets through a value the
|
||||||
|
// model's own `boolean` cast then stores as false anyway. The same
|
||||||
|
// value goes to the guard and to the write.
|
||||||
|
$deactivating = array_key_exists('active', $validated) && ! $request->boolean('active');
|
||||||
|
|
||||||
// The same refusal the web screen makes, and for the same reason:
|
// The same refusal the web screen makes, and for the same reason:
|
||||||
// locking yourself out is never what was meant.
|
// locking yourself out is never what was meant.
|
||||||
if ($user->is($actor) && ($validated['active'] ?? true) === false) {
|
if ($user->is($actor) && $deactivating) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'active' => __('You cannot deactivate your own account.'),
|
'active' => __('You cannot deactivate your own account.'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$attributes = array_intersect_key($validated, array_flip(['name', 'email', 'active', 'password']));
|
$attributes = array_intersect_key($validated, array_flip(['name', 'email', 'password']));
|
||||||
|
|
||||||
|
if (array_key_exists('active', $validated)) {
|
||||||
|
$attributes['active'] = $request->boolean('active');
|
||||||
|
}
|
||||||
|
|
||||||
if (array_key_exists('role_id', $validated)) {
|
if (array_key_exists('role_id', $validated)) {
|
||||||
$attributes['role_id'] = (int) $validated['role_id'];
|
$attributes['role_id'] = (int) $validated['role_id'];
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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\Identity\FirstAdministrator;
|
||||||
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\UserType;
|
use App\Modules\Identity\UserType;
|
||||||
@@ -54,17 +55,46 @@ class SetupController extends Controller
|
|||||||
'password' => ['required', 'confirmed', Password::defaults()],
|
'password' => ['required', 'confirmed', Password::defaults()],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->settings->set(Setting::SiteName, $validated['site_name']);
|
// The check above is not enough on its own: it is a plain read, and
|
||||||
|
// between it and the insert a second setup request can do the same
|
||||||
|
// read and insert an administrator of its own. Everything this
|
||||||
|
// request writes therefore happens inside the claim, so a request
|
||||||
|
// that loses the race writes nothing at all — not the site name
|
||||||
|
// either. See FirstAdministrator.
|
||||||
|
$admin = FirstAdministrator::claim(
|
||||||
|
fn (): bool => ! $this->setupIsComplete(),
|
||||||
|
function () use ($validated): User {
|
||||||
|
$this->settings->set(Setting::SiteName, $validated['site_name']);
|
||||||
|
|
||||||
$admin = User::create([
|
$admin = User::create([
|
||||||
'type' => UserType::Staff,
|
'type' => UserType::Staff,
|
||||||
'active' => true,
|
'active' => true,
|
||||||
'role_id' => Role::query()->where('name', SystemRole::SystemAdministrator->value)->value('id'),
|
'role_id' => Role::query()->where('name', SystemRole::SystemAdministrator->value)->value('id'),
|
||||||
'name' => $validated['name'],
|
'name' => $validated['name'],
|
||||||
'email' => $validated['email'],
|
'email' => $validated['email'],
|
||||||
'password' => $validated['password'],
|
'password' => $validated['password'],
|
||||||
'email_verified_at' => now(),
|
]);
|
||||||
]);
|
|
||||||
|
// forceFill, not part of the create() array:
|
||||||
|
// email_verified_at is deliberately absent from
|
||||||
|
// User::$fillable, so mass assignment dropped it in silence
|
||||||
|
// and this account was never marked verified. The first
|
||||||
|
// administrator typed their own address into the form in
|
||||||
|
// front of them; there is nobody to confirm it to. (Inert
|
||||||
|
// today, since MustVerifyEmail is not enabled on the model,
|
||||||
|
// but the column is what a later switch would read.)
|
||||||
|
$admin->forceFill(['email_verified_at' => now()])->save();
|
||||||
|
|
||||||
|
return $admin;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Somebody else finished setup while this request was in flight.
|
||||||
|
// Theirs is the administrator that exists; this one is sent to the
|
||||||
|
// login screen like any other visitor to an installed site.
|
||||||
|
if ($admin === null) {
|
||||||
|
return redirect()->route('home');
|
||||||
|
}
|
||||||
|
|
||||||
// v1 logged installation as action 0; setup is a recorded action.
|
// v1 logged installation as action 0; setup is a recorded action.
|
||||||
$this->activity->log(Action::SetupCompleted, $admin);
|
$this->activity->log(Action::SetupCompleted, $admin);
|
||||||
@@ -97,8 +127,19 @@ class SetupController extends Controller
|
|||||||
return Inertia::render('setup-success');
|
return Inertia::render('setup-success');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trashed staff count, for the reason EnsureSetupIsComplete gives:
|
||||||
|
* this asks whether the installation was ever set up, and store()
|
||||||
|
* below is the door a stranger walks through if the answer is wrong.
|
||||||
|
* The middleware and this must agree — one of them saying "not set
|
||||||
|
* up" while the other says "set up" is either a redirect loop or an
|
||||||
|
* open form.
|
||||||
|
*
|
||||||
|
* @phpstan-impure asking twice can honestly give two answers, which is
|
||||||
|
* the entire reason store() asks a second time under a lock
|
||||||
|
*/
|
||||||
private function setupIsComplete(): bool
|
private function setupIsComplete(): bool
|
||||||
{
|
{
|
||||||
return User::query()->where('type', UserType::Staff)->exists();
|
return User::query()->withTrashed()->where('type', UserType::Staff)->exists();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use App\Modules\Identity\Models\Role;
|
|||||||
use App\Modules\Identity\StaffAccounts;
|
use App\Modules\Identity\StaffAccounts;
|
||||||
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
|
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
|
||||||
use App\Modules\Identity\UserType;
|
use App\Modules\Identity\UserType;
|
||||||
|
use App\Modules\Platform\Seats\SeatAllowance;
|
||||||
use App\Support\Pagination;
|
use App\Support\Pagination;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
@@ -26,9 +27,17 @@ use Inertia\Inertia;
|
|||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Staff ("system users") management — community edition only; managed
|
* Staff ("system users") management. Clients are a different population
|
||||||
* installations create them outside the application. Clients are a different
|
* managed by the Clients module: they never appear here.
|
||||||
* population managed by the Clients module: they never appear here.
|
*
|
||||||
|
* Available on both editions since 2.2.0. A managed installation is sold a
|
||||||
|
* number of seats and fills them itself — see Capability::UsersManage for
|
||||||
|
* why capacity is the platform's and who fills it is the tenant's.
|
||||||
|
*
|
||||||
|
* That makes a full installation an ordinary state rather than an error,
|
||||||
|
* so `index()` reports the seat position and `create()` refuses to open a
|
||||||
|
* form nothing can be submitted through. SeatAllowance::guardStaff() still
|
||||||
|
* runs in `store()`: this is the courtesy, that is the rule.
|
||||||
*/
|
*/
|
||||||
class UsersController extends Controller
|
class UsersController extends Controller
|
||||||
{
|
{
|
||||||
@@ -37,6 +46,7 @@ class UsersController extends Controller
|
|||||||
private readonly AccountContentDeletion $accountDeletion,
|
private readonly AccountContentDeletion $accountDeletion,
|
||||||
private readonly ApiTokens $apiTokens,
|
private readonly ApiTokens $apiTokens,
|
||||||
private readonly StaffAccounts $accounts,
|
private readonly StaffAccounts $accounts,
|
||||||
|
private readonly SeatAllowance $seats,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): Response
|
public function index(Request $request): Response
|
||||||
@@ -98,12 +108,29 @@ class UsersController extends Controller
|
|||||||
'filters' => $filters,
|
'filters' => $filters,
|
||||||
'roles' => Role::query()->orderBy('name')->get(['id', 'name'])
|
'roles' => Role::query()->orderBy('name')->get(['id', 'name'])
|
||||||
->map(fn (Role $role): array => ['id' => $role->id, 'name' => $role->name])->all(),
|
->map(fn (Role $role): array => ['id' => $role->id, 'name' => $role->name])->all(),
|
||||||
'reassign_candidates' => $this->accountDeletion->candidates(),
|
// Same rule as the clients list: the picker belongs to the
|
||||||
|
// delete dialog, so it is sent to whoever may open one.
|
||||||
|
'reassign_candidates' => $this->actor()->can('delete_users')
|
||||||
|
? $this->accountDeletion->candidates($this->actor())
|
||||||
|
: [],
|
||||||
|
// Null on a self-hosted install: no limit, nothing to say.
|
||||||
|
'seats' => $this->seats->staffState(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(): Response
|
public function create(): RedirectResponse|Response
|
||||||
{
|
{
|
||||||
|
// Turned away here rather than on submit. Somebody reaching this
|
||||||
|
// by link or bookmark used to fill in a name, an email and a
|
||||||
|
// password they had to invent, and learn the installation was full
|
||||||
|
// from a validation error under the email field — which reads as a
|
||||||
|
// fault with the address rather than a fact about the plan.
|
||||||
|
$seats = $this->seats->staffState();
|
||||||
|
|
||||||
|
if ($seats !== null && $seats['full']) {
|
||||||
|
return redirect()->route('users.index')->with('error', $seats['message']);
|
||||||
|
}
|
||||||
|
|
||||||
return Inertia::render('users/create', [
|
return Inertia::render('users/create', [
|
||||||
'roles' => $this->roleOptions(),
|
'roles' => $this->roleOptions(),
|
||||||
'clients' => $this->clientOptions(),
|
'clients' => $this->clientOptions(),
|
||||||
@@ -161,7 +188,9 @@ class UsersController extends Controller
|
|||||||
&& $this->accounts->isAdministratorRole($user->role_id)
|
&& $this->accounts->isAdministratorRole($user->role_id)
|
||||||
&& $this->accounts->activeAdministratorCount() === 1,
|
&& $this->accounts->activeAdministratorCount() === 1,
|
||||||
'content' => $this->accountContent->summarize($user),
|
'content' => $this->accountContent->summarize($user),
|
||||||
'reassign_candidates' => $this->accountDeletion->candidates($user->id),
|
'reassign_candidates' => $this->actor()->can('delete_users')
|
||||||
|
? $this->accountDeletion->candidates($this->actor(), $user->id)
|
||||||
|
: [],
|
||||||
// Read-only, deliberately: an administrator may see that an
|
// Read-only, deliberately: an administrator may see that an
|
||||||
// integration exists and what it is allowed to do, but only
|
// integration exists and what it is allowed to do, but only
|
||||||
// the owner can rename, re-scope or revoke it. See ApiTokens.
|
// the owner can rename, re-scope or revoke it. See ApiTokens.
|
||||||
|
|||||||
@@ -40,12 +40,17 @@ class EnforceTwoFactor
|
|||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
// password.confirm is on this list because the two-factor mutation
|
// password.confirm* is on this list because the two-factor mutation
|
||||||
// routes now require it: without the exemption, enrolling would
|
// routes now require it: without the exemption, enrolling would
|
||||||
// redirect to the confirm-password screen, which this middleware
|
// redirect to the confirm-password screen, which this middleware
|
||||||
// would redirect straight back to two-factor.show — a loop that
|
// would redirect straight back to two-factor.show — a loop that
|
||||||
// locks the user out of the only exit.
|
// locks the user out of the only exit.
|
||||||
if ($request->routeIs('two-factor.*', 'password.confirm', 'logout', 'locale.update')) {
|
//
|
||||||
|
// The pattern covers both halves of that screen. Naming only the
|
||||||
|
// GET left the form rendering and its submission redirected away,
|
||||||
|
// so the password was never confirmed and the loop stayed shut
|
||||||
|
// one step further along than before.
|
||||||
|
if ($request->routeIs('two-factor.*', 'password.confirm*', 'logout', 'locale.update')) {
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
* sent to the first-run setup screen. The database is the only source of
|
* sent to the first-run setup screen. The database is the only source of
|
||||||
* truth — no install flags. Client accounts do not count: setup is about
|
* truth — no install flags. Client accounts do not count: setup is about
|
||||||
* having an administrator.
|
* having an administrator.
|
||||||
|
*
|
||||||
|
* Trashed staff count. "Has this installation been set up" is not the
|
||||||
|
* same question as "does it have a working administrator right now", and
|
||||||
|
* only the first one belongs here: a soft-deleted staff row is still
|
||||||
|
* evidence that setup happened, and an installation that has lost its
|
||||||
|
* last administrator needs a recovery path, not a stranger filling in
|
||||||
|
* the first-run form. Deleting a staff account is guarded against
|
||||||
|
* reaching zero (StaffAccounts::guardLastAdministrator), so this is the
|
||||||
|
* second lock rather than the first — but the first one is asked at five
|
||||||
|
* separate doors, and this one is asked once.
|
||||||
*/
|
*/
|
||||||
class EnsureSetupIsComplete
|
class EnsureSetupIsComplete
|
||||||
{
|
{
|
||||||
@@ -25,7 +35,7 @@ class EnsureSetupIsComplete
|
|||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (User::query()->where('type', UserType::Staff)->exists()) {
|
if (User::query()->withTrashed()->where('type', UserType::Staff)->exists()) {
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Models\User;
|
|||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Clients\ClientProvisioning;
|
use App\Modules\Clients\ClientProvisioning;
|
||||||
use App\Modules\Identity\AuthSource;
|
use App\Modules\Identity\AuthSource;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,6 +56,19 @@ class LdapProvisioner
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same reason as SocialProvisioner: a deleted account keeps its
|
||||||
|
// address until erasure removes the row, so provisioning over one
|
||||||
|
// raises a QueryException at the moment of login. Refused here, the
|
||||||
|
// sign-in fails the ordinary way instead, and a directory identity
|
||||||
|
// does not silently reclaim an account somebody deleted.
|
||||||
|
if (! $this->clients->addressIsFree($identity->email)) {
|
||||||
|
Log::warning('A directory identity was not provisioned: the address belongs to a deleted account.', [
|
||||||
|
'email' => $identity->email,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return $this->clients->provision(
|
return $this->clients->provision(
|
||||||
name: $identity->name,
|
name: $identity->name,
|
||||||
email: $identity->email,
|
email: $identity->email,
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Identity;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Modules\Identity\Ldap\LdapAuthenticator;
|
||||||
|
use Illuminate\Auth\SessionGuard;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a password is this account's password.
|
||||||
|
*
|
||||||
|
* The sibling of SignIn, on the other side of the line it draws. SignIn is
|
||||||
|
* everything that happens *after* a credential checks out; this is the one
|
||||||
|
* question asked before it, for the one credential source that has two
|
||||||
|
* possible homes -- the local hash, or the directory the account was
|
||||||
|
* provisioned from.
|
||||||
|
*
|
||||||
|
* It exists for the reason SignIn gives for existing: "the way they get
|
||||||
|
* broken is by being written twice". The sign-in form asked this question
|
||||||
|
* properly, taking a directory bind when the local hash is a placeholder
|
||||||
|
* nobody holds. The confirm-password screen asked only half of it, and so
|
||||||
|
* refused every directory account the password it actually has.
|
||||||
|
*
|
||||||
|
* The order is the sign-in form's, and matters: the local hash is tried
|
||||||
|
* first so an account that answers locally never generates directory
|
||||||
|
* traffic, and an account whose credentials are *known* to live in the
|
||||||
|
* directory skips the local check entirely, because there the local hash
|
||||||
|
* is a Str::password(64) placeholder that cannot match anything.
|
||||||
|
*/
|
||||||
|
class PasswordVerification
|
||||||
|
{
|
||||||
|
public function __construct(private readonly LdapAuthenticator $ldap) {}
|
||||||
|
|
||||||
|
public function verify(User $user, string $password): bool
|
||||||
|
{
|
||||||
|
if (! $this->ldap->isDirectoryAccount($user)
|
||||||
|
&& Auth::guard('web')->validate(['email' => $user->email, 'password' => $password])) {
|
||||||
|
$this->rehashIfStale($user, $password);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$identity = $this->ldap->attempt($user->email, $password, $user);
|
||||||
|
|
||||||
|
if ($identity === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->ldap->stamp($user, $identity);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-hash a password stored under weaker settings than this
|
||||||
|
* installation now uses.
|
||||||
|
*
|
||||||
|
* Laravel does this inside SessionGuard::attempt(), which neither
|
||||||
|
* caller uses -- they verify and then hand 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
|
||||||
|
* 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 rehashIfStale(User $user, string $password): 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, ['password' => $password]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Modules\Identity\Social;
|
namespace App\Modules\Identity\Social;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Modules\Identity\AccountLookup;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which local account, if any, a provider identity signs in as.
|
* Which local account, if any, a provider identity signs in as.
|
||||||
@@ -28,6 +29,7 @@ class SocialAuthenticator
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly SocialProvisioner $provisioner,
|
private readonly SocialProvisioner $provisioner,
|
||||||
|
private readonly AccountLookup $accounts,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function resolve(SocialSettings $settings, SocialIdentity $identity): SocialResolution
|
public function resolve(SocialSettings $settings, SocialIdentity $identity): SocialResolution
|
||||||
@@ -74,7 +76,12 @@ class SocialAuthenticator
|
|||||||
// directory that omits the claim.
|
// directory that omits the claim.
|
||||||
$trusted = $identity->emailVerified || ! $settings->require_verified_email;
|
$trusted = $identity->emailVerified || ! $settings->require_verified_email;
|
||||||
|
|
||||||
$existing = User::query()->where('email', $identity->email)->first();
|
// Exactly this address, not whatever the database's collation
|
||||||
|
// calls equal. utf8mb4_unicode_ci folds accents, so a verified
|
||||||
|
// sign-in as administrator@éxample.com — a domain somebody else
|
||||||
|
// can own — selected administrator@example.com and this method
|
||||||
|
// then linked the attacker's subject to it (GHSA-wgxf-v8cr-37mj).
|
||||||
|
$existing = $this->accounts->byEmail($identity->email);
|
||||||
|
|
||||||
if ($existing !== null) {
|
if ($existing !== null) {
|
||||||
// 4/5. The takeover, refused. An unverified address may not
|
// 4/5. The takeover, refused. An unverified address may not
|
||||||
|
|||||||
@@ -39,8 +39,40 @@ final readonly class SocialIdentity
|
|||||||
* | LinkedIn | `email_verified` claim |
|
* | LinkedIn | `email_verified` claim |
|
||||||
* | OpenID Connect | `email_verified` claim, from the ID token |
|
* | OpenID Connect | `email_verified` claim, from the ID token |
|
||||||
* | GitHub | An address at all — Socialite's GithubProvider replaces `email` with the result of `getEmailByToken()`, which only ever returns one that is **primary and verified** |
|
* | GitHub | An address at all — Socialite's GithubProvider replaces `email` with the result of `getEmailByToken()`, which only ever returns one that is **primary and verified** |
|
||||||
* | Microsoft | The token's `tid` matching the configured tenant. Entra does not emit a usable `email_verified`, and its `email` claim is user-mutable — pinning the tenant is what makes it mean anything (this is the *nOAuth* class of bug) |
|
* | Microsoft | The token's `tid` matching the configured tenant **and** `xms_edov` — see below |
|
||||||
* | Facebook | Nothing. The Graph API has no equivalent claim, so an address from Facebook is never treated as verified |
|
* | Facebook | Nothing. The Graph API has no equivalent claim, so an address from Facebook is never treated as verified |
|
||||||
|
*
|
||||||
|
* ### Microsoft takes two claims, not one
|
||||||
|
*
|
||||||
|
* Entra emits no usable `email_verified`, and its `email` claim is
|
||||||
|
* user-mutable — populated from `otherMails`/proxyAddresses for a B2B
|
||||||
|
* guest, among other places. Pinning the tenant was the first answer
|
||||||
|
* and it is half of one: it defeats the classic cross-tenant *nOAuth*,
|
||||||
|
* where a stranger's own tenant asserts your address, because a
|
||||||
|
* foreign tenant carries a different `tid`.
|
||||||
|
*
|
||||||
|
* It does nothing about the same attack from *inside* the pinned
|
||||||
|
* tenant. A colleague, or a guest somebody invited, could shape their
|
||||||
|
* `email` claim to an administrator's address and have their subject
|
||||||
|
* bound to that account (GHSA-2rfh-v3j2-2jg7). Tenant-pinning answers
|
||||||
|
* "which directory said this", never "does this person own that
|
||||||
|
* address".
|
||||||
|
*
|
||||||
|
* `xms_edov` is Microsoft's own answer to the second question — the
|
||||||
|
* optional claim meaning the tenant has verified it owns the email's
|
||||||
|
* domain — and their guidance says to require it wherever `email`
|
||||||
|
* identifies an account. Absent is treated as unverified, which is the
|
||||||
|
* only safe reading: it is absent by default, so anything else would
|
||||||
|
* be no check at all.
|
||||||
|
*
|
||||||
|
* **What an installation has to do.** The claim must be added to the
|
||||||
|
* app registration (Token configuration → optional claims → `xms_edov`
|
||||||
|
* on the ID token). Until it is, Microsoft sign-in still works and
|
||||||
|
* still creates new accounts — it simply stops silently attaching
|
||||||
|
* itself to accounts that already exist, and says so, pointing the
|
||||||
|
* person at signing in with a password and connecting the provider
|
||||||
|
* from their settings. Accounts already linked are unaffected: they
|
||||||
|
* resolve by subject, before this is consulted at all.
|
||||||
*/
|
*/
|
||||||
public static function fromSocialite(
|
public static function fromSocialite(
|
||||||
SocialProvider $provider,
|
SocialProvider $provider,
|
||||||
@@ -72,7 +104,8 @@ final readonly class SocialIdentity
|
|||||||
|| ($raw['email_verified'] ?? null) === 'true',
|
|| ($raw['email_verified'] ?? null) === 'true',
|
||||||
SocialProvider::Github => true,
|
SocialProvider::Github => true,
|
||||||
SocialProvider::Microsoft => is_string($settings->tenant_id)
|
SocialProvider::Microsoft => is_string($settings->tenant_id)
|
||||||
&& ($raw['tid'] ?? null) === $settings->tenant_id,
|
&& ($raw['tid'] ?? null) === $settings->tenant_id
|
||||||
|
&& (($raw['xms_edov'] ?? null) === true || ($raw['xms_edov'] ?? null) === 'true'),
|
||||||
SocialProvider::Facebook => false,
|
SocialProvider::Facebook => false,
|
||||||
},
|
},
|
||||||
name: is_string($user->getName()) && trim($user->getName()) !== ''
|
name: is_string($user->getName()) && trim($user->getName()) !== ''
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Models\User;
|
|||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Clients\ClientProvisioning;
|
use App\Modules\Clients\ClientProvisioning;
|
||||||
use App\Modules\Identity\AuthSource;
|
use App\Modules\Identity\AuthSource;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,6 +45,21 @@ class SocialProvisioner
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A deleted account still holds its address, and the insert below
|
||||||
|
// would hit the unique index — a 500 in the middle of a sign-in.
|
||||||
|
// Refusing here gives the caller the same "there is no account here
|
||||||
|
// for that address" it gives every other unprovisionable identity,
|
||||||
|
// which is also all a stranger should learn: whether an address was
|
||||||
|
// once an account here is not the provider's to publish.
|
||||||
|
if (! $this->clients->addressIsFree($identity->email)) {
|
||||||
|
Log::warning('A provider identity was not provisioned: the address belongs to a deleted account.', [
|
||||||
|
'provider' => $settings->provider->value,
|
||||||
|
'email' => $identity->email,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return $this->clients->provision(
|
return $this->clients->provision(
|
||||||
name: $identity->name ?? $identity->email,
|
name: $identity->name ?? $identity->email,
|
||||||
email: $identity->email,
|
email: $identity->email,
|
||||||
|
|||||||
@@ -62,19 +62,20 @@ class TwoFactorService
|
|||||||
|
|
||||||
$replayKey = "two-factor.used.{$user->id}.".hash('sha256', $code);
|
$replayKey = "two-factor.used.{$user->id}.".hash('sha256', $code);
|
||||||
|
|
||||||
if (Cache::has($replayKey)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->engine->verifyKey($secret, $code) === false) {
|
if ($this->engine->verifyKey($secret, $code) === false) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A TOTP code is valid for one window either side; block reuse
|
// Claiming the code *is* the answer. Cache::add writes only if the
|
||||||
// for slightly longer than that.
|
// key is absent, so of two requests carrying the same valid code
|
||||||
Cache::put($replayKey, true, now()->addSeconds(90));
|
// exactly one is told true — where has()-then-put() let both read
|
||||||
|
// "unused" before either wrote, and a code intercepted once could
|
||||||
return true;
|
// be spent twice inside its window. Same mechanism, and the same
|
||||||
|
// reason, as the preview log's debounce.
|
||||||
|
//
|
||||||
|
// A TOTP code is valid for one window either side; the claim
|
||||||
|
// outlives that by a little.
|
||||||
|
return Cache::add($replayKey, true, now()->addSeconds(90));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -46,13 +46,22 @@ class NotificationPreferencesController extends Controller
|
|||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
assert($user !== null);
|
assert($user !== null);
|
||||||
|
|
||||||
|
$keys = $this->emailableKeys();
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'preferences' => ['required', 'array'],
|
// Bounded by the registry, and unique on the type. The
|
||||||
|
// Rule::in below checks each value; it says nothing about how
|
||||||
|
// many there are or whether they repeat, and the loop writes
|
||||||
|
// one row per element. The count comes from the registry
|
||||||
|
// rather than a literal because the registry is open --
|
||||||
|
// modules register their own types into it, so a number here
|
||||||
|
// would be wrong the moment one does.
|
||||||
|
'preferences' => ['required', 'array', 'max:'.count($keys)],
|
||||||
// Against the registry, not merely "a string": a preference row
|
// Against the registry, not merely "a string": a preference row
|
||||||
// for a type nothing can send is a row that will never be read
|
// for a type nothing can send is a row that will never be read
|
||||||
// again, and the screen only ever offers back what edit() gave
|
// again, and the screen only ever offers back what edit() gave
|
||||||
// it.
|
// it.
|
||||||
'preferences.*.type' => ['required', 'string', Rule::in($this->emailableKeys())],
|
'preferences.*.type' => ['required', 'string', 'distinct', Rule::in($keys)],
|
||||||
'preferences.*.email_enabled' => ['required', 'boolean'],
|
'preferences.*.email_enabled' => ['required', 'boolean'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Platform\Announcements\Events;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single message a package wants put in front of staff.
|
||||||
|
*
|
||||||
|
* Shown twice, from one source: a band across the top of the dashboard,
|
||||||
|
* and an icon beside the notification bell that opens the same words on
|
||||||
|
* every other page. One event rather than two because "the same message"
|
||||||
|
* is the requirement — two props would drift the day somebody edits one.
|
||||||
|
*
|
||||||
|
* Not a widget, on purpose. The widget grid is a closed list of keys that
|
||||||
|
* dashboard.tsx renders one by one, and each viewer arranges it — so a
|
||||||
|
* message that matters would sit wherever somebody happened to drag it,
|
||||||
|
* or under a fold, or switched off. A band above the grid is seen without
|
||||||
|
* competing with the columns for space.
|
||||||
|
*
|
||||||
|
* **Core knows nothing about what it says.** Title, body, the label on the
|
||||||
|
* button and where the button goes all come from the listener. The first
|
||||||
|
* caller is the hosted edition telling a free instance what a paid plan
|
||||||
|
* would give it, which is commercial copy belonging to one offering and
|
||||||
|
* has no place in the public repository.
|
||||||
|
*
|
||||||
|
* One at a time, deliberately. A dashboard that can accumulate banners
|
||||||
|
* accumulates them, and the second one is what teaches people to skip the
|
||||||
|
* first. A listener that finds one already set should leave it alone
|
||||||
|
* rather than overwrite it.
|
||||||
|
*/
|
||||||
|
class ResolvingAnnouncement
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array{title: string, body: string, action_label: string|null, action_url: string|null, tone: string}|null
|
||||||
|
*/
|
||||||
|
public ?array $announcement = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
/** Whether the viewer is a staff account. */
|
||||||
|
public readonly bool $isStaff,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audience is declared by the listener and enforced here, rather than
|
||||||
|
* each listener remembering to check `isStaff`.
|
||||||
|
*
|
||||||
|
* The first version of this refused clients outright, which was right
|
||||||
|
* for the only message that existed — a hosted instance telling its
|
||||||
|
* administrator about their plan. It stopped being right when a
|
||||||
|
* message needed to reach the *clients* of a shared instance, and the
|
||||||
|
* safe way to allow that is not to drop the guard: it is to make
|
||||||
|
* every caller say who it is talking to, so a listener that forgets
|
||||||
|
* reaches nobody rather than everybody.
|
||||||
|
*/
|
||||||
|
public const AUDIENCE_STAFF = 'staff';
|
||||||
|
|
||||||
|
public const AUDIENCE_CLIENTS = 'clients';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `audience` is required and has no default. A message for staff and
|
||||||
|
* a message for the people they share with are different messages,
|
||||||
|
* and a signature that let one be mistaken for the other would put
|
||||||
|
* the mistake in the quiet direction.
|
||||||
|
*
|
||||||
|
* `tone` picks the accent the band is drawn in. Two values, because
|
||||||
|
* two is what the difference is worth: `info` for something worth
|
||||||
|
* knowing, `warning` for something worth acting on. Anything else
|
||||||
|
* falls back to `info` rather than rendering unstyled.
|
||||||
|
*/
|
||||||
|
public function show(
|
||||||
|
string $title,
|
||||||
|
string $body,
|
||||||
|
string $audience,
|
||||||
|
?string $actionLabel = null,
|
||||||
|
?string $actionUrl = null,
|
||||||
|
string $tone = 'info',
|
||||||
|
): void {
|
||||||
|
if ($this->announcement !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Silently ignored rather than thrown, and deliberately: a
|
||||||
|
// listener aimed at the wrong audience should show nothing, not
|
||||||
|
// break the page it was trying to decorate. An unrecognised value
|
||||||
|
// reaches nobody for the same reason.
|
||||||
|
$intended = match ($audience) {
|
||||||
|
self::AUDIENCE_STAFF => $this->isStaff,
|
||||||
|
self::AUDIENCE_CLIENTS => ! $this->isStaff,
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (! $intended) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->announcement = [
|
||||||
|
'title' => $title,
|
||||||
|
'body' => $body,
|
||||||
|
'action_label' => $actionLabel,
|
||||||
|
'action_url' => $actionUrl,
|
||||||
|
'tone' => in_array($tone, ['info', 'warning'], true) ? $tone : 'info',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Platform\Branding;
|
||||||
|
|
||||||
|
use App\Modules\Api\Events\RegisteringApiModules;
|
||||||
|
use App\Modules\Files\Thumbnails\Events\RenderingImage;
|
||||||
|
use App\Modules\Files\Thumbnails\Events\ResolvingImageRendering;
|
||||||
|
use App\Modules\Platform\Branding\Models\BrandingSetting;
|
||||||
|
use App\Modules\Platform\Branding\Watermark\ThumbnailWatermarker;
|
||||||
|
use App\Modules\Platform\Capabilities\Capability;
|
||||||
|
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An installation dressed in its own logo, and a watermark on what its
|
||||||
|
* clients and visitors see.
|
||||||
|
*
|
||||||
|
* Lived in the private cloud-modules package until 2026-08-28, gated
|
||||||
|
* Cloud-only. That was a fact about where the code had been written
|
||||||
|
* rather than about who should have it: nothing here needs a hosted
|
||||||
|
* platform, and a self-hosted installation wanting its own mark on the
|
||||||
|
* pages it serves is the ordinary case rather than the exotic one.
|
||||||
|
*
|
||||||
|
* **What did not move.** Hiding the "Powered by ProjectSend" line is the
|
||||||
|
* white-label half, and white-labelling is one of the things a hosted
|
||||||
|
* customer pays for. Its listener still ships only in cloud-modules, so
|
||||||
|
* an installation without that package has no code able to answer "hide
|
||||||
|
* it" — flipping an edition variable buys nothing. This module carries
|
||||||
|
* the column, because it owns the table, and no way to set it.
|
||||||
|
*
|
||||||
|
* **What a plan withholds is a separate question.** A free hosted plan
|
||||||
|
* has branding subtracted from its environment, which the capability
|
||||||
|
* registry applies; see PROJECTSEND_CAPABILITIES_DISABLED. The row is
|
||||||
|
* never deleted by that, so a plan that lapses and resumes restores what
|
||||||
|
* the customer had rather than asking them to build it again.
|
||||||
|
*/
|
||||||
|
class BrandingServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
// Registered unconditionally. The listeners ask whether branding
|
||||||
|
// is available each time they fire, so an edition change, or a
|
||||||
|
// plan change that subtracts the capability, takes effect on the
|
||||||
|
// next request rather than needing a restart.
|
||||||
|
// Through the module registry rather than routes/api.php, so the
|
||||||
|
// URL stays /api/v1/modules/branding/* exactly as it was when this
|
||||||
|
// shipped in a package. A caller's integration does not care which
|
||||||
|
// repository the code moved to, and moving the path would be a
|
||||||
|
// breaking change dressed up as a refactor.
|
||||||
|
Event::listen(RegisteringApiModules::class, function (RegisteringApiModules $event): void {
|
||||||
|
$event->register(
|
||||||
|
slug: 'branding',
|
||||||
|
routes: __DIR__.'/api-routes.php',
|
||||||
|
capability: Capability::Branding->value,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Event::listen(RenderingImage::class, [ThumbnailWatermarker::class, 'handle']);
|
||||||
|
Event::listen(ResolvingImageRendering::class, [ThumbnailWatermarker::class, 'resolve']);
|
||||||
|
|
||||||
|
// Gated like the screen that sets it. Without the capability there
|
||||||
|
// is no branding page to reach, so a row that outlived a gate
|
||||||
|
// change — a downgraded plan, a restored backup — would put
|
||||||
|
// somebody's logo on every page of an installation offering no way
|
||||||
|
// to see it, change it or take it off. Evaluated per request, so
|
||||||
|
// uploading a logo or changing plan takes effect on the next one.
|
||||||
|
Inertia::share('branding', fn (): array => [
|
||||||
|
'logo_url' => $this->available()
|
||||||
|
? BrandingSetting::query()->first()?->logoUrl()
|
||||||
|
: null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function available(): bool
|
||||||
|
{
|
||||||
|
return $this->app->make(CapabilityRegistry::class)->has(Capability::Branding);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Platform\Branding\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Routing\Controller;
|
||||||
|
use App\Modules\Platform\Branding\Models\BrandingSetting;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This installation's branding — its logo and its thumbnail watermark —
|
||||||
|
* over the host's API.
|
||||||
|
*
|
||||||
|
* Read-only on purpose: uploading an image is a multipart flow with
|
||||||
|
* content-sniffing rules that only make sense with a file picker in front
|
||||||
|
* of them (see the web controller), and nothing has asked to automate it.
|
||||||
|
* An integration that wants to render this installation's branding — an
|
||||||
|
* email builder, a status page — only needs to read it.
|
||||||
|
*
|
||||||
|
* This controller knows nothing about authentication, rate limiting,
|
||||||
|
* error formats or which edition it is running in. The host supplies all
|
||||||
|
* of that: the module is registered through RegisteringApiModules, which
|
||||||
|
* mounts these routes inside the API's own auth stack and behind
|
||||||
|
* `capability:branding.customize`. That is the whole point of the seam —
|
||||||
|
* a package declares paths and controllers, and nothing else.
|
||||||
|
*/
|
||||||
|
class BrandingController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get this installation's logo.
|
||||||
|
*
|
||||||
|
* Returns a null `logo_url` when no logo has been uploaded, which is
|
||||||
|
* the normal state rather than an error.
|
||||||
|
*/
|
||||||
|
public function show(): JsonResponse
|
||||||
|
{
|
||||||
|
$setting = BrandingSetting::query()->first();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => [
|
||||||
|
'logo_url' => $setting?->logoUrl(),
|
||||||
|
'updated_at' => $setting?->updated_at?->toIso8601String(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the watermark applied to this installation's rendered images.
|
||||||
|
*
|
||||||
|
* Applies to the thumbnails and previews clients and anonymous
|
||||||
|
* public visitors see; what this installation's own staff see is
|
||||||
|
* never marked. The stored files, and every download of them, are
|
||||||
|
* never altered either way.
|
||||||
|
*
|
||||||
|
* `enabled` is false whenever no watermark is being drawn, including
|
||||||
|
* when the toggle is on but its image has since been removed —
|
||||||
|
* it answers "is this installation watermarking?", not "which way is
|
||||||
|
* the switch pointing?". `position` is one of `top-left`,
|
||||||
|
* `top-center`, `top-right`, `middle-left`, `center`, `middle-right`,
|
||||||
|
* `bottom-left`, `bottom-center`, `bottom-right`; `size` is the
|
||||||
|
* percentage of the image the mark is fitted into, and `opacity`
|
||||||
|
* a percentage.
|
||||||
|
*
|
||||||
|
* Read-only, same as the logo: an integration rendering its own
|
||||||
|
* derivative images can reproduce the mark, but uploading one is a
|
||||||
|
* multipart flow with content-sniffing rules that only make sense
|
||||||
|
* behind a file picker.
|
||||||
|
*/
|
||||||
|
public function watermark(): JsonResponse
|
||||||
|
{
|
||||||
|
// Falls back to an unsaved instance so an installation that has
|
||||||
|
// never opened the branding screen answers with the defaults it
|
||||||
|
// would start from, rather than a payload of nulls a caller would
|
||||||
|
// have to invent its own meaning for.
|
||||||
|
$setting = BrandingSetting::query()->first() ?? new BrandingSetting;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => [
|
||||||
|
'enabled' => $setting->watermarksThumbnails(),
|
||||||
|
'image_url' => $setting->watermarkUrl(),
|
||||||
|
'position' => $setting->watermark_position->value,
|
||||||
|
'size' => $setting->watermark_size,
|
||||||
|
'opacity' => $setting->watermark_opacity,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Platform\Branding\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use App\Modules\Files\Thumbnails\Events\ImageRenderingChanged;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Routing\Controller;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
use App\Modules\Platform\Branding\Models\BrandingSetting;
|
||||||
|
use App\Modules\Platform\Branding\Watermark\WatermarkPosition;
|
||||||
|
use App\Modules\Platform\Branding\Watermark\WatermarkSample;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two site-wide pieces of artwork: the logo shown in the sidebar in
|
||||||
|
* place of the default icon, and the mark stamped onto the thumbnails
|
||||||
|
* and previews clients and public visitors see. Every route here is gated end-to-end by the host's
|
||||||
|
* `capability:branding.customize` middleware (see routes.php) — this
|
||||||
|
* module has no idea what edition it's running in, it just trusts the
|
||||||
|
* gate.
|
||||||
|
*/
|
||||||
|
class BrandingController extends Controller
|
||||||
|
{
|
||||||
|
public function edit(): Response
|
||||||
|
{
|
||||||
|
// An unsaved instance rather than `current()`: rendering a settings
|
||||||
|
// screen must not write a row, and the model carries the same
|
||||||
|
// defaults the table does (see its $attributes) so the form starts
|
||||||
|
// on the values a first save would produce.
|
||||||
|
$setting = BrandingSetting::query()->first() ?? new BrandingSetting;
|
||||||
|
|
||||||
|
return Inertia::render('branding/edit', [
|
||||||
|
'logo_url' => $setting->logoUrl(),
|
||||||
|
// Read, never written here. Hiding attribution is the
|
||||||
|
// white-label half and stays a hosted feature: the switch is
|
||||||
|
// rendered only where Capability::AttributionHide is held, and
|
||||||
|
// the route that saves it is registered by cloud-modules. Core
|
||||||
|
// carries the column because it owns the table, and carries no
|
||||||
|
// way to set it.
|
||||||
|
'hide_attribution' => $setting->hide_attribution,
|
||||||
|
'watermark' => [
|
||||||
|
'enabled' => $setting->watermark_enabled,
|
||||||
|
'image_url' => $setting->watermarkUrl(),
|
||||||
|
'position' => $setting->watermark_position->value,
|
||||||
|
'size' => $setting->watermark_size,
|
||||||
|
'opacity' => $setting->watermark_opacity,
|
||||||
|
],
|
||||||
|
'watermark_positions' => WatermarkPosition::values(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'logo' => ['required', 'image', 'max:2048'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @var UploadedFile $upload */
|
||||||
|
$upload = $validated['logo'];
|
||||||
|
|
||||||
|
$setting = BrandingSetting::current();
|
||||||
|
|
||||||
|
if ($setting->logo_path !== null) {
|
||||||
|
Storage::disk('public')->delete($setting->logo_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$setting->update(['logo_path' => $this->storeImage($upload)]);
|
||||||
|
|
||||||
|
return back()->with('success', __('Logo updated.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(): RedirectResponse
|
||||||
|
{
|
||||||
|
$setting = BrandingSetting::query()->first();
|
||||||
|
|
||||||
|
if ($setting?->logo_path !== null) {
|
||||||
|
Storage::disk('public')->delete($setting->logo_path);
|
||||||
|
$setting->update(['logo_path' => null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', __('Logo removed.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the whole watermark form at once — toggle, artwork, placement,
|
||||||
|
* scale and opacity. One endpoint rather than one per field because
|
||||||
|
* they are only meaningful together: turning it on without an image,
|
||||||
|
* or changing the size without seeing the position, are not states
|
||||||
|
* worth being able to save.
|
||||||
|
*/
|
||||||
|
public function updateWatermark(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$setting = BrandingSetting::current();
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
// The image is optional on every save *except* the one that
|
||||||
|
// turns watermarking on with nothing stored yet — otherwise
|
||||||
|
// adjusting the opacity would mean re-picking the file each
|
||||||
|
// time. `exclude_if` keeps the rule off the payload entirely
|
||||||
|
// rather than requiring a re-upload.
|
||||||
|
'image' => [
|
||||||
|
$setting->watermark_path === null && $request->boolean('enabled') ? 'required' : 'nullable',
|
||||||
|
'image',
|
||||||
|
'max:2048',
|
||||||
|
],
|
||||||
|
'enabled' => ['required', 'boolean'],
|
||||||
|
'position' => ['required', Rule::in(WatermarkPosition::values())],
|
||||||
|
'size' => ['required', 'integer', 'min:5', 'max:100'],
|
||||||
|
'opacity' => ['required', 'integer', 'min:1', 'max:100'],
|
||||||
|
], [
|
||||||
|
'image.required' => __('Choose the image to use as the watermark.'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$attributes = [
|
||||||
|
'watermark_enabled' => (bool) $validated['enabled'],
|
||||||
|
'watermark_position' => $validated['position'],
|
||||||
|
'watermark_size' => (int) $validated['size'],
|
||||||
|
'watermark_opacity' => (int) $validated['opacity'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (($validated['image'] ?? null) instanceof UploadedFile) {
|
||||||
|
if ($setting->watermark_path !== null) {
|
||||||
|
Storage::disk('public')->delete($setting->watermark_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$attributes['watermark_path'] = $this->storeImage($validated['image']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$setting->update($attributes);
|
||||||
|
|
||||||
|
$this->forgetRenderedImages();
|
||||||
|
|
||||||
|
return back()->with('success', __('Watermark settings saved.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stand-in photograph with the mark drawn on it, so the settings
|
||||||
|
* screen can show what a client will actually see. Staff surfaces are
|
||||||
|
* never watermarked, so without this an administrator has no way to
|
||||||
|
* judge their own settings short of signing in as a client.
|
||||||
|
*
|
||||||
|
* Takes placement, scale and opacity from the *query string* rather
|
||||||
|
* than from the saved row: the point is to answer "what would this
|
||||||
|
* look like" while the form is still being adjusted. The artwork
|
||||||
|
* itself has to be the stored one — an unsaved file lives in the
|
||||||
|
* browser, not on this server — which is why the screen tells you to
|
||||||
|
* save after choosing a new image.
|
||||||
|
*
|
||||||
|
* Drawn by the same WatermarkPainter that renders the real thing, so
|
||||||
|
* the sample cannot flatter the settings.
|
||||||
|
*/
|
||||||
|
public function watermarkSample(Request $request, WatermarkSample $sample): SymfonyResponse
|
||||||
|
{
|
||||||
|
$setting = BrandingSetting::query()->first();
|
||||||
|
$markPath = $setting?->watermark_path;
|
||||||
|
|
||||||
|
// Not 404 for "you have not uploaded one yet" — the screen asks for
|
||||||
|
// this image before there is anything to draw, and a broken <img>
|
||||||
|
// is a worse answer than none. It hides the sample instead.
|
||||||
|
abort_if($markPath === null || ! Storage::disk('public')->exists($markPath), 404);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'position' => ['required', Rule::in(WatermarkPosition::values())],
|
||||||
|
'size' => ['required', 'integer', 'min:5', 'max:100'],
|
||||||
|
'opacity' => ['required', 'integer', 'min:1', 'max:100'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$image = $sample->render(
|
||||||
|
Storage::disk('public')->path($markPath),
|
||||||
|
WatermarkPosition::from($validated['position']),
|
||||||
|
(int) $validated['size'],
|
||||||
|
(int) $validated['opacity'],
|
||||||
|
);
|
||||||
|
|
||||||
|
return new SymfonyResponse($image->toString('image/png'), 200, [
|
||||||
|
'Content-Type' => 'image/png',
|
||||||
|
// Every request has different parameters and the artwork behind
|
||||||
|
// it can be replaced at any moment; a cached sample would show
|
||||||
|
// an administrator the settings they had a minute ago.
|
||||||
|
'Cache-Control' => 'no-store, max-age=0',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop the artwork and switch watermarking off with it — an "enabled"
|
||||||
|
* that no image backs is not a state this screen can leave behind.
|
||||||
|
*/
|
||||||
|
public function destroyWatermark(): RedirectResponse
|
||||||
|
{
|
||||||
|
$setting = BrandingSetting::query()->first();
|
||||||
|
|
||||||
|
if ($setting === null) {
|
||||||
|
return back();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($setting->watermark_path !== null) {
|
||||||
|
Storage::disk('public')->delete($setting->watermark_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$setting->update([
|
||||||
|
'watermark_path' => null,
|
||||||
|
'watermark_enabled' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->forgetRenderedImages();
|
||||||
|
|
||||||
|
return back()->with('success', __('Watermark removed.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The host caches every image it renders and never revisits it, so
|
||||||
|
* without this a settings change would only reach files nobody has
|
||||||
|
* looked at yet.
|
||||||
|
*
|
||||||
|
* Dispatched by *string* class name: the host's event class cannot be
|
||||||
|
* constructed from here (this package builds with no host present),
|
||||||
|
* and it carries no payload precisely so that it doesn't have to be.
|
||||||
|
* With no host listening this is an inert no-op.
|
||||||
|
*/
|
||||||
|
private function forgetRenderedImages(): void
|
||||||
|
{
|
||||||
|
Event::dispatch(new ImageRenderingChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The extension comes from the *content*, never from the uploaded
|
||||||
|
* filename. This disk is web-served (public/storage is symlinked into
|
||||||
|
* the document root and nginx serves it as a static file), and the
|
||||||
|
* `image` rule only inspects the sniffed content — so a GIF whose
|
||||||
|
* filename says ".html" passes validation and would then be stored,
|
||||||
|
* and served back, as text/html: stored XSS on this app's own origin,
|
||||||
|
* from any account that can reach this page. guessExtension() is
|
||||||
|
* derived from the same sniffed mime type the validator just
|
||||||
|
* accepted, so the two can no longer disagree.
|
||||||
|
*/
|
||||||
|
private function storeImage(UploadedFile $upload): string
|
||||||
|
{
|
||||||
|
$extension = $upload->guessExtension() ?? 'bin';
|
||||||
|
|
||||||
|
$path = $upload->storeAs('branding', Str::uuid().'.'.$extension, 'public');
|
||||||
|
|
||||||
|
if ($path === false) {
|
||||||
|
throw new RuntimeException('Could not store the uploaded image.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Platform\Branding\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use App\Modules\Platform\Branding\Watermark\WatermarkPosition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single-row settings table — see the migration's comment for why no
|
||||||
|
* tenant/owner column is needed.
|
||||||
|
*
|
||||||
|
* @property int $id
|
||||||
|
* @property string|null $logo_path
|
||||||
|
* @property bool $watermark_enabled
|
||||||
|
* @property string|null $watermark_path
|
||||||
|
* @property WatermarkPosition $watermark_position
|
||||||
|
* @property int $watermark_size
|
||||||
|
* @property int $watermark_opacity
|
||||||
|
* @property bool $hide_attribution
|
||||||
|
* @property \Illuminate\Support\Carbon|null $created_at
|
||||||
|
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||||
|
*/
|
||||||
|
class BrandingSetting extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'branding_settings';
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'watermark_enabled' => 'boolean',
|
||||||
|
'watermark_position' => WatermarkPosition::class,
|
||||||
|
'watermark_size' => 'integer',
|
||||||
|
'watermark_opacity' => 'integer',
|
||||||
|
'hide_attribution' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the migration's column defaults, so an unsaved instance
|
||||||
|
* answers the same as a freshly created row would. That is what lets
|
||||||
|
* the settings screen render `new BrandingSetting` on an install that
|
||||||
|
* has never touched branding, instead of either creating a row on a
|
||||||
|
* GET or restating these numbers a second time in the controller.
|
||||||
|
*/
|
||||||
|
protected $attributes = [
|
||||||
|
'watermark_enabled' => false,
|
||||||
|
'watermark_position' => 'bottom-right',
|
||||||
|
'watermark_size' => 30,
|
||||||
|
'watermark_opacity' => 60,
|
||||||
|
'hide_attribution' => false,
|
||||||
|
];
|
||||||
|
|
||||||
|
public static function current(): self
|
||||||
|
{
|
||||||
|
return static::query()->firstOrCreate([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logoUrl(): ?string
|
||||||
|
{
|
||||||
|
return $this->logo_path === null ? null : Storage::disk('public')->url($this->logo_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function watermarkUrl(): ?string
|
||||||
|
{
|
||||||
|
return $this->watermark_path === null ? null : Storage::disk('public')->url($this->watermark_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The artwork to stamp on a thumbnail being rendered right now, or
|
||||||
|
* null when this installation is not watermarking.
|
||||||
|
*
|
||||||
|
* Phrased as "which image, if any" rather than as a boolean because
|
||||||
|
* the toggle alone is not enough to act on: removing the image
|
||||||
|
* leaves the toggle standing, and a row restored from a backup can
|
||||||
|
* carry an `enabled` that its file no longer backs. Answering both
|
||||||
|
* halves at once means a caller cannot check one and use the other.
|
||||||
|
*/
|
||||||
|
public function activeWatermarkPath(): ?string
|
||||||
|
{
|
||||||
|
return $this->watermark_enabled ? $this->watermark_path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function watermarksThumbnails(): bool
|
||||||
|
{
|
||||||
|
return $this->activeWatermarkPath() !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user