mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-04 05:25:51 +00:00
Compare commits
168 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -6,6 +6,15 @@ PROJECTSEND_EDITION=community
|
|||||||
# configured at /system/settings/captcha.
|
# configured at /system/settings/captcha.
|
||||||
# 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
|
||||||
|
|||||||
+311
-393
@@ -10,8 +10,297 @@ Anything under **Upgrade notes** is something you have to do, not something we d
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
This section collects changes as they land; the release process turns it into a numbered entry when
|
This section collects changes as they land; the release process turns it into a numbered entry
|
||||||
a version is cut.
|
when a version is cut.
|
||||||
|
|
||||||
|
|
||||||
|
## 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 +366,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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+105
-52
@@ -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)
|
||||||
@@ -546,9 +593,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.
|
||||||
*/
|
*/
|
||||||
@@ -46,10 +52,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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,43 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$event = new ResolvingAnnouncement(isStaff: $user->isStaff());
|
||||||
|
|
||||||
|
Event::dispatch($event);
|
||||||
|
|
||||||
|
return $event->announcement;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,12 @@
|
|||||||
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\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;
|
||||||
@@ -115,10 +114,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 +124,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'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ 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;
|
||||||
@@ -153,6 +154,20 @@ class ClientsController extends Controller
|
|||||||
|
|
||||||
$this->activity->log(Action::UserCreated, subject: $client);
|
$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'] ?? []);
|
||||||
|
|
||||||
if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) {
|
if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) {
|
||||||
@@ -191,7 +206,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 +221,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 +379,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')
|
||||||
|
|||||||
@@ -98,12 +98,29 @@ 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),
|
'default_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
|
||||||
@@ -142,6 +159,20 @@ class ClientsController extends Controller
|
|||||||
|
|
||||||
$this->activity->log(Action::UserCreated, subject: $client);
|
$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'] ?? []);
|
||||||
|
|
||||||
if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) {
|
if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) {
|
||||||
@@ -154,7 +185,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');
|
||||||
|
|
||||||
@@ -202,7 +233,9 @@ class ClientsController extends Controller
|
|||||||
->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 +267,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
|||||||
@@ -268,6 +268,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 +291,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 +302,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,251 @@
|
|||||||
|
<?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}
|
||||||
|
*/
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['method' => $this->detect(), 'detected' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* @return array{method: string, detected: 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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -303,44 +326,32 @@ class FilesController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,14 @@ class ChunkedUploadsController extends Controller
|
|||||||
|
|
||||||
if ($quotaBytes > 0 && $this->storageUsage->usedBytes($user) + (int) $validated['size'] > $quotaBytes) {
|
if ($quotaBytes > 0 && $this->storageUsage->usedBytes($user) + (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),
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,7 +313,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,45 @@ 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);
|
$this->scope->folders($user)->findOrFail($folderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
$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.'));
|
||||||
}
|
}
|
||||||
@@ -463,7 +444,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 +485,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 +511,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,
|
||||||
@@ -405,10 +411,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 +447,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,10 +5,16 @@ 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\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;
|
||||||
@@ -22,6 +28,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 +73,9 @@ 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 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,9 +217,11 @@ 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())));
|
||||||
|
|
||||||
@@ -237,6 +249,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.
|
||||||
@@ -306,6 +326,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
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -134,6 +134,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
|
||||||
@@ -140,9 +146,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 +176,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 +272,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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -56,9 +57,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;
|
||||||
@@ -102,8 +101,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 +121,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,6 +7,7 @@ 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 Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@@ -32,20 +33,39 @@ 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 User::query()
|
||||||
->when($excludeId, fn (Builder $query, int $id) => $query->whereKeyNot($id))
|
->when($excludeId, fn (Builder $query, int $id) => $query->whereKeyNot($id))
|
||||||
|
->when($viewer, fn (Builder $query, User $for) => $query->where(fn (Builder $reachable) => $reachable
|
||||||
|
->where('type', UserType::Staff)
|
||||||
|
->orWhereIn('id', $this->scope->clients($for)->select('users.id'))))
|
||||||
->where('active', true)
|
->where('active', true)
|
||||||
->with('role')
|
->with('role')
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
|||||||
@@ -97,8 +97,16 @@ 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.
|
||||||
|
*/
|
||||||
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,64 @@
|
|||||||
|
<?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,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `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 $actionLabel = null, ?string $actionUrl = null, string $tone = 'info'): void
|
||||||
|
{
|
||||||
|
if ($this->announcement !== null) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Platform\Branding\Watermark;
|
||||||
|
|
||||||
|
use claviska\SimpleImage;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use App\Modules\Files\Thumbnails\Events\RenderingImage;
|
||||||
|
use App\Modules\Files\Thumbnails\Events\ResolvingImageRendering;
|
||||||
|
use App\Modules\Files\Thumbnails\ImageAudience;
|
||||||
|
use App\Modules\Platform\Capabilities\Capability;
|
||||||
|
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||||
|
use App\Modules\Platform\Branding\Models\BrandingSetting;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stamps the configured mark onto an image the host is about to render,
|
||||||
|
* for both of the host's rendering hooks:
|
||||||
|
*
|
||||||
|
* - `RenderingImage` — the drawing itself, on a thumbnail or a preview.
|
||||||
|
* - `ResolvingImageRendering` — the host asking, before it decodes
|
||||||
|
* anything, whether this viewer has to be served a rendering at all.
|
||||||
|
* Answering yes is what turns a client's preview from the stored file
|
||||||
|
* into a watermarked copy; leaving it alone is what keeps previews
|
||||||
|
* free on installations that do not watermark.
|
||||||
|
*
|
||||||
|
* Both events are duck-typed (`object`, `$event->audience`) rather than
|
||||||
|
* imported: this package is built and tested with no host application
|
||||||
|
* present, so `use App\Modules\Files\...` would not resolve. See the
|
||||||
|
* host's own docblocks and docs/extension-points-architecture.md in the
|
||||||
|
* host repo.
|
||||||
|
*
|
||||||
|
* Nothing here throws. The host deliberately does not wrap listeners in
|
||||||
|
* a try/catch — a listener that fails takes the request down with it —
|
||||||
|
* and for a decoration that is the wrong trade: an unreadable or
|
||||||
|
* since-deleted watermark file must degrade to a plain image, not to a
|
||||||
|
* broken one on every listing row in the app. Failures are logged so the
|
||||||
|
* setting can be fixed rather than silently doing nothing.
|
||||||
|
*
|
||||||
|
* The one asymmetry worth knowing: `wouldMark()` and `apply()` ask the
|
||||||
|
* same question a moment apart, so a watermark switched off between the
|
||||||
|
* two would yield a rendered-but-unmarked preview. That is a plain copy
|
||||||
|
* of the original at preview size — the correct content, reached by a
|
||||||
|
* slower path — and it self-corrects on the next request, since saving
|
||||||
|
* the setting flushes the cache anyway.
|
||||||
|
*/
|
||||||
|
class ThumbnailWatermarker
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly WatermarkPainter $painter,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The audience whose images go unmarked: this installation's own
|
||||||
|
* staff. Watermarking exists for the copies that leave the building —
|
||||||
|
* clients in the portal, anonymous visitors on a public listing — and
|
||||||
|
* stamping the staff file manager and file editor too would only
|
||||||
|
* obscure the originals from the people who uploaded them.
|
||||||
|
*/
|
||||||
|
public function handle(RenderingImage $event): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if ($event->audience === ImageAudience::Staff) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->apply($event->image);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Log::warning('Could not watermark a rendered image: '.$exception->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an image must be rendered rather than served as stored.
|
||||||
|
* Only ever sets the flag — never clears it, since another listener's
|
||||||
|
* yes is not this one's to overrule.
|
||||||
|
*/
|
||||||
|
public function resolve(ResolvingImageRendering $event): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if ($event->audience === ImageAudience::Staff) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->wouldMark()) {
|
||||||
|
$event->required = true;
|
||||||
|
}
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
// Leaves the host on its fast path, which serves the original
|
||||||
|
// — the behaviour of every installation that does not
|
||||||
|
// watermark, and never a failed request.
|
||||||
|
Log::warning('Could not decide whether to watermark a preview: '.$exception->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether there is a mark to draw at all: switched on, with an image
|
||||||
|
* that is still on disk. Deliberately the same three conditions
|
||||||
|
* apply() checks, so the host is never told to render something this
|
||||||
|
* listener would then decline to touch.
|
||||||
|
*/
|
||||||
|
private function wouldMark(): bool
|
||||||
|
{
|
||||||
|
if (! $this->capabilityAvailable()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$markPath = BrandingSetting::query()->first()?->activeWatermarkPath();
|
||||||
|
|
||||||
|
return $markPath !== null && Storage::disk('public')->exists($markPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function apply(SimpleImage $canvas): void
|
||||||
|
{
|
||||||
|
if (! $this->capabilityAvailable()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$setting = BrandingSetting::query()->first();
|
||||||
|
|
||||||
|
if ($setting === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$markPath = $setting->activeWatermarkPath();
|
||||||
|
|
||||||
|
if ($markPath === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$disk = Storage::disk('public');
|
||||||
|
|
||||||
|
if (! $disk->exists($markPath)) {
|
||||||
|
Log::warning('Watermarking is on but its image is missing from disk: '.$markPath);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->painter->paint(
|
||||||
|
$canvas,
|
||||||
|
$disk->path($markPath),
|
||||||
|
$setting->watermark_position,
|
||||||
|
$setting->watermark_size,
|
||||||
|
$setting->watermark_opacity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watermarking is part of the Cloud-exclusive Branding capability, so
|
||||||
|
* it renders nothing where that capability is absent — the same
|
||||||
|
* "no capability, no output" stance the host takes for Custom Assets.
|
||||||
|
* The capability registry holds the one definition of
|
||||||
|
* the check; the shared logo answers to it too.
|
||||||
|
*/
|
||||||
|
private function capabilityAvailable(): bool
|
||||||
|
{
|
||||||
|
return app(CapabilityRegistry::class)->has(Capability::Branding);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Platform\Branding\Watermark;
|
||||||
|
|
||||||
|
use claviska\SimpleImage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws the mark onto a canvas. The only place that decides how a
|
||||||
|
* watermark is positioned, scaled and blended.
|
||||||
|
*
|
||||||
|
* Extracted so the settings screen's live sample and the real rendering
|
||||||
|
* pipeline cannot drift: an administrator tuning the size slider against
|
||||||
|
* a preview drawn by *different* code would be tuning against a lie, and
|
||||||
|
* the lie would be discovered on a client's screen. The sample and the
|
||||||
|
* thumbnail a client actually gets are the same function, called with
|
||||||
|
* different arguments.
|
||||||
|
*
|
||||||
|
* Takes its settings as arguments rather than reading BrandingSetting,
|
||||||
|
* for the same reason: the sample renders values that are still unsaved
|
||||||
|
* in a form.
|
||||||
|
*/
|
||||||
|
class WatermarkPainter
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The mark's clearance from the edge it is anchored to, as a fraction
|
||||||
|
* of the canvas's shorter side. A fraction rather than a pixel count
|
||||||
|
* so a 300px thumbnail and a 1600px preview look like the same
|
||||||
|
* design. Not a setting: the difference between "flush against the
|
||||||
|
* edge" and "a few pixels in" is the whole of the visual judgement,
|
||||||
|
* and there is no useful second answer to offer an administrator.
|
||||||
|
*/
|
||||||
|
private const EDGE_INSET_RATIO = 0.04;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $markPath an absolute local path to the artwork
|
||||||
|
* @param int $size percentage of the canvas the mark is fitted into
|
||||||
|
* @param int $opacity percentage
|
||||||
|
*/
|
||||||
|
public function paint(
|
||||||
|
SimpleImage $canvas,
|
||||||
|
string $markPath,
|
||||||
|
WatermarkPosition $position,
|
||||||
|
int $size,
|
||||||
|
int $opacity,
|
||||||
|
): void {
|
||||||
|
$width = $canvas->getWidth();
|
||||||
|
$height = $canvas->getHeight();
|
||||||
|
|
||||||
|
// A box that is `size`% of *both* dimensions, so the setting reads
|
||||||
|
// the same on a portrait and a landscape canvas and a wide mark
|
||||||
|
// can never overflow a narrow one.
|
||||||
|
$mark = new SimpleImage($markPath);
|
||||||
|
|
||||||
|
$scale = min(
|
||||||
|
$width * $size / 100 / $mark->getWidth(),
|
||||||
|
$height * $size / 100 / $mark->getHeight(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Scaled by hand rather than with bestFit(), which returns early
|
||||||
|
// when the image already fits: a small logo would then keep its
|
||||||
|
// native size and the size setting would silently do nothing above
|
||||||
|
// whatever percentage happened to match it. Enlarging a small mark
|
||||||
|
// is soft, but it is what was asked for — a control that only works
|
||||||
|
// in one direction is worse than a slightly blurry one.
|
||||||
|
$mark->resize(
|
||||||
|
max(1, (int) round($mark->getWidth() * $scale)),
|
||||||
|
max(1, (int) round($mark->getHeight() * $scale)),
|
||||||
|
);
|
||||||
|
|
||||||
|
$inset = max(1, (int) round(min($width, $height) * self::EDGE_INSET_RATIO));
|
||||||
|
|
||||||
|
$canvas->overlay(
|
||||||
|
$mark,
|
||||||
|
$position->anchor(),
|
||||||
|
$opacity / 100,
|
||||||
|
$inset,
|
||||||
|
$inset,
|
||||||
|
// Offsets measured inward from whichever edge the anchor names,
|
||||||
|
// so one inset value works for all eight edge positions instead
|
||||||
|
// of needing its sign flipped per corner. Centre ignores them.
|
||||||
|
calculateOffsetFromEdge: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Platform\Branding\Watermark;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the watermark sits on a thumbnail: the four corners, the
|
||||||
|
* midpoint of each of the four edges, and the centre.
|
||||||
|
*
|
||||||
|
* The stored values are this module's own vocabulary, deliberately not
|
||||||
|
* SimpleImage's anchor strings — `anchor()` translates. SimpleImage
|
||||||
|
* decides an anchor by substring-matching 'top'/'bottom'/'left'/'right',
|
||||||
|
* so 'center' means "neither" on an axis and the two vocabularies happen
|
||||||
|
* to overlap today; storing its spelling in our database would make that
|
||||||
|
* coincidence a schema commitment.
|
||||||
|
*/
|
||||||
|
enum WatermarkPosition: string
|
||||||
|
{
|
||||||
|
case TopLeft = 'top-left';
|
||||||
|
case TopCenter = 'top-center';
|
||||||
|
case TopRight = 'top-right';
|
||||||
|
case MiddleLeft = 'middle-left';
|
||||||
|
case Center = 'center';
|
||||||
|
case MiddleRight = 'middle-right';
|
||||||
|
case BottomLeft = 'bottom-left';
|
||||||
|
case BottomCenter = 'bottom-center';
|
||||||
|
case BottomRight = 'bottom-right';
|
||||||
|
|
||||||
|
public function anchor(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::TopLeft => 'top left',
|
||||||
|
self::TopCenter => 'top',
|
||||||
|
self::TopRight => 'top right',
|
||||||
|
self::MiddleLeft => 'left',
|
||||||
|
self::Center => 'center',
|
||||||
|
self::MiddleRight => 'right',
|
||||||
|
self::BottomLeft => 'bottom left',
|
||||||
|
self::BottomCenter => 'bottom',
|
||||||
|
self::BottomRight => 'bottom right',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function values(): array
|
||||||
|
{
|
||||||
|
return array_map(fn (self $case): string => $case->value, self::cases());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Platform\Branding\Watermark;
|
||||||
|
|
||||||
|
use claviska\SimpleImage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stand-in photograph the settings screen draws the mark on, so an
|
||||||
|
* administrator can judge placement, scale and opacity without going to
|
||||||
|
* find a client account and a real file.
|
||||||
|
*
|
||||||
|
* Drawn rather than shipped as an asset: a stock photograph would be a
|
||||||
|
* licensing question and a binary in a git repository, and would only
|
||||||
|
* ever exercise whatever tones that one picture happens to contain. This
|
||||||
|
* is built to answer the question the sample exists for — "will my mark
|
||||||
|
* still read?" — with a full dark-to-light ramp under it, plus a couple
|
||||||
|
* of hard edges, so a too-transparent or too-small mark is obvious
|
||||||
|
* against at least one part of it.
|
||||||
|
*/
|
||||||
|
class WatermarkSample
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Roughly the proportions of a landscape photograph, and about the
|
||||||
|
* size the settings screen shows it at — big enough to judge, small
|
||||||
|
* enough to re-render on every keystroke.
|
||||||
|
*/
|
||||||
|
private const WIDTH = 480;
|
||||||
|
|
||||||
|
private const HEIGHT = 300;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly WatermarkPainter $painter,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $markPath an absolute local path to the artwork
|
||||||
|
*/
|
||||||
|
public function render(string $markPath, WatermarkPosition $position, int $size, int $opacity): SimpleImage
|
||||||
|
{
|
||||||
|
$canvas = $this->backdrop();
|
||||||
|
|
||||||
|
$this->painter->paint($canvas, $markPath, $position, $size, $opacity);
|
||||||
|
|
||||||
|
return $canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function backdrop(): SimpleImage
|
||||||
|
{
|
||||||
|
$canvas = (new SimpleImage())->fromNew(self::WIDTH, self::HEIGHT, '#1f2937');
|
||||||
|
|
||||||
|
// A left-to-right ramp, one column at a time — GD has no gradient
|
||||||
|
// primitive, and 480 lines is imperceptible next to the encode
|
||||||
|
// that follows.
|
||||||
|
for ($x = 0; $x < self::WIDTH; $x++) {
|
||||||
|
$shade = (int) round(24 + ($x / self::WIDTH) * 210);
|
||||||
|
|
||||||
|
// alpha 1 is *opaque* in SimpleImage's vocabulary — 0 is the
|
||||||
|
// fully transparent one ('transparent' normalizes to alpha 0).
|
||||||
|
// Getting that backwards draws the whole ramp invisibly, which
|
||||||
|
// no assertion about the mark itself would ever have caught.
|
||||||
|
$canvas->line($x, 0, $x, self::HEIGHT, [
|
||||||
|
'red' => $shade, 'green' => $shade, 'blue' => $shade, 'alpha' => 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two blocks at the extremes of the ramp, so every corner and edge
|
||||||
|
// the position picker offers has both a light and a dark
|
||||||
|
// neighbourhood somewhere near it.
|
||||||
|
$this->fill($canvas, 0, 0, (int) (self::WIDTH * 0.28), (int) (self::HEIGHT * 0.34), '#f8fafc');
|
||||||
|
$this->fill($canvas, (int) (self::WIDTH * 0.68), (int) (self::HEIGHT * 0.62), self::WIDTH, self::HEIGHT, '#0b1120');
|
||||||
|
|
||||||
|
return $canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A filled rectangle, drawn as a run of vertical lines.
|
||||||
|
*
|
||||||
|
* `rectangle(..., 'filled')` does exist and would be the obvious call,
|
||||||
|
* but SimpleImage's own docblock types that parameter `integer|array`,
|
||||||
|
* so passing its documented magic string fails static analysis. Lines
|
||||||
|
* cost nothing here and keep the analyser honest instead of teaching
|
||||||
|
* it to ignore a whole category of argument-type error in this file.
|
||||||
|
*
|
||||||
|
* @param string|array<string, int> $color
|
||||||
|
*/
|
||||||
|
private function fill(SimpleImage $canvas, int $x1, int $y1, int $x2, int $y2, string|array $color): void
|
||||||
|
{
|
||||||
|
for ($x = $x1; $x <= $x2; $x++) {
|
||||||
|
$canvas->line($x, $y1, $x, $y2, $color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use App\Modules\Platform\Branding\Http\Controllers\Api\BrandingController;
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Branding — module API routes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Mounted by the host at /api/v1/modules/branding, inside the API's auth
|
||||||
|
| stack (bearer token, active staff account) and behind
|
||||||
|
| `capability:branding.customize`. None of that is restated here — the host
|
||||||
|
| applies it, which is why these are plain relative paths.
|
||||||
|
|
|
||||||
|
| `token-can:` names a permission from the *host's* vocabulary. A module
|
||||||
|
| cannot invent ability strings: they have to reach the token-issuance UI
|
||||||
|
| and the reserved-namespace invariant, so they belong in the host's
|
||||||
|
| Permission enum. `edit_settings` is the same key the web branding routes
|
||||||
|
| use, so the API boundary mirrors the web one rather than inventing a
|
||||||
|
| second answer to "who may see the logo".
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
Route::get('logo', [BrandingController::class, 'show'])
|
||||||
|
->middleware('token-can:edit_settings')
|
||||||
|
->name('logo.show');
|
||||||
|
|
||||||
|
Route::get('watermark', [BrandingController::class, 'watermark'])
|
||||||
|
->middleware('token-can:edit_settings')
|
||||||
|
->name('watermark.show');
|
||||||
@@ -32,6 +32,24 @@ enum Capability: string
|
|||||||
case EmailTransportConfigure = 'email.transport.configure';
|
case EmailTransportConfigure = 'email.transport.configure';
|
||||||
case SystemUpdates = 'system.updates';
|
case SystemUpdates = 'system.updates';
|
||||||
|
|
||||||
|
// Community-only — whether this installation may switch off the
|
||||||
|
// project news on its dashboard.
|
||||||
|
//
|
||||||
|
// Note what is Community-only: the *choice*, not the news. A managed
|
||||||
|
// instance still fetches and still shows it, and cannot be made to
|
||||||
|
// stop. That is the difference from SystemUpdates beside it, and it
|
||||||
|
// is worth stating because the two look alike and are opposites. An
|
||||||
|
// update notice is useless on a hosted tenant — they cannot act on
|
||||||
|
// it, the image is ours — so the check does not run at all there.
|
||||||
|
// News is the reverse: announcements about the product are exactly
|
||||||
|
// what a hosted customer should be told, and an administrator
|
||||||
|
// switching them off for everybody on that instance is not a
|
||||||
|
// preference we meant to hand over.
|
||||||
|
//
|
||||||
|
// A self-hosted operator keeps the switch, because there nobody else
|
||||||
|
// decides what their installation reaches out for.
|
||||||
|
case NewsConfigure = 'news.configure';
|
||||||
|
|
||||||
// Community-only — scheduled-task run history and failed-queue-job
|
// Community-only — scheduled-task run history and failed-queue-job
|
||||||
// visibility. Cut on managed installations, where infrastructure
|
// visibility. Cut on managed installations, where infrastructure
|
||||||
// monitoring happens outside this application; a transient failure
|
// monitoring happens outside this application; a transient failure
|
||||||
@@ -46,10 +64,30 @@ enum Capability: string
|
|||||||
// cloud-modules below.
|
// cloud-modules below.
|
||||||
case CustomAssets = 'custom_assets.manage';
|
case CustomAssets = 'custom_assets.manage';
|
||||||
|
|
||||||
// Cloud-only — code lives in the private projectsend/cloud-modules
|
// Both editions. An installation dressing itself in its own logo, and
|
||||||
// package (github.com/projectsend/cloud-modules), never in this repo.
|
// watermarking what its clients and visitors see, is not a hosted
|
||||||
|
// concern -- it was Cloud-only because the code happened to live in
|
||||||
|
// the private package, which is a fact about where somebody typed it
|
||||||
|
// rather than about who should have it. Moved into core 2026-08-28.
|
||||||
|
//
|
||||||
|
// What a *plan* withholds is a different question from what an
|
||||||
|
// edition has, and it is answered by subtracting this key from an
|
||||||
|
// instance's environment rather than by moving it back. See
|
||||||
|
// CapabilityRegistry.
|
||||||
case Branding = 'branding.customize';
|
case Branding = 'branding.customize';
|
||||||
|
|
||||||
|
// Cloud-only, and deliberately not part of Branding above: taking
|
||||||
|
// ProjectSend's name off the pages somebody's own visitors see is the
|
||||||
|
// white-label half, and white-labelling is one of the things a hosted
|
||||||
|
// customer pays for.
|
||||||
|
//
|
||||||
|
// The gate is not this key. It is that the only code able to answer
|
||||||
|
// "hide it" ships in the private package, so an installation without
|
||||||
|
// that package has no listener to run and flipping an edition
|
||||||
|
// variable buys nothing. This key exists so a screen knows whether to
|
||||||
|
// offer the switch at all. See ResolvingAttribution.
|
||||||
|
case AttributionHide = 'attribution.hide';
|
||||||
|
|
||||||
// Cloud-only — the storage backend is ours, supplied by the
|
// Cloud-only — the storage backend is ours, supplied by the
|
||||||
// environment when the instance is provisioned and not the customer's
|
// environment when the instance is provisioned and not the customer's
|
||||||
// to see or change. The counterpart of StorageConfigure above rather
|
// to see or change. The counterpart of StorageConfigure above rather
|
||||||
@@ -59,11 +97,36 @@ enum Capability: string
|
|||||||
// simply inert and files stay on local disk.
|
// simply inert and files stay on local disk.
|
||||||
case StorageManaged = 'storage.managed';
|
case StorageManaged = 'storage.managed';
|
||||||
|
|
||||||
|
// Both editions, and present by default: a self-hosted installation
|
||||||
|
// has this screen today and needs it, because nobody else is going to
|
||||||
|
// supply its keys. It exists as a key so a managed platform can
|
||||||
|
// subtract it, and the reason to subtract it is narrower than the
|
||||||
|
// reason LDAP and social login stayed ungated.
|
||||||
|
//
|
||||||
|
// On a managed installation the administrator and the host are the
|
||||||
|
// same person, but the *reputation* is not theirs. Every tenant is a
|
||||||
|
// name under one shared domain, sending mail from one shared pool. An
|
||||||
|
// administrator who sets the provider to none, or who leaves the keys
|
||||||
|
// alone and just unticks the four per-form switches, turns their own
|
||||||
|
// public forms into an open door and spends everybody else's
|
||||||
|
// deliverability doing it. That is the same shape as Storage: not a
|
||||||
|
// feature somebody paid for, but a setting whose blast radius reaches
|
||||||
|
// past the installation that holds it.
|
||||||
|
//
|
||||||
|
// All-or-nothing on the route, read included, exactly as Storage and
|
||||||
|
// Branding are. Per-field gating in the controller would not do:
|
||||||
|
// switching the CAPTCHA off does not need the key fields at all, so
|
||||||
|
// the PATCH has to be closed too, and the middleware closes both
|
||||||
|
// verbs at once.
|
||||||
|
case CaptchaConfigure = 'captcha.configure';
|
||||||
|
|
||||||
// Cloud-only — managed installations supply CAPTCHA keys centrally, so
|
// Cloud-only — managed installations supply CAPTCHA keys centrally, so
|
||||||
// protection is on before anybody finds the settings screen. The
|
// protection is on before anybody finds the settings screen. The
|
||||||
// feature itself is in both editions and behind no capability: this
|
// feature itself is in both editions: this covers only the option of
|
||||||
// covers only the option of using *our* credentials, which cannot ship
|
// using *our* credentials, which cannot ship inside a self-hosted
|
||||||
// inside a self-hosted package.
|
// package. Distinct from CaptchaConfigure above — that one says
|
||||||
|
// whether the screen opens at all, this one says what it may offer
|
||||||
|
// once it does.
|
||||||
case CaptchaManagedKeys = 'captcha.managed_keys';
|
case CaptchaManagedKeys = 'captcha.managed_keys';
|
||||||
|
|
||||||
// Cloud-only — letting an AI assistant act on this installation on
|
// Cloud-only — letting an AI assistant act on this installation on
|
||||||
@@ -73,10 +136,14 @@ enum Capability: string
|
|||||||
// package is installed, not a flag an installation can set. Present
|
// package is installed, not a flag an installation can set. Present
|
||||||
// in this enum even so, because a package cannot extend a closed one
|
// in this enum even so, because a package cannot extend a closed one
|
||||||
// — core has to publish the key before anything can gate on it.
|
// — core has to publish the key before anything can gate on it.
|
||||||
// Cloud-only — staff seats on a managed instance belong to the
|
// Cloud-only — marks an installation that a platform provisioned and
|
||||||
// platform that sold them rather than to the instance, so the tenant's
|
// looks after, for the screens that have to know the difference.
|
||||||
// own /users screens stay closed (see UsersManage above) and a control
|
//
|
||||||
// plane creates, deactivates and password-resets them from outside.
|
// It does not close the tenant's own /users screens. It used to say
|
||||||
|
// so, and that stopped being true when UsersManage opened on both
|
||||||
|
// editions: a platform sells the seats, the tenant decides who sits
|
||||||
|
// in them. Capacity is the platform's, and it arrives as
|
||||||
|
// PROJECTSEND_PLATFORM_MAX_STAFF_USERS rather than as a shut door.
|
||||||
//
|
//
|
||||||
// The seat *number* deliberately does not live here. There are no
|
// The seat *number* deliberately does not live here. There are no
|
||||||
// billing or plan tiers in this application to key off — the same
|
// billing or plan tiers in this application to key off — the same
|
||||||
@@ -101,12 +168,15 @@ enum Capability: string
|
|||||||
self::StorageConfigure,
|
self::StorageConfigure,
|
||||||
self::EmailTransportConfigure,
|
self::EmailTransportConfigure,
|
||||||
self::SystemUpdates,
|
self::SystemUpdates,
|
||||||
|
self::NewsConfigure,
|
||||||
self::SchedulerMonitoring,
|
self::SchedulerMonitoring,
|
||||||
self::CustomAssets => [Edition::Community],
|
self::CustomAssets => [Edition::Community],
|
||||||
|
|
||||||
self::UsersManage => [Edition::Community, Edition::Cloud],
|
self::UsersManage,
|
||||||
|
self::CaptchaConfigure,
|
||||||
|
self::Branding => [Edition::Community, Edition::Cloud],
|
||||||
|
|
||||||
self::Branding,
|
self::AttributionHide,
|
||||||
self::StorageManaged,
|
self::StorageManaged,
|
||||||
self::CaptchaManagedKeys,
|
self::CaptchaManagedKeys,
|
||||||
self::PlatformManaged,
|
self::PlatformManaged,
|
||||||
|
|||||||
@@ -4,11 +4,63 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Modules\Platform\Capabilities;
|
namespace App\Modules\Platform\Capabilities;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this installation may do.
|
||||||
|
*
|
||||||
|
* An edition grants a set of capabilities; an operator may take some of
|
||||||
|
* them away. Those are different questions and the asymmetry between them
|
||||||
|
* is the whole design:
|
||||||
|
*
|
||||||
|
* **Subtraction only.** `PROJECTSEND_CAPABILITIES_DISABLED` can remove a
|
||||||
|
* key the edition grants. Nothing can add one. An environment variable
|
||||||
|
* that could grant a capability would put the proprietary screens of the
|
||||||
|
* hosted edition one line of `.env` away on every self-hosted install,
|
||||||
|
* which is not a gate at all — so the list is read, intersected with what
|
||||||
|
* the edition already allows, and can only ever make the answer smaller.
|
||||||
|
*
|
||||||
|
* **Why it exists.** A plan is not an edition. There are no billing tiers
|
||||||
|
* in this application to key off, and inventing one here would be a claim
|
||||||
|
* the rest of the codebase cannot back up — the same objection
|
||||||
|
* config/api.php makes about installation-level rate limits. This is not
|
||||||
|
* that: it is the operator telling the installation a fact about itself,
|
||||||
|
* exactly as PROJECTSEND_PLATFORM_MAX_STAFF_USERS does for seats. The
|
||||||
|
* platform knows what it sold; the installation is told, and enforces.
|
||||||
|
*
|
||||||
|
* **Unknown keys are ignored, not fatal.** A variable outlives the plan
|
||||||
|
* that wrote it and the release that named the key. An instance that
|
||||||
|
* refuses to boot because it was told to disable something that no longer
|
||||||
|
* exists would be a self-inflicted outage on upgrade day.
|
||||||
|
*/
|
||||||
class CapabilityRegistry
|
class CapabilityRegistry
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private readonly array $disabled;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string>|string|null $disabled keys this installation
|
||||||
|
* has been told it may not
|
||||||
|
* use; a comma-separated
|
||||||
|
* string is what the
|
||||||
|
* environment supplies
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Edition $edition,
|
private readonly Edition $edition,
|
||||||
) {}
|
array|string|null $disabled = [],
|
||||||
|
) {
|
||||||
|
// Parsed here rather than read from config(), so the registry stays
|
||||||
|
// a value object that can be constructed with nothing but its two
|
||||||
|
// facts -- which is what lets it be unit-tested without booting an
|
||||||
|
// application, and what stops the edition and the subtraction being
|
||||||
|
// read from two different places at two different times.
|
||||||
|
$this->disabled = is_array($disabled)
|
||||||
|
? $disabled
|
||||||
|
: array_values(array_filter(
|
||||||
|
array_map(trim(...), explode(',', (string) $disabled)),
|
||||||
|
fn (string $key): bool => $key !== '',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
public function edition(): Edition
|
public function edition(): Edition
|
||||||
{
|
{
|
||||||
@@ -17,7 +69,8 @@ class CapabilityRegistry
|
|||||||
|
|
||||||
public function has(Capability $capability): bool
|
public function has(Capability $capability): bool
|
||||||
{
|
{
|
||||||
return $capability->availableIn($this->edition);
|
return $capability->availableIn($this->edition)
|
||||||
|
&& ! in_array($capability->value, $this->disabled, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,8 +20,12 @@ use Illuminate\Console\Command;
|
|||||||
* administrator editing a database table by hand, guessing which of
|
* administrator editing a database table by hand, guessing which of
|
||||||
* several rows matters.
|
* several rows matters.
|
||||||
*
|
*
|
||||||
* PROJECTSEND_CAPTCHA_DISABLED does the same thing for anyone who would
|
* PROJECTSEND_CAPTCHA_DISABLED is the other half of the same escape
|
||||||
* rather touch .env than run artisan.
|
* hatch, and not merely the .env spelling of this one: it is checked
|
||||||
|
* first, ahead of the key source, so it is the only one of the two that
|
||||||
|
* works on an installation running the platform's managed keys. This
|
||||||
|
* command writes a setting those installations never read, and says so
|
||||||
|
* rather than reporting a success it did not have.
|
||||||
*/
|
*/
|
||||||
class DisableCaptchaCommand extends Command
|
class DisableCaptchaCommand extends Command
|
||||||
{
|
{
|
||||||
@@ -29,7 +33,7 @@ class DisableCaptchaCommand extends Command
|
|||||||
|
|
||||||
protected $description = 'Switch off the CAPTCHA on public forms';
|
protected $description = 'Switch off the CAPTCHA on public forms';
|
||||||
|
|
||||||
public function handle(Settings $settings): int
|
public function handle(Settings $settings, Captcha $captcha): int
|
||||||
{
|
{
|
||||||
$settings->set(Setting::CaptchaProvider, 'none');
|
$settings->set(Setting::CaptchaProvider, 'none');
|
||||||
|
|
||||||
@@ -38,6 +42,24 @@ class DisableCaptchaCommand extends Command
|
|||||||
Captcha::forgetDisplayCache();
|
Captcha::forgetDisplayCache();
|
||||||
CaptchaVerifier::forgetOutage();
|
CaptchaVerifier::forgetOutage();
|
||||||
|
|
||||||
|
// Managed keys are not this setting. Captcha::resolve() reaches
|
||||||
|
// them from config and returns before it ever looks at
|
||||||
|
// Setting::CaptchaProvider, so on an installation using them the
|
||||||
|
// write above changed a value nothing reads. Saying "CAPTCHA is
|
||||||
|
// off" there would be false, and false in the worst direction: an
|
||||||
|
// operator who is still being challenged would stop looking,
|
||||||
|
// having just been told the thing challenging them is gone.
|
||||||
|
//
|
||||||
|
// Read after the write rather than before it, because the write is
|
||||||
|
// what makes the answer meaningful — if this still resolves to
|
||||||
|
// something, the something is not ours to switch off.
|
||||||
|
if ($captcha->managedKeysSelected()) {
|
||||||
|
$this->warn('Nothing changed. This installation uses CAPTCHA keys supplied by the platform, and those do not come from the setting this command writes.');
|
||||||
|
$this->line('Set PROJECTSEND_CAPTCHA_DISABLED=true in the environment and restart to switch it off.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
$this->info('CAPTCHA is off. Your keys are still stored — switch it back on at /system/settings/captcha.');
|
$this->info('CAPTCHA is off. Your keys are still stored — switch it back on at /system/settings/captcha.');
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
|
|||||||
@@ -24,13 +24,18 @@ use Inertia\Response;
|
|||||||
/**
|
/**
|
||||||
* Configuring the CAPTCHA on public forms.
|
* Configuring the CAPTCHA on public forms.
|
||||||
*
|
*
|
||||||
* Available in **both** editions and behind no capability, for the reason
|
* Available in **both** editions, and behind Capability::CaptchaConfigure
|
||||||
* LDAP settled and social login repeated: this is an administrator's
|
* — present by default, so a self-hosted installation keeps the screen,
|
||||||
* setting, not an edition difference. What *is* an edition difference is
|
* and removable by an operator whose tenants share a domain and a sending
|
||||||
* the option of using the platform's own keys, and that is enforced per
|
* reputation. Enforced entirely by the `capability:captcha.configure`
|
||||||
* field rather than on the route — the shape EmailSettingsController uses
|
* route middleware, which covers the PATCH as well as the GET: turning
|
||||||
* for SMTP, so a hand-crafted PATCH cannot select a key source this
|
* the CAPTCHA off needs no gated field at all, so nothing short of
|
||||||
* installation has no keys for.
|
* closing the write would have closed it.
|
||||||
|
*
|
||||||
|
* Which keys this installation may point at is a second question, and
|
||||||
|
* that one is still enforced per field below rather than on the route —
|
||||||
|
* the shape EmailSettingsController uses for SMTP, so a hand-crafted
|
||||||
|
* PATCH cannot select a key source this installation has no keys for.
|
||||||
*
|
*
|
||||||
* The secret key follows the pattern MailProviderSettings established and
|
* The secret key follows the pattern MailProviderSettings established and
|
||||||
* LdapSettings and SocialSettings repeated: it is never sent to the
|
* LdapSettings and SocialSettings repeated: it is never sent to the
|
||||||
|
|||||||
@@ -162,8 +162,9 @@ class EmailOAuthController extends Controller
|
|||||||
'refresh_token' => null,
|
'refresh_token' => null,
|
||||||
'token_expires_at' => null,
|
'token_expires_at' => null,
|
||||||
'account_email' => null,
|
'account_email' => null,
|
||||||
'last_error' => null,
|
]);
|
||||||
])->save();
|
$connection->clearFailure();
|
||||||
|
$connection->save();
|
||||||
|
|
||||||
$this->activateConnection();
|
$this->activateConnection();
|
||||||
|
|
||||||
|
|||||||
@@ -185,8 +185,8 @@ class EmailSettingsController extends Controller
|
|||||||
'refresh_token' => null,
|
'refresh_token' => null,
|
||||||
'token_expires_at' => null,
|
'token_expires_at' => null,
|
||||||
'account_email' => null,
|
'account_email' => null,
|
||||||
'last_error' => null,
|
|
||||||
]);
|
]);
|
||||||
|
$connection->clearFailure();
|
||||||
}
|
}
|
||||||
|
|
||||||
$connection->save();
|
$connection->save();
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ class PrivacySettingsController extends Controller
|
|||||||
'account_erasure_grace_days' => $this->settings->get(Setting::AccountErasureGraceDays),
|
'account_erasure_grace_days' => $this->settings->get(Setting::AccountErasureGraceDays),
|
||||||
'account_erasure_content_action' => $this->settings->get(Setting::AccountErasureContentAction),
|
'account_erasure_content_action' => $this->settings->get(Setting::AccountErasureContentAction),
|
||||||
'account_erasure_reassign_to' => $this->settings->get(Setting::AccountErasureReassignTo),
|
'account_erasure_reassign_to' => $this->settings->get(Setting::AccountErasureReassignTo),
|
||||||
'reassign_candidates' => $this->accountDeletion->candidates(),
|
// Installation-wide on purpose: this is the default every
|
||||||
|
// erasure will use, stored once for everybody, and the page is
|
||||||
|
// already behind edit_settings.
|
||||||
|
'reassign_candidates' => $this->accountDeletion->candidates(null),
|
||||||
'api_request_log_retention_days' => $this->settings->get(Setting::ApiRequestLogRetentionDays),
|
'api_request_log_retention_days' => $this->settings->get(Setting::ApiRequestLogRetentionDays),
|
||||||
'discourage_search_indexing' => $this->settings->get(Setting::DiscourageSearchIndexing),
|
'discourage_search_indexing' => $this->settings->get(Setting::DiscourageSearchIndexing),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ class SystemSettingsController extends Controller
|
|||||||
{
|
{
|
||||||
$canManageUpdates = $this->capabilities->has(Capability::SystemUpdates)
|
$canManageUpdates = $this->capabilities->has(Capability::SystemUpdates)
|
||||||
&& $request->user()?->can('manage_updates') === true;
|
&& $request->user()?->can('manage_updates') === true;
|
||||||
|
// No permission beside it, unlike updates: turning the news card
|
||||||
|
// off is an ordinary settings change, and edit_settings already
|
||||||
|
// gates this whole screen.
|
||||||
|
$canConfigureNews = $this->capabilities->has(Capability::NewsConfigure);
|
||||||
|
|
||||||
return Inertia::render('system/settings/general', [
|
return Inertia::render('system/settings/general', [
|
||||||
'site_name' => $this->settings->get(Setting::SiteName),
|
'site_name' => $this->settings->get(Setting::SiteName),
|
||||||
@@ -62,6 +66,15 @@ class SystemSettingsController extends Controller
|
|||||||
'viewer_timezone' => $request->user()?->timezone,
|
'viewer_timezone' => $request->user()?->timezone,
|
||||||
'can_manage_updates' => $canManageUpdates,
|
'can_manage_updates' => $canManageUpdates,
|
||||||
'check_for_updates' => $canManageUpdates ? $this->settings->get(Setting::CheckForUpdates) : null,
|
'check_for_updates' => $canManageUpdates ? $this->settings->get(Setting::CheckForUpdates) : null,
|
||||||
|
// Its own capability, and deliberately not $canManageUpdates:
|
||||||
|
// the update block disappears on a managed instance because
|
||||||
|
// nobody there can act on it, while this one disappears
|
||||||
|
// because the news must keep arriving whether or not the
|
||||||
|
// instance's administrator would have chosen it. Null where
|
||||||
|
// the choice is not theirs, so the page renders no switch
|
||||||
|
// rather than a switch that would do nothing.
|
||||||
|
'can_configure_news' => $canConfigureNews,
|
||||||
|
'fetch_news' => $canConfigureNews ? $this->settings->get(Setting::FetchNews) : null,
|
||||||
'last_checked_at' => $canManageUpdates ? $this->lastCheckedAt()?->toIso8601String() : null,
|
'last_checked_at' => $canManageUpdates ? $this->lastCheckedAt()?->toIso8601String() : null,
|
||||||
'check_result' => $request->session()->get('update_check_result'),
|
'check_result' => $request->session()->get('update_check_result'),
|
||||||
]);
|
]);
|
||||||
@@ -123,6 +136,10 @@ class SystemSettingsController extends Controller
|
|||||||
{
|
{
|
||||||
$canManageUpdates = $this->capabilities->has(Capability::SystemUpdates)
|
$canManageUpdates = $this->capabilities->has(Capability::SystemUpdates)
|
||||||
&& $request->user()?->can('manage_updates') === true;
|
&& $request->user()?->can('manage_updates') === true;
|
||||||
|
// No permission beside it, unlike updates: turning the news card
|
||||||
|
// off is an ordinary settings change, and edit_settings already
|
||||||
|
// gates this whole screen.
|
||||||
|
$canConfigureNews = $this->capabilities->has(Capability::NewsConfigure);
|
||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
'site_name' => ['required', 'string', 'max:255'],
|
'site_name' => ['required', 'string', 'max:255'],
|
||||||
@@ -133,6 +150,10 @@ class SystemSettingsController extends Controller
|
|||||||
// "follow APP_TIMEZONE", and only a fresh install has that.
|
// "follow APP_TIMEZONE", and only a fresh install has that.
|
||||||
'timezone' => ['sometimes', 'string', 'timezone', Rule::in($this->timezones->all())],
|
'timezone' => ['sometimes', 'string', 'timezone', Rule::in($this->timezones->all())],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if ($canConfigureNews) {
|
||||||
|
$rules['fetch_news'] = ['sometimes', 'boolean'];
|
||||||
|
}
|
||||||
if ($canManageUpdates) {
|
if ($canManageUpdates) {
|
||||||
// Omitting the field (any caller not sending it, not just this
|
// Omitting the field (any caller not sending it, not just this
|
||||||
// page's own form) leaves the current value alone rather than
|
// page's own form) leaves the current value alone rather than
|
||||||
@@ -156,6 +177,13 @@ class SystemSettingsController extends Controller
|
|||||||
$this->settings->set(Setting::CheckForUpdates, $validated['check_for_updates']);
|
$this->settings->set(Setting::CheckForUpdates, $validated['check_for_updates']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Never read where the choice is not this installation's, so a
|
||||||
|
// hand-crafted PATCH cannot switch off the news on a managed
|
||||||
|
// instance any more than the absent checkbox could.
|
||||||
|
if ($canConfigureNews && array_key_exists('fetch_news', $validated)) {
|
||||||
|
$this->settings->set(Setting::FetchNews, $validated['fetch_news']);
|
||||||
|
}
|
||||||
|
|
||||||
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'general']);
|
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'general']);
|
||||||
|
|
||||||
return back();
|
return back();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Modules\Platform\Http\Middleware;
|
|||||||
use App\Modules\Platform\Capabilities\Capability;
|
use App\Modules\Platform\Capabilities\Capability;
|
||||||
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||||
use App\Modules\Platform\Capabilities\CapabilityUnavailable;
|
use App\Modules\Platform\Capabilities\CapabilityUnavailable;
|
||||||
|
use App\Support\ApiSurface;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
@@ -17,6 +18,12 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
* API requests get a machine-readable 403; web requests get a 404 so
|
* API requests get a machine-readable 403; web requests get a 404 so
|
||||||
* unavailable features are absent, not teased.
|
* unavailable features are absent, not teased.
|
||||||
*
|
*
|
||||||
|
* Which of the two a request is comes from the route (see ApiSurface), not
|
||||||
|
* from its Accept header. Whether an endpoint exists in this edition is a
|
||||||
|
* property of the installation; deciding it from what the caller is
|
||||||
|
* willing to parse answered the same API route 403 or 404 depending on
|
||||||
|
* nothing but a header, and routes/api.php promises the 403.
|
||||||
|
*
|
||||||
* The API half throws CapabilityUnavailable rather than returning a body,
|
* The API half throws CapabilityUnavailable rather than returning a body,
|
||||||
* so the refusal goes through ProblemDetails like every other API error
|
* so the refusal goes through ProblemDetails like every other API error
|
||||||
* instead of being the one response shaped differently from the rest.
|
* instead of being the one response shaped differently from the rest.
|
||||||
@@ -35,7 +42,7 @@ class EnsureCapability
|
|||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->expectsJson()) {
|
if (ApiSurface::matches($request)) {
|
||||||
throw new CapabilityUnavailable($capability, $this->capabilities->edition());
|
throw new CapabilityUnavailable($capability, $this->capabilities->edition());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,25 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Modules\Platform\Installation\Console;
|
namespace App\Modules\Platform\Installation\Console;
|
||||||
|
|
||||||
|
use App\Modules\Audit\Action;
|
||||||
|
use App\Modules\Audit\ActivityLog;
|
||||||
|
use App\Modules\Files\Models\File;
|
||||||
|
use App\Modules\Identity\TwoFactor\TwoFactorEnforcement;
|
||||||
|
use App\Modules\Identity\UserType;
|
||||||
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||||
|
use App\Modules\Platform\Installation\Events\ResolvingInstallationStatus;
|
||||||
|
use App\Modules\Platform\Scheduling\ScheduledTaskRun;
|
||||||
|
use App\Modules\Platform\Scheduling\TaskRunStatus;
|
||||||
use App\Modules\Platform\Seats\SeatAllowance;
|
use App\Modules\Platform\Seats\SeatAllowance;
|
||||||
|
use App\Modules\Platform\Settings\Setting;
|
||||||
|
use App\Modules\Platform\Settings\Settings;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Database\Migrations\Migrator;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What this installation is, as a fact rather than a screen.
|
* What this installation is, as a fact rather than a screen.
|
||||||
@@ -33,14 +49,164 @@ use Illuminate\Console\Command;
|
|||||||
* an inactive account, or a soft-deleted one — and the divergence looks
|
* an inactive account, or a soft-deleted one — and the divergence looks
|
||||||
* like a billing fault rather than a counting one. So there is one
|
* like a billing fault rather than a counting one. So there is one
|
||||||
* definition and this reads it.
|
* definition and this reads it.
|
||||||
|
*
|
||||||
|
* ### Is anybody there
|
||||||
|
*
|
||||||
|
* `activity.last_staff_login_at` answers the one question a platform
|
||||||
|
* cannot answer from outside: whether a human still uses this
|
||||||
|
* installation. It is a timestamp and nothing else — no name, no address,
|
||||||
|
* no session. Only interactive sign-ins reach it, because that is all
|
||||||
|
* Laravel's Login event fires for: an integration polling the API every
|
||||||
|
* hour must not make a dormant installation look busy.
|
||||||
|
*
|
||||||
|
* Derived from the activity log rather than denormalised onto `users`. A
|
||||||
|
* column would need a migration, a listener change and a backfill to save
|
||||||
|
* one indexed MAX() over a table that is small on exactly the
|
||||||
|
* installations anybody asks this about. The log is never pruned, and
|
||||||
|
* erasure anonymises entries rather than deleting them (`actor_type`
|
||||||
|
* survives on purpose — see AccountEraser), so the answer does not change
|
||||||
|
* when the person who gave it is forgotten.
|
||||||
|
*
|
||||||
|
* **That "never pruned" is now somebody's safety argument.** The hosted
|
||||||
|
* platform warns, pauses and finally removes a free instance nobody has
|
||||||
|
* signed in to, and this field is what it counts from. Retention or
|
||||||
|
* pruning added to `activity_log` would not break anything here — it
|
||||||
|
* would quietly make old installations look dormant, and the thing that
|
||||||
|
* acts on that reading deletes them. Anyone adding it needs to give this
|
||||||
|
* field another source first, not merely check that the tests still
|
||||||
|
* pass.
|
||||||
|
*
|
||||||
|
* ### Storage is the application's number, not the disk's
|
||||||
|
*
|
||||||
|
* `storage.bytes` is what this installation holds, summed from the rows
|
||||||
|
* that record it. Measuring the directory instead was correct until
|
||||||
|
* external storage went live, and silently stopped being: an upload that
|
||||||
|
* resolves to a bucket leaves nothing on the volume to measure, so a
|
||||||
|
* figure taken from the filesystem freezes while the account keeps
|
||||||
|
* filling. `by_disk` is the same sum split by where the bytes went, which
|
||||||
|
* is the only way to see what is still sitting on local disk from before
|
||||||
|
* a cutover.
|
||||||
|
*
|
||||||
|
* Trashed files are excluded because they hold no bytes: File's `deleted`
|
||||||
|
* hook removes them, so a soft-deleted row is a record of something that
|
||||||
|
* is gone rather than something still costing anything.
|
||||||
|
*
|
||||||
|
* ### Health is what a container cannot show from outside
|
||||||
|
*
|
||||||
|
* A tenant's queue worker dying is invisible to anything watching the
|
||||||
|
* container: it is still up, and zips quietly stop building while mail
|
||||||
|
* stops going out. Same for migrations that failed after a deploy — the
|
||||||
|
* application answers every request and is a schema behind. Neither is a
|
||||||
|
* secret; both are already visible to anyone who can open the database,
|
||||||
|
* which is anyone who can run this command.
|
||||||
|
*
|
||||||
|
* ### What core cannot answer
|
||||||
|
*
|
||||||
|
* `modules` is filled by whatever packages are installed, through
|
||||||
|
* ResolvingInstallationStatus. A platform that provisioned a bucket knows
|
||||||
|
* what it asked for; only the installation knows what loaded.
|
||||||
|
*
|
||||||
|
* ### `capabilities` is compared, not displayed
|
||||||
|
*
|
||||||
|
* The control plane reads this list against the plan it wrote for the
|
||||||
|
* tenant — "this instance is on the free plan and still grants branding"
|
||||||
|
* is a comparison, not a glance. So the *keys* and their order are a
|
||||||
|
* contract: renaming one, or reordering the enum they come from, breaks
|
||||||
|
* that comparison while every test here keeps passing. A key that changes
|
||||||
|
* meaning needs a new key, not an edit.
|
||||||
|
*
|
||||||
|
* ### `usage` and `health.scheduler` are charted, so their keys are a promise
|
||||||
|
*
|
||||||
|
* The hosted platform's customer dashboard plots these over time. That
|
||||||
|
* makes the key names a contract in the same way `capabilities` is one,
|
||||||
|
* and it fails in a nastier way: a renamed capability key breaks a
|
||||||
|
* comparison that somebody is watching, while a renamed `usage` key
|
||||||
|
* produces a chart that is silently *empty* rather than an error. Nobody
|
||||||
|
* gets paged for a flat line.
|
||||||
|
*
|
||||||
|
* So: add keys freely, and never rename or repurpose one. A key whose
|
||||||
|
* meaning changes needs a new key, not a new value — "downloads" that
|
||||||
|
* quietly starts excluding staff is worse than "downloads" disappearing,
|
||||||
|
* because the second is noticed.
|
||||||
|
*
|
||||||
|
* `usage` is a **rolling window, deliberately, and has no lifetime
|
||||||
|
* totals**. Not a presentation choice: `activity_log` is never pruned
|
||||||
|
* (see above), so a lifetime count over it gets slower every day of the
|
||||||
|
* installation's life, while a windowed one stays flat forever. The
|
||||||
|
* window is stated in the document as `window_days` rather than assumed
|
||||||
|
* by the reader, so changing it is visible to whoever is plotting it.
|
||||||
|
*
|
||||||
|
* Every count here rides one of the two composite indexes added for it —
|
||||||
|
* see the migration adding them, which also records why they have to
|
||||||
|
* ship as a pair.
|
||||||
|
*
|
||||||
|
* ### The scheduler is the one thing nothing else can see
|
||||||
|
*
|
||||||
|
* `health.queues` catches a dead worker. Nothing catches a dead
|
||||||
|
* *scheduler*, and its symptom is not a stalled feature: expired files
|
||||||
|
* stop being purged, so content that was supposed to become unreachable
|
||||||
|
* stays reachable, and orphans and stale uploads accumulate against a
|
||||||
|
* quota nobody is watching. The installation looks completely healthy
|
||||||
|
* while it happens, to its operator and to its administrator alike.
|
||||||
|
*
|
||||||
|
* ### A version is a decision, a commit is a fact
|
||||||
|
*
|
||||||
|
* `build` says which commit this installation was built from. A version
|
||||||
|
* string is chosen by somebody and stamped; two images can carry the same
|
||||||
|
* one and different code — an image built from the tag, and one built
|
||||||
|
* from the branch that tag sits on. A fleet spent a day reporting "2.2.0"
|
||||||
|
* from images that were not the released 2.2.0, and nothing inside any of
|
||||||
|
* them could have said so.
|
||||||
|
*
|
||||||
|
* Null on a source checkout, all four fields, because `config/build.php`
|
||||||
|
* is written by build-release.sh and a checkout is not a build. That is
|
||||||
|
* the honest answer rather than a missing one: "I was not built" and "I
|
||||||
|
* will not say" are different, and only the first is true here.
|
||||||
*/
|
*/
|
||||||
class StatusCommand extends Command
|
class StatusCommand extends Command
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* The rolling window every `usage` count is measured over.
|
||||||
|
*
|
||||||
|
* Emitted in the document as `window_days` rather than left for the
|
||||||
|
* reader to know, because a number that is charted and a number that
|
||||||
|
* is assumed diverge exactly once and silently.
|
||||||
|
*/
|
||||||
|
private const USAGE_WINDOW_DAYS = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The actions `usage.actions` counts, and the whole of it.
|
||||||
|
*
|
||||||
|
* An allowlist rather than a `group by action`, for two reasons that
|
||||||
|
* happen to agree. Privacy: this document leaves the installation, and
|
||||||
|
* cases land in Action most weeks — an open group-by would start
|
||||||
|
* shipping new action names outward with nobody having decided that
|
||||||
|
* they should go, and some of them (`account.erased`,
|
||||||
|
* `two_factor.reset`, `password.updated`) are somebody's compliance
|
||||||
|
* event rather than a business metric. Cost: five keyed counts measure
|
||||||
|
* ~30x cheaper than one `group by action` over the same window,
|
||||||
|
* because each rides (action, created_at) while the group-by starts
|
||||||
|
* from created_at and reads rows.
|
||||||
|
*
|
||||||
|
* These five answer "is my library growing, are people being added, is
|
||||||
|
* anything being shared" and nothing else. Uploads and downloads are
|
||||||
|
* their own fields; none of these names a person.
|
||||||
|
*
|
||||||
|
* @var list<Action>
|
||||||
|
*/
|
||||||
|
private const USAGE_ACTIONS = [
|
||||||
|
Action::UserCreated,
|
||||||
|
Action::ClientSelfRegistered,
|
||||||
|
Action::FileAssigned,
|
||||||
|
Action::ShareLinkCreated,
|
||||||
|
Action::GroupCreated,
|
||||||
|
];
|
||||||
|
|
||||||
protected $signature = 'projectsend:status {--json : Emit machine-readable JSON on stdout}';
|
protected $signature = 'projectsend:status {--json : Emit machine-readable JSON on stdout}';
|
||||||
|
|
||||||
protected $description = 'Report this installation\'s version, edition, capabilities and seat usage';
|
protected $description = 'Report this installation\'s version, edition, capabilities and seat usage';
|
||||||
|
|
||||||
public function handle(CapabilityRegistry $capabilities, SeatAllowance $seats): int
|
public function handle(CapabilityRegistry $capabilities, SeatAllowance $seats, Settings $settings): int
|
||||||
{
|
{
|
||||||
$status = [
|
$status = [
|
||||||
'version' => (string) config('projectsend.version'),
|
'version' => (string) config('projectsend.version'),
|
||||||
@@ -60,6 +226,48 @@ class StatusCommand extends Command
|
|||||||
'limit' => $seats->clientLimit(),
|
'limit' => $seats->clientLimit(),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
'activity' => [
|
||||||
|
// Null means "no staff account has ever signed in here",
|
||||||
|
// and is emitted rather than left out for the same reason
|
||||||
|
// an unlimited seat count is: a watcher has to be able to
|
||||||
|
// tell that apart from "we got no answer". Collapsing the
|
||||||
|
// two is how a broken probe reads as a dormant fleet.
|
||||||
|
'last_staff_login_at' => $this->lastLoginAt(UserType::Staff),
|
||||||
|
// The staff timestamp says the administrator still shows
|
||||||
|
// up. This one says their customers do, which is a
|
||||||
|
// different question and the more interesting half: an
|
||||||
|
// installation whose only visitor is the person paying
|
||||||
|
// for it is one nobody is getting value from.
|
||||||
|
'last_client_login_at' => $this->lastLoginAt(UserType::Client),
|
||||||
|
],
|
||||||
|
'storage' => $this->storage(),
|
||||||
|
'usage' => $this->usage(),
|
||||||
|
'health' => $this->health(),
|
||||||
|
'settings' => [
|
||||||
|
// Echoed back rather than assumed: an operator writes the
|
||||||
|
// environment variable, and this is the installation
|
||||||
|
// saying what it actually applied. Read the way
|
||||||
|
// EnforceTwoFactor reads it, down to what an unreadable
|
||||||
|
// value falls back to -- reporting a stricter answer than
|
||||||
|
// the middleware enforces would be worse than reporting
|
||||||
|
// none at all.
|
||||||
|
'two_factor_enforcement' => $this->enforcement($settings),
|
||||||
|
],
|
||||||
|
// Cast so an installation with no packages emits {} rather
|
||||||
|
// than [] -- an empty PHP array encodes as a list, and a
|
||||||
|
// reader unmarshalling a map breaks on the day it happens to
|
||||||
|
// be empty rather than on the day it is written.
|
||||||
|
'modules' => (object) $this->modules(),
|
||||||
|
'build' => [
|
||||||
|
// `channel` is 'release' or 'dev'. An internal build names
|
||||||
|
// itself after its commit and can never be published, so a
|
||||||
|
// fleet reading 'dev' is looking at something deliberate
|
||||||
|
// rather than at a mistake.
|
||||||
|
'commit' => $this->buildFact('commit'),
|
||||||
|
'ref' => $this->buildFact('ref'),
|
||||||
|
'channel' => $this->buildFact('channel'),
|
||||||
|
'built_at' => $this->buildFact('built_at'),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($this->option('json')) {
|
if ($this->option('json')) {
|
||||||
@@ -72,10 +280,362 @@ class StatusCommand extends Command
|
|||||||
$this->line('Capabilities: '.(implode(', ', $status['capabilities']) ?: 'none'));
|
$this->line('Capabilities: '.(implode(', ', $status['capabilities']) ?: 'none'));
|
||||||
$this->line('Staff seats: '.$this->seatLine($status['seats']['staff']));
|
$this->line('Staff seats: '.$this->seatLine($status['seats']['staff']));
|
||||||
$this->line('Clients: '.$this->seatLine($status['seats']['clients']));
|
$this->line('Clients: '.$this->seatLine($status['seats']['clients']));
|
||||||
|
$this->line('Last staff login: '.($status['activity']['last_staff_login_at'] ?? 'never'));
|
||||||
|
$this->line('Storage: '.number_format($status['storage']['bytes']).' bytes in '.$status['storage']['files'].' files');
|
||||||
|
$this->line('Build: '.($status['build']['ref'] ?? 'not a build')
|
||||||
|
.($status['build']['channel'] === 'dev' ? ' (dev)' : ''));
|
||||||
|
$this->line('Health: '.$status['health']['pending_migrations'].' migrations pending, '
|
||||||
|
.$status['health']['failed_jobs'].' failed jobs, '
|
||||||
|
.array_sum(array_filter($status['health']['queues'], 'is_int')).' queued');
|
||||||
|
$this->line('Scheduler: '.($status['health']['scheduler']['last_run_at'] ?? 'never run')
|
||||||
|
.' ('.$status['health']['scheduler']['failing'].' failing)');
|
||||||
|
$this->line('Last '.self::USAGE_WINDOW_DAYS.'d: '
|
||||||
|
.array_sum($status['usage']['downloads']).' downloads, '
|
||||||
|
.$status['usage']['uploads'].' uploads');
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function buildFact(string $key): ?string
|
||||||
|
{
|
||||||
|
$value = config("build.$key");
|
||||||
|
|
||||||
|
return is_string($value) && $value !== '' ? $value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function enforcement(Settings $settings): string
|
||||||
|
{
|
||||||
|
$value = $settings->get(Setting::TwoFactorEnforcement);
|
||||||
|
|
||||||
|
$enforcement = (is_string($value) ? TwoFactorEnforcement::tryFrom($value) : null)
|
||||||
|
?? TwoFactorEnforcement::None;
|
||||||
|
|
||||||
|
return $enforcement->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this installation holds, from the rows that record it.
|
||||||
|
*
|
||||||
|
* @return array{bytes: int, files: int, by_disk: object}
|
||||||
|
*/
|
||||||
|
private function storage(): array
|
||||||
|
{
|
||||||
|
$perDisk = File::query()
|
||||||
|
->groupBy('disk')
|
||||||
|
->selectRaw('disk, sum(size) as bytes, count(*) as files')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'bytes' => (int) $perDisk->sum(fn (File $row): int => (int) $row->getAttribute('bytes')),
|
||||||
|
'files' => (int) $perDisk->sum(fn (File $row): int => (int) $row->getAttribute('files')),
|
||||||
|
// Keyed by disk name rather than a list, because the reader
|
||||||
|
// wants one of them by name — "how much is still local" — and
|
||||||
|
// not to walk a list looking for it.
|
||||||
|
// Same reason as `modules`: an installation holding no files
|
||||||
|
// at all must still answer with a map.
|
||||||
|
'by_disk' => (object) $perDisk
|
||||||
|
->mapWithKeys(fn (File $row): array => [
|
||||||
|
(string) $row->getAttribute('disk') => [
|
||||||
|
'bytes' => (int) $row->getAttribute('bytes'),
|
||||||
|
'files' => (int) $row->getAttribute('files'),
|
||||||
|
],
|
||||||
|
])->all(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{pending_migrations: int, failed_jobs: int, failed_jobs_latest_at: string|null, queues: array<string, int|null>, scheduler: array{last_run_at: string|null, failing: int}}
|
||||||
|
*/
|
||||||
|
private function health(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'pending_migrations' => $this->pendingMigrations(),
|
||||||
|
'failed_jobs' => $this->failedJobs(),
|
||||||
|
'failed_jobs_latest_at' => $this->latestFailureAt(),
|
||||||
|
// The two this application actually runs workers for. A depth
|
||||||
|
// is not a fault on its own -- a busy installation has one --
|
||||||
|
// but a depth that only ever grows is a worker that died, and
|
||||||
|
// nothing outside the container can see the difference.
|
||||||
|
'queues' => [
|
||||||
|
'default' => $this->queueDepth('default'),
|
||||||
|
'zips' => $this->queueDepth('zips'),
|
||||||
|
],
|
||||||
|
'scheduler' => $this->scheduler(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the scheduler is running, and whether what it runs works.
|
||||||
|
*
|
||||||
|
* One row per known command, upserted on every run, so this is a
|
||||||
|
* dozen rows however old the installation is.
|
||||||
|
*
|
||||||
|
* `last_run_at` is null when nothing has ever run — a brand new
|
||||||
|
* installation, or one whose scheduler has never been wired up at
|
||||||
|
* all — and those are different from "ran, a long time ago", which
|
||||||
|
* is a timestamp. The reader decides what counts as too old; every
|
||||||
|
* task in routes/console.php is daily, so anything past about a day
|
||||||
|
* means nobody is running it. Deliberately not judged here: a
|
||||||
|
* threshold belongs to whoever is watching, and baking one in would
|
||||||
|
* make the answer wrong for anyone whose schedule is not ours.
|
||||||
|
*
|
||||||
|
* The failure *message* is deliberately not reported. This document
|
||||||
|
* leaves the installation, and a task's error text is the one field
|
||||||
|
* here that can carry a filesystem path, a hostname or an exception
|
||||||
|
* from somebody's storage backend. A count says "go and look",
|
||||||
|
* which is all a watcher needs and all it is owed.
|
||||||
|
*
|
||||||
|
* `failing` counts commands whose *most recent* run failed, not
|
||||||
|
* failures over time — the row is upserted, so a task that failed
|
||||||
|
* last night and succeeded this morning is not failing. A task that
|
||||||
|
* has never run is not counted here either; it is absent from the
|
||||||
|
* table, which is what `last_run_at` is for.
|
||||||
|
*
|
||||||
|
* @return array{last_run_at: string|null, failing: int}
|
||||||
|
*/
|
||||||
|
private function scheduler(): array
|
||||||
|
{
|
||||||
|
$lastRun = ScheduledTaskRun::query()->max('ran_at');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'last_run_at' => $lastRun === null ? null : Carbon::parse($lastRun)->toIso8601String(),
|
||||||
|
'failing' => ScheduledTaskRun::query()
|
||||||
|
->where('status', TaskRunStatus::Failed)
|
||||||
|
->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What has been happening here lately.
|
||||||
|
*
|
||||||
|
* Every figure is a count over the same rolling window and there are
|
||||||
|
* no lifetime totals — see the class docblock for why that is a
|
||||||
|
* correctness decision rather than a presentational one.
|
||||||
|
*
|
||||||
|
* Downloads are split the way the installation's own dashboard
|
||||||
|
* splits them (DashboardController::transferSeries), on purpose: the
|
||||||
|
* administrator and whatever is reading this document have to be able
|
||||||
|
* to agree about a number they can both see. Staff downloads are
|
||||||
|
* reported rather than dropped so a reader can choose, but they are
|
||||||
|
* their own key precisely because they are not audience traffic — an
|
||||||
|
* administrator opening their own upload to check it is not somebody
|
||||||
|
* receiving a file.
|
||||||
|
*
|
||||||
|
* @return array{window_days: int, downloads: array{staff: int, clients: int, anonymous: int}, uploads: int, actions: object}
|
||||||
|
*/
|
||||||
|
private function usage(): array
|
||||||
|
{
|
||||||
|
$since = now()->subDays(self::USAGE_WINDOW_DAYS);
|
||||||
|
|
||||||
|
$downloads = [
|
||||||
|
Action::FileDownloaded->value,
|
||||||
|
Action::ShareLinkDownloaded->value,
|
||||||
|
Action::PublicFileDownloaded->value,
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'window_days' => self::USAGE_WINDOW_DAYS,
|
||||||
|
'downloads' => [
|
||||||
|
'staff' => $this->countActionsByActor($downloads, $since, UserType::Staff->value),
|
||||||
|
'clients' => $this->countActionsByActor($downloads, $since, UserType::Client->value),
|
||||||
|
// Null actor_type is the anonymous case: a share link or
|
||||||
|
// the public listing, served to somebody with no account
|
||||||
|
// at all. It is the traffic an administrator has no other
|
||||||
|
// way to see.
|
||||||
|
'anonymous' => $this->countActionsByActor($downloads, $since, null),
|
||||||
|
],
|
||||||
|
'uploads' => $this->countActions([Action::FileUploaded->value], $since),
|
||||||
|
// Cast for the reason `modules` is: an empty PHP array
|
||||||
|
// encodes as a list, and a reader unmarshalling a map breaks
|
||||||
|
// on the day it happens to be empty rather than on the day it
|
||||||
|
// is written. It cannot be empty today, but the allowlist is
|
||||||
|
// meant to be edited.
|
||||||
|
//
|
||||||
|
// What that costs, confirmed against the reader rather than
|
||||||
|
// guessed at: the hosted platform's probe decodes this block
|
||||||
|
// into a typed struct and discards a block it cannot read, and
|
||||||
|
// Go refuses a JSON list into a map outright. So a `[]` here
|
||||||
|
// would not lose `actions` — it would lose the whole `usage`
|
||||||
|
// block, downloads and uploads with it, on the day a tenant
|
||||||
|
// happened to have no counted activity. The quietest
|
||||||
|
// installations would stop reporting and nothing would log a
|
||||||
|
// fault. Both shapes are pinned by tests on that side too.
|
||||||
|
'actions' => (object) $this->usageActions($since),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
private function usageActions(Carbon $since): array
|
||||||
|
{
|
||||||
|
$counts = [];
|
||||||
|
|
||||||
|
// One keyed count each rather than a single grouped query: this
|
||||||
|
// is both the cheaper shape (each rides (action, created_at);
|
||||||
|
// a group-by starts from created_at and reads rows) and the one
|
||||||
|
// that can only ever emit keys somebody chose. See USAGE_ACTIONS.
|
||||||
|
foreach (self::USAGE_ACTIONS as $action) {
|
||||||
|
$counts[$action->value] = $this->countActions([$action->value], $since);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many of these actions happened in the window, by anyone.
|
||||||
|
*
|
||||||
|
* @param list<string> $actions
|
||||||
|
*/
|
||||||
|
private function countActions(array $actions, Carbon $since): int
|
||||||
|
{
|
||||||
|
return ActivityLog::query()
|
||||||
|
->whereIn('action', $actions)
|
||||||
|
->where('created_at', '>=', $since)
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same count, narrowed to one kind of actor.
|
||||||
|
*
|
||||||
|
* Separate from countActions() rather than an optional argument on
|
||||||
|
* it, because the argument would have to carry three states — staff,
|
||||||
|
* client, and *nobody at all* — and null already means the third.
|
||||||
|
* An optional `?string $actorType = null` reads as "no filter" at
|
||||||
|
* every call site and would have silently reported the installation's
|
||||||
|
* whole download total in the anonymous column.
|
||||||
|
*
|
||||||
|
* @param list<string> $actions
|
||||||
|
* @param string|null $actorType null is the anonymous case: a share
|
||||||
|
* link or the public listing, served
|
||||||
|
* to somebody with no account
|
||||||
|
*/
|
||||||
|
private function countActionsByActor(array $actions, Carbon $since, ?string $actorType): int
|
||||||
|
{
|
||||||
|
$query = ActivityLog::query()
|
||||||
|
->whereIn('action', $actions)
|
||||||
|
->where('created_at', '>=', $since);
|
||||||
|
|
||||||
|
return ($actorType === null
|
||||||
|
? $query->whereNull('actor_type')
|
||||||
|
: $query->where('actor_type', $actorType)
|
||||||
|
)->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pendingMigrations(): int
|
||||||
|
{
|
||||||
|
/** @var Migrator $migrator */
|
||||||
|
$migrator = app('migrator');
|
||||||
|
|
||||||
|
// Every path, not just database/migrations: a package registers
|
||||||
|
// its own, and a package migration left unrun is exactly the kind
|
||||||
|
// of half-deploy this is here to report.
|
||||||
|
$files = $migrator->getMigrationFiles(array_merge([database_path('migrations')], $migrator->paths()));
|
||||||
|
|
||||||
|
return count(array_diff(array_keys($files), $migrator->getRepository()->getRan()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function failedJobs(): int
|
||||||
|
{
|
||||||
|
$table = config('queue.failed.table');
|
||||||
|
|
||||||
|
if (! is_string($table) || $table === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::table($table)->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the most recent job failed, or null if none has.
|
||||||
|
*
|
||||||
|
* `failed_jobs` on its own cannot answer whether anything is wrong
|
||||||
|
* *now*, and reading it as though it could is a category error rather
|
||||||
|
* than a threshold that needs tuning. It is a history: the table is
|
||||||
|
* swept daily by projectsend:purge-failed-jobs, so the count spans a
|
||||||
|
* retention window — one whose length the installation chooses on the
|
||||||
|
* Scheduler Monitoring screen, and which can be set to 0 for "keep
|
||||||
|
* forever" by somebody who treats a failed job as evidence rather
|
||||||
|
* than as debris.
|
||||||
|
*
|
||||||
|
* So the same number means different things on two identical
|
||||||
|
* installations, and on a keep-forever one it grows without bound
|
||||||
|
* until any fixed threshold trips. A fleet comparing tenants on the
|
||||||
|
* count alone is comparing their retention settings.
|
||||||
|
*
|
||||||
|
* This is the field that answers the question actually being asked —
|
||||||
|
* "has anything failed lately" — because a timestamp is independent
|
||||||
|
* of how long the rows are kept. A count of 27 whose newest entry is
|
||||||
|
* three weeks old is an installation that has been healthy for three
|
||||||
|
* weeks and has not been swept yet.
|
||||||
|
*
|
||||||
|
* The exception text stays out, for the reason the scheduler's
|
||||||
|
* message does: it carries paths, hostnames and stack traces, and
|
||||||
|
* this document leaves the installation.
|
||||||
|
*/
|
||||||
|
private function latestFailureAt(): ?string
|
||||||
|
{
|
||||||
|
$table = config('queue.failed.table');
|
||||||
|
|
||||||
|
if (! is_string($table) || $table === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$latest = DB::table($table)->max('failed_at');
|
||||||
|
|
||||||
|
return $latest === null ? null : Carbon::parse($latest)->toIso8601String();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Null rather than a crash when the queue cannot be reached, and null
|
||||||
|
* rather than zero: an unreachable Redis is not an empty queue, and a
|
||||||
|
* reader watching for a worker that died would read the second as
|
||||||
|
* everything being fine.
|
||||||
|
*
|
||||||
|
* This command is a probe, and a probe that dies on one unreachable
|
||||||
|
* dependency tells the reader nothing about the facts it could still
|
||||||
|
* have answered.
|
||||||
|
*/
|
||||||
|
private function queueDepth(string $queue): ?int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return Queue::size($queue);
|
||||||
|
} catch (Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string|int|bool|null>
|
||||||
|
*/
|
||||||
|
private function modules(): array
|
||||||
|
{
|
||||||
|
$event = new ResolvingInstallationStatus;
|
||||||
|
|
||||||
|
Event::dispatch($event);
|
||||||
|
|
||||||
|
return $event->facts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most recent interactive sign-in by this kind of account, or
|
||||||
|
* null if there has never been one.
|
||||||
|
*/
|
||||||
|
private function lastLoginAt(UserType $type): ?string
|
||||||
|
{
|
||||||
|
$latest = ActivityLog::query()
|
||||||
|
->where('action', Action::Login->value)
|
||||||
|
->where('actor_type', $type->value)
|
||||||
|
->max('created_at');
|
||||||
|
|
||||||
|
// Answered out of (action, actor_type, created_at) without
|
||||||
|
// reading a row: the two equalities are that index's prefix and
|
||||||
|
// the MAX is the last entry under them. Before that index existed
|
||||||
|
// this was a scan of every login the installation had ever
|
||||||
|
// recorded, with a primary-key lookup per row to check the actor.
|
||||||
|
return $latest === null ? null : Carbon::parse($latest)->toIso8601String();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{used: int, limit: int|null} $seat
|
* @param array{used: int, limit: int|null} $seat
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Platform\Installation\Events;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "What else is worth knowing about this installation?" — asked once,
|
||||||
|
* by `projectsend:status`, of whatever packages happen to be installed.
|
||||||
|
*
|
||||||
|
* Core cannot answer for them. A managed installation's storage backend
|
||||||
|
* and the version of the package providing it live in
|
||||||
|
* projectsend/cloud-modules, which this repository is public and must
|
||||||
|
* not reference; a control plane still has to be able to observe them,
|
||||||
|
* and observing is exactly what that command is for.
|
||||||
|
*
|
||||||
|
* The distinction this exists to preserve: a platform writing eight
|
||||||
|
* environment variables knows what it *asked for*. Only the installation
|
||||||
|
* knows what actually loaded. Those came apart once — a bucket was
|
||||||
|
* provisioned and a token minted while the container ignored both,
|
||||||
|
* because its image predated the module that reads them, and the
|
||||||
|
* configuration sitting beside the files looked perfectly correct.
|
||||||
|
*
|
||||||
|
* Listened to by *string* class name from a package, same as every
|
||||||
|
* other hook here — see docs/extension-points-architecture.md.
|
||||||
|
*/
|
||||||
|
final class ResolvingInstallationStatus
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* What listeners have reported, keyed by name.
|
||||||
|
*
|
||||||
|
* Scalars and null only: this is serialised to JSON for a reader
|
||||||
|
* that is not this application, and a shape it has to walk is a
|
||||||
|
* shape it has to be taught. Null is a real answer — "asked, and
|
||||||
|
* the thing is not here" — and it must survive to the document
|
||||||
|
* rather than being dropped, for the reason the whole file's null
|
||||||
|
* handling exists: absent and "nothing to report" are different
|
||||||
|
* facts, and a reader that cannot tell them apart guesses.
|
||||||
|
*
|
||||||
|
* @var array<string, string|int|bool|null>
|
||||||
|
*/
|
||||||
|
public array $facts = [];
|
||||||
|
|
||||||
|
public function report(string $key, string|int|bool|null $value): void
|
||||||
|
{
|
||||||
|
$this->facts[$key] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,9 +50,18 @@ class RefreshMailOAuthTokensCommand extends Command
|
|||||||
$hadError = $connection->last_error !== null;
|
$hadError = $connection->last_error !== null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$brokers->for($connection->provider)->refresh($connection);
|
// Serialised against sends: refresh() on its own is the
|
||||||
|
// other half of the race freshAccessToken()'s lock is
|
||||||
|
// there to stop.
|
||||||
|
$refreshed = $brokers->for($connection->provider)->refreshSerially($connection);
|
||||||
|
|
||||||
$this->info("Refreshed {$connection->provider->value} ({$connection->account_email}).");
|
// Standing aside is a healthy outcome, not a silent one:
|
||||||
|
// somebody else is refreshing this very connection, which
|
||||||
|
// slides the window just as well. Saying "Refreshed" for
|
||||||
|
// it would describe a token request that never happened.
|
||||||
|
$this->info($refreshed
|
||||||
|
? "Refreshed {$connection->provider->value} ({$connection->account_email})."
|
||||||
|
: "Skipped {$connection->provider->value} ({$connection->account_email}): a refresh is already in progress.");
|
||||||
|
|
||||||
// Back from the dead (an admin fixed things upstream
|
// Back from the dead (an admin fixed things upstream
|
||||||
// without reconnecting): the applier may have been
|
// without reconnecting): the applier may have been
|
||||||
@@ -71,7 +80,17 @@ class RefreshMailOAuthTokensCommand extends Command
|
|||||||
// notification would otherwise repeat daily for as long
|
// notification would otherwise repeat daily for as long
|
||||||
// as nobody reconnects, and a nagging alert trains
|
// as nobody reconnects, and a nagging alert trains
|
||||||
// people to ignore the one that matters.
|
// people to ignore the one that matters.
|
||||||
if (! $hadError) {
|
//
|
||||||
|
// Asked of broken_notified_at, not of last_error. The
|
||||||
|
// question is "have the admins been told", and last_error
|
||||||
|
// cannot answer it: the send path writes that column too
|
||||||
|
// (OAuthCodeFlowBroker::refresh, reached from
|
||||||
|
// freshAccessToken) and notifies nobody. On an
|
||||||
|
// installation that actually sends mail, that write lands
|
||||||
|
// first — so reading it as "already told them" left this
|
||||||
|
// silent for good, on exactly the installations whose
|
||||||
|
// password-reset mail rides on the connection.
|
||||||
|
if ($connection->broken_notified_at === null) {
|
||||||
$recipients = array_values(User::query()->where('type', UserType::Staff)->get()
|
$recipients = array_values(User::query()->where('type', UserType::Staff)->get()
|
||||||
->filter(fn (User $staff): bool => $permissions->allows($staff, Permission::EditSettings))
|
->filter(fn (User $staff): bool => $permissions->allows($staff, Permission::EditSettings))
|
||||||
->all());
|
->all());
|
||||||
@@ -80,6 +99,9 @@ class RefreshMailOAuthTokensCommand extends Command
|
|||||||
'provider' => $connection->provider->label(),
|
'provider' => $connection->provider->label(),
|
||||||
'account' => (string) $connection->account_email,
|
'account' => (string) $connection->account_email,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$connection->broken_notified_at = now();
|
||||||
|
$connection->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
$mailConfig->flush();
|
$mailConfig->flush();
|
||||||
|
|||||||
@@ -37,6 +37,19 @@ interface MailOAuthBroker
|
|||||||
*/
|
*/
|
||||||
public function refresh(MailOAuthConnection $connection): void;
|
public function refresh(MailOAuthConnection $connection): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A refresh that is not racing a send: the scheduled health check's
|
||||||
|
* way in, serialised against freshAccessToken() on the same
|
||||||
|
* connection.
|
||||||
|
*
|
||||||
|
* False when it stood aside because somebody else holds the lock, so
|
||||||
|
* a caller reporting to a human can say that rather than claim a
|
||||||
|
* refresh it did not do.
|
||||||
|
*
|
||||||
|
* @throws MailOAuthException
|
||||||
|
*/
|
||||||
|
public function refreshSerially(MailOAuthConnection $connection): bool;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An access token currently valid for at least a small safety margin,
|
* An access token currently valid for at least a small safety margin,
|
||||||
* refreshing first when needed — what transports call at send time.
|
* refreshing first when needed — what transports call at send time.
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ use Illuminate\Support\Carbon;
|
|||||||
* @property Carbon|null $token_expires_at
|
* @property Carbon|null $token_expires_at
|
||||||
* @property Carbon|null $last_refreshed_at
|
* @property Carbon|null $last_refreshed_at
|
||||||
* @property string|null $last_error
|
* @property string|null $last_error
|
||||||
|
* @property Carbon|null $broken_notified_at
|
||||||
*/
|
*/
|
||||||
class MailOAuthConnection extends Model
|
class MailOAuthConnection extends Model
|
||||||
{
|
{
|
||||||
@@ -51,9 +52,27 @@ class MailOAuthConnection extends Model
|
|||||||
'refresh_token' => 'encrypted',
|
'refresh_token' => 'encrypted',
|
||||||
'token_expires_at' => 'datetime',
|
'token_expires_at' => 'datetime',
|
||||||
'last_refreshed_at' => 'datetime',
|
'last_refreshed_at' => 'datetime',
|
||||||
|
'broken_notified_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The failure is over: the error and the record of having alarmed
|
||||||
|
* about it go together, because they describe one state.
|
||||||
|
*
|
||||||
|
* One method rather than two nulls at each call site. The three
|
||||||
|
* places that end a failure — a successful refresh, a disconnect, a
|
||||||
|
* changed client id — must never clear one and keep the other: a
|
||||||
|
* connection that is healthy but still marked "already told them"
|
||||||
|
* would go quiet the next time it dies, which is the shape of the
|
||||||
|
* bug this column was added to close.
|
||||||
|
*/
|
||||||
|
public function clearFailure(): void
|
||||||
|
{
|
||||||
|
$this->last_error = null;
|
||||||
|
$this->broken_notified_at = null;
|
||||||
|
}
|
||||||
|
|
||||||
public static function for(MailProvider $provider): self
|
public static function for(MailProvider $provider): self
|
||||||
{
|
{
|
||||||
return static::query()->firstOrNew(['provider' => $provider->value]);
|
return static::query()->firstOrNew(['provider' => $provider->value]);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user