mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-04 21:43:57 +00:00
Compare commits
115 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| defe488391 | |||
| fc5651faad | |||
| b838036a9a | |||
| 02eafb473b | |||
| 674781e57a | |||
| f39ad46dd6 | |||
| a1773cad5e | |||
| 17fc9ff4cb | |||
| 776d3d99f4 | |||
| 9ddd39c41d | |||
| 19c449ee20 | |||
| 4b998cda92 | |||
| 4164678ebc | |||
| fc758c701a | |||
| 250e8664d3 | |||
| 640c5db591 | |||
| e1cd010f9d | |||
| c2dd2c758a | |||
| 9d4b096c19 | |||
| 763777d282 | |||
| f424fe5365 | |||
| cd8da6a117 | |||
| db1dd71f3c | |||
| c8de16101f | |||
| cb53120779 | |||
| 84e9f6e2fe |
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+221
-2
@@ -10,8 +10,227 @@ 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.
|
||||||
|
|
||||||
|
**Closed holes in who can see what**
|
||||||
|
|
||||||
|
- A staff member limited to their own assigned clients could read the names of other clients out of
|
||||||
|
file details. Sharing means a file can reach somebody through one client while it was uploaded by
|
||||||
|
another, or while it is also shared with another. That is normal and the file is theirs to open —
|
||||||
|
but the uploader's name, the other recipient's name, and both their ID numbers were being sent
|
||||||
|
along with it, on the library list, the file's edit page, the details panel, the per-client file
|
||||||
|
list, and the matching API responses. A group holding none of their clients was named the same
|
||||||
|
way. The file list could also be filtered by uploader, which answered "does this client of yours
|
||||||
|
share files with that client of mine" without naming anybody.
|
||||||
|
|
||||||
|
Those names are now left out for a limited staff member, and the uploader filter no longer answers
|
||||||
|
for a client they are not assigned to. Administrators and any unrestricted role see exactly what
|
||||||
|
they saw before.
|
||||||
|
|
||||||
|
**Who this affected.** Only installations using the Client Manager role, or a custom role with
|
||||||
|
"Limit to assigned clients" switched on, and only where files are shared with more than one client
|
||||||
|
or through groups. No files, downloads or credentials were reachable this way — a file belonging
|
||||||
|
to a client outside the roster was refused before, and still is.
|
||||||
|
|
||||||
|
Reported by [@Noorkhalel](https://github.com/Noorkhalel) (GHSA-whmp-p9hv-r7j7).
|
||||||
|
|
||||||
|
## 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
|
## 2.2.1 — 28 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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use App\Modules\Audit\ActivityPresenter;
|
|||||||
use App\Modules\Audit\DashboardWidgetPreferences;
|
use App\Modules\Audit\DashboardWidgetPreferences;
|
||||||
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 +52,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 +157,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 +254,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 +479,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 +504,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) {
|
||||||
@@ -206,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);
|
||||||
@@ -364,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,7 +98,12 @@ 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.
|
// Null on a self-hosted install: no limit, nothing to say.
|
||||||
'seats' => $this->seats->clientState(),
|
'seats' => $this->seats->clientState(),
|
||||||
]);
|
]);
|
||||||
@@ -154,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) {
|
||||||
@@ -166,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');
|
||||||
|
|
||||||
@@ -214,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)
|
||||||
|
: [],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,16 @@ 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.
|
||||||
*
|
*
|
||||||
* 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,6 +37,8 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
*/
|
*/
|
||||||
class StoredFileResponse
|
class StoredFileResponse
|
||||||
{
|
{
|
||||||
|
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
|
||||||
{
|
{
|
||||||
@@ -60,11 +63,6 @@ class StoredFileResponse
|
|||||||
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,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use App\Modules\Audit\ActivityLogger;
|
|||||||
use App\Modules\Clients\ClientStorageUsage;
|
use App\Modules\Clients\ClientStorageUsage;
|
||||||
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\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;
|
||||||
@@ -21,9 +22,12 @@ use App\Modules\Files\Models\Folder;
|
|||||||
use App\Modules\Files\Storage\ResolvingUploadDisk;
|
use App\Modules\Files\Storage\ResolvingUploadDisk;
|
||||||
use App\Modules\Files\Uploads\StoreUploadedFile;
|
use App\Modules\Files\Uploads\StoreUploadedFile;
|
||||||
use App\Modules\Files\Uploads\UploadExtensionPolicy;
|
use App\Modules\Files\Uploads\UploadExtensionPolicy;
|
||||||
|
use App\Modules\Platform\Localization\LocalDay;
|
||||||
|
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||||
use App\Modules\Platform\Settings\Setting;
|
use App\Modules\Platform\Settings\Setting;
|
||||||
use App\Modules\Platform\Settings\Settings;
|
use App\Modules\Platform\Settings\Settings;
|
||||||
use App\Support\Rules;
|
use App\Support\Rules;
|
||||||
|
use Carbon\Carbon;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||||
@@ -59,6 +63,8 @@ class FilesController extends Controller
|
|||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
private readonly CommentingRules $commenting,
|
private readonly CommentingRules $commenting,
|
||||||
private readonly StaffLibraryScope $scope,
|
private readonly StaffLibraryScope $scope,
|
||||||
|
private readonly ClientIdentityScope $identity,
|
||||||
|
private readonly TimezoneRegistry $timezones,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -110,6 +116,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 +147,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 +283,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.
|
||||||
@@ -306,7 +332,7 @@ class FilesController extends Controller
|
|||||||
$attributes = array_intersect_key($validated, array_flip(['name', 'description', 'folder_id']));
|
$attributes = array_intersect_key($validated, array_flip(['name', 'description', 'folder_id']));
|
||||||
|
|
||||||
if (array_key_exists('expires_at', $validated) && $user->can('set_file_expiration_date')) {
|
if (array_key_exists('expires_at', $validated) && $user->can('set_file_expiration_date')) {
|
||||||
$attributes['expires_at'] = $validated['expires_at'];
|
$attributes['expires_at'] = $this->expiryInstant($validated['expires_at'], $user);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (array_key_exists('download_limit', $validated) && $user->can('limit_downloads')) {
|
if (array_key_exists('download_limit', $validated) && $user->can('limit_downloads')) {
|
||||||
@@ -356,4 +382,29 @@ class FilesController extends Controller
|
|||||||
|
|
||||||
return response()->json(status: 204);
|
return response()->json(status: 204);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an `expires_at` value means.
|
||||||
|
*
|
||||||
|
* A bare `YYYY-MM-DD` is a calendar day, and a calendar day ends where
|
||||||
|
* the person naming it lives — the same rule the web form's date input
|
||||||
|
* gets from FilesController::expiryInstant. Stored as it arrives it
|
||||||
|
* would be midnight UTC instead, so a file asked to expire on the 12th
|
||||||
|
* would die at the *start* of the 12th, and for a caller west of
|
||||||
|
* Greenwich partway through the 11th.
|
||||||
|
*
|
||||||
|
* Anything carrying a time is an instant the caller named on purpose
|
||||||
|
* and is stored as it arrives, unchanged from before: the API can
|
||||||
|
* express a moment, and a date input cannot.
|
||||||
|
*/
|
||||||
|
private function expiryInstant(?string $value, User $setter): ?Carbon
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1
|
||||||
|
? LocalDay::end($value, $this->timezones->resolve($setter))
|
||||||
|
: Carbon::parse($value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +10,7 @@ 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;
|
||||||
@@ -49,6 +50,7 @@ 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,
|
||||||
@@ -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->expiryDateFor($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,
|
||||||
@@ -303,7 +305,19 @@ class FilesController extends Controller
|
|||||||
// own expiry — same "leave it alone if you lack the permission"
|
// own expiry — same "leave it alone if you lack the permission"
|
||||||
// rule as the upload_public gate below.
|
// rule as the upload_public gate below.
|
||||||
if ($request->user()?->can('set_file_expiration_date') === true) {
|
if ($request->user()?->can('set_file_expiration_date') === true) {
|
||||||
$attributes['expires_at'] = $this->expiryInstant($validated['expires_at'] ?? null, $request->user());
|
$posted = $validated['expires_at'] ?? null;
|
||||||
|
|
||||||
|
// Re-derived only when the date actually changed. The form was
|
||||||
|
// rendered with the stored instant read back as a date in *this*
|
||||||
|
// viewer's zone, and posts it again untouched with every other
|
||||||
|
// edit — so deriving it unconditionally moves the expiry by the
|
||||||
|
// difference between two people's zones each time somebody
|
||||||
|
// merely renames the file. Compared against the same string the
|
||||||
|
// form was given, above, so "unchanged" means what the editor
|
||||||
|
// saw.
|
||||||
|
if ($posted !== $this->expiryDateFor($file, $request->user())) {
|
||||||
|
$attributes['expires_at'] = $this->expiryInstant($posted, $request->user());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same rule again for the download cap, behind its own
|
// Same rule again for the download cap, behind its own
|
||||||
@@ -504,9 +518,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);
|
||||||
}
|
}
|
||||||
@@ -540,4 +568,16 @@ class FilesController extends Controller
|
|||||||
? null
|
? null
|
||||||
: LocalDay::end($date, $this->timezones->resolve($setter));
|
: LocalDay::end($date, $this->timezones->resolve($setter));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inverse: the calendar date a stored expiry falls on for this
|
||||||
|
* viewer, which is what the date input is given and what it posts back.
|
||||||
|
*
|
||||||
|
* The pair has to agree, or a re-save reads one date and writes
|
||||||
|
* another.
|
||||||
|
*/
|
||||||
|
private function expiryDateFor(File $file, ?User $viewer): ?string
|
||||||
|
{
|
||||||
|
return $file->expires_at?->copy()->setTimezone($this->timezones->resolve($viewer))->toDateString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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' => [
|
||||||
|
|||||||
@@ -245,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.',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')),
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -46,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
|
||||||
@@ -59,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}%")
|
||||||
|
|||||||
@@ -108,7 +108,11 @@ 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.
|
// Null on a self-hosted install: no limit, nothing to say.
|
||||||
'seats' => $this->seats->staffState(),
|
'seats' => $this->seats->staffState(),
|
||||||
]);
|
]);
|
||||||
@@ -184,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.
|
||||||
|
|||||||
@@ -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,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');
|
||||||
@@ -46,10 +46,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
|
||||||
@@ -108,9 +128,10 @@ enum Capability: string
|
|||||||
self::SchedulerMonitoring,
|
self::SchedulerMonitoring,
|
||||||
self::CustomAssets => [Edition::Community],
|
self::CustomAssets => [Edition::Community],
|
||||||
|
|
||||||
self::UsersManage => [Edition::Community, Edition::Cloud],
|
self::UsersManage,
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ use App\Modules\Identity\TwoFactor\TwoFactorEnforcement;
|
|||||||
use App\Modules\Identity\UserType;
|
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\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\Setting;
|
||||||
use App\Modules\Platform\Settings\Settings;
|
use App\Modules\Platform\Settings\Settings;
|
||||||
@@ -65,6 +67,15 @@ use Throwable;
|
|||||||
* survives on purpose — see AccountEraser), so the answer does not change
|
* survives on purpose — see AccountEraser), so the answer does not change
|
||||||
* when the person who gave it is forgotten.
|
* 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 is the application's number, not the disk's
|
||||||
*
|
*
|
||||||
* `storage.bytes` is what this installation holds, summed from the rows
|
* `storage.bytes` is what this installation holds, summed from the rows
|
||||||
@@ -94,9 +105,103 @@ use Throwable;
|
|||||||
* `modules` is filled by whatever packages are installed, through
|
* `modules` is filled by whatever packages are installed, through
|
||||||
* ResolvingInstallationStatus. A platform that provisioned a bucket knows
|
* ResolvingInstallationStatus. A platform that provisioned a bucket knows
|
||||||
* what it asked for; only the installation knows what loaded.
|
* 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';
|
||||||
@@ -127,9 +232,16 @@ class StatusCommand extends Command
|
|||||||
// an unlimited seat count is: a watcher has to be able to
|
// an unlimited seat count is: a watcher has to be able to
|
||||||
// tell that apart from "we got no answer". Collapsing the
|
// tell that apart from "we got no answer". Collapsing the
|
||||||
// two is how a broken probe reads as a dormant fleet.
|
// two is how a broken probe reads as a dormant fleet.
|
||||||
'last_staff_login_at' => $this->lastStaffLoginAt(),
|
'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(),
|
'storage' => $this->storage(),
|
||||||
|
'usage' => $this->usage(),
|
||||||
'health' => $this->health(),
|
'health' => $this->health(),
|
||||||
'settings' => [
|
'settings' => [
|
||||||
// Echoed back rather than assumed: an operator writes the
|
// Echoed back rather than assumed: an operator writes the
|
||||||
@@ -146,6 +258,16 @@ class StatusCommand extends Command
|
|||||||
// reader unmarshalling a map breaks on the day it happens to
|
// reader unmarshalling a map breaks on the day it happens to
|
||||||
// be empty rather than on the day it is written.
|
// be empty rather than on the day it is written.
|
||||||
'modules' => (object) $this->modules(),
|
'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')) {
|
||||||
@@ -160,13 +282,27 @@ class StatusCommand extends Command
|
|||||||
$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('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('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, '
|
$this->line('Health: '.$status['health']['pending_migrations'].' migrations pending, '
|
||||||
.$status['health']['failed_jobs'].' failed jobs, '
|
.$status['health']['failed_jobs'].' failed jobs, '
|
||||||
.array_sum(array_filter($status['health']['queues'], 'is_int')).' queued');
|
.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
|
private function enforcement(Settings $settings): string
|
||||||
{
|
{
|
||||||
$value = $settings->get(Setting::TwoFactorEnforcement);
|
$value = $settings->get(Setting::TwoFactorEnforcement);
|
||||||
@@ -208,13 +344,14 @@ class StatusCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{pending_migrations: int, failed_jobs: int, queues: array<string, int|null>}
|
* @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
|
private function health(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'pending_migrations' => $this->pendingMigrations(),
|
'pending_migrations' => $this->pendingMigrations(),
|
||||||
'failed_jobs' => $this->failedJobs(),
|
'failed_jobs' => $this->failedJobs(),
|
||||||
|
'failed_jobs_latest_at' => $this->latestFailureAt(),
|
||||||
// The two this application actually runs workers for. A depth
|
// The two this application actually runs workers for. A depth
|
||||||
// is not a fault on its own -- a busy installation has one --
|
// 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
|
// but a depth that only ever grows is a worker that died, and
|
||||||
@@ -223,9 +360,168 @@ class StatusCommand extends Command
|
|||||||
'default' => $this->queueDepth('default'),
|
'default' => $this->queueDepth('default'),
|
||||||
'zips' => $this->queueDepth('zips'),
|
'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
|
private function pendingMigrations(): int
|
||||||
{
|
{
|
||||||
/** @var Migrator $migrator */
|
/** @var Migrator $migrator */
|
||||||
@@ -250,6 +546,46 @@ class StatusCommand extends Command
|
|||||||
return DB::table($table)->count();
|
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
|
* 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
|
* rather than zero: an unreachable Redis is not an empty queue, and a
|
||||||
@@ -282,18 +618,21 @@ class StatusCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The most recent interactive staff sign-in, or null if there has
|
* The most recent interactive sign-in by this kind of account, or
|
||||||
* never been one.
|
* null if there has never been one.
|
||||||
*/
|
*/
|
||||||
private function lastStaffLoginAt(): ?string
|
private function lastLoginAt(UserType $type): ?string
|
||||||
{
|
{
|
||||||
$latest = ActivityLog::query()
|
$latest = ActivityLog::query()
|
||||||
->where('action', Action::Login->value)
|
->where('action', Action::Login->value)
|
||||||
->where('actor_type', UserType::Staff->value)
|
->where('actor_type', $type->value)
|
||||||
->max('created_at');
|
->max('created_at');
|
||||||
|
|
||||||
// `action` and `actor_type` carry an index each, so this narrows
|
// Answered out of (action, actor_type, created_at) without
|
||||||
// on one of them rather than reading the log.
|
// 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();
|
return $latest === null ? null : Carbon::parse($latest)->toIso8601String();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Modules\Platform\Mail;
|
namespace App\Modules\Platform\Mail;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Cache\Lock;
|
||||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||||
use Illuminate\Http\Client\Response;
|
use Illuminate\Http\Client\Response;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
@@ -84,6 +85,53 @@ abstract class OAuthCodeFlowBroker implements MailOAuthBroker
|
|||||||
$this->storeTokens($connection, $response);
|
$this->storeTokens($connection, $response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scheduled refresh, holding the same lock a send would.
|
||||||
|
*
|
||||||
|
* freshAccessToken() takes that lock because a refresh token is good
|
||||||
|
* for exactly one use, and it names this command as one of the racers:
|
||||||
|
* "a worker racing the nightly refresh command means the slower one
|
||||||
|
* spends a token the faster one has already replaced", which the
|
||||||
|
* provider answers with an invalid_grant indistinguishable from a
|
||||||
|
* revoked grant. The command was doing its refresh outside the lock,
|
||||||
|
* so it was the other half of that race rather than a party to it.
|
||||||
|
*
|
||||||
|
* Unlike freshAccessToken() this refreshes a token that is still
|
||||||
|
* usable, which is the point of the daily run: a delegated refresh
|
||||||
|
* token dies of disuse, and the refresh keeps the window sliding.
|
||||||
|
*
|
||||||
|
* Taken rather than waited for, unlike the send path: nobody is
|
||||||
|
* standing at a screen here, and a held lock means somebody is
|
||||||
|
* refreshing this very connection right now — which slides the window
|
||||||
|
* and establishes its health just as well as doing it again would.
|
||||||
|
* Spending the token behind them is the false alarm the lock exists to
|
||||||
|
* prevent.
|
||||||
|
*
|
||||||
|
* Returns false in that case, so the scheduled command can report
|
||||||
|
* standing aside instead of announcing a refresh that never happened.
|
||||||
|
*/
|
||||||
|
public function refreshSerially(MailOAuthConnection $connection): bool
|
||||||
|
{
|
||||||
|
$lock = $this->refreshLock($connection);
|
||||||
|
|
||||||
|
if (! $lock->get()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Re-read first: the winner may have stored tokens while this
|
||||||
|
// call was waiting, and refreshing the copy walked in with
|
||||||
|
// would spend a refresh token that is no longer current.
|
||||||
|
$connection->refresh();
|
||||||
|
|
||||||
|
$this->refresh($connection);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
$lock->release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function freshAccessToken(MailOAuthConnection $connection): string
|
public function freshAccessToken(MailOAuthConnection $connection): string
|
||||||
{
|
{
|
||||||
if ($this->stillUsable($connection)) {
|
if ($this->stillUsable($connection)) {
|
||||||
@@ -104,7 +152,7 @@ abstract class OAuthCodeFlowBroker implements MailOAuthBroker
|
|||||||
// re-read the row instead of trusting the copy it walked in with: by
|
// re-read the row instead of trusting the copy it walked in with: by
|
||||||
// the time the lock is theirs, the winner has already stored a token
|
// the time the lock is theirs, the winner has already stored a token
|
||||||
// they can just use.
|
// they can just use.
|
||||||
$lock = Cache::lock('mail-oauth-refresh:'.$connection->provider->value, 30);
|
$lock = $this->refreshLock($connection);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$lock->block(15);
|
$lock->block(15);
|
||||||
@@ -135,6 +183,16 @@ abstract class OAuthCodeFlowBroker implements MailOAuthBroker
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Whether the stored access token has enough life left to send with. */
|
/** Whether the stored access token has enough life left to send with. */
|
||||||
|
/**
|
||||||
|
* One refresh at a time per connection, whoever is asking. The TTL
|
||||||
|
* outlives a token request and releases the claim if the holder dies
|
||||||
|
* mid-flight.
|
||||||
|
*/
|
||||||
|
private function refreshLock(MailOAuthConnection $connection): Lock
|
||||||
|
{
|
||||||
|
return Cache::lock('mail-oauth-refresh:'.$connection->provider->value, 30);
|
||||||
|
}
|
||||||
|
|
||||||
private function stillUsable(MailOAuthConnection $connection): bool
|
private function stillUsable(MailOAuthConnection $connection): bool
|
||||||
{
|
{
|
||||||
$token = $connection->access_token;
|
$token = $connection->access_token;
|
||||||
@@ -172,7 +230,7 @@ abstract class OAuthCodeFlowBroker implements MailOAuthBroker
|
|||||||
}
|
}
|
||||||
|
|
||||||
$connection->last_refreshed_at = now();
|
$connection->last_refreshed_at = now();
|
||||||
$connection->last_error = null;
|
$connection->clearFailure();
|
||||||
$connection->save();
|
$connection->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ class PlatformServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
return new CapabilityRegistry(
|
return new CapabilityRegistry(
|
||||||
$edition instanceof Edition ? $edition : Edition::from($edition),
|
$edition instanceof Edition ? $edition : Edition::from($edition),
|
||||||
|
// Read on every resolve rather than once, for the same
|
||||||
|
// reason the edition is: a test that sets it expects the
|
||||||
|
// next resolve to honour it.
|
||||||
|
config('projectsend.capabilities_disabled'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ use PDOException;
|
|||||||
* EmailTemplateResolver). A database failure there should stay loud: those
|
* EmailTemplateResolver). A database failure there should stay loud: those
|
||||||
* run long after the install, where "quietly fell back to defaults" hides a
|
* run long after the install, where "quietly fell back to defaults" hides a
|
||||||
* real outage instead of enabling a legitimate first run.
|
* real outage instead of enabling a legitimate first run.
|
||||||
|
*
|
||||||
|
* Two entry points, same rule. rememberForever() is for values worth
|
||||||
|
* keeping; read() is for the ones that must not be kept — a credential
|
||||||
|
* read on the boot path needs the identical "the database may not answer
|
||||||
|
* yet" guarantee, and stating it twice is how the two drift apart.
|
||||||
*/
|
*/
|
||||||
final class BootSettingsCache
|
final class BootSettingsCache
|
||||||
{
|
{
|
||||||
@@ -60,6 +65,33 @@ final class BootSettingsCache
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same protection for a value that is deliberately *not* cached.
|
||||||
|
*
|
||||||
|
* A credential must not sit in the cache store, so it is read on each
|
||||||
|
* boot that actually needs it — but that read lands on the same path
|
||||||
|
* as the cached ones and must survive the same missing database. The
|
||||||
|
* caller has already been handed a cached array saying the feature is
|
||||||
|
* configured; that array can be warm while the database is, right now,
|
||||||
|
* unreachable.
|
||||||
|
*
|
||||||
|
* @template TValue
|
||||||
|
*
|
||||||
|
* @param Closure(): TValue $read Reads the real value from the database.
|
||||||
|
* @param TValue $whenUnavailable Returned as-is when the database cannot answer.
|
||||||
|
* @return TValue
|
||||||
|
*/
|
||||||
|
public static function read(Closure $read, mixed $whenUnavailable = null): mixed
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $read();
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
self::warnOnce('(uncached credential read)', $e);
|
||||||
|
|
||||||
|
return $whenUnavailable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Once per process: an install that has not been migrated yet would
|
* Once per process: an install that has not been migrated yet would
|
||||||
* otherwise log this on every artisan command, and a genuine database
|
* otherwise log this on every artisan command, and a genuine database
|
||||||
|
|||||||
@@ -44,7 +44,13 @@ class ExternalStorageConfigApplier
|
|||||||
// Bumped on any shape change to the resolved array below — a stale
|
// Bumped on any shape change to the resolved array below — a stale
|
||||||
// rememberForever value under an old key would otherwise crash every
|
// rememberForever value under an old key would otherwise crash every
|
||||||
// boot with "Undefined array key" (apply() calls resolve() unconditionally).
|
// boot with "Undefined array key" (apply() calls resolve() unconditionally).
|
||||||
private const CACHE_KEY = 'platform.external_storage_settings.v2';
|
// v3: the S3 secret and the GCS key file left the shape. The cache
|
||||||
|
// store encrypts nothing and rememberForever never expires, so on the
|
||||||
|
// documented CACHE_STORE=database they sat in clear — the service
|
||||||
|
// account's private key included — in the same database whose dump
|
||||||
|
// the `encrypted` cast exists to survive. Both are now read straight
|
||||||
|
// from the row, by the provider branch that uses them.
|
||||||
|
private const CACHE_KEY = 'platform.external_storage_settings.v3';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CapabilityRegistry $capabilities,
|
private readonly CapabilityRegistry $capabilities,
|
||||||
@@ -67,7 +73,9 @@ class ExternalStorageConfigApplier
|
|||||||
|
|
||||||
match ($provider) {
|
match ($provider) {
|
||||||
StorageProvider::S3 => $this->applyS3($resolved),
|
StorageProvider::S3 => $this->applyS3($resolved),
|
||||||
StorageProvider::Gcs => $this->applyGcs($resolved),
|
// No $resolved: everything GCS needs from the row is the key
|
||||||
|
// file, and that is a credential the cache no longer holds.
|
||||||
|
StorageProvider::Gcs => $this->applyGcs(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if ($resolved['root'] !== null) {
|
if ($resolved['root'] !== null) {
|
||||||
@@ -86,22 +94,22 @@ class ExternalStorageConfigApplier
|
|||||||
private function applyS3(array $resolved): void
|
private function applyS3(array $resolved): void
|
||||||
{
|
{
|
||||||
Config::set('filesystems.disks.files_external.key', $resolved['key']);
|
Config::set('filesystems.disks.files_external.key', $resolved['key']);
|
||||||
Config::set('filesystems.disks.files_external.secret', $resolved['secret']);
|
Config::set('filesystems.disks.files_external.secret', $this->credential('secret'));
|
||||||
Config::set('filesystems.disks.files_external.region', $resolved['region']);
|
Config::set('filesystems.disks.files_external.region', $resolved['region']);
|
||||||
Config::set('filesystems.disks.files_external.endpoint', $resolved['endpoint']);
|
Config::set('filesystems.disks.files_external.endpoint', $resolved['endpoint']);
|
||||||
Config::set('filesystems.disks.files_external.use_path_style_endpoint', $resolved['use_path_style']);
|
Config::set('filesystems.disks.files_external.use_path_style_endpoint', $resolved['use_path_style']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function applyGcs(): void
|
||||||
* @param array<string, mixed> $resolved
|
|
||||||
*/
|
|
||||||
private function applyGcs(array $resolved): void
|
|
||||||
{
|
{
|
||||||
// Decoded here rather than stored decoded: the column holds the
|
// Decoded here rather than stored decoded: the column holds the
|
||||||
// key file verbatim, exactly as Google issued it, so that what an
|
// key file verbatim, exactly as Google issued it, so that what an
|
||||||
// administrator pasted is what can be handed back to them and
|
// administrator pasted is what can be handed back to them and
|
||||||
// compared against the console.
|
// compared against the console.
|
||||||
$keyFile = json_decode((string) $resolved['key_file'], true);
|
//
|
||||||
|
// Read from the row rather than from $resolved: it is a private
|
||||||
|
// key, and the cached array no longer carries one.
|
||||||
|
$keyFile = json_decode((string) $this->credential('key_file'), true);
|
||||||
|
|
||||||
Config::set('filesystems.disks.files_external.key_file', is_array($keyFile) ? $keyFile : null);
|
Config::set('filesystems.disks.files_external.key_file', is_array($keyFile) ? $keyFile : null);
|
||||||
|
|
||||||
@@ -118,6 +126,28 @@ class ExternalStorageConfigApplier
|
|||||||
Cache::forget(self::CACHE_KEY);
|
Cache::forget(self::CACHE_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One credential column, read from the row rather than from the cache.
|
||||||
|
*
|
||||||
|
* The same rule MailOAuthConnection states for tokens — "must never
|
||||||
|
* travel through the boot-config cache" — applied to the two columns
|
||||||
|
* on this row that are credentials: the S3 secret access key and the
|
||||||
|
* GCS service account key file. Reached only from the provider branch
|
||||||
|
* that uses one, and only when isActive() has already said the disk is
|
||||||
|
* configured and permitted, so nothing is read on an installation that
|
||||||
|
* stores files locally.
|
||||||
|
*
|
||||||
|
* Guarded like the cached read beside it: resolve() can hand back a
|
||||||
|
* warm "configured" from a database that has since stopped answering,
|
||||||
|
* and booting must survive that.
|
||||||
|
*/
|
||||||
|
private function credential(string $column): ?string
|
||||||
|
{
|
||||||
|
return BootSettingsCache::read(
|
||||||
|
fn (): ?string => ExternalStorageSettings::current()->{$column},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function resolveDisk(ResolvingUploadDisk $event): void
|
public function resolveDisk(ResolvingUploadDisk $event): void
|
||||||
{
|
{
|
||||||
if ($this->isActive()) {
|
if ($this->isActive()) {
|
||||||
@@ -142,14 +172,14 @@ class ExternalStorageConfigApplier
|
|||||||
* filled in and active, nothing more. Callers AND the capability check
|
* filled in and active, nothing more. Callers AND the capability check
|
||||||
* live and uncached — see class docblock.
|
* live and uncached — see class docblock.
|
||||||
*
|
*
|
||||||
* @return array{configured: bool, provider: string, key: string|null, secret: string|null, key_file: string|null, region: string|null, bucket: string|null, endpoint: string|null, use_path_style: bool, root: string|null}
|
* @return array{configured: bool, provider: string, key: string|null, region: string|null, bucket: string|null, endpoint: string|null, use_path_style: bool, root: string|null}
|
||||||
*/
|
*/
|
||||||
private function resolve(): array
|
private function resolve(): array
|
||||||
{
|
{
|
||||||
$blank = [
|
$blank = [
|
||||||
'configured' => false,
|
'configured' => false,
|
||||||
'provider' => StorageProvider::S3->value,
|
'provider' => StorageProvider::S3->value,
|
||||||
'key' => null, 'secret' => null, 'key_file' => null,
|
'key' => null,
|
||||||
'region' => null, 'bucket' => null,
|
'region' => null, 'bucket' => null,
|
||||||
'endpoint' => null, 'use_path_style' => false, 'root' => null,
|
'endpoint' => null, 'use_path_style' => false, 'root' => null,
|
||||||
];
|
];
|
||||||
@@ -173,8 +203,6 @@ class ExternalStorageConfigApplier
|
|||||||
'configured' => true,
|
'configured' => true,
|
||||||
'provider' => $settings->provider->value,
|
'provider' => $settings->provider->value,
|
||||||
'key' => $settings->key,
|
'key' => $settings->key,
|
||||||
'secret' => $settings->secret,
|
|
||||||
'key_file' => $settings->key_file,
|
|
||||||
'region' => $settings->region,
|
'region' => $settings->region,
|
||||||
'bucket' => $settings->bucket,
|
'bucket' => $settings->bucket,
|
||||||
'endpoint' => $settings->endpoint,
|
'endpoint' => $settings->endpoint,
|
||||||
|
|||||||
@@ -42,7 +42,13 @@ class MailConfigApplier
|
|||||||
// connection row at send time — only readiness and the connected
|
// connection row at send time — only readiness and the connected
|
||||||
// address are cheap enough to be worth caching, and neither is a
|
// address are cheap enough to be worth caching, and neither is a
|
||||||
// credential.
|
// credential.
|
||||||
private const CACHE_KEY = 'platform.mail_provider_settings.v3';
|
// v4: the SMTP password left for the same reason the tokens never
|
||||||
|
// arrived. The cache store encrypts nothing and rememberForever never
|
||||||
|
// expires, so on the documented CACHE_STORE=database it wrote the
|
||||||
|
// password in clear into the same database whose dump the `encrypted`
|
||||||
|
// cast exists to survive. It is now read straight from the row, and
|
||||||
|
// only on the boot that actually configures an SMTP transport.
|
||||||
|
private const CACHE_KEY = 'platform.mail_provider_settings.v4';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CapabilityRegistry $capabilities,
|
private readonly CapabilityRegistry $capabilities,
|
||||||
@@ -67,7 +73,7 @@ class MailConfigApplier
|
|||||||
Config::set('mail.mailers.smtp.host', $resolved['host']);
|
Config::set('mail.mailers.smtp.host', $resolved['host']);
|
||||||
Config::set('mail.mailers.smtp.port', $resolved['port']);
|
Config::set('mail.mailers.smtp.port', $resolved['port']);
|
||||||
Config::set('mail.mailers.smtp.username', $resolved['username']);
|
Config::set('mail.mailers.smtp.username', $resolved['username']);
|
||||||
Config::set('mail.mailers.smtp.password', $resolved['password']);
|
Config::set('mail.mailers.smtp.password', $this->password());
|
||||||
Config::set('mail.mailers.smtp.encryption', $resolved['encryption']);
|
Config::set('mail.mailers.smtp.encryption', $resolved['encryption']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,13 +92,33 @@ class MailConfigApplier
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{transport_configured: bool, host: string|null, port: int|null, username: string|null, password: string|null, encryption: string|null, from_address: string|null, from_name: string|null, oauth_mailer: string|null, oauth_ready: bool, oauth_account: string|null}
|
* The SMTP password, read from the row rather than from the cache.
|
||||||
|
*
|
||||||
|
* The same rule MailOAuthConnection states for tokens — "must never
|
||||||
|
* travel through the boot-config cache" — applied to the credential
|
||||||
|
* this class configures itself. Reached only from the SMTP branch of
|
||||||
|
* apply(), so an installation using OAuth, or one that has never
|
||||||
|
* opened the Email screen, still boots without touching the table.
|
||||||
|
*
|
||||||
|
* Guarded like the cached read beside it: resolve() can hand back a
|
||||||
|
* warm "transport_configured" from a database that has since stopped
|
||||||
|
* answering, and booting must survive that.
|
||||||
|
*/
|
||||||
|
private function password(): ?string
|
||||||
|
{
|
||||||
|
return BootSettingsCache::read(
|
||||||
|
fn (): ?string => MailProviderSettings::current()->password,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{transport_configured: bool, host: string|null, port: int|null, username: string|null, encryption: string|null, from_address: string|null, from_name: string|null, oauth_mailer: string|null, oauth_ready: bool, oauth_account: string|null}
|
||||||
*/
|
*/
|
||||||
private function resolve(): array
|
private function resolve(): array
|
||||||
{
|
{
|
||||||
$blank = [
|
$blank = [
|
||||||
'transport_configured' => false,
|
'transport_configured' => false,
|
||||||
'host' => null, 'port' => null, 'username' => null, 'password' => null, 'encryption' => null,
|
'host' => null, 'port' => null, 'username' => null, 'encryption' => null,
|
||||||
'from_address' => null, 'from_name' => null,
|
'from_address' => null, 'from_name' => null,
|
||||||
'oauth_mailer' => null, 'oauth_ready' => false, 'oauth_account' => null,
|
'oauth_mailer' => null, 'oauth_ready' => false, 'oauth_account' => null,
|
||||||
];
|
];
|
||||||
@@ -130,7 +156,6 @@ class MailConfigApplier
|
|||||||
'host' => $settings->host,
|
'host' => $settings->host,
|
||||||
'port' => $settings->port,
|
'port' => $settings->port,
|
||||||
'username' => $settings->username,
|
'username' => $settings->username,
|
||||||
'password' => $settings->password,
|
|
||||||
'encryption' => $settings->encryption === 'none' ? null : $settings->encryption,
|
'encryption' => $settings->encryption === 'none' ? null : $settings->encryption,
|
||||||
'from_address' => $settings->from_address,
|
'from_address' => $settings->from_address,
|
||||||
'from_name' => $settings->from_name,
|
'from_name' => $settings->from_name,
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a request is on the API rather than on the web site.
|
||||||
|
*
|
||||||
|
* Two questions used to answer this, and both answer something else. A
|
||||||
|
* path test alone (`api/*`) is wrong because two staff *pages* live under
|
||||||
|
* that prefix — the API dashboard at /api and the OpenAPI reference at
|
||||||
|
* /api/docs, both registered in routes/web.php — and their errors belong
|
||||||
|
* to the web site: a signed-out visitor to /api/docs wants the login
|
||||||
|
* redirect, not a 401 telling them to send a Bearer token. An
|
||||||
|
* `expectsJson()` test is wrong because the Accept header is the caller's
|
||||||
|
* preference, not a property of the route: whether an endpoint exists in
|
||||||
|
* this edition cannot depend on what the caller is willing to parse.
|
||||||
|
*
|
||||||
|
* So: under the API prefix, and not part of the `web` middleware group.
|
||||||
|
* The group is what actually separates the two — sessions, cookies and
|
||||||
|
* CSRF on one side, tokens on the other — and it stays right for a future
|
||||||
|
* /api/v2 without this being edited.
|
||||||
|
*
|
||||||
|
* An unmatched path has no route to ask, and that is the API's answer:
|
||||||
|
* a request to a URL under the API prefix that resolves to nothing is a
|
||||||
|
* 404 the API should describe in its own error format.
|
||||||
|
*/
|
||||||
|
class ApiSurface
|
||||||
|
{
|
||||||
|
public static function matches(Request $request): bool
|
||||||
|
{
|
||||||
|
if (! $request->is('api/*')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! in_array('web', $request->route()?->middleware() ?? [], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,6 +104,33 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions) {
|
->withExceptions(function (Exceptions $exceptions) {
|
||||||
|
// Every credential this application stores encrypted, named again
|
||||||
|
// here so a failed validation does not write it back out in clear.
|
||||||
|
//
|
||||||
|
// A ValidationException flashes the request's input into the
|
||||||
|
// session so the form can be repopulated, minus this list. The
|
||||||
|
// framework's own three cover the login and password forms; none
|
||||||
|
// of the settings screens' credentials were on it, and
|
||||||
|
// config/session.php stores sessions in the database by default
|
||||||
|
// with `encrypt => false`. So a mistyped storage form put the
|
||||||
|
// secret access key in clear into the same database whose dump the
|
||||||
|
// `encrypted` cast exists to survive — and a service account key
|
||||||
|
// file, which is most likely to fail validation exactly when it
|
||||||
|
// was pasted incompletely, put a private key there.
|
||||||
|
//
|
||||||
|
// Merged with the framework's defaults rather than replacing them.
|
||||||
|
// The cost is that these fields come back blank after a failed
|
||||||
|
// save, which is what every one of these screens already does on a
|
||||||
|
// successful one: they are write-only, and a blank means "keep
|
||||||
|
// what is stored".
|
||||||
|
$exceptions->dontFlash([
|
||||||
|
'secret', // ExternalStorageSettingsController (S3)
|
||||||
|
'key_file', // ExternalStorageSettingsController (GCS)
|
||||||
|
'bind_password', // LdapSettingsController
|
||||||
|
'client_secret', // SocialLoginSettingsController, EmailSettingsController
|
||||||
|
'secret_key', // CaptchaSettingsController
|
||||||
|
]);
|
||||||
|
|
||||||
// RFC 7807 for /api/* only. Everything else — web pages, Inertia
|
// RFC 7807 for /api/* only. Everything else — web pages, Inertia
|
||||||
// requests, the public share links — keeps Laravel's own handling
|
// requests, the public share links — keeps Laravel's own handling
|
||||||
// untouched, which is why this is scoped by path rather than by
|
// untouched, which is why this is scoped by path rather than by
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Modules\Files\FilesServiceProvider;
|
|||||||
use App\Modules\Groups\GroupsServiceProvider;
|
use App\Modules\Groups\GroupsServiceProvider;
|
||||||
use App\Modules\Identity\IdentityServiceProvider;
|
use App\Modules\Identity\IdentityServiceProvider;
|
||||||
use App\Modules\Notifications\NotificationsServiceProvider;
|
use App\Modules\Notifications\NotificationsServiceProvider;
|
||||||
|
use App\Modules\Platform\Branding\BrandingServiceProvider;
|
||||||
use App\Modules\Platform\PlatformServiceProvider;
|
use App\Modules\Platform\PlatformServiceProvider;
|
||||||
use App\Providers\AppServiceProvider;
|
use App\Providers\AppServiceProvider;
|
||||||
|
|
||||||
@@ -19,5 +20,6 @@ return [
|
|||||||
IdentityServiceProvider::class,
|
IdentityServiceProvider::class,
|
||||||
NotificationsServiceProvider::class,
|
NotificationsServiceProvider::class,
|
||||||
PlatformServiceProvider::class,
|
PlatformServiceProvider::class,
|
||||||
|
BrandingServiceProvider::class,
|
||||||
AppServiceProvider::class,
|
AppServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|||||||
Generated
+12
-12
@@ -2811,16 +2811,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/commonmark",
|
"name": "league/commonmark",
|
||||||
"version": "2.9.0",
|
"version": "2.10.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/thephpleague/commonmark.git",
|
"url": "https://github.com/thephpleague/commonmark.git",
|
||||||
"reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529"
|
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/5703d83ba3da3b2e356a5fedc848ed6d8ffb6529",
|
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
|
||||||
"reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529",
|
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2857,7 +2857,7 @@
|
|||||||
"type": "library",
|
"type": "library",
|
||||||
"extra": {
|
"extra": {
|
||||||
"branch-alias": {
|
"branch-alias": {
|
||||||
"dev-main": "2.10-dev"
|
"dev-main": "2.11-dev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
@@ -2914,7 +2914,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-08-03T13:42:31+00:00"
|
"time": "2026-08-11T16:06:25+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/config",
|
"name": "league/config",
|
||||||
@@ -3883,16 +3883,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "nette/schema",
|
"name": "nette/schema",
|
||||||
"version": "v1.3.5",
|
"version": "v1.3.6",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/nette/schema.git",
|
"url": "https://github.com/nette/schema.git",
|
||||||
"reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002"
|
"reference": "c54350438cd6914616f790a49cb424605f421562"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002",
|
"url": "https://api.github.com/repos/nette/schema/zipball/c54350438cd6914616f790a49cb424605f421562",
|
||||||
"reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002",
|
"reference": "c54350438cd6914616f790a49cb424605f421562",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -3944,9 +3944,9 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/nette/schema/issues",
|
"issues": "https://github.com/nette/schema/issues",
|
||||||
"source": "https://github.com/nette/schema/tree/v1.3.5"
|
"source": "https://github.com/nette/schema/tree/v1.3.6"
|
||||||
},
|
},
|
||||||
"time": "2026-02-23T03:47:12+00:00"
|
"time": "2026-08-16T21:58:41+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "nette/utils",
|
"name": "nette/utils",
|
||||||
|
|||||||
+16
-1
@@ -62,12 +62,27 @@ return [
|
|||||||
// umask 0077 still produces 0700 and still cannot be
|
// umask 0077 still produces 0700 and still cannot be
|
||||||
// traversed; INSTALL.md covers fixing that, because it cannot
|
// traversed; INSTALL.md covers fixing that, because it cannot
|
||||||
// be fixed from this file.
|
// be fixed from this file.
|
||||||
|
//
|
||||||
|
// Both directory keys, because which one Flysystem reads is
|
||||||
|
// decided elsewhere: FilesystemManager passes
|
||||||
|
// `directory_visibility ?? visibility ?? private` as the
|
||||||
|
// default visibility for directories, so with `visibility`
|
||||||
|
// public just below, it reads `dir.public` and never looks at
|
||||||
|
// `dir.private`. Naming only the private one asked for 0755
|
||||||
|
// from a key nobody consults, and got 0755 anyway because that
|
||||||
|
// is Flysystem's default for a public directory — the right
|
||||||
|
// answer from the wrong place, which is the kind that stops
|
||||||
|
// being right quietly. Adding `directory_visibility` here, or
|
||||||
|
// a change to that default, would have been enough.
|
||||||
// Spread rather than two ternaries so that leaving the flag
|
// Spread rather than two ternaries so that leaving the flag
|
||||||
// off is not merely equivalent to the old configuration but
|
// off is not merely equivalent to the old configuration but
|
||||||
// literally it — no install that does not need this sees its
|
// literally it — no install that does not need this sees its
|
||||||
// file modes change.
|
// file modes change.
|
||||||
...(env('FILES_WEB_SERVER_READABLE', false)
|
...(env('FILES_WEB_SERVER_READABLE', false)
|
||||||
? ['visibility' => 'public', 'permissions' => ['dir' => ['private' => 0755]]]
|
? [
|
||||||
|
'visibility' => 'public',
|
||||||
|
'permissions' => ['dir' => ['public' => 0755, 'private' => 0755]],
|
||||||
|
]
|
||||||
: []),
|
: []),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
+44
-1
@@ -49,6 +49,27 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Capabilities this installation has been told it may not use
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Comma-separated capability keys, subtracted from what the edition
|
||||||
|
| grants. Only ever subtracted: nothing here can switch a capability on,
|
||||||
|
| because a variable that could would put the hosted edition's screens
|
||||||
|
| one line of .env away on every self-hosted install.
|
||||||
|
|
|
||||||
|
| For a managed installation whose plan does not include something its
|
||||||
|
| edition otherwise has -- branding.customize on a free plan is the case
|
||||||
|
| this was built for. Unknown keys are ignored rather than fatal: the
|
||||||
|
| variable outlives both the plan that wrote it and the release that
|
||||||
|
| named the key, and an instance refusing to boot over a stale one would
|
||||||
|
| be an outage on upgrade day.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'capabilities_disabled' => env('PROJECTSEND_CAPABILITIES_DISABLED'),
|
||||||
|
|
||||||
'platform' => [
|
'platform' => [
|
||||||
'max_staff_users' => env('PROJECTSEND_PLATFORM_MAX_STAFF_USERS'),
|
'max_staff_users' => env('PROJECTSEND_PLATFORM_MAX_STAFF_USERS'),
|
||||||
'max_clients' => env('PROJECTSEND_PLATFORM_MAX_CLIENTS'),
|
'max_clients' => env('PROJECTSEND_PLATFORM_MAX_CLIENTS'),
|
||||||
@@ -65,6 +86,28 @@ return [
|
|||||||
'parts_path' => env('UPLOAD_PARTS_PATH'),
|
'parts_path' => env('UPLOAD_PARTS_PATH'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| How downloads leave the server
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Uploads live outside the web root, so PHP authorizes every download
|
||||||
|
| before any byte moves. What differs is what happens next: PHP can
|
||||||
|
| stream the file itself, or hand the web server a header naming the
|
||||||
|
| file and let it do the work. The header is faster and each server
|
||||||
|
| spells it differently — a server that does not recognise the one it
|
||||||
|
| is sent serves the empty body instead, which is a 0-byte download.
|
||||||
|
|
|
||||||
|
| 'auto' (the default) uses nginx's X-Accel-Redirect when the server
|
||||||
|
| says it is nginx, and PHP streaming otherwise. 'nginx', 'xsendfile'
|
||||||
|
| (Apache with mod_xsendfile, or LiteSpeed) and 'php' state it
|
||||||
|
| outright. Read here rather than through env() elsewhere, so that
|
||||||
|
| `php artisan config:cache` does not silently blank it.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'file_delivery' => env('PROJECTSEND_FILE_DELIVERY', 'auto'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Chunked upload part size (MB)
|
| Chunked upload part size (MB)
|
||||||
@@ -118,7 +161,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'version' => '2.2.1',
|
'version' => '2.3.0',
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// A single-row table: one logo per install. Under the planned
|
||||||
|
// DB-per-tenant model each cloud tenant has its own database, so
|
||||||
|
// "one row" already means "one per tenant" — no tenant/owner
|
||||||
|
// column needed.
|
||||||
|
Schema::create('branding_settings', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('logo_path')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('branding_settings');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// Watermarking rides on the same single row as the sidebar logo:
|
||||||
|
// it is part of the same Cloud-exclusive Branding capability, and
|
||||||
|
// an install either brands itself or does not. Its image is stored
|
||||||
|
// separately from `logo_path` on purpose — a logo drawn on a light
|
||||||
|
// sidebar and a mark stamped over arbitrary photographs are
|
||||||
|
// different artwork, and installs that want both want two files.
|
||||||
|
Schema::table('branding_settings', function (Blueprint $table) {
|
||||||
|
$table->boolean('watermark_enabled')->default(false);
|
||||||
|
$table->string('watermark_path')->nullable();
|
||||||
|
$table->string('watermark_position', 20)->default('bottom-right');
|
||||||
|
|
||||||
|
// Both percentages, not pixels: a thumbnail is bounded to 300px
|
||||||
|
// on its longest side but its actual size depends on the
|
||||||
|
// original's aspect ratio, so an absolute width would land
|
||||||
|
// differently on a portrait than on a landscape.
|
||||||
|
$table->unsignedTinyInteger('watermark_size')->default(30);
|
||||||
|
$table->unsignedTinyInteger('watermark_opacity')->default(60);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('branding_settings', function (Blueprint $table) {
|
||||||
|
$table->dropColumn([
|
||||||
|
'watermark_enabled',
|
||||||
|
'watermark_path',
|
||||||
|
'watermark_position',
|
||||||
|
'watermark_size',
|
||||||
|
'watermark_opacity',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// The last piece of white-labelling: replacing the logo but
|
||||||
|
// leaving "Powered by ProjectSend" under every client's file
|
||||||
|
// list only half-does the job this capability sells. Rides on
|
||||||
|
// the same single row as the logo and the watermark for the
|
||||||
|
// same reason they ride together — an install either brands
|
||||||
|
// itself or does not.
|
||||||
|
//
|
||||||
|
// Phrased as "hide" rather than "show" so the default is false
|
||||||
|
// and every existing row keeps naming ProjectSend without a
|
||||||
|
// backfill.
|
||||||
|
Schema::table('branding_settings', function (Blueprint $table) {
|
||||||
|
$table->boolean('hide_attribution')->default(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('branding_settings', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('hide_attribution');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// `last_error` was answering two questions at once — the table's
|
||||||
|
// own comment says so: "what the settings page's warning and the
|
||||||
|
// admin notification read". The warning wants "is this connection
|
||||||
|
// broken", and any writer may answer it; the notification wants
|
||||||
|
// "have the admins been told", which only the notifier can.
|
||||||
|
//
|
||||||
|
// They came apart because the send path writes last_error too
|
||||||
|
// (OAuthCodeFlowBroker::refresh, reached from freshAccessToken).
|
||||||
|
// On an installation that actually sends mail, that write lands
|
||||||
|
// first, and the daily command then read it as "already notified"
|
||||||
|
// and stayed silent forever.
|
||||||
|
//
|
||||||
|
// Cleared wherever last_error is cleared, and only there:
|
||||||
|
// a successful refresh, a disconnect, and a changed client id.
|
||||||
|
Schema::table('mail_oauth_connections', function (Blueprint $table) {
|
||||||
|
$table->timestamp('broken_notified_at')->nullable()->after('last_error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('mail_oauth_connections', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('broken_notified_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two indexes every windowed count over `activity_log` needs.
|
||||||
|
*
|
||||||
|
* **They ship as a pair, and adding only the second one is a
|
||||||
|
* regression.** That is the whole reason this comment is long.
|
||||||
|
*
|
||||||
|
* Measured on 2.1M rows (MySQL 8.4, two years of history, ~800k of them
|
||||||
|
* downloads), which is a mid-size installation and not a stress test:
|
||||||
|
*
|
||||||
|
* | query | before | after |
|
||||||
|
* |-----------------------------------------|--------|---------|
|
||||||
|
* | last staff/client sign-in | 0.63s | 0.0004s |
|
||||||
|
* | downloads in the last 30 days | 1.07s | 0.022s |
|
||||||
|
* | uploads in the last 30 days | 0.42s | 0.005s |
|
||||||
|
*
|
||||||
|
* ### Why a date window was *slower* than no date window
|
||||||
|
*
|
||||||
|
* Counting every download ever took 0.46s; counting the last 30 days of
|
||||||
|
* them took 1.07s. Not a mistake: with only the single-column indexes,
|
||||||
|
* the planner picks `created_at`, reaches the window, and then has to do
|
||||||
|
* a primary-key lookup on each row to read `action`. The unbounded count
|
||||||
|
* stays inside the `action` index and never touches a row. So the naive
|
||||||
|
* "just add a date filter" made the query cost more, which is the
|
||||||
|
* opposite of what anybody writing it expects.
|
||||||
|
*
|
||||||
|
* ### Why (action, created_at) alone is not the fix
|
||||||
|
*
|
||||||
|
* It fixes the windowed counts and breaks the query
|
||||||
|
* `projectsend:status` already runs on every tenant, every hour:
|
||||||
|
* `last_staff_login_at` goes from **0.63s to 7.7s**, reproduced. The
|
||||||
|
* planner switches to the new index, still needs `actor_type` — which is
|
||||||
|
* not in it — and does a primary-key lookup per row; and because the
|
||||||
|
* scan is now ordered by `created_at` rather than by id, those lookups
|
||||||
|
* are scattered instead of sequential.
|
||||||
|
*
|
||||||
|
* That is why (action, actor_type, created_at) is here too, and why
|
||||||
|
* dropping it as "redundant, the two-column one covers it" is exactly
|
||||||
|
* the change that would put the regression back. It is not redundant:
|
||||||
|
* it is the only one of the two that answers a question filtered by
|
||||||
|
* actor without reading rows.
|
||||||
|
*
|
||||||
|
* ### Cost
|
||||||
|
*
|
||||||
|
* About 170 MB of index at 2.1M rows, against a 228 MB primary key.
|
||||||
|
* Real, and bought with a 1500x improvement on a query that was already
|
||||||
|
* running hourly before any of the new reporting existed.
|
||||||
|
*
|
||||||
|
* On MySQL both are added in place, so an existing installation stays
|
||||||
|
* readable and writable while it happens — but on a large `activity_log`
|
||||||
|
* it is minutes, not seconds, and it is the slowest part of the upgrade
|
||||||
|
* that carries it.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('activity_log', function (Blueprint $table) {
|
||||||
|
// "What did this kind of account do, and when did they last
|
||||||
|
// do it" — both sign-in timestamps, and the download split.
|
||||||
|
$table->index(['action', 'actor_type', 'created_at'], 'activity_log_action_actor_created_index');
|
||||||
|
|
||||||
|
// "How many of these happened in the window", for actions
|
||||||
|
// nobody is narrowing by actor.
|
||||||
|
$table->index(['action', 'created_at'], 'activity_log_action_created_index');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('activity_log', function (Blueprint $table) {
|
||||||
|
$table->dropIndex('activity_log_action_actor_created_index');
|
||||||
|
$table->dropIndex('activity_log_action_created_index');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -147,6 +147,43 @@ VOLUME ["/var/www/html/storage"]
|
|||||||
# an environment variable survives that.
|
# an environment variable survives that.
|
||||||
ENV PROJECTSEND_IMAGE=1
|
ENV PROJECTSEND_IMAGE=1
|
||||||
|
|
||||||
|
# This is a production image, so it says so itself.
|
||||||
|
#
|
||||||
|
# The entrypoint seeds storage/.env from .env.example on first boot when no
|
||||||
|
# .env exists yet, and .env.example is the development template:
|
||||||
|
# APP_ENV=local, APP_DEBUG=true. compose.example.yaml sets both correctly,
|
||||||
|
# so the documented way to run this was never affected -- but `docker run`
|
||||||
|
# with nothing but a database address, a Portainer/unRAID/TrueNAS template,
|
||||||
|
# or a Kubernetes manifest naming only DB/Redis/APP_URL, all quietly got a
|
||||||
|
# debug build.
|
||||||
|
#
|
||||||
|
# Two consequences an operator would not expect and cannot see from the
|
||||||
|
# outside:
|
||||||
|
#
|
||||||
|
# - APP_DEBUG=true renders Laravel's exception page -- stack trace,
|
||||||
|
# file, surrounding source -- to whoever triggered the 500, signed in
|
||||||
|
# or not. php.ini's display_errors=Off does not prevent it: Laravel
|
||||||
|
# renders that page itself.
|
||||||
|
# - PasswordPolicy appends ->uncompromised() only when
|
||||||
|
# app()->isProduction(), so on APP_ENV=local an administrator's
|
||||||
|
# "reject known-breached passwords" never ran, while descriptor() went
|
||||||
|
# on advertising it and the security settings screen went on showing
|
||||||
|
# it as active.
|
||||||
|
#
|
||||||
|
# Set here rather than in the seeded .env so that an installation already
|
||||||
|
# running on a stale .env is fixed by pulling the image, not only a fresh
|
||||||
|
# one.
|
||||||
|
#
|
||||||
|
# What this does and does not outrank. Laravel builds its env repository
|
||||||
|
# immutable (Illuminate\Support\Env), so a real environment variable wins
|
||||||
|
# over the .env file. `docker run -e`, compose `environment:` and a
|
||||||
|
# Kubernetes `env:` all set real environment variables and therefore still
|
||||||
|
# win over these -- an operator who asks for something explicitly gets it.
|
||||||
|
# Editing APP_ENV or APP_DEBUG *inside* storage/.env no longer takes
|
||||||
|
# effect, because these are real environment variables and that file is
|
||||||
|
# not; `-e APP_DEBUG=true` is the way to turn debug on deliberately.
|
||||||
|
ENV APP_ENV=production APP_DEBUG=false
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
# Laravel's health route (bootstrap/app.php: health: '/up'). Hitting it
|
# Laravel's health route (bootstrap/app.php: health: '/up'). Hitting it
|
||||||
|
|||||||
@@ -17,7 +17,23 @@ services:
|
|||||||
# Put a TLS-terminating proxy in front of this in any real install.
|
# Put a TLS-terminating proxy in front of this in any real install.
|
||||||
# ProjectSend issues download links and password-reset emails using
|
# ProjectSend issues download links and password-reset emails using
|
||||||
# APP_URL, so that value — not this port — is what users must reach.
|
# APP_URL, so that value — not this port — is what users must reach.
|
||||||
- "8080:80"
|
#
|
||||||
|
# Bound to the loopback address, not to every interface, because
|
||||||
|
# TRUSTED_PROXIES below is "*". That setting tells the application to
|
||||||
|
# believe the X-Forwarded-For header of whoever connects to it, which
|
||||||
|
# is correct behind a proxy and catastrophic when anybody can connect
|
||||||
|
# directly: a visitor who reaches this port themselves is then the
|
||||||
|
# "proxy", and can hand the application any client IP they like —
|
||||||
|
# which is enough to walk straight through the login lockout, every
|
||||||
|
# named rate limit, and the address recorded in the download log.
|
||||||
|
#
|
||||||
|
# Publishing on the loopback address keeps the proxy (on this host,
|
||||||
|
# or in this compose file) able to reach it while nothing off the
|
||||||
|
# machine can. If you move the proxy to another host, publish on the
|
||||||
|
# interface it comes from and narrow TRUSTED_PROXIES to that address
|
||||||
|
# or subnet at the same time — the two settings only make sense
|
||||||
|
# together.
|
||||||
|
- "127.0.0.1:8080:80"
|
||||||
environment:
|
environment:
|
||||||
APP_URL: https://files.example.com
|
APP_URL: https://files.example.com
|
||||||
APP_ENV: production
|
APP_ENV: production
|
||||||
@@ -55,6 +71,10 @@ services:
|
|||||||
# Without it every visitor appears to come from the proxy: the login
|
# Without it every visitor appears to come from the proxy: the login
|
||||||
# rate limiter treats all of your users as one attacker, and the
|
# rate limiter treats all of your users as one attacker, and the
|
||||||
# download log records the proxy's address.
|
# download log records the proxy's address.
|
||||||
|
#
|
||||||
|
# "*" means "trust whoever connects to me", which is only safe when
|
||||||
|
# nothing but the proxy can — which is what the loopback binding
|
||||||
|
# above is for. Change one and you have to change the other.
|
||||||
TRUSTED_PROXIES: "*"
|
TRUSTED_PROXIES: "*"
|
||||||
|
|
||||||
# Optional: uncomment these — with a password of your own — to create
|
# Optional: uncomment these — with a password of your own — to create
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
# Put a TLS-terminating proxy in front of this in any real install.
|
# Put a TLS-terminating proxy in front of this in any real install.
|
||||||
- "8080:80"
|
# Bound to loopback because TRUSTED_PROXIES below is "*": the
|
||||||
|
# application then believes the X-Forwarded-For of whoever connects,
|
||||||
|
# so nobody but the proxy may be able to.
|
||||||
|
- "127.0.0.1:8080:80"
|
||||||
environment:
|
environment:
|
||||||
APP_URL: https://files.example.com
|
APP_URL: https://files.example.com
|
||||||
APP_ENV: production
|
APP_ENV: production
|
||||||
@@ -58,6 +61,8 @@ services:
|
|||||||
|
|
||||||
# Required whenever anything sits between your visitors and this
|
# Required whenever anything sits between your visitors and this
|
||||||
# container — including the reverse proxy you should be running.
|
# container — including the reverse proxy you should be running.
|
||||||
|
# "*" trusts whoever connects, so it goes together with the loopback
|
||||||
|
# binding above: change one and you have to change the other.
|
||||||
TRUSTED_PROXIES: "*"
|
TRUSTED_PROXIES: "*"
|
||||||
|
|
||||||
# Optional: uncomment these — with a password of your own — to create
|
# Optional: uncomment these — with a password of your own — to create
|
||||||
|
|||||||
+4
-2
@@ -449,8 +449,10 @@ the core vocabulary. A slug must be unique and lowercase; a clash throws at boot
|
|||||||
silently shadowing.
|
silently shadowing.
|
||||||
|
|
||||||
Module endpoints are deliberately absent from the document above — `OpenApiContractTest` skips
|
Module endpoints are deliberately absent from the document above — `OpenApiContractTest` skips
|
||||||
`api/v1/modules/*` — so each package documents its own surface in its own repository. The
|
`api/v1/modules/*` — because that document is served unauthenticated and has to be identical on
|
||||||
`branding` module's endpoints are in `packages/cloud-modules/docs/api.md`.
|
every installation. The modules that ship with the application document their endpoints in
|
||||||
|
[`api-modules.md`](api-modules.md); a module living in its own package documents its surface in its
|
||||||
|
own repository.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# API — module endpoints
|
||||||
|
|
||||||
|
Optional modules add endpoints under `/api/v1/modules/{module}/…`. They are **not** in the committed
|
||||||
|
[`api/openapi.json`](api/openapi.json): `OpenApiContractTest` skips `api/v1/modules/*`, because that
|
||||||
|
document is served unauthenticated and has to be identical on every installation, while a module's
|
||||||
|
paths exist only where the module does. This file is the documentation for the modules that ship
|
||||||
|
with the application; a module living in its own package documents its surface in its own repository.
|
||||||
|
|
||||||
|
Everything [`api-guide.md`](api-guide.md) describes — bearer-token authentication, ability checks,
|
||||||
|
RFC 7807 errors, rate limits — applies unchanged. The core supplies all of it; none of it is
|
||||||
|
restated per module.
|
||||||
|
|
||||||
|
`GET /api/v1/me` lists the modules an installation actually carries, so an integration can check for
|
||||||
|
`branding` before calling anything below rather than guessing from a 404.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branding
|
||||||
|
|
||||||
|
Mounted at `/api/v1/modules/branding`, behind `capability:branding.customize`. Every edition has
|
||||||
|
that capability; a hosted plan can have it subtracted from its environment, in which case these
|
||||||
|
paths answer 403 like any other gated route.
|
||||||
|
|
||||||
|
Both endpoints are read-only. Uploading either image is a multipart flow whose content-sniffing
|
||||||
|
rules only make sense behind a file picker, and settings writes follow the rule that there is never
|
||||||
|
a generic `PATCH /settings`.
|
||||||
|
|
||||||
|
Hiding the attribution line is not here. That switch is Cloud-only, has no API surface, and its
|
||||||
|
column is written by the `cloud-modules` package.
|
||||||
|
|
||||||
|
### `GET /logo` — ability: `edit_settings`
|
||||||
|
|
||||||
|
The logo shown in place of the default sidebar icon.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"logo_url": "https://example.test/storage/branding/9f3c….png",
|
||||||
|
"updated_at": "2026-08-07T16:02:30+00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`logo_url` is `null` when no logo has been uploaded, which is the normal state rather than an error.
|
||||||
|
|
||||||
|
### `GET /watermark` — ability: `edit_settings`
|
||||||
|
|
||||||
|
The mark stamped onto the thumbnails and previews clients and anonymous public visitors see. What
|
||||||
|
this installation's own staff see goes unmarked, and the stored files — including every download —
|
||||||
|
are never altered.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"enabled": true,
|
||||||
|
"image_url": "https://example.test/storage/branding/a68a….png",
|
||||||
|
"position": "bottom-right",
|
||||||
|
"size": 35,
|
||||||
|
"opacity": 55
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `enabled` | Whether client- and public-facing images are *actually* being watermarked. False whenever nothing is 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?" What staff see is never marked regardless. |
|
||||||
|
| `image_url` | The artwork, or `null` if none was ever chosen. |
|
||||||
|
| `position` | One of `top-left`, `top-center`, `top-right`, `middle-left`, `center`, `middle-right`, `bottom-left`, `bottom-center`, `bottom-right`. |
|
||||||
|
| `size` | Percentage of the image the mark is scaled to fit inside, keeping its proportions — so a thumbnail and a preview carry the same design at different scales. 5–100. |
|
||||||
|
| `opacity` | Percentage. 1–100. |
|
||||||
|
|
||||||
|
An installation that has never opened the branding screen answers with the defaults it would start
|
||||||
|
from (`enabled: false`, `bottom-right`, `30`, `60`) rather than a payload of nulls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deferred, on purpose
|
||||||
|
|
||||||
|
- **Writes for either image.** See above.
|
||||||
|
- **Rendered thumbnails themselves.** Already deferred by the host ([`api-todo.md`](api-todo.md));
|
||||||
|
the watermark endpoint exists so an integration generating its own derivative images can reproduce
|
||||||
|
the installation's mark, not as a step toward serving thumbnails over the API.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user