mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-04 13:33:22 +00:00
Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fba5f30436 | |||
| 0f66f9030c | |||
| 7c16733c16 | |||
| d7d7acce85 | |||
| 334b11d562 | |||
| da1f432d87 | |||
| 82dd475f8f | |||
| b758fca19c | |||
| 02946abf85 | |||
| b7ac44e77b | |||
| 50a6a19455 | |||
| 8de28059db | |||
| ea214fc27e | |||
| 922be7226c | |||
| 1e30e83f11 | |||
| d32788e4a1 | |||
| c3503a0651 | |||
| 51477cbd02 | |||
| 7c9847981a | |||
| 7da4635f13 | |||
| ddf09677f0 | |||
| 9b2aea4812 | |||
| 96107fdcd5 | |||
| eecd5b804d | |||
| 616aa49867 | |||
| 525c464327 | |||
| 97596da7d0 | |||
| 2ebadf0793 | |||
| 25e4f77b63 | |||
| 90ed2d60b9 | |||
| d6fd5a917d | |||
| 6340b71dca | |||
| fb931819e2 | |||
| 41b22d003e | |||
| 78d5067c6b | |||
| b7cc5e8615 | |||
| ed82d748ea | |||
| fe3b7b7018 | |||
| 6783fa0b81 | |||
| 4556ccf691 | |||
| 85572eb45e | |||
| 227a08dfce | |||
| 43e9985b2b | |||
| f931c6a492 | |||
| 1a3260a397 | |||
| 07e7132747 | |||
| f4fd194991 | |||
| ea45943f40 | |||
| ce96313710 | |||
| 77dd5ff90b | |||
| 8984aba7d8 | |||
| 188848b549 | |||
| 7264c44fd7 | |||
| 3e24ccd42f | |||
| 74077993de | |||
| da7eb6f67d | |||
| 35d68a792b | |||
| bde86c10e4 | |||
| c72adadc44 | |||
| 1ed29ec072 | |||
| 9af0d643b1 | |||
| 19ee9d9833 | |||
| cad112522d | |||
| 81bb136e9e | |||
| a2bc3fa163 | |||
| ef6f8fea56 | |||
| 927c8fc991 | |||
| 144f5fc578 | |||
| 383c3b2ff5 | |||
| b9807bf610 | |||
| d91cf97bcb | |||
| 89b3d34c8f | |||
| d09cb602c1 | |||
| b7a94d4479 | |||
| fdcdad7fb2 | |||
| ff26fac9c5 | |||
| a7e883ef70 | |||
| 90009b7029 | |||
| 9508750c60 | |||
| 9c6f4df5bc | |||
| 2903a1da6d | |||
| c11cb3cc63 | |||
| 5117511946 | |||
| b6f4770795 | |||
| 037439e1f2 | |||
| 6b99e37d01 | |||
| f676e09bb2 | |||
| eb3d6e321d | |||
| d89807b237 | |||
| 7ff2674e4f | |||
| 262cb2457a | |||
| a285f86b93 | |||
| bc68a24ef5 | |||
| 1644d634d5 | |||
| abbe9a3acc | |||
| 3dc407a777 | |||
| d8ef21bb6a | |||
| 479dc61d2d | |||
| 530f30606d | |||
| afc2c74617 | |||
| d62c62f788 | |||
| 7be81d3586 | |||
| 92f50fdb85 | |||
| 27c289a4d6 | |||
| 5e60d2ef88 | |||
| 21cae2acb1 | |||
| defe488391 | |||
| fc5651faad | |||
| b838036a9a | |||
| 5a9133bb07 | |||
| 02eafb473b | |||
| 674781e57a | |||
| f39ad46dd6 | |||
| a1773cad5e | |||
| 17fc9ff4cb | |||
| 776d3d99f4 | |||
| 9ddd39c41d | |||
| 19c449ee20 | |||
| 4b998cda92 | |||
| 4164678ebc | |||
| fc758c701a | |||
| f2b705beee | |||
| 250e8664d3 | |||
| 640c5db591 | |||
| e1cd010f9d | |||
| c2dd2c758a | |||
| 9d4b096c19 | |||
| 763777d282 | |||
| f424fe5365 | |||
| cd8da6a117 | |||
| db1dd71f3c | |||
| c8de16101f | |||
| cb53120779 | |||
| 84e9f6e2fe |
@@ -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
|
||||||
|
|||||||
+246
-2
@@ -10,8 +10,252 @@ Anything under **Upgrade notes** is something you have to do, not something we d
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
This section collects changes as they land; the release process turns it into a numbered entry when
|
This section collects changes as they land; the release process turns it into a numbered entry
|
||||||
a version is cut.
|
when a version is cut.
|
||||||
|
|
||||||
|
|
||||||
|
## 2.4.0 — 8 September 2026
|
||||||
|
|
||||||
|
Clients can now look after the files they uploaded, and this release closes three ways somebody
|
||||||
|
could see a little more than they should.
|
||||||
|
|
||||||
|
**New**
|
||||||
|
|
||||||
|
- **Clients can edit and delete the files they uploaded**, with the name, description, expiry,
|
||||||
|
categories, download limit and public flag each behind the permission that already governs it.
|
||||||
|
A file shared *with* a client is still not theirs to touch.
|
||||||
|
- **A switch to stop this installation fetching the project news**, on Settings → General. On by
|
||||||
|
default; off means the request is never made.
|
||||||
|
|
||||||
|
**Closed holes in who can see what**
|
||||||
|
|
||||||
|
- A staff member limited to their assigned clients could read other clients' names, and their IDs,
|
||||||
|
out of file details and the uploader filter. Reported by
|
||||||
|
[@Noorkhalel](https://github.com/Noorkhalel) (GHSA-whmp-p9hv-r7j7).
|
||||||
|
- Download links to external storage now last a minute instead of an hour. Previews keep the hour.
|
||||||
|
- Eight advisories in bundled dependencies, including an XSS bypass in the markdown renderer that
|
||||||
|
builds your email templates.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
|
||||||
|
- A failed upload keeps its parts, so retrying it works instead of needing the whole file again.
|
||||||
|
- `projectsend:captcha-off` no longer claims success on an installation whose CAPTCHA keys are
|
||||||
|
supplied centrally, where it changed nothing.
|
||||||
|
|
||||||
|
### Upgrade notes
|
||||||
|
|
||||||
|
- **Resuming an interrupted download from external storage more than a minute after it started now
|
||||||
|
fails.** Start it again from ProjectSend. Local-disk installations and zip bundles are unaffected.
|
||||||
|
- **If your temporary directory is on a small or separate volume, allow headroom for twice your
|
||||||
|
largest allowed upload.** Only while a file is being assembled, and nothing needs configuring.
|
||||||
|
|
||||||
|
Thanks to [@Noorkhalel](https://github.com/Noorkhalel), [@denkfabrik-li](https://github.com/denkfabrik-li)
|
||||||
|
and [@mehmedturk](https://github.com/mehmedturk) for reporting and fixing.
|
||||||
|
|
||||||
|
### Issues closed since 2.3.0
|
||||||
|
|
||||||
|
The summary above is what changed. This is the paper trail, for anyone who wants to read the
|
||||||
|
original report.
|
||||||
|
|
||||||
|
- [#1765](https://github.com/projectsend/projectsend/issues/1765) — Projectsend 2.2.1 thumbnail issue after file upload
|
||||||
|
- [#1771](https://github.com/projectsend/projectsend/issues/1771) — Permissions granted to the Client role are not applied to client accounts
|
||||||
|
|
||||||
|
## 2.3.0 — 1 September 2026
|
||||||
|
|
||||||
|
If you run ProjectSend on Apache or LiteSpeed, this is the release to take. It installed fine on
|
||||||
|
both before. Then every download arrived empty and every thumbnail was broken. That is fixed, and
|
||||||
|
you do not have to configure anything. Installations on nginx were never affected and nothing
|
||||||
|
changes for them.
|
||||||
|
|
||||||
|
The rest is mostly security work. Most of it is the same kind of thing: a screen or an API endpoint
|
||||||
|
that showed a little more than the person asking was allowed to see.
|
||||||
|
|
||||||
|
**New**
|
||||||
|
|
||||||
|
- **Downloads work on any web server.** Your files sit outside the web root, so ProjectSend checks
|
||||||
|
permission on every download before anything is sent. The fast way to finish is to hand the file
|
||||||
|
to the web server. Each web server wants that asked for differently, and until now ProjectSend
|
||||||
|
only knew how to ask nginx. On Apache and LiteSpeed it asked anyway, nothing answered, and the
|
||||||
|
visitor got an empty file. Now it works out what it is talking to. If it cannot hand the file
|
||||||
|
over, it sends the file itself, which is slower under load but works everywhere.
|
||||||
|
- **Apache and LiteSpeed can still have the fast version.** Install `mod_xsendfile` (LiteSpeed
|
||||||
|
needs no module), point `XSendFilePath` at your storage directory, and set
|
||||||
|
`PROJECTSEND_FILE_DELIVERY=xsendfile`. See the upgrade notes.
|
||||||
|
- **The dashboard tells you which way downloads are going out.** If PHP is sending them, there is a
|
||||||
|
warning next to it and a short explanation of what that costs you and how to change it. This is
|
||||||
|
the kind of thing that is invisible until the day the site falls over, so it says so up front.
|
||||||
|
- **Your logo and your watermark, on every installation.** Upload a logo and it replaces ours in
|
||||||
|
the sidebar and on your public pages. Add a watermark and it goes on the thumbnails and previews
|
||||||
|
your clients and visitors see. Staff still see the originals, and the watermark is never written
|
||||||
|
into the stored file, so you can turn it off again.
|
||||||
|
- **You can find out which build you are running.** Two images can say "2.2.1" and contain
|
||||||
|
different code. `projectsend:status` now reports the commit it was built from.
|
||||||
|
- **You will know if the nightly jobs stop running.** When the scheduler dies, nothing looks wrong.
|
||||||
|
You find out weeks later, when a file you expired is still downloadable. ProjectSend now reports
|
||||||
|
when its scheduled work last ran and whether any of it failed.
|
||||||
|
- **You get told when the mailbox stops working**, even when a send noticed the problem before the
|
||||||
|
scheduled check did.
|
||||||
|
|
||||||
|
**Closed holes in who can see what**
|
||||||
|
|
||||||
|
- [#1745](https://github.com/projectsend/projectsend/pull/1745) — Gate the comment moderation
|
||||||
|
surfaces on reading, not just on the library. Permission to moderate comments was letting somebody
|
||||||
|
read them, which is not the same thing: on the moderation screen and through the API, a role that
|
||||||
|
could moderate comments but could not open any file was shown every comment in the installation —
|
||||||
|
the text, staff-only notes, the client each conversation belongs to, and a visitor's IP address —
|
||||||
|
about files it would be refused on. Approving a comment over the API handed back its body the same
|
||||||
|
way.
|
||||||
|
|
||||||
|
**Who this affected.** Only installations with a custom role built that way. None of the roles
|
||||||
|
ProjectSend ships is affected: Account Manager, the only one that moderates comments, can read
|
||||||
|
files as well, and so can a System Administrator. If you did build such a role, it can no longer
|
||||||
|
moderate — give it one of the file permissions (upload, edit files, or edit other people's files)
|
||||||
|
and it works again, now seeing only the comments on files it can actually open.
|
||||||
|
|
||||||
|
- [#1759](https://github.com/projectsend/projectsend/pull/1759) — Publish the example Docker
|
||||||
|
quickstart on the loopback address instead of every network interface. The example set
|
||||||
|
`TRUSTED_PROXIES: "*"`, which tells ProjectSend to believe the client address forwarded by
|
||||||
|
whoever connects to it. That is right behind a reverse proxy and wrong when anyone can reach the
|
||||||
|
container directly, because then anyone can claim any address: enough to walk past the login
|
||||||
|
lockout, every rate limit, and the address written to the download log and to guest comments.
|
||||||
|
|
||||||
|
**Who this affected.** Installations started from `compose.example.yaml` or from the Docker Hub
|
||||||
|
page, where port 8080 was reachable from outside the machine. A published Docker port is not
|
||||||
|
covered by a host firewall such as `ufw`, so this was often open without anyone intending it.
|
||||||
|
|
||||||
|
- [#1760](https://github.com/projectsend/projectsend/pull/1760) — Have the Docker image default to
|
||||||
|
production. On first boot the image copied its settings from the development template, which sets
|
||||||
|
`APP_ENV=local` and `APP_DEBUG=true`. Two things followed that you could not see from inside the
|
||||||
|
application: every server error showed its stack trace — file, line and surrounding source — to
|
||||||
|
whoever triggered it, signed in or not; and **"reject known-breached passwords" never actually
|
||||||
|
ran**, while the security settings screen went on reporting it as switched on.
|
||||||
|
|
||||||
|
**Who this affected.** Anyone who started the container without setting those two values: a plain
|
||||||
|
`docker run` with a database address, the Portainer, unRAID and TrueNAS templates, or a Kubernetes
|
||||||
|
manifest naming only the database and `APP_URL`. Installations using `compose.example.yaml`, which
|
||||||
|
sets both correctly, were never affected.
|
||||||
|
|
||||||
|
- The client portal dashboard lists only files that client can open. The API dashboard's recent
|
||||||
|
activity is cut the same way.
|
||||||
|
- Three lists were showing more than the viewer was allowed to see: the reassignment picker, the
|
||||||
|
account conversion list, and the membership an API member write handed back.
|
||||||
|
- Mail and storage credentials no longer end up in the boot configuration cache. A settings form
|
||||||
|
that gets rejected no longer sends the credential back to the browser.
|
||||||
|
- Connecting a sign-in provider asks for your password again. Every password prompt in front of an
|
||||||
|
account now has its own rate limit instead of sharing one. A two-factor code is claimed in a
|
||||||
|
single step, so the same code cannot be used twice.
|
||||||
|
- An expired file no longer locks a whole group shut for staff assigned to particular clients. A
|
||||||
|
shared folder's contents count towards what a client can reach. A client is added to the roster
|
||||||
|
of the staff member who created them.
|
||||||
|
- Whether something is an API request is decided by the route, not by a header the caller sets.
|
||||||
|
- The interface font is served from your own installation. Loading a page no longer tells a font
|
||||||
|
CDN who is reading it.
|
||||||
|
- A stored filename can no longer push a control character into a response header.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
|
||||||
|
- The zip progress bar stops polling when you leave the page.
|
||||||
|
- A zip that fails to build no longer tells the person who asked for it why, in the server's words.
|
||||||
|
- Previews are written to a temporary file first, so a half-written one is never served. A file's
|
||||||
|
previews are deleted even when its storage cannot be reached.
|
||||||
|
- An expiry date no longer moves because somebody else saved the file at the same time. Setting one
|
||||||
|
through the API means what it means on the web form.
|
||||||
|
- Updating a client through the API no longer wipes custom fields the request never mentioned.
|
||||||
|
- The transfers chart lines up with the timezone its data is stored in.
|
||||||
|
- Creating an account over a deleted one's email address is refused instead of crashing.
|
||||||
|
- A comment still shows who wrote it after that account is deleted.
|
||||||
|
- Marking a file as a new version no longer emails people about a file they already had.
|
||||||
|
- The password reset and confirm-password screens say where the account's password actually lives,
|
||||||
|
which matters if you use LDAP or a sign-in provider.
|
||||||
|
- A refused upload names the quota you are actually up against. A bulk edit that is refused says
|
||||||
|
which permission was missing.
|
||||||
|
- Uploaded folders get the permissions the storage library actually asks for.
|
||||||
|
- The public preview log no longer records the same view repeatedly.
|
||||||
|
- Updating with `update.sh` no longer silently switches off route, event and view caching. The
|
||||||
|
script wiped the compiled caches while replacing the files, which is also how ProjectSend
|
||||||
|
recognised that you had cached them in the first place — so it rebuilt nothing, and every update
|
||||||
|
quietly left the site slower than the install instructions promised.
|
||||||
|
- Every new screen in this release is translated into all sixteen languages.
|
||||||
|
|
||||||
|
**Before you upgrade, read the notes below.**
|
||||||
|
|
||||||
|
### Upgrade notes
|
||||||
|
|
||||||
|
- **This upgrade adds two indexes to the activity log, and on a big installation that takes
|
||||||
|
minutes.** It is the slowest part. Nothing goes offline while it runs — the application keeps
|
||||||
|
answering — but do not expect the migration to finish in seconds.
|
||||||
|
- **On Apache or LiteSpeed you need to do nothing, but there is something worth doing.** Downloads
|
||||||
|
will start working on their own. PHP will be sending them, which ties up a worker process for the
|
||||||
|
whole of each download. That is fine on a quiet site and not fine on a busy one. To move to the
|
||||||
|
fast path: install `mod_xsendfile` (LiteSpeed needs no module), allow your storage directory with
|
||||||
|
`XSendFilePath`, then set `PROJECTSEND_FILE_DELIVERY=xsendfile` in `.env`. The dashboard will
|
||||||
|
confirm the change.
|
||||||
|
|
||||||
|
- **If you copied the example Docker file, `http://<your-server-ip>:8080` will stop answering.**
|
||||||
|
That is the change. Reach the application through your reverse proxy, as `APP_URL` describes. If
|
||||||
|
your proxy runs on a different machine, publish the port on the interface it arrives from and
|
||||||
|
replace `TRUSTED_PROXIES: "*"` with that address or subnet — the two settings only make sense
|
||||||
|
together.
|
||||||
|
|
||||||
|
- **Docker: `APP_ENV` and `APP_DEBUG` set inside `storage/.env` no longer take effect.** The image
|
||||||
|
now sets them itself, and a real environment variable always beats that file. If you had turned
|
||||||
|
debug on by editing `storage/.env`, pass `-e APP_DEBUG=true` (or `environment:` in compose)
|
||||||
|
instead. Anything you already set that way keeps working unchanged.
|
||||||
|
|
||||||
|
Thanks to [@denkfabrik-li](https://github.com/denkfabrik-li), who wrote all forty-four pull
|
||||||
|
requests in this release, and to [@prbt2016](https://github.com/prbt2016), who reported the Apache
|
||||||
|
download failure that started the delivery work.
|
||||||
|
|
||||||
|
### Pull requests merged since 2.2.1
|
||||||
|
|
||||||
|
The summary above is what changed. This is the paper trail, for anyone who wants to read the
|
||||||
|
original change. No issues were closed in this cycle — the work arrived as pull requests.
|
||||||
|
|
||||||
|
- [#1718](https://github.com/projectsend/projectsend/pull/1718) — Narrow the reassignment picker to what a viewer may see
|
||||||
|
- [#1719](https://github.com/projectsend/projectsend/pull/1719) — Count a shared folder's contents as reach, not just the folder
|
||||||
|
- [#1720](https://github.com/projectsend/projectsend/pull/1720) — Stop an expired file locking a group shut for a scoped staff member
|
||||||
|
- [#1721](https://github.com/projectsend/projectsend/pull/1721) — Scope the API dashboard's recent actions to what the viewer may read
|
||||||
|
- [#1722](https://github.com/projectsend/projectsend/pull/1722) — Show the portal dashboard the files a client can actually open
|
||||||
|
- [#1723](https://github.com/projectsend/projectsend/pull/1723) — Stop a client PATCH clearing custom fields it never mentioned
|
||||||
|
- [#1725](https://github.com/projectsend/projectsend/pull/1725) — Write a rendition through a temporary file, and never serve an empty one
|
||||||
|
- [#1726](https://github.com/projectsend/projectsend/pull/1726) — Delete a file's renditions even when its own disk cannot be resolved
|
||||||
|
- [#1727](https://github.com/projectsend/projectsend/pull/1727) — Give an API expiry date the same meaning the web gives it
|
||||||
|
- [#1728](https://github.com/projectsend/projectsend/pull/1728) — Stop an expiry moving because somebody else saved the file
|
||||||
|
- [#1729](https://github.com/projectsend/projectsend/pull/1729) — Decide what is an API request from the route, not from the caller's headers
|
||||||
|
- [#1730](https://github.com/projectsend/projectsend/pull/1730) — Refuse to provision over a deleted account's address instead of crashing
|
||||||
|
- [#1731](https://github.com/projectsend/projectsend/pull/1731) — Fail a zip build without handing the requester the server's reason
|
||||||
|
- [#1732](https://github.com/projectsend/projectsend/pull/1732) — Debounce the public preview log the way the signed-in one already is
|
||||||
|
- [#1734](https://github.com/projectsend/projectsend/pull/1734) — Name the quota a client is actually held to when an upload is refused
|
||||||
|
- [#1735](https://github.com/projectsend/projectsend/pull/1735) — Stop an editable-once checkbox locking before anybody ticks it
|
||||||
|
- [#1736](https://github.com/projectsend/projectsend/pull/1736) — Put a client on the roster of the scoped staff member who created them
|
||||||
|
- [#1737](https://github.com/projectsend/projectsend/pull/1737) — Compare the transfers window against the column's own timezone
|
||||||
|
- [#1738](https://github.com/projectsend/projectsend/pull/1738) — Claim a TOTP code atomically instead of checking then writing
|
||||||
|
- [#1739](https://github.com/projectsend/projectsend/pull/1739) — Refresh a mailbox on the schedule under the lock a send would hold
|
||||||
|
- [#1740](https://github.com/projectsend/projectsend/pull/1740) — Leave the caches update.sh's own update command needs to see
|
||||||
|
- [#1741](https://github.com/projectsend/projectsend/pull/1741) — Ask about the zips queue on every path that could answer it
|
||||||
|
- [#1742](https://github.com/projectsend/projectsend/pull/1742) — Set the directory permission Flysystem actually reads
|
||||||
|
- [#1743](https://github.com/projectsend/projectsend/pull/1743) — Check the read half of the redirect rule at every door, not one
|
||||||
|
- [#1744](https://github.com/projectsend/projectsend/pull/1744) — Stop a version link telling people about a file they already had
|
||||||
|
- [#1745](https://github.com/projectsend/projectsend/pull/1745) — Gate the comment moderation surfaces on reading, not just on the library
|
||||||
|
- [#1746](https://github.com/projectsend/projectsend/pull/1746) — Say what expiry does to a client-scoped staff member's library
|
||||||
|
- [#1747](https://github.com/projectsend/projectsend/pull/1747) — Say which permission a bulk edit was actually missing
|
||||||
|
- [#1748](https://github.com/projectsend/projectsend/pull/1748) — Let a password reset know where the account's credentials live
|
||||||
|
- [#1749](https://github.com/projectsend/projectsend/pull/1749) — A deleted account is still the person who wrote the comment
|
||||||
|
- [#1750](https://github.com/projectsend/projectsend/pull/1750) — Tell the admins the mailbox is dead, even when a send noticed first
|
||||||
|
- [#1751](https://github.com/projectsend/projectsend/pull/1751) — Keep the mail and storage credentials out of the boot-config cache
|
||||||
|
- [#1752](https://github.com/projectsend/projectsend/pull/1752) — Bound the two preference endpoints by their own registries
|
||||||
|
- [#1753](https://github.com/projectsend/projectsend/pull/1753) — Narrow the conversion list to the clients its own refusal allows
|
||||||
|
- [#1754](https://github.com/projectsend/projectsend/pull/1754) — Narrow the membership an API member write hands back
|
||||||
|
- [#1755](https://github.com/projectsend/projectsend/pull/1755) — Give every password check in front of an account its own bucket
|
||||||
|
- [#1756](https://github.com/projectsend/projectsend/pull/1756) — Make linking a provider re-prove the password
|
||||||
|
- [#1757](https://github.com/projectsend/projectsend/pull/1757) — Stop a rejected settings form flashing the credential it carried
|
||||||
|
- [#1758](https://github.com/projectsend/projectsend/pull/1758) — Let the confirm-password screen ask where the password lives
|
||||||
|
- [#1759](https://github.com/projectsend/projectsend/pull/1759) — Publish the quickstart on loopback, since it trusts any proxy
|
||||||
|
- [#1760](https://github.com/projectsend/projectsend/pull/1760) — Have the production image default to production
|
||||||
|
- [#1761](https://github.com/projectsend/projectsend/pull/1761) — Serve the interface font from the installation, not from a font CDN
|
||||||
|
- [#1762](https://github.com/projectsend/projectsend/pull/1762) — Run the auth and settings screens through the translator
|
||||||
|
- [#1763](https://github.com/projectsend/projectsend/pull/1763) — Stop the zip poll when its page goes away
|
||||||
|
- [#1764](https://github.com/projectsend/projectsend/pull/1764) — Honour Laravel's placeholder case convention in t()
|
||||||
|
|
||||||
## 2.2.1 — 28 August 2026
|
## 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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ use App\Modules\Platform\Settings\Settings;
|
|||||||
use App\Modules\Platform\Updates\LatestReleaseInfo;
|
use App\Modules\Platform\Updates\LatestReleaseInfo;
|
||||||
use App\Modules\Platform\Updates\RunningCodeState;
|
use App\Modules\Platform\Updates\RunningCodeState;
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
|
use App\Modules\Platform\Announcements\Events\ResolvingAnnouncement;
|
||||||
|
use App\Modules\Platform\Navigation\Events\ResolvingNavigationLinks;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
use Inertia\Middleware;
|
use Inertia\Middleware;
|
||||||
|
|
||||||
class HandleInertiaRequests extends Middleware
|
class HandleInertiaRequests extends Middleware
|
||||||
@@ -84,6 +87,19 @@ class HandleInertiaRequests extends Middleware
|
|||||||
// ignore this and always show it.
|
// ignore this and always show it.
|
||||||
'attribution' => app(Attribution::class)->visible(),
|
'attribution' => app(Attribution::class)->visible(),
|
||||||
'capabilities' => $capabilities->enabledKeys(),
|
'capabilities' => $capabilities->enabledKeys(),
|
||||||
|
// Sidebar entries a package asked for. Shared rather than
|
||||||
|
// passed per page because the sidebar is on every page, and
|
||||||
|
// dispatched unconditionally so that with nothing listening
|
||||||
|
// the list is empty and the sidebar is exactly what it was.
|
||||||
|
// See ResolvingNavigationLinks for why core never learns what
|
||||||
|
// is in it.
|
||||||
|
'extra_nav_links' => $this->extraNavLinks($request),
|
||||||
|
// Shared rather than a dashboard prop, because it is shown in
|
||||||
|
// two places — the band on the dashboard and the icon beside
|
||||||
|
// the notification bell everywhere else — and "the same
|
||||||
|
// message" is the requirement. Two props would drift the day
|
||||||
|
// somebody edited one.
|
||||||
|
'announcement' => $this->announcement($request),
|
||||||
// Shared rather than passed by each page: the sign-in buttons,
|
// Shared rather than passed by each page: the sign-in buttons,
|
||||||
// the registration form and the Connected accounts nav entry
|
// the registration form and the Connected accounts nav entry
|
||||||
// all need the same list, and a nav entry to a screen with
|
// all need the same list, and a nav entry to a screen with
|
||||||
@@ -301,4 +317,43 @@ class HandleInertiaRequests extends Middleware
|
|||||||
/** @var array<string, string> */
|
/** @var array<string, string> */
|
||||||
return app('translator')->getLoader()->load($locale, '*', '*');
|
return app('translator')->getLoader()->load($locale, '*', '*');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{title: string, url: string, external: bool, icon: string|null}>
|
||||||
|
*/
|
||||||
|
private function extraNavLinks(Request $request): array
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
// Staff only, decided here rather than in each listener: these
|
||||||
|
// render in the administration area, and a client's portal shows
|
||||||
|
// their own files and nothing about the installation.
|
||||||
|
$event = new ResolvingNavigationLinks(isStaff: $user !== null && $user->isStaff());
|
||||||
|
|
||||||
|
if (! $event->isStaff) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Event::dispatch($event);
|
||||||
|
|
||||||
|
return $event->links;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{title: string, body: string, action_label: string|null, action_url: string|null, tone: string}|null
|
||||||
|
*/
|
||||||
|
private function announcement(Request $request): ?array
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$event = new ResolvingAnnouncement(isStaff: $user->isStaff());
|
||||||
|
|
||||||
|
Event::dispatch($event);
|
||||||
|
|
||||||
|
return $event->announcement;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,12 @@
|
|||||||
namespace App\Http\Requests\Auth;
|
namespace App\Http\Requests\Auth;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Modules\Identity\Ldap\LdapAuthenticator;
|
|
||||||
use App\Modules\Identity\Ldap\LdapProvisioner;
|
use App\Modules\Identity\Ldap\LdapProvisioner;
|
||||||
|
use App\Modules\Identity\PasswordVerification;
|
||||||
use App\Modules\Identity\SignIn;
|
use App\Modules\Identity\SignIn;
|
||||||
use App\Modules\Platform\Captcha\CaptchaForm;
|
use App\Modules\Platform\Captcha\CaptchaForm;
|
||||||
use App\Support\Rules;
|
use App\Support\Rules;
|
||||||
use Illuminate\Auth\Events\Lockout;
|
use Illuminate\Auth\Events\Lockout;
|
||||||
use Illuminate\Auth\SessionGuard;
|
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -115,10 +114,9 @@ class LoginRequest extends FormRequest
|
|||||||
/**
|
/**
|
||||||
* The account whose password checks out, or null.
|
* The account whose password checks out, or null.
|
||||||
*
|
*
|
||||||
* The local hash is tried first and the directory only on failure, so
|
* The rule itself -- local hash first, directory when the credentials
|
||||||
* a login that succeeds locally never generates directory traffic.
|
* live there -- is PasswordVerification's, because this is no longer
|
||||||
* The exception is an account whose credentials are known to live in
|
* the only screen that has to ask it. See that class.
|
||||||
* the directory, where the local hash is a placeholder nobody holds.
|
|
||||||
*/
|
*/
|
||||||
private function verifyCredentials(?User $user): ?User
|
private function verifyCredentials(?User $user): ?User
|
||||||
{
|
{
|
||||||
@@ -126,67 +124,9 @@ class LoginRequest extends FormRequest
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ldap = app(LdapAuthenticator::class);
|
return app(PasswordVerification::class)->verify($user, (string) $this->string('password'))
|
||||||
|
? $user
|
||||||
if (! $ldap->isDirectoryAccount($user)
|
: null;
|
||||||
&& Auth::validate($this->only('email', 'password'))) {
|
|
||||||
$this->upgradeHashIfStale($user);
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
$identity = $ldap->attempt(
|
|
||||||
(string) $this->string('email'),
|
|
||||||
(string) $this->string('password'),
|
|
||||||
$user,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($identity === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$ldap->stamp($user, $identity);
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-hash a password stored under weaker settings than this
|
|
||||||
* installation now uses.
|
|
||||||
*
|
|
||||||
* Laravel does this for you inside SessionGuard::attempt(), but this
|
|
||||||
* form does not use attempt() — it verifies with Auth::validate() and
|
|
||||||
* hands the account to SignIn, which calls Auth::login(). Neither
|
|
||||||
* re-hashes, so without this an account keeps whatever cost it was
|
|
||||||
* created under forever, and raising BCRYPT_ROUNDS would quietly
|
|
||||||
* apply to new accounts only.
|
|
||||||
*
|
|
||||||
* That is not hypothetical: every account the v1 migration carries
|
|
||||||
* across arrives as `$2y$08$…`, because v1 hashed at cost 8, and
|
|
||||||
* would otherwise stay four times cheaper to attack than an account
|
|
||||||
* created here.
|
|
||||||
*
|
|
||||||
* **Only ever called on the local branch.** On the directory branch
|
|
||||||
* the submitted plaintext is the *LDAP* password and the local hash
|
|
||||||
* is a `Str::password(64)` placeholder nobody holds; writing the
|
|
||||||
* directory credential into it would mint a second way into the
|
|
||||||
* account that keeps working after LDAP is switched off.
|
|
||||||
*/
|
|
||||||
private function upgradeHashIfStale(User $user): void
|
|
||||||
{
|
|
||||||
$guard = Auth::guard('web');
|
|
||||||
|
|
||||||
// getProvider() is on SessionGuard rather than on the StatefulGuard
|
|
||||||
// contract. This guard is a SessionGuard in every configuration this
|
|
||||||
// application ships; the check is here so a custom driver degrades
|
|
||||||
// to "no re-hash" instead of a fatal on the login path.
|
|
||||||
if (! $guard instanceof SessionGuard) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No-ops unless the hasher says the stored digest needs it, so
|
|
||||||
// this costs an already-current account nothing.
|
|
||||||
$guard->getProvider()->rehashPasswordIfRequired($user, $this->only('password'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Models\User;
|
|||||||
use App\Modules\Api\Auth\ApiTokens;
|
use App\Modules\Api\Auth\ApiTokens;
|
||||||
use App\Modules\Api\Models\ApiRequestLog;
|
use App\Modules\Api\Models\ApiRequestLog;
|
||||||
use App\Modules\Audit\ActivityLog;
|
use App\Modules\Audit\ActivityLog;
|
||||||
|
use App\Modules\Audit\ActivityLogScope;
|
||||||
use App\Modules\Audit\ActivityOrigin;
|
use App\Modules\Audit\ActivityOrigin;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -27,6 +28,7 @@ class ApiUsage
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ApiUsageScope $scope,
|
private readonly ApiUsageScope $scope,
|
||||||
|
private readonly ActivityLogScope $activityLog,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -145,7 +147,23 @@ class ApiUsage
|
|||||||
*/
|
*/
|
||||||
public function recentActions(User $viewer, bool $installWide, int $limit = 15): array
|
public function recentActions(User $viewer, bool $installWide, int $limit = 15): array
|
||||||
{
|
{
|
||||||
$query = ActivityLog::query()->where('origin', ActivityOrigin::Api);
|
// Narrowed through ActivityLogScope, exactly as the activity page,
|
||||||
|
// the download history and the dashboard widget are.
|
||||||
|
// `view_actions_log` decides whether the install-wide view opens at
|
||||||
|
// all, but it is not the whole answer for a client-scoped viewer: a
|
||||||
|
// row carries the subject's name, so an unscoped feed reads out file
|
||||||
|
// and client names to somebody who gets a 403 on the files
|
||||||
|
// themselves. The Client Manager role ships with the permission, so
|
||||||
|
// this is the default configuration, not an exotic one.
|
||||||
|
//
|
||||||
|
// Applied on both sides of the branch rather than only in the
|
||||||
|
// install-wide one: the own-actor filter below already stays inside
|
||||||
|
// what the scope allows, and a boundary that only exists in one arm
|
||||||
|
// of an `if` is one refactor away from not existing.
|
||||||
|
$query = $this->activityLog->apply(
|
||||||
|
ActivityLog::query()->where('origin', ActivityOrigin::Api),
|
||||||
|
$viewer,
|
||||||
|
);
|
||||||
|
|
||||||
if (! $installWide) {
|
if (! $installWide) {
|
||||||
$query->where('actor_id', $viewer->id);
|
$query->where('actor_id', $viewer->id);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Modules\Api\Support;
|
namespace App\Modules\Api\Support;
|
||||||
|
|
||||||
use App\Modules\Platform\Capabilities\CapabilityUnavailable;
|
use App\Modules\Platform\Capabilities\CapabilityUnavailable;
|
||||||
|
use App\Support\ApiSurface;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Auth\AuthenticationException;
|
use Illuminate\Auth\AuthenticationException;
|
||||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
@@ -16,7 +17,8 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RFC 7807 error bodies for /api/* only.
|
* RFC 7807 error bodies for the API surface only -- see ApiSurface, which
|
||||||
|
* is the same question the capability middleware asks.
|
||||||
*
|
*
|
||||||
* Two properties this class exists to guarantee:
|
* Two properties this class exists to guarantee:
|
||||||
*
|
*
|
||||||
@@ -55,7 +57,7 @@ class ProblemDetails
|
|||||||
|
|
||||||
public function shouldHandle(Request $request): bool
|
public function shouldHandle(Request $request): bool
|
||||||
{
|
{
|
||||||
return $request->is('api/*');
|
return ApiSurface::matches($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render(Request $request, Throwable $e): JsonResponse
|
public function render(Request $request, Throwable $e): JsonResponse
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ use App\Modules\Audit\ActivityLog;
|
|||||||
use App\Modules\Audit\ActivityLogScope;
|
use App\Modules\Audit\ActivityLogScope;
|
||||||
use App\Modules\Audit\ActivityPresenter;
|
use App\Modules\Audit\ActivityPresenter;
|
||||||
use App\Modules\Audit\DashboardWidgetPreferences;
|
use App\Modules\Audit\DashboardWidgetPreferences;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
use App\Modules\Clients\ClientStorageUsage;
|
use App\Modules\Clients\ClientStorageUsage;
|
||||||
use App\Modules\Files\Access\StaffLibraryScope;
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
|
use App\Modules\Files\Delivery\FileDelivery;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
use App\Modules\Groups\Models\Group;
|
use App\Modules\Groups\Models\Group;
|
||||||
use App\Modules\Identity\UserType;
|
use App\Modules\Identity\UserType;
|
||||||
@@ -51,6 +53,7 @@ class DashboardController extends Controller
|
|||||||
private readonly Settings $settings,
|
private readonly Settings $settings,
|
||||||
private readonly ApiUsage $apiUsage,
|
private readonly ApiUsage $apiUsage,
|
||||||
private readonly StorageDurability $storageDurability,
|
private readonly StorageDurability $storageDurability,
|
||||||
|
private readonly FileDelivery $fileDelivery,
|
||||||
private readonly Installation $installation,
|
private readonly Installation $installation,
|
||||||
private readonly TimezoneRegistry $timezones,
|
private readonly TimezoneRegistry $timezones,
|
||||||
private readonly SystemEnvironment $environment,
|
private readonly SystemEnvironment $environment,
|
||||||
@@ -155,8 +158,12 @@ class DashboardController extends Controller
|
|||||||
*
|
*
|
||||||
* Every boundary is built in the viewer's zone, so "last week" ends
|
* Every boundary is built in the viewer's zone, so "last week" ends
|
||||||
* when their evening does and not at whatever hour UTC midnight falls
|
* when their evening does and not at whatever hour UTC midnight falls
|
||||||
* on for them. The returned instants are still absolute — only the
|
* on for them. The instants are absolute, but they carry that zone —
|
||||||
* day edges moved — so they compare against the UTC column directly.
|
* and a Carbon handed to the query builder is formatted in its own
|
||||||
|
* zone, offset discarded, so comparing one against a UTC column asks
|
||||||
|
* a question nine hours out for a viewer in Tokyo. transferSeries()
|
||||||
|
* converts before it compares; the day cursor there keeps them as
|
||||||
|
* they are, because that half really is about the viewer's calendar.
|
||||||
*
|
*
|
||||||
* @return array{0: Carbon, 1: Carbon, 2: string}
|
* @return array{0: Carbon, 1: Carbon, 2: string}
|
||||||
*/
|
*/
|
||||||
@@ -248,7 +255,13 @@ class DashboardController extends Controller
|
|||||||
|
|
||||||
$rows = ActivityLog::query()
|
$rows = ActivityLog::query()
|
||||||
->whereIn('action', [Action::FileUploaded->value, ...array_map(fn (Action $a): string => $a->value, $downloadActions)])
|
->whereIn('action', [Action::FileUploaded->value, ...array_map(fn (Action $a): string => $a->value, $downloadActions)])
|
||||||
->whereBetween('created_at', [$from, $to])
|
// In UTC, because that is what the column is. The query
|
||||||
|
// builder formats a Carbon in whatever zone the object holds
|
||||||
|
// and drops the offset, so passing the viewer's midnight
|
||||||
|
// straight in compares "2026-08-22 00:00:00" against a UTC
|
||||||
|
// column — nine hours of somebody else's day, at both ends,
|
||||||
|
// for a viewer in Tokyo.
|
||||||
|
->whereBetween('created_at', [$from->copy()->utc(), $to->copy()->utc()])
|
||||||
->get(['action', 'actor_type', 'created_at'])
|
->get(['action', 'actor_type', 'created_at'])
|
||||||
// Bucketed by the viewer's calendar day. Grouping on the UTC
|
// Bucketed by the viewer's calendar day. Grouping on the UTC
|
||||||
// one puts an evening upload from anywhere west of Greenwich
|
// one puts an evening upload from anywhere west of Greenwich
|
||||||
@@ -467,7 +480,7 @@ class DashboardController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string|int|bool|array<string, string|null>|null>
|
* @return array<string, array<string, bool|string|null>|bool|int|string|null>
|
||||||
*/
|
*/
|
||||||
private function systemInfo(): array
|
private function systemInfo(): array
|
||||||
{
|
{
|
||||||
@@ -492,28 +505,36 @@ class DashboardController extends Controller
|
|||||||
// Installation. Always present, unlike storage_durability, which
|
// Installation. Always present, unlike storage_durability, which
|
||||||
// is null whenever the durability question does not apply.
|
// is null whenever the durability question does not apply.
|
||||||
'install_kind' => $this->installation->kind()->value,
|
'install_kind' => $this->installation->kind()->value,
|
||||||
|
// How downloads leave the server, and whether that was
|
||||||
|
// detected or stated. Reported even when it is the fast path:
|
||||||
|
// "my downloads are handed to the web server" is worth being
|
||||||
|
// able to confirm at a glance, not only worth warning about
|
||||||
|
// when it is false — the same reasoning as storage_durability.
|
||||||
|
'file_delivery' => $this->fileDelivery->describe(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function clientDashboard(User $client): Response
|
private function clientDashboard(User $client): Response
|
||||||
{
|
{
|
||||||
$assignedFiles = File::query()->whereHas('assignments', function ($query) use ($client): void {
|
// File::scopeVisibleToClient is the single source of truth for
|
||||||
$query->where(function ($direct) use ($client): void {
|
// client file access, and this page has to agree with the portal it
|
||||||
$direct->where('assignable_type', User::class)->where('assignable_id', $client->id);
|
// introduces. Restating the assignment half here made it disagree
|
||||||
})->orWhere(function ($viaGroup) use ($client): void {
|
// in both directions: it counted expired files, which the scope
|
||||||
$viaGroup->where('assignable_type', Group::class)
|
// ends by excluding and /my-files therefore never shows, and it
|
||||||
->whereIn('assignable_id', $client->memberOfGroups()->pluck('groups.id'));
|
// missed everything that reaches a client another way — a file in a
|
||||||
});
|
// folder shared with them, their own portal upload, and a revision,
|
||||||
});
|
// which owns no assignment row and inherits its original's
|
||||||
|
// recipients.
|
||||||
|
$visibleFiles = File::query()->visibleToClient($client);
|
||||||
|
|
||||||
return Inertia::render('portal/dashboard', [
|
return Inertia::render('portal/dashboard', [
|
||||||
'files_count' => (clone $assignedFiles)->count(),
|
'files_count' => (clone $visibleFiles)->count(),
|
||||||
'groups_count' => $client->memberOfGroups()->where('public', true)->count(),
|
'groups_count' => $client->memberOfGroups()->where('public', true)->count(),
|
||||||
'storage' => [
|
'storage' => [
|
||||||
'used_bytes' => $this->storageUsage->usedBytes($client),
|
'used_bytes' => $this->storageUsage->usedBytes($client),
|
||||||
'quota_bytes' => $this->storageUsage->quotaBytes($client) ?: null,
|
'quota_bytes' => $this->storageUsage->quotaBytes($client) ?: null,
|
||||||
],
|
],
|
||||||
'latest_files' => $assignedFiles->orderByDesc('created_at')->limit(5)->get()
|
'latest_files' => $visibleFiles->orderByDesc('created_at')->limit(5)->get()
|
||||||
->map(fn (File $file): array => [
|
->map(fn (File $file): array => [
|
||||||
'id' => $file->id,
|
'id' => $file->id,
|
||||||
'name' => $file->name,
|
'name' => $file->name,
|
||||||
|
|||||||
@@ -45,8 +45,14 @@ class DashboardWidgetPreferencesController extends Controller
|
|||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'columns' => ['required', 'integer', 'between:1,4'],
|
'columns' => ['required', 'integer', 'between:1,4'],
|
||||||
'widgets' => ['required', 'array'],
|
// Bounded by the allowlist itself, and unique on the key. The
|
||||||
'widgets.*.widget_key' => ['required', 'string', Rule::in(self::WIDGET_KEYS)],
|
// Rule::in below checks each value; it says nothing about how
|
||||||
|
// many there are or whether they repeat, and the loop writes
|
||||||
|
// one row per element. A layout has at most one entry per
|
||||||
|
// widget, so anything longer than the registry is not a layout
|
||||||
|
// this screen could have produced.
|
||||||
|
'widgets' => ['required', 'array', 'max:'.count(self::WIDGET_KEYS)],
|
||||||
|
'widgets.*.widget_key' => ['required', 'string', 'distinct', Rule::in(self::WIDGET_KEYS)],
|
||||||
'widgets.*.enabled' => ['required', 'boolean'],
|
'widgets.*.enabled' => ['required', 'boolean'],
|
||||||
'widgets.*.column_index' => ['required', 'integer', 'between:0,3'],
|
'widgets.*.column_index' => ['required', 'integer', 'between:0,3'],
|
||||||
'widgets.*.position' => ['required', 'integer', 'min:0'],
|
'widgets.*.position' => ['required', 'integer', 'min:0'],
|
||||||
|
|||||||
@@ -158,7 +158,23 @@ class ClientPortalCustomFields
|
|||||||
*/
|
*/
|
||||||
private function isLocked(ClientCustomField $field, BaseCollection $values): bool
|
private function isLocked(ClientCustomField $field, BaseCollection $values): bool
|
||||||
{
|
{
|
||||||
return $field->client_editability === ClientFieldEditability::EditableOnce
|
if ($field->client_editability !== ClientFieldEditability::EditableOnce) {
|
||||||
&& filled($values->get($field->id));
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stored = $values->get($field->id);
|
||||||
|
|
||||||
|
// A checkbox has a stored value from the first save onwards: an
|
||||||
|
// unticked box is written as '0', and filled('0') is true. Asking
|
||||||
|
// "is anything stored" therefore locked the field on the first save
|
||||||
|
// of the form it sits on, whatever the client had chosen — and a
|
||||||
|
// box they never ticked can then never be ticked. '0' is the
|
||||||
|
// absence of a decision, which is the state the other types express
|
||||||
|
// as null, so it is what an unlocked checkbox looks like.
|
||||||
|
if ($field->type === ClientCustomFieldType::Checkbox) {
|
||||||
|
return $stored === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
return filled($stored);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,22 @@ class ClientProvisioning
|
|||||||
return $this->settings->get(Setting::ClientsAutoApprove) === true;
|
return $this->settings->get(Setting::ClientsAutoApprove) === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an address is free for a new account.
|
||||||
|
*
|
||||||
|
* The unique index on `email` spans soft-deleted rows — AvailableEmailRule
|
||||||
|
* is built on exactly that, so a deleted account keeps its address until
|
||||||
|
* erasure takes the row away. The registration form learns this from
|
||||||
|
* validation. The machine paths have no form to validate: a directory or
|
||||||
|
* an identity provider hands over an address and provision() inserts it,
|
||||||
|
* so without asking first the insert raises a QueryException in the
|
||||||
|
* middle of somebody's sign-in.
|
||||||
|
*/
|
||||||
|
public function addressIsFree(string $email): bool
|
||||||
|
{
|
||||||
|
return ! User::withTrashed()->where('email', $email)->exists();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param bool|null $autoApprove Null asks Setting::ClientsAutoApprove,
|
* @param bool|null $autoApprove Null asks Setting::ClientsAutoApprove,
|
||||||
* which is the right question for the
|
* which is the right question for the
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ use App\Modules\Identity\UserType;
|
|||||||
use App\Modules\Platform\Settings\Setting;
|
use App\Modules\Platform\Settings\Setting;
|
||||||
use App\Modules\Platform\Settings\Settings;
|
use App\Modules\Platform\Settings\Settings;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
@@ -153,6 +154,20 @@ class ClientsController extends Controller
|
|||||||
|
|
||||||
$this->activity->log(Action::UserCreated, subject: $client);
|
$this->activity->log(Action::UserCreated, subject: $client);
|
||||||
|
|
||||||
|
$creator = $request->user();
|
||||||
|
assert($creator !== null);
|
||||||
|
|
||||||
|
// A client-scoped creator would otherwise lose the client they just
|
||||||
|
// made. guardTarget() answers 404 for anything off their roster, so
|
||||||
|
// the record they created is not theirs to open, and
|
||||||
|
// StaffLibraryScope::clients() leaves it out of their list as well —
|
||||||
|
// the client exists, is welcomed by email, and is invisible to the
|
||||||
|
// person who made it. Their own roster is where a client they
|
||||||
|
// created belongs; an unscoped creator has no roster to add to.
|
||||||
|
if ($creator->isClientScoped()) {
|
||||||
|
$creator->assignedClients()->attach($client->id);
|
||||||
|
}
|
||||||
|
|
||||||
$this->saveCustomFieldValues($client, $validated['custom_field_values'] ?? []);
|
$this->saveCustomFieldValues($client, $validated['custom_field_values'] ?? []);
|
||||||
|
|
||||||
if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) {
|
if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) {
|
||||||
@@ -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,33 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
* asking. The one thing it knows is the thing each caller kept getting
|
* asking. The one thing it knows is the thing each caller kept getting
|
||||||
* wrong on its own: that `$file->disk` decides how the bytes travel.
|
* wrong on its own: that `$file->disk` decides how the bytes travel.
|
||||||
*
|
*
|
||||||
* Local disk: X-Accel-Redirect, so nginx streams the file and PHP never
|
* Local disk: handed to FileDelivery, which decides whether the web
|
||||||
* touches the bytes. Anything else — S3, GCS and friends — gets a
|
* server sends the bytes or PHP does. Anything else — S3, GCS and
|
||||||
* short-lived presigned URL carrying the disposition, which an object
|
* friends — gets a short-lived presigned URL carrying the disposition,
|
||||||
* store ranges just as well.
|
* which an object store ranges just as well.
|
||||||
*
|
*
|
||||||
* That distinction matters most for inline(): a <video> seeking through
|
* That distinction matters most for inline(): a <video> seeking through
|
||||||
* an hour of footage issues a long tail of Range requests, and nginx's
|
* an hour of footage issues a long tail of Range requests. Every local
|
||||||
* static handler answers those with 206s on its own, dropping the
|
* delivery method answers those — nginx's static handler on the fast
|
||||||
* Content-Length below in favour of the range it actually served.
|
* path, BinaryFileResponse when PHP is streaming — each dropping the
|
||||||
|
* Content-Length passed here in favour of the range actually served.
|
||||||
|
*
|
||||||
|
* The two paths are not equally revocable, which is why the lifetimes
|
||||||
|
* below differ. Every local delivery method authorises one response and
|
||||||
|
* no more — nginx's X-Accel-Redirect, Apache's X-Sendfile, or PHP
|
||||||
|
* streaming the bytes itself: these bytes, now, to this request, and
|
||||||
|
* nothing that outlives it. A presigned URL is a bearer
|
||||||
|
* credential — whoever holds it can fetch the file without passing the
|
||||||
|
* caller's checks again, and it outlives them: a download cap that is
|
||||||
|
* spent in the meantime, an expires_at that falls in between, an
|
||||||
|
* assignment that is withdrawn. Nothing here can revoke one, so the only
|
||||||
|
* dial is how long it lasts.
|
||||||
|
*
|
||||||
|
* A download needs to survive being followed, which is a redirect and a
|
||||||
|
* request: a minute is generous. A preview is held by the player for as
|
||||||
|
* long as somebody watches, and each seek outside the buffer is a fresh
|
||||||
|
* Range request against the same URL, so it keeps the hour. That is the
|
||||||
|
* trade, stated rather than left in a single number.
|
||||||
*
|
*
|
||||||
* Callers of inline() must have established that the mime type is
|
* Callers of inline() must have established that the mime type is
|
||||||
* inline-safe first; PreviewKind is the allowlist, and the reason there
|
* inline-safe first; PreviewKind is the allowlist, and the reason there
|
||||||
@@ -36,35 +54,48 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
*/
|
*/
|
||||||
class StoredFileResponse
|
class StoredFileResponse
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Long enough for a browser, a download manager or a queued transfer
|
||||||
|
* to follow the redirect and start the request. An object store
|
||||||
|
* checks the signature when the request arrives, not while it runs,
|
||||||
|
* so a transfer that begins inside this window finishes however long
|
||||||
|
* it takes.
|
||||||
|
*/
|
||||||
|
private const DOWNLOAD_LINK_SECONDS = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A preview is watched, not fetched: the player holds this URL and
|
||||||
|
* issues a Range request every time somebody seeks past the buffer,
|
||||||
|
* so it has to outlive the viewing rather than the redirect.
|
||||||
|
*/
|
||||||
|
private const PREVIEW_LINK_SECONDS = 3600;
|
||||||
|
|
||||||
|
public function __construct(private readonly FileDelivery $delivery) {}
|
||||||
|
|
||||||
/** Shown in place — a preview. */
|
/** Shown in place — a preview. */
|
||||||
public function inline(File $file): Response|RedirectResponse
|
public function inline(File $file): Response|RedirectResponse
|
||||||
{
|
{
|
||||||
return $this->make($file, ContentDisposition::inline($file->original_name));
|
return $this->make($file, ContentDisposition::inline($file->original_name), self::PREVIEW_LINK_SECONDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handed over — a download. */
|
/** Handed over — a download. */
|
||||||
public function attachment(File $file): Response|RedirectResponse
|
public function attachment(File $file): Response|RedirectResponse
|
||||||
{
|
{
|
||||||
return $this->make($file, ContentDisposition::attachment($file->original_name));
|
return $this->make($file, ContentDisposition::attachment($file->original_name), self::DOWNLOAD_LINK_SECONDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function make(File $file, string $disposition): Response|RedirectResponse
|
private function make(File $file, string $disposition, int $linkSeconds): Response|RedirectResponse
|
||||||
{
|
{
|
||||||
if ($file->disk !== 'files') {
|
if ($file->disk !== 'files') {
|
||||||
$url = Storage::disk($file->disk)->temporaryUrl(
|
$url = Storage::disk($file->disk)->temporaryUrl(
|
||||||
$file->path,
|
$file->path,
|
||||||
now()->addHour(),
|
now()->addSeconds($linkSeconds),
|
||||||
['ResponseContentDisposition' => $disposition],
|
['ResponseContentDisposition' => $disposition],
|
||||||
);
|
);
|
||||||
|
|
||||||
return redirect()->away($url);
|
return redirect()->away($url);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response('', 200, [
|
return $this->delivery->serve($file->path, $file->mime_type, $disposition, $file->size);
|
||||||
'X-Accel-Redirect' => '/protected-files/'.$file->path,
|
|
||||||
'Content-Type' => $file->mime_type,
|
|
||||||
'Content-Disposition' => $disposition,
|
|
||||||
'Content-Length' => (string) $file->size,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Files\Editing;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Modules\Audit\Action;
|
||||||
|
use App\Modules\Audit\ActivityLogger;
|
||||||
|
use App\Modules\Comments\CommentingRules;
|
||||||
|
use App\Modules\Comments\CommentScope;
|
||||||
|
use App\Modules\Files\Models\File;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place that decides which fields an editor may actually write.
|
||||||
|
*
|
||||||
|
* Three surfaces edit a file — the staff editor, `/api/v1/files/{file}`,
|
||||||
|
* and now a client's own uploads in the portal — and they had grown two
|
||||||
|
* copies of the same eight permission checks with a third about to be
|
||||||
|
* written. The checks are not hard; the problem is that they are *easy*,
|
||||||
|
* so a new field gets added to one caller and the drift is invisible until
|
||||||
|
* somebody finds the surface where the gate is missing.
|
||||||
|
*
|
||||||
|
* The split is deliberate: **callers normalise, this gates.** A caller
|
||||||
|
* turns its own request shape into `$changes` — form semantics versus the
|
||||||
|
* API's `sometimes`, a date string versus an instant — and this decides
|
||||||
|
* what the actor is allowed to write, writes it, and records what happened.
|
||||||
|
*
|
||||||
|
* `$changes` uses array_key_exists semantics throughout: a key that is
|
||||||
|
* absent is left alone, a key present with `null` is written as null. That
|
||||||
|
* is the API's existing contract, and the web forms post every field they
|
||||||
|
* own, so it is also the forms'.
|
||||||
|
*
|
||||||
|
* Two things deliberately do NOT live here, because they are the caller's
|
||||||
|
* and getting them wrong is how a boundary breaks:
|
||||||
|
*
|
||||||
|
* - **Whether this actor may edit this file at all.** That is
|
||||||
|
* `Gate::authorize('update', $file)` and FilePolicy. Nothing below
|
||||||
|
* re-checks it.
|
||||||
|
* - **Whether a destination folder is reachable.** Staff ask
|
||||||
|
* StaffLibraryScope; a client asks `Folder::uploadableBy()`. Those are
|
||||||
|
* different questions with the same shape, and the staff one answers
|
||||||
|
* `true` for any client — see FilePolicy::update()'s note.
|
||||||
|
*/
|
||||||
|
class ApplyFileEdits
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ActivityLogger $activity,
|
||||||
|
private readonly CommentingRules $commenting,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $changes only the fields the caller
|
||||||
|
* wants written; absent keys
|
||||||
|
* are left as they are
|
||||||
|
*/
|
||||||
|
public function apply(User $actor, File $file, array $changes): void
|
||||||
|
{
|
||||||
|
$attributes = [];
|
||||||
|
|
||||||
|
// Covered by the permission to edit the file at all, which the
|
||||||
|
// policy has already settled by the time anything reaches here.
|
||||||
|
foreach (['name', 'description', 'folder_id'] as $field) {
|
||||||
|
if (array_key_exists($field, $changes)) {
|
||||||
|
$attributes[$field] = $changes[$field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only meaningful while the comment scope is `selected`, and only
|
||||||
|
// offered by a form then — but a request reaching here directly
|
||||||
|
// must not be able to set a flag the UI is currently hiding.
|
||||||
|
if (array_key_exists('commentable', $changes) && $this->commenting->scope() === CommentScope::SelectedFiles) {
|
||||||
|
$attributes['commentable'] = $changes['commentable'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// From here down, every field has a permission of its own, and the
|
||||||
|
// rule for all of them is the same: lacking it leaves the field
|
||||||
|
// exactly as it was rather than failing the request. An editor who
|
||||||
|
// may rename a file but not publish it saves a rename, and the
|
||||||
|
// public state does not move. The web and the API have always
|
||||||
|
// behaved this way; it is why the portal can reuse both forms.
|
||||||
|
if (array_key_exists('expires_at', $changes) && $actor->can('set_file_expiration_date')) {
|
||||||
|
$attributes['expires_at'] = $changes['expires_at'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('download_limit', $changes) && $actor->can('limit_downloads')) {
|
||||||
|
$attributes['download_limit'] = $changes['download_limit'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('download_limit_scope', $changes) && $actor->can('limit_downloads')) {
|
||||||
|
$attributes['download_limit_scope'] = $changes['download_limit_scope'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$wasPublic = $file->public;
|
||||||
|
|
||||||
|
if (array_key_exists('public', $changes) && $actor->can('upload_public')) {
|
||||||
|
$attributes['public'] = $changes['public'];
|
||||||
|
|
||||||
|
// A caller that offers the slug passes what was submitted; one
|
||||||
|
// that does not simply omits the key and gets a derived slug.
|
||||||
|
// The client portal is the second kind on purpose — an
|
||||||
|
// installation-wide unique slug chosen by a client is a name to
|
||||||
|
// squat and an existence oracle to probe, for no benefit over a
|
||||||
|
// slug made from the name they already chose.
|
||||||
|
//
|
||||||
|
// Omitting the slug on an update keeps the current one: it must
|
||||||
|
// not silently change just because the name did.
|
||||||
|
$submitted = is_string($changes['slug'] ?? null) ? trim($changes['slug']) : '';
|
||||||
|
|
||||||
|
$attributes['slug'] = $submitted !== ''
|
||||||
|
? $submitted
|
||||||
|
: ($file->slug ?: File::uniqueSlugFrom(
|
||||||
|
is_string($changes['name'] ?? null) ? $changes['name'] : $file->name,
|
||||||
|
$file->id,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$file->update($attributes);
|
||||||
|
|
||||||
|
// After the write, not inside it: categories are a relation, not a
|
||||||
|
// column. Gated by their own key, so an editor who may rename but
|
||||||
|
// not categorise leaves them untouched.
|
||||||
|
if (array_key_exists('categories', $changes) && $actor->can('set_file_categories')) {
|
||||||
|
$file->categories()->sync($changes['categories']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->activity->log(Action::FileUpdated, subject: $file);
|
||||||
|
|
||||||
|
// Publishing and unpublishing are their own entries. A file
|
||||||
|
// becoming reachable without a login is not a detail of "file
|
||||||
|
// updated", and it is the line an audit is most likely to be read
|
||||||
|
// for.
|
||||||
|
if (! $wasPublic && $file->public) {
|
||||||
|
$this->activity->log(Action::FileMadePublic, subject: $file, context: ['slug' => $file->slug]);
|
||||||
|
} elseif ($wasPublic && ! $file->public) {
|
||||||
|
$this->activity->log(Action::FileMadePrivate, subject: $file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Files\Editing;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Modules\Files\Models\File;
|
||||||
|
use App\Modules\Platform\Localization\LocalDay;
|
||||||
|
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reading and writing a file's expiry in the zone of whoever is looking.
|
||||||
|
*
|
||||||
|
* The stored value is an instant. What a person sets is a calendar day,
|
||||||
|
* and "the 12th" means the end of the 12th where *they* live — otherwise a
|
||||||
|
* file asked to expire on the 12th dies partway through the 11th for
|
||||||
|
* anyone west of Greenwich, and gives anyone east of it most of a day
|
||||||
|
* nobody promised.
|
||||||
|
*
|
||||||
|
* The two halves have to agree, which is the whole reason they sit
|
||||||
|
* together: a form is rendered with asShown() and posts the same string
|
||||||
|
* back untouched with every other edit, so a caller compares against
|
||||||
|
* asShown() to tell "the editor changed the date" from "the editor renamed
|
||||||
|
* the file and the date came along for the ride". Re-deriving on every
|
||||||
|
* save instead moves the expiry by the difference between two people's
|
||||||
|
* zones each time somebody edits anything.
|
||||||
|
*
|
||||||
|
* Was three private copies — the staff editor, the API, and now the client
|
||||||
|
* portal — of which the API's was the only one that could read a
|
||||||
|
* timestamp.
|
||||||
|
*/
|
||||||
|
class FileExpiry
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly TimezoneRegistry $timezones,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stored instant as the calendar day a form should show, in the
|
||||||
|
* viewer's zone. Null when the file never expires.
|
||||||
|
*/
|
||||||
|
public function asShown(File $file, ?User $viewer): ?string
|
||||||
|
{
|
||||||
|
return $file->expires_at?->copy()->setTimezone($this->timezones->resolve($viewer))->toDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The instant a submitted value actually names.
|
||||||
|
*
|
||||||
|
* A bare `YYYY-MM-DD` is a calendar day and means the end of it where
|
||||||
|
* the setter is — what every date input posts. Anything carrying a
|
||||||
|
* time is an instant somebody named on purpose and is stored as it
|
||||||
|
* arrives: the API can express a moment, and a date input cannot.
|
||||||
|
*/
|
||||||
|
public function instant(?string $value, ?User $setter): ?Carbon
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1
|
||||||
|
? LocalDay::end($value, $this->timezones->resolve($setter))
|
||||||
|
: Carbon::parse($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,15 +24,37 @@ class FileDiskCleanup
|
|||||||
{
|
{
|
||||||
public function delete(File $file): void
|
public function delete(File $file): void
|
||||||
{
|
{
|
||||||
try {
|
$this->attempt($file, fn () => Storage::disk($file->disk)->delete($file->path));
|
||||||
Storage::disk($file->disk)->delete($file->path);
|
|
||||||
|
|
||||||
// Every rendition, for every audience — a deleted file's bytes
|
// Every rendition, for every audience — a deleted file's bytes must
|
||||||
// must not survive on disk because whoever wrote the cleanup
|
// not survive on disk because whoever wrote the cleanup only knew
|
||||||
// only knew about the one copy they had in mind.
|
// about the one copy they had in mind.
|
||||||
|
//
|
||||||
|
// Attempted separately from the original above, not because the two
|
||||||
|
// are unrelated but because they are on different disks: renditions
|
||||||
|
// are always local, and Storage::disk() throws outright for a name
|
||||||
|
// with no configured driver — which is exactly the state the
|
||||||
|
// original's disk is in when this fails at all. Sharing one `try`
|
||||||
|
// meant a file whose source disk had been removed kept every cached
|
||||||
|
// copy of itself, and nothing looks for those again:
|
||||||
|
// OrphanFileScanner skips the rendition directories on purpose.
|
||||||
|
$this->attempt($file, function () use ($file): void {
|
||||||
foreach (ThumbnailGenerator::pathsFor($file->id, $file->mime_type) as $renditionPath) {
|
foreach (ThumbnailGenerator::pathsFor($file->id, $file->mime_type) as $renditionPath) {
|
||||||
Storage::disk('files')->delete($renditionPath);
|
Storage::disk('files')->delete($renditionPath);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliberately tolerant, as the class docblock says: the warning is the
|
||||||
|
* whole report. Nothing else will find these bytes -- the row is
|
||||||
|
* soft-deleted, and OrphanFileScanner::knownPaths() counts a trashed
|
||||||
|
* row's path as claimed, so a scan never lists it.
|
||||||
|
*/
|
||||||
|
private function attempt(File $file, callable $work): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$work();
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
Log::warning('Could not remove disk bytes for deleted file '.$file->id.': '.$exception->getMessage());
|
Log::warning('Could not remove disk bytes for deleted file '.$file->id.': '.$exception->getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,15 @@ use App\Modules\Files\Models\File;
|
|||||||
/**
|
/**
|
||||||
* Ownership rules as policy methods (brief §6.13): "own" versus
|
* Ownership rules as policy methods (brief §6.13): "own" versus
|
||||||
* "others'" files map onto the v1 permission pairs. Clients may only
|
* "others'" files map onto the v1 permission pairs. Clients may only
|
||||||
* view/download what is assigned to them, directly or via a group. For
|
* view/download what is assigned to them, directly or via a group, and may
|
||||||
* client-scoped staff, every action is additionally gated by the
|
* edit or delete only what they uploaded themselves. For client-scoped
|
||||||
* StaffLibraryScope, so direct access can't reach out-of-scope files.
|
* staff, every action is additionally gated by the StaffLibraryScope, so
|
||||||
|
* direct access can't reach out-of-scope files.
|
||||||
|
*
|
||||||
|
* Every method here branches on isStaff() before it reaches the scope.
|
||||||
|
* That is not stylistic: StaffLibraryScope answers "is this *restricted*
|
||||||
|
* staff member allowed?", and its "no restriction" answer is `true`. A
|
||||||
|
* client falling through to it is handed the whole library. See update().
|
||||||
*/
|
*/
|
||||||
class FilePolicy
|
class FilePolicy
|
||||||
{
|
{
|
||||||
@@ -33,8 +39,25 @@ class FilePolicy
|
|||||||
|
|
||||||
public function update(User $user, File $file): bool
|
public function update(User $user, File $file): bool
|
||||||
{
|
{
|
||||||
|
// A client edits what they uploaded and nothing else. Deliberately
|
||||||
|
// its own branch rather than a shared one, because the staff branch
|
||||||
|
// below is unsafe for a client in two ways at once.
|
||||||
|
//
|
||||||
|
// First, `edit_others_files` must never be reachable here. It is a
|
||||||
|
// staff key by construction: a client has no "others' files" they
|
||||||
|
// could hold a legitimate claim over, only files somebody shared
|
||||||
|
// with them, and being shown a file is not being given it. Granting
|
||||||
|
// that key to the Client role does nothing, and a test pins that.
|
||||||
|
//
|
||||||
|
// Second, and the trap: StaffLibraryScope::allowsFile() returns
|
||||||
|
// true outright for anyone who is not client-*scoped* staff —
|
||||||
|
// User::isClientScoped() is `isStaff() && role->client_scoped`, so
|
||||||
|
// it is false for every client. That predicate means "this staff
|
||||||
|
// member is unrestricted", and a client reaching it would inherit
|
||||||
|
// "unrestricted" over the whole library. Nothing here may touch the
|
||||||
|
// staff scope.
|
||||||
if (! $user->isStaff()) {
|
if (! $user->isStaff()) {
|
||||||
return false;
|
return $file->isOwnedBy($user) && $user->can('edit_files');
|
||||||
}
|
}
|
||||||
|
|
||||||
$permitted = $file->isOwnedBy($user) ? $user->can('edit_files') : $user->can('edit_others_files');
|
$permitted = $file->isOwnedBy($user) ? $user->can('edit_files') : $user->can('edit_others_files');
|
||||||
@@ -72,8 +95,11 @@ class FilePolicy
|
|||||||
|
|
||||||
public function delete(User $user, File $file): bool
|
public function delete(User $user, File $file): bool
|
||||||
{
|
{
|
||||||
|
// Their own upload, and only with the key — same two reasons as
|
||||||
|
// update() above, `delete_others_files` standing in for
|
||||||
|
// `edit_others_files`.
|
||||||
if (! $user->isStaff()) {
|
if (! $user->isStaff()) {
|
||||||
return false;
|
return $file->isOwnedBy($user) && $user->can('delete_files');
|
||||||
}
|
}
|
||||||
|
|
||||||
$permitted = $file->isOwnedBy($user) ? $user->can('delete_files') : $user->can('delete_others_files');
|
$permitted = $file->isOwnedBy($user) ? $user->can('delete_files') : $user->can('delete_others_files');
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Modules\Files;
|
namespace App\Modules\Files;
|
||||||
|
|
||||||
|
use App\Modules\Files\Access\ClientIdentityScope;
|
||||||
use App\Modules\Files\Access\StaffLibraryScope;
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
use App\Modules\Files\Models\Folder;
|
use App\Modules\Files\Models\Folder;
|
||||||
@@ -30,6 +31,10 @@ class FilesServiceProvider extends ServiceProvider
|
|||||||
// reached twice. Scoped rather than a singleton so a long-lived
|
// reached twice. Scoped rather than a singleton so a long-lived
|
||||||
// queue worker starts each job with an empty memo.
|
// queue worker starts each job with an empty memo.
|
||||||
$this->app->scoped(StaffLibraryScope::class);
|
$this->app->scoped(StaffLibraryScope::class);
|
||||||
|
|
||||||
|
// Same lifetime, same reason: the identity rule memoises a roster
|
||||||
|
// per viewer and the file listings ask it once per row.
|
||||||
|
$this->app->scoped(ClientIdentityScope::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ use App\Modules\Api\Support\PollingQuery;
|
|||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
use App\Modules\Clients\ClientStorageUsage;
|
use App\Modules\Clients\ClientStorageUsage;
|
||||||
use App\Modules\Comments\CommentingRules;
|
use App\Modules\Files\Access\ClientIdentityScope;
|
||||||
use App\Modules\Comments\CommentScope;
|
|
||||||
use App\Modules\Files\Access\StaffLibraryScope;
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
use App\Modules\Files\Access\ViewableFileScope;
|
use App\Modules\Files\Access\ViewableFileScope;
|
||||||
use App\Modules\Files\DownloadLimitScope;
|
use App\Modules\Files\DownloadLimitScope;
|
||||||
|
use App\Modules\Files\Editing\ApplyFileEdits;
|
||||||
|
use App\Modules\Files\Editing\FileExpiry;
|
||||||
use App\Modules\Files\Http\Resources\Api\FileResource;
|
use App\Modules\Files\Http\Resources\Api\FileResource;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
use App\Modules\Files\Models\Folder;
|
use App\Modules\Files\Models\Folder;
|
||||||
@@ -57,8 +58,10 @@ class FilesController extends Controller
|
|||||||
private readonly UploadExtensionPolicy $extensionPolicy,
|
private readonly UploadExtensionPolicy $extensionPolicy,
|
||||||
private readonly ClientStorageUsage $storageUsage,
|
private readonly ClientStorageUsage $storageUsage,
|
||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
private readonly CommentingRules $commenting,
|
|
||||||
private readonly StaffLibraryScope $scope,
|
private readonly StaffLibraryScope $scope,
|
||||||
|
private readonly ClientIdentityScope $identity,
|
||||||
|
private readonly ApplyFileEdits $fileEdits,
|
||||||
|
private readonly FileExpiry $expiry,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -110,6 +113,17 @@ class FilesController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (array_key_exists('uploaded_by', $filters) && $filters['uploaded_by'] !== null) {
|
if (array_key_exists('uploaded_by', $filters) && $filters['uploaded_by'] !== null) {
|
||||||
|
// A filter is a question, and this one asks "did client N put
|
||||||
|
// anything into my library". Answered plainly it is an oracle:
|
||||||
|
// a client-scoped caller could walk the id space and learn
|
||||||
|
// which clients off their roster share files with clients on
|
||||||
|
// it, without ever reading a name. So an id this caller may
|
||||||
|
// not identify matches nothing — indistinguishable from a
|
||||||
|
// client who has uploaded nothing, which is the point.
|
||||||
|
if (! $this->identity->permitsClientId($user, (int) $filters['uploaded_by'])) {
|
||||||
|
$query->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
$query->where('files.uploaded_by', $filters['uploaded_by']);
|
$query->where('files.uploaded_by', $filters['uploaded_by']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,9 +144,12 @@ class FilesController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Expiry is a filter, not a default: staff see expired files in the
|
// Expiry is a filter, not a default: staff see expired files in the
|
||||||
// UI too (that is how they notice and act on them). Only the client
|
// UI too (that is how they notice and act on them). Dropping them
|
||||||
// branch of the visibility rules drops them, and it does so inside
|
// is the client branch's rule, applied inside the visibility scopes
|
||||||
// ViewableFileScope where it belongs.
|
// where it belongs — which is also why a client-scoped caller does
|
||||||
|
// not get their clients' expired files back here whatever this
|
||||||
|
// filter says: their library is built on that same branch. See
|
||||||
|
// File::isExpired.
|
||||||
if ($request->has('expired') && ($filters['expired'] ?? null) !== null) {
|
if ($request->has('expired') && ($filters['expired'] ?? null) !== null) {
|
||||||
$request->boolean('expired') ? $query->expired() : $query->notExpired();
|
$request->boolean('expired') ? $query->expired() : $query->notExpired();
|
||||||
}
|
}
|
||||||
@@ -263,6 +280,12 @@ class FilesController extends Controller
|
|||||||
* without the matching permission leaves that field untouched rather
|
* without the matching permission leaves that field untouched rather
|
||||||
* than failing the whole request, which mirrors the web interface.
|
* than failing the whole request, which mirrors the web interface.
|
||||||
*
|
*
|
||||||
|
* `expires_at` accepts either a calendar day (`2026-09-12`) or a full
|
||||||
|
* timestamp. A day means the end of that day in the caller's timezone,
|
||||||
|
* which is what the same value means on the web and what the file's
|
||||||
|
* own `expires_at` reads back as; a timestamp is taken as the instant
|
||||||
|
* it names.
|
||||||
|
*
|
||||||
* `commentable` only has an effect while the installation's comment
|
* `commentable` only has an effect while the installation's comment
|
||||||
* setting is "only files marked as commentable"; under any other
|
* setting is "only files marked as commentable"; under any other
|
||||||
* setting it is ignored, again rather than failing.
|
* setting it is ignored, again rather than failing.
|
||||||
@@ -303,44 +326,32 @@ class FilesController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$attributes = array_intersect_key($validated, array_flip(['name', 'description', 'folder_id']));
|
// `sometimes` throughout the rules above means $validated already
|
||||||
|
// holds exactly the fields the caller sent, which is the same
|
||||||
|
// array_key_exists contract ApplyFileEdits reads — so the payload
|
||||||
|
// passes through almost untouched. Which of them this token's user
|
||||||
|
// may actually write is that class's decision, shared with the
|
||||||
|
// staff editor and the client portal.
|
||||||
|
$changes = array_intersect_key($validated, array_flip([
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'folder_id',
|
||||||
|
'commentable',
|
||||||
|
'download_limit',
|
||||||
|
'download_limit_scope',
|
||||||
|
'public',
|
||||||
|
'slug',
|
||||||
|
'categories',
|
||||||
|
]));
|
||||||
|
|
||||||
if (array_key_exists('expires_at', $validated) && $user->can('set_file_expiration_date')) {
|
// The one field that needs converting rather than passing along: a
|
||||||
$attributes['expires_at'] = $validated['expires_at'];
|
// caller may send a calendar day or a full timestamp, and a day
|
||||||
|
// means the end of that day where the caller is.
|
||||||
|
if (array_key_exists('expires_at', $validated)) {
|
||||||
|
$changes['expires_at'] = $this->expiry->instant($validated['expires_at'], $user);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (array_key_exists('download_limit', $validated) && $user->can('limit_downloads')) {
|
$this->fileEdits->apply($user, $file, $changes);
|
||||||
$attributes['download_limit'] = $validated['download_limit'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (array_key_exists('download_limit_scope', $validated) && $user->can('limit_downloads')) {
|
|
||||||
$attributes['download_limit_scope'] = $validated['download_limit_scope'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (array_key_exists('commentable', $validated) && $this->commenting->scope() === CommentScope::SelectedFiles) {
|
|
||||||
$attributes['commentable'] = $validated['commentable'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$wasPublic = $file->public;
|
|
||||||
|
|
||||||
if (array_key_exists('public', $validated) && $user->can('upload_public')) {
|
|
||||||
$attributes['public'] = $validated['public'];
|
|
||||||
$attributes['slug'] = ($validated['slug'] ?? '') ?: ($file->slug ?: File::uniqueSlugFrom($validated['name'] ?? $file->name, $file->id));
|
|
||||||
}
|
|
||||||
|
|
||||||
$file->update($attributes);
|
|
||||||
|
|
||||||
if (array_key_exists('categories', $validated) && $user->can('set_file_categories')) {
|
|
||||||
$file->categories()->sync($validated['categories']);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->activity->log(Action::FileUpdated, subject: $file);
|
|
||||||
|
|
||||||
if (! $wasPublic && $file->public) {
|
|
||||||
$this->activity->log(Action::FileMadePublic, subject: $file, context: ['slug' => $file->slug]);
|
|
||||||
} elseif ($wasPublic && ! $file->public) {
|
|
||||||
$this->activity->log(Action::FileMadePrivate, subject: $file);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new FileResource($file->fresh()?->load(['folder', 'uploader', 'categories']) ?? $file);
|
return new FileResource($file->fresh()?->load(['folder', 'uploader', 'categories']) ?? $file);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,14 @@ class ChunkedUploadsController extends Controller
|
|||||||
|
|
||||||
if ($quotaBytes > 0 && $this->storageUsage->usedBytes($user) + (int) $validated['size'] > $quotaBytes) {
|
if ($quotaBytes > 0 && $this->storageUsage->usedBytes($user) + (int) $validated['size'] > $quotaBytes) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'size' => __('This upload would exceed your storage quota of :quota MB.', ['quota' => (string) $user->storage_quota_mb]),
|
'size' => __('This upload would exceed your storage quota of :quota MB.', [
|
||||||
|
// The resolved quota, not the column: a client who
|
||||||
|
// was never given one of their own carries 0 there
|
||||||
|
// and inherits the site default, so printing the
|
||||||
|
// column reads "your storage quota of 0 MB" at the
|
||||||
|
// moment somebody is asking what their limit is.
|
||||||
|
'quota' => (string) $this->storageUsage->quotaMb($user),
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,7 +313,9 @@ class ChunkedUploadsController extends Controller
|
|||||||
$session->delete();
|
$session->delete();
|
||||||
|
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'size' => __('This upload would exceed your storage quota of :quota MB.', ['quota' => (string) $user->storage_quota_mb]),
|
'size' => __('This upload would exceed your storage quota of :quota MB.', [
|
||||||
|
'quota' => (string) $this->storageUsage->quotaMb($user),
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Modules\Files\Http\Controllers;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Modules\Files\Access\ClientIdentityScope;
|
||||||
use App\Modules\Files\Access\StaffLibraryScope;
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
use App\Modules\Files\Models\Category;
|
use App\Modules\Files\Models\Category;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
@@ -28,6 +29,7 @@ class ClientFilesController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly StaffLibraryScope $scope,
|
private readonly StaffLibraryScope $scope,
|
||||||
|
private readonly ClientIdentityScope $identity,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request, User $client): Response
|
public function index(Request $request, User $client): Response
|
||||||
@@ -66,7 +68,11 @@ class ClientFilesController extends Controller
|
|||||||
'size' => $file->size,
|
'size' => $file->size,
|
||||||
'created_at' => $file->created_at?->toIso8601String(),
|
'created_at' => $file->created_at?->toIso8601String(),
|
||||||
'uploaded_by_client' => $file->uploaded_by === $client->id,
|
'uploaded_by_client' => $file->uploaded_by === $client->id,
|
||||||
'uploader' => $file->uploader?->name,
|
// Being allowed to browse this client's files does not
|
||||||
|
// extend to the other clients who shared files with them:
|
||||||
|
// a file reaches this listing through the client in the
|
||||||
|
// URL, and its uploader can be somebody else entirely.
|
||||||
|
'uploader' => $this->identity->nameOf($viewer, $file->uploader),
|
||||||
'downloads_count' => $file->downloads_count,
|
'downloads_count' => $file->downloads_count,
|
||||||
'can_download' => Gate::forUser($viewer)->allows('view', $file),
|
'can_download' => Gate::forUser($viewer)->allows('view', $file),
|
||||||
'categories' => $file->categories->map(fn (Category $category): array => [
|
'categories' => $file->categories->map(fn (Category $category): array => [
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Modules\Files\Http\Controllers;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
|
use App\Modules\Files\Delivery\FileDelivery;
|
||||||
use App\Modules\Platform\Settings\Setting;
|
use App\Modules\Platform\Settings\Setting;
|
||||||
use App\Modules\Platform\Settings\Settings;
|
use App\Modules\Platform\Settings\Settings;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
@@ -24,12 +25,21 @@ class DownloadSettingsController extends Controller
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Settings $settings,
|
private readonly Settings $settings,
|
||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
|
private readonly FileDelivery $delivery,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function edit(): Response
|
public function edit(): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('system/settings/downloads', [
|
return Inertia::render('system/settings/downloads', [
|
||||||
'max_zip_download_size_mb' => $this->settings->get(Setting::MaxZipDownloadSizeMb),
|
'max_zip_download_size_mb' => $this->settings->get(Setting::MaxZipDownloadSizeMb),
|
||||||
|
// Not a setting, and shown here because this is where somebody
|
||||||
|
// coming from v1 looks for one: v1 had a "Download method"
|
||||||
|
// dropdown on its uploads options screen. It is an environment
|
||||||
|
// variable now rather than a stored setting, because it
|
||||||
|
// describes the server the installation is running on rather
|
||||||
|
// than a preference — a value in the database can be restored
|
||||||
|
// onto a different server and be wrong there.
|
||||||
|
'file_delivery' => $this->delivery->describe(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Modules\Audit\ActivityLog;
|
|||||||
use App\Modules\Audit\ActivityPresenter;
|
use App\Modules\Audit\ActivityPresenter;
|
||||||
use App\Modules\Audit\DownloadPresenter;
|
use App\Modules\Audit\DownloadPresenter;
|
||||||
use App\Modules\Comments\CommentingRules;
|
use App\Modules\Comments\CommentingRules;
|
||||||
|
use App\Modules\Files\Access\ClientIdentityScope;
|
||||||
use App\Modules\Files\Access\DownloadAllowance;
|
use App\Modules\Files\Access\DownloadAllowance;
|
||||||
use App\Modules\Files\Access\ShareTargets;
|
use App\Modules\Files\Access\ShareTargets;
|
||||||
use App\Modules\Files\DownloadLimitScope;
|
use App\Modules\Files\DownloadLimitScope;
|
||||||
@@ -79,6 +80,7 @@ class FileDetailsController extends Controller
|
|||||||
private readonly ActivityPresenter $presenter,
|
private readonly ActivityPresenter $presenter,
|
||||||
private readonly DownloadPresenter $downloadPresenter,
|
private readonly DownloadPresenter $downloadPresenter,
|
||||||
private readonly ShareTargets $shareTargets,
|
private readonly ShareTargets $shareTargets,
|
||||||
|
private readonly ClientIdentityScope $identity,
|
||||||
private readonly CommentingRules $commenting,
|
private readonly CommentingRules $commenting,
|
||||||
private readonly FileVersionLinks $versionLinks,
|
private readonly FileVersionLinks $versionLinks,
|
||||||
private readonly DownloadAllowance $allowance,
|
private readonly DownloadAllowance $allowance,
|
||||||
@@ -100,7 +102,10 @@ class FileDetailsController extends Controller
|
|||||||
'size' => $file->size,
|
'size' => $file->size,
|
||||||
'mime_type' => $file->mime_type,
|
'mime_type' => $file->mime_type,
|
||||||
'checksum' => $file->checksum,
|
'checksum' => $file->checksum,
|
||||||
'uploader' => $file->uploader?->name,
|
// Null when the uploader is a client this viewer may not
|
||||||
|
// be told about, which reads the same as an uploader whose
|
||||||
|
// account has since been deleted.
|
||||||
|
'uploader' => $this->identity->nameOf($viewer, $file->uploader),
|
||||||
'folder' => $file->folder?->only('id', 'name'),
|
'folder' => $file->folder?->only('id', 'name'),
|
||||||
'categories' => $file->categories()->orderBy('name')->get()
|
'categories' => $file->categories()->orderBy('name')->get()
|
||||||
->map(fn (Category $category): array => ['id' => $category->id, 'name' => $category->name, 'color' => $category->color])
|
->map(fn (Category $category): array => ['id' => $category->id, 'name' => $category->name, 'color' => $category->color])
|
||||||
@@ -140,7 +145,7 @@ class FileDetailsController extends Controller
|
|||||||
// Resolved from the chain root for a revision (ShareTargets
|
// Resolved from the chain root for a revision (ShareTargets
|
||||||
// does that), so this names who really has the file. The panel
|
// does that), so this names who really has the file. The panel
|
||||||
// says where those recipients are set.
|
// says where those recipients are set.
|
||||||
'shares' => $this->shareTargets->assigned($file),
|
'shares' => $this->shareTargets->assignedFor($file, $viewer),
|
||||||
'sharing_root' => $file->isRevision()
|
'sharing_root' => $file->isRevision()
|
||||||
? File::query()->find($file->sharingOwnerId())?->only('id', 'name')
|
? File::query()->find($file->sharingOwnerId())?->only('id', 'name')
|
||||||
: null,
|
: null,
|
||||||
@@ -368,7 +373,7 @@ class FileDetailsController extends Controller
|
|||||||
'name' => $folder->name,
|
'name' => $folder->name,
|
||||||
'files_count' => $folder->files()->count(),
|
'files_count' => $folder->files()->count(),
|
||||||
'children_count' => $folder->children()->count(),
|
'children_count' => $folder->children()->count(),
|
||||||
'creator' => $folder->creator?->name,
|
'creator' => $this->identity->nameOf($viewer, $folder->creator),
|
||||||
'created_at' => $folder->created_at?->toIso8601String(),
|
'created_at' => $folder->created_at?->toIso8601String(),
|
||||||
'open_url' => route('files.index', ['folder' => $folder->id], false),
|
'open_url' => route('files.index', ['folder' => $folder->id], false),
|
||||||
// Read-only here, same as a file's shares — sharing (and every
|
// Read-only here, same as a file's shares — sharing (and every
|
||||||
@@ -377,7 +382,7 @@ class FileDetailsController extends Controller
|
|||||||
'edit_url' => route('folders.share', $folder, false),
|
'edit_url' => route('folders.share', $folder, false),
|
||||||
'can_update' => Gate::forUser($viewer)->allows('update', $folder),
|
'can_update' => Gate::forUser($viewer)->allows('update', $folder),
|
||||||
'can_view_activity' => $viewer->can('view_actions_log'),
|
'can_view_activity' => $viewer->can('view_actions_log'),
|
||||||
'shares' => $this->shareTargets->assigned($folder),
|
'shares' => $this->shareTargets->assignedFor($folder, $viewer),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,15 +12,16 @@ use App\Modules\Files\Delivery\StoredFileResponse;
|
|||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authorized downloads without the bytes ever traversing PHP: the app
|
* Authorized downloads: the app checks the policy, and StoredFileResponse
|
||||||
* checks the policy, and StoredFileResponse answers with either an
|
* decides how the bytes travel — a presigned URL when the file lives on
|
||||||
* X-Accel-Redirect for nginx to stream from the protected location
|
* external storage, and otherwise whichever local delivery method this
|
||||||
* (brief §3) or a presigned URL when the file lives on external storage,
|
* installation's web server understands (see FileDelivery). On nginx that
|
||||||
* since nginx has no way to serve bytes it doesn't have on disk.
|
* is an X-Accel-Redirect and the bytes never traverse PHP at all; on a
|
||||||
|
* server with no such header PHP streams them, which is slower and works.
|
||||||
*/
|
*/
|
||||||
class FileDownloadController extends Controller
|
class FileDownloadController extends Controller
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ namespace App\Modules\Files\Http\Controllers;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLogger;
|
|
||||||
use App\Modules\Files\Access\DownloadAllowance;
|
use App\Modules\Files\Access\DownloadAllowance;
|
||||||
|
use App\Modules\Files\Delivery\FileDelivery;
|
||||||
use App\Modules\Files\Delivery\StoredFileResponse;
|
use App\Modules\Files\Delivery\StoredFileResponse;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
use App\Modules\Files\Preview\PreviewKind;
|
use App\Modules\Files\Preview\PreviewKind;
|
||||||
|
use App\Modules\Files\Preview\PreviewLog;
|
||||||
use App\Modules\Files\Thumbnails\Events\ResolvingImageRendering;
|
use App\Modules\Files\Thumbnails\Events\ResolvingImageRendering;
|
||||||
use App\Modules\Files\Thumbnails\ImageAudience;
|
use App\Modules\Files\Thumbnails\ImageAudience;
|
||||||
use App\Modules\Files\Thumbnails\ImageRendition;
|
use App\Modules\Files\Thumbnails\ImageRendition;
|
||||||
@@ -21,15 +22,14 @@ use App\Modules\Platform\Settings\Settings;
|
|||||||
use App\Support\ContentDisposition;
|
use App\Support\ContentDisposition;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Two inline (never `attachment`) views of a file, same X-Accel-Redirect
|
* Two inline (never `attachment`) views of a file, delivered the same way
|
||||||
* pattern as FileDownloadController: a bounded thumbnail for listing rows,
|
* FileDownloadController delivers one: a bounded thumbnail for listing rows,
|
||||||
* and a larger view opened in a new tab when a thumbnail is clicked.
|
* and a larger view opened in a new tab when a thumbnail is clicked.
|
||||||
* `thumbnail()` stays unlogged — it fires automatically as an `<img src>`
|
* `thumbnail()` stays unlogged — it fires automatically as an `<img src>`
|
||||||
* for every row on every listing render, not a deliberate action, and
|
* for every row on every listing render, not a deliberate action, and
|
||||||
@@ -72,11 +72,12 @@ class FileThumbnailController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ThumbnailGenerator $thumbnails,
|
private readonly ThumbnailGenerator $thumbnails,
|
||||||
private readonly ActivityLogger $activity,
|
private readonly PreviewLog $previews,
|
||||||
private readonly DownloadAllowance $allowance,
|
private readonly DownloadAllowance $allowance,
|
||||||
private readonly StoredFileResponse $bytes,
|
private readonly StoredFileResponse $bytes,
|
||||||
private readonly LocalSourceFile $source,
|
private readonly LocalSourceFile $source,
|
||||||
private readonly Settings $settings,
|
private readonly Settings $settings,
|
||||||
|
private readonly FileDelivery $delivery,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function thumbnail(Request $request, File $file): Response
|
public function thumbnail(Request $request, File $file): Response
|
||||||
@@ -144,7 +145,10 @@ class FileThumbnailController extends Controller
|
|||||||
// file.
|
// file.
|
||||||
abort_unless($this->allowance->allows($file, $request->user()), 403);
|
abort_unless($this->allowance->allows($file, $request->user()), 403);
|
||||||
|
|
||||||
$this->logPreview($file, $request);
|
// Debounced, because a browser turns one video into dozens of
|
||||||
|
// Range requests — see PreviewLog, which the anonymous twin in
|
||||||
|
// PublicGroupsController::preview shares.
|
||||||
|
$this->previews->record(Action::FilePreviewed, $file, $request->user());
|
||||||
|
|
||||||
if ($kind === PreviewKind::Image) {
|
if ($kind === PreviewKind::Image) {
|
||||||
$audience = ImageAudience::forViewer($request->user());
|
$audience = ImageAudience::forViewer($request->user());
|
||||||
@@ -164,29 +168,6 @@ class FileThumbnailController extends Controller
|
|||||||
return $this->bytes->inline($file);
|
return $this->bytes->inline($file);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* One log row per viewer per file per five minutes.
|
|
||||||
*
|
|
||||||
* Watching a video is a single deliberate act that the browser turns
|
|
||||||
* into dozens of Range requests against this route, and each one
|
|
||||||
* arrives here indistinguishable from someone clicking preview again.
|
|
||||||
* Cache::add is the whole mechanism: it writes only if the key is
|
|
||||||
* absent, so the first request through the window logs and the rest
|
|
||||||
* are silent, without a read-then-write race between two of them.
|
|
||||||
*
|
|
||||||
* Keyed by viewer, so one client's playback never suppresses another
|
|
||||||
* person's preview of the same file. Anonymous viewers do not reach
|
|
||||||
* this route at all — see PublicGroupsController::preview.
|
|
||||||
*/
|
|
||||||
private function logPreview(File $file, Request $request): void
|
|
||||||
{
|
|
||||||
$key = 'file-preview-logged:'.$file->id.':'.($request->user()->id ?? 'guest');
|
|
||||||
|
|
||||||
if (Cache::add($key, true, now()->addMinutes(5))) {
|
|
||||||
$this->activity->log(Action::FilePreviewed, subject: $file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The cached rendition's path on the local disk, generating it first
|
* The cached rendition's path on the local disk, generating it first
|
||||||
* if this is the first time anyone has asked for it. Null only when
|
* if this is the first time anyone has asked for it. Null only when
|
||||||
@@ -202,8 +183,19 @@ class FileThumbnailController extends Controller
|
|||||||
|
|
||||||
$disk = Storage::disk('files');
|
$disk = Storage::disk('files');
|
||||||
|
|
||||||
|
// Existence is the cache, and an empty file is not a rendition: it
|
||||||
|
// is what a render that died before writing anything leaves behind,
|
||||||
|
// and serving it hands the viewer a broken image for as long as the
|
||||||
|
// file lives — nothing invalidates a rendition once it is there.
|
||||||
|
// ThumbnailGenerator writes through a temporary file now, so this
|
||||||
|
// state can no longer be created here; it can still be inherited
|
||||||
|
// from an installation that ran an older version.
|
||||||
if ($disk->exists($path)) {
|
if ($disk->exists($path)) {
|
||||||
return $path;
|
if ($disk->size($path) > 0) {
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
$disk->delete($path);
|
||||||
}
|
}
|
||||||
|
|
||||||
$disk->makeDirectory(dirname($path));
|
$disk->makeDirectory(dirname($path));
|
||||||
@@ -221,10 +213,12 @@ class FileThumbnailController extends Controller
|
|||||||
|
|
||||||
private function serve(File $file, string $path): Response
|
private function serve(File $file, string $path): Response
|
||||||
{
|
{
|
||||||
return response('', 200, [
|
// No Content-Length: this is the rendition's size, not the
|
||||||
'X-Accel-Redirect' => '/protected-files/'.$path,
|
// original file's, and $file->size is the wrong number for it.
|
||||||
'Content-Type' => $file->mime_type,
|
return $this->delivery->serve(
|
||||||
'Content-Disposition' => ContentDisposition::inline($file->original_name),
|
$path,
|
||||||
]);
|
$file->mime_type,
|
||||||
|
ContentDisposition::inline($file->original_name),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ use App\Modules\Audit\Action;
|
|||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
use App\Modules\Comments\CommentingRules;
|
use App\Modules\Comments\CommentingRules;
|
||||||
use App\Modules\Comments\CommentScope;
|
use App\Modules\Comments\CommentScope;
|
||||||
|
use App\Modules\Files\Access\ClientIdentityScope;
|
||||||
use App\Modules\Files\Access\ShareTargets;
|
use App\Modules\Files\Access\ShareTargets;
|
||||||
use App\Modules\Files\Access\StaffLibraryScope;
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
use App\Modules\Files\DownloadLimitScope;
|
use App\Modules\Files\DownloadLimitScope;
|
||||||
|
use App\Modules\Files\Editing\ApplyFileEdits;
|
||||||
|
use App\Modules\Files\Editing\FileExpiry;
|
||||||
use App\Modules\Files\Models\Category;
|
use App\Modules\Files\Models\Category;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
use App\Modules\Files\Models\Folder;
|
use App\Modules\Files\Models\Folder;
|
||||||
@@ -22,13 +25,10 @@ use App\Modules\Files\Uploads\StoreUploadedFile;
|
|||||||
use App\Modules\Files\Uploads\UploadExtensionPolicy;
|
use App\Modules\Files\Uploads\UploadExtensionPolicy;
|
||||||
use App\Modules\Files\Versions\FileVersionLinks;
|
use App\Modules\Files\Versions\FileVersionLinks;
|
||||||
use App\Modules\Files\Versions\FileVersions;
|
use App\Modules\Files\Versions\FileVersions;
|
||||||
use App\Modules\Platform\Localization\LocalDay;
|
|
||||||
use App\Modules\Platform\Localization\TimezoneRegistry;
|
|
||||||
use App\Modules\Platform\Settings\Setting;
|
use App\Modules\Platform\Settings\Setting;
|
||||||
use App\Modules\Platform\Settings\Settings;
|
use App\Modules\Platform\Settings\Settings;
|
||||||
use App\Support\PublicUrl;
|
use App\Support\PublicUrl;
|
||||||
use App\Support\Rules;
|
use App\Support\Rules;
|
||||||
use Carbon\Carbon;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
@@ -49,10 +49,12 @@ class FilesController extends Controller
|
|||||||
private readonly StaffLibraryScope $scope,
|
private readonly StaffLibraryScope $scope,
|
||||||
private readonly PublicUrl $publicUrl,
|
private readonly PublicUrl $publicUrl,
|
||||||
private readonly ShareTargets $shareTargets,
|
private readonly ShareTargets $shareTargets,
|
||||||
|
private readonly ClientIdentityScope $identity,
|
||||||
private readonly CommentingRules $commenting,
|
private readonly CommentingRules $commenting,
|
||||||
private readonly FileVersions $versions,
|
private readonly FileVersions $versions,
|
||||||
private readonly FileVersionLinks $versionLinks,
|
private readonly FileVersionLinks $versionLinks,
|
||||||
private readonly TimezoneRegistry $timezones,
|
private readonly ApplyFileEdits $fileEdits,
|
||||||
|
private readonly FileExpiry $expiry,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function create(Request $request): Response
|
public function create(Request $request): Response
|
||||||
@@ -164,7 +166,7 @@ class FilesController extends Controller
|
|||||||
'original_name' => $file->original_name,
|
'original_name' => $file->original_name,
|
||||||
'size' => $file->size,
|
'size' => $file->size,
|
||||||
'mime_type' => $file->mime_type,
|
'mime_type' => $file->mime_type,
|
||||||
'uploader' => $file->uploader?->name,
|
'uploader' => $this->identity->nameOf($viewer, $file->uploader),
|
||||||
'folder_id' => $file->folder_id,
|
'folder_id' => $file->folder_id,
|
||||||
'public' => $file->public,
|
'public' => $file->public,
|
||||||
'commentable' => $file->commentable,
|
'commentable' => $file->commentable,
|
||||||
@@ -173,7 +175,7 @@ class FilesController extends Controller
|
|||||||
// calendar date the editor typed — read back in their
|
// calendar date the editor typed — read back in their
|
||||||
// zone, not the server's, or a file set to expire on the
|
// zone, not the server's, or a file set to expire on the
|
||||||
// 12th reopens showing the 11th.
|
// 12th reopens showing the 11th.
|
||||||
'expires_at' => $file->expires_at?->copy()->setTimezone($this->timezones->resolve($request->user()))->toDateString(),
|
'expires_at' => $this->expiry->asShown($file, $request->user()),
|
||||||
'expired' => $file->isExpired(),
|
'expired' => $file->isExpired(),
|
||||||
'download_limit' => $file->download_limit,
|
'download_limit' => $file->download_limit,
|
||||||
'download_limit_scope' => ($file->download_limit_scope ?? DownloadLimitScope::Total)->value,
|
'download_limit_scope' => ($file->download_limit_scope ?? DownloadLimitScope::Total)->value,
|
||||||
@@ -274,6 +276,8 @@ class FilesController extends Controller
|
|||||||
// change comparison below matches the model's int.
|
// change comparison below matches the model's int.
|
||||||
$folderId = isset($validated['folder_id']) ? (int) $validated['folder_id'] : null;
|
$folderId = isset($validated['folder_id']) ? (int) $validated['folder_id'] : null;
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
// Gate::authorize above cannot pass without one.
|
||||||
|
assert($user !== null);
|
||||||
|
|
||||||
// Reparenting through update() is the same privileged write as
|
// Reparenting through update() is the same privileged write as
|
||||||
// move()/bulkUpdate(), so it needs the same guard: the destination
|
// move()/bulkUpdate(), so it needs the same guard: the destination
|
||||||
@@ -281,68 +285,45 @@ class FilesController extends Controller
|
|||||||
// folder actually changes, so re-saving a file that already sits in
|
// folder actually changes, so re-saving a file that already sits in
|
||||||
// an out-of-scope folder (reachable via a direct client share) still
|
// an out-of-scope folder (reachable via a direct client share) still
|
||||||
// works.
|
// works.
|
||||||
if ($folderId !== null && $folderId !== $file->folder_id && $user !== null) {
|
if ($folderId !== null && $folderId !== $file->folder_id) {
|
||||||
$this->scope->folders($user)->findOrFail($folderId);
|
$this->scope->folders($user)->findOrFail($folderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
$attributes = [
|
// Normalised into the shape ApplyFileEdits reads, then handed
|
||||||
|
// over: which of these the actor may actually write is that
|
||||||
|
// class's decision, and it is the same decision the API and the
|
||||||
|
// client portal get. See its docblock for why the split is here.
|
||||||
|
$changes = [
|
||||||
'name' => $validated['name'],
|
'name' => $validated['name'],
|
||||||
'description' => $validated['description'] ?? null,
|
'description' => $validated['description'] ?? null,
|
||||||
'folder_id' => $folderId,
|
'folder_id' => $folderId,
|
||||||
|
// Present unconditionally; the comment scope decides whether it
|
||||||
|
// is honoured. Defaulted to the stored value so a form that
|
||||||
|
// does not render the field cannot clear it.
|
||||||
|
'commentable' => $validated['commentable'] ?? $file->commentable,
|
||||||
|
'download_limit' => $validated['download_limit'] ?? null,
|
||||||
|
'download_limit_scope' => $validated['download_limit_scope'] ?? DownloadLimitScope::Total->value,
|
||||||
|
'public' => $validated['public'] ?? $file->public,
|
||||||
|
'slug' => $validated['slug'] ?? '',
|
||||||
|
'categories' => $validated['categories'] ?? [],
|
||||||
];
|
];
|
||||||
|
|
||||||
// Only meaningful while the comment scope is `selected`, and only
|
// The one field that is conditionally *present* rather than
|
||||||
// offered by the page then — but a request reaching here directly
|
// conditionally honoured, and the reason it cannot move into
|
||||||
// must not be able to set a flag the UI is currently hiding, the
|
// ApplyFileEdits: the form was rendered with the stored instant
|
||||||
// same shape as the upload_public gate below.
|
// read back as a date in this viewer's zone, and posts it again
|
||||||
if ($this->commenting->scope() === CommentScope::SelectedFiles) {
|
// untouched with every other edit. Re-deriving it unconditionally
|
||||||
$attributes['commentable'] = $validated['commentable'] ?? $file->commentable;
|
// would move the expiry by the difference between two people's
|
||||||
|
// zones each time somebody merely renamed the file. Compared
|
||||||
|
// against the same string the form was given, so "unchanged" means
|
||||||
|
// what the editor actually saw.
|
||||||
|
$posted = $validated['expires_at'] ?? null;
|
||||||
|
|
||||||
|
if ($posted !== $this->expiry->asShown($file, $user)) {
|
||||||
|
$changes['expires_at'] = $this->expiry->instant($posted, $user);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only a user who can set expiration dates may change this file's
|
$this->fileEdits->apply($user, $file, $changes);
|
||||||
// own expiry — same "leave it alone if you lack the permission"
|
|
||||||
// rule as the upload_public gate below.
|
|
||||||
if ($request->user()?->can('set_file_expiration_date') === true) {
|
|
||||||
$attributes['expires_at'] = $this->expiryInstant($validated['expires_at'] ?? null, $request->user());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same rule again for the download cap, behind its own
|
|
||||||
// permission — the one that already gates a share link's
|
|
||||||
// max_downloads, since both are the same question asked about
|
|
||||||
// different objects.
|
|
||||||
if ($request->user()?->can('limit_downloads') === true) {
|
|
||||||
$attributes['download_limit'] = $validated['download_limit'] ?? null;
|
|
||||||
$attributes['download_limit_scope'] = $validated['download_limit_scope'] ?? DownloadLimitScope::Total->value;
|
|
||||||
}
|
|
||||||
|
|
||||||
$wasPublic = $file->public;
|
|
||||||
|
|
||||||
// Only a user who can manage public state may change it — a user
|
|
||||||
// who can edit a file but lacks upload_public leaves its public
|
|
||||||
// state exactly as it was, same rule as FoldersController::update.
|
|
||||||
if ($request->user()?->can('upload_public') === true) {
|
|
||||||
$attributes['public'] = $validated['public'] ?? $file->public;
|
|
||||||
// Omitting the field on an update leaves the current slug
|
|
||||||
// alone — it must not silently change just because the name
|
|
||||||
// did.
|
|
||||||
$attributes['slug'] = ($validated['slug'] ?? '') ?: ($file->slug ?: File::uniqueSlugFrom($validated['name'], $file->id));
|
|
||||||
}
|
|
||||||
|
|
||||||
$file->update($attributes);
|
|
||||||
|
|
||||||
// Categories are gated by their own permission; leave them untouched
|
|
||||||
// for a user who can edit the file but not set categories.
|
|
||||||
if ($request->user()?->can('set_file_categories') === true) {
|
|
||||||
$file->categories()->sync($validated['categories'] ?? []);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->activity->log(Action::FileUpdated, subject: $file);
|
|
||||||
|
|
||||||
if (! $wasPublic && $file->public) {
|
|
||||||
$this->activity->log(Action::FileMadePublic, subject: $file, context: ['slug' => $file->slug]);
|
|
||||||
} elseif ($wasPublic && ! $file->public) {
|
|
||||||
$this->activity->log(Action::FileMadePrivate, subject: $file);
|
|
||||||
}
|
|
||||||
|
|
||||||
return back()->with('success', __('File updated.'));
|
return back()->with('success', __('File updated.'));
|
||||||
}
|
}
|
||||||
@@ -463,7 +444,7 @@ class FilesController extends Controller
|
|||||||
// update()'s expires_at handling.
|
// update()'s expires_at handling.
|
||||||
if ($validated['expiration_action'] !== 'no_change' && $canSetExpiration) {
|
if ($validated['expiration_action'] !== 'no_change' && $canSetExpiration) {
|
||||||
$attributes['expires_at'] = $validated['expiration_action'] === 'set'
|
$attributes['expires_at'] = $validated['expiration_action'] === 'set'
|
||||||
? $this->expiryInstant($validated['expires_at'], $user)
|
? $this->expiry->instant($validated['expires_at'], $user)
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,9 +485,23 @@ class FilesController extends Controller
|
|||||||
});
|
});
|
||||||
|
|
||||||
$requested = count($validated['file_ids']);
|
$requested = count($validated['file_ids']);
|
||||||
$message = $updated < $requested
|
|
||||||
? __(':updated of :requested selected files were updated. The rest were skipped because you don\'t have permission to edit them.', ['updated' => $updated, 'requested' => $requested])
|
// Two different reasons a selected file can go unchanged, and they
|
||||||
: trans_choice(':count file updated.|:count files updated.', $updated, ['count' => $updated]);
|
// are not the same sentence. Files dropped by the Gate::allows
|
||||||
|
// filter above are ones this user may not edit at all. A file that
|
||||||
|
// survived the filter and still changed nothing was editable --
|
||||||
|
// every field they asked to change was one their role does not let
|
||||||
|
// them set, which is the case the single-file editor states
|
||||||
|
// separately too. Reporting the first reason for the second told a
|
||||||
|
// staff member with edit_files but without set_file_expiration_date
|
||||||
|
// that three files they own are not theirs to edit.
|
||||||
|
$unreachable = $requested - $files->count();
|
||||||
|
|
||||||
|
$message = match (true) {
|
||||||
|
$updated === $requested => trans_choice(':count file updated.|:count files updated.', $updated, ['count' => $updated]),
|
||||||
|
$updated + $unreachable === $requested => __(':updated of :requested selected files were updated. The rest were skipped because you don\'t have permission to edit them.', ['updated' => $updated, 'requested' => $requested]),
|
||||||
|
default => __(':updated of :requested selected files were updated. The rest were skipped because you don\'t have permission to make those changes.', ['updated' => $updated, 'requested' => $requested]),
|
||||||
|
};
|
||||||
|
|
||||||
return back()->with('success', $message);
|
return back()->with('success', $message);
|
||||||
}
|
}
|
||||||
@@ -516,28 +511,17 @@ class FilesController extends Controller
|
|||||||
Gate::authorize('delete', $file);
|
Gate::authorize('delete', $file);
|
||||||
|
|
||||||
$name = $file->name;
|
$name = $file->name;
|
||||||
// Soft delete; the bytes stay on disk until a purge policy
|
// Soft delete of the row — but not of the bytes. File::booted()'s
|
||||||
// lands with the retention work.
|
// `deleted` hook runs FileDiskCleanup on commit, so the upload and
|
||||||
|
// every cached rendition of it are gone from disk by the time this
|
||||||
|
// returns. The row is kept because version chains, the activity
|
||||||
|
// log and the erasure grace period all still point at it; nothing
|
||||||
|
// serves it (route-model binding 404s), and nothing ever
|
||||||
|
// forceDelete()s it either.
|
||||||
$file->delete();
|
$file->delete();
|
||||||
|
|
||||||
$this->activity->log(Action::FileDeleted, context: ['name' => $name]);
|
$this->activity->log(Action::FileDeleted, context: ['name' => $name]);
|
||||||
|
|
||||||
return redirect()->route('files.index')->with('success', __('File deleted.'));
|
return redirect()->route('files.index')->with('success', __('File deleted.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The instant a `<input type="date">` expiry actually falls on.
|
|
||||||
*
|
|
||||||
* The form posts a bare `YYYY-MM-DD`, which Eloquent would otherwise
|
|
||||||
* store as midnight UTC — so "expires on the 12th" would cut the file
|
|
||||||
* off partway through the 11th for anyone in the Americas, and give
|
|
||||||
* anyone east of Greenwich most of a day they were not promised. It
|
|
||||||
* means the end of the 12th where the person setting it lives.
|
|
||||||
*/
|
|
||||||
private function expiryInstant(?string $date, ?User $setter): ?Carbon
|
|
||||||
{
|
|
||||||
return $date === null
|
|
||||||
? null
|
|
||||||
: LocalDay::end($date, $this->timezones->resolve($setter));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Modules\Audit\Action;
|
|||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
use App\Modules\Comments\Access\VisibleCommentScope;
|
use App\Modules\Comments\Access\VisibleCommentScope;
|
||||||
use App\Modules\Comments\CommentingRules;
|
use App\Modules\Comments\CommentingRules;
|
||||||
|
use App\Modules\Files\Access\ClientIdentityScope;
|
||||||
use App\Modules\Files\Access\DownloadAllowance;
|
use App\Modules\Files\Access\DownloadAllowance;
|
||||||
use App\Modules\Files\Access\ShareTargets;
|
use App\Modules\Files\Access\ShareTargets;
|
||||||
use App\Modules\Files\Access\StaffLibraryScope;
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
@@ -54,6 +55,7 @@ class FoldersController extends Controller
|
|||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
private readonly PublicUrl $publicUrl,
|
private readonly PublicUrl $publicUrl,
|
||||||
private readonly ShareTargets $shareTargets,
|
private readonly ShareTargets $shareTargets,
|
||||||
|
private readonly ClientIdentityScope $identity,
|
||||||
private readonly BreadcrumbBuilder $breadcrumbs,
|
private readonly BreadcrumbBuilder $breadcrumbs,
|
||||||
private readonly CommentingRules $commenting,
|
private readonly CommentingRules $commenting,
|
||||||
private readonly VisibleCommentScope $comments,
|
private readonly VisibleCommentScope $comments,
|
||||||
@@ -240,7 +242,11 @@ class FoldersController extends Controller
|
|||||||
'original_name' => $file->original_name,
|
'original_name' => $file->original_name,
|
||||||
'mime_type' => $file->mime_type,
|
'mime_type' => $file->mime_type,
|
||||||
'size' => $file->size,
|
'size' => $file->size,
|
||||||
'uploader' => $file->uploader ? [
|
// The whole block goes, not just the name: type and role
|
||||||
|
// describe the same person, and "a client uploaded this" on a
|
||||||
|
// row whose uploader is off this viewer's roster narrows who
|
||||||
|
// it could be just as effectively as naming them.
|
||||||
|
'uploader' => ($file->uploader !== null && $this->identity->permits($user, $file->uploader)) ? [
|
||||||
'name' => $file->uploader->name,
|
'name' => $file->uploader->name,
|
||||||
'type' => $file->uploader->type->value,
|
'type' => $file->uploader->type->value,
|
||||||
'role' => $file->uploader->role?->name,
|
'role' => $file->uploader->role?->name,
|
||||||
|
|||||||
@@ -5,10 +5,16 @@ declare(strict_types=1);
|
|||||||
namespace App\Modules\Files\Http\Controllers;
|
namespace App\Modules\Files\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Modules\Audit\Action;
|
||||||
|
use App\Modules\Audit\ActivityLogger;
|
||||||
use App\Modules\Clients\ClientStorageUsage;
|
use App\Modules\Clients\ClientStorageUsage;
|
||||||
use App\Modules\Comments\Access\VisibleCommentScope;
|
use App\Modules\Comments\Access\VisibleCommentScope;
|
||||||
use App\Modules\Comments\CommentingRules;
|
use App\Modules\Comments\CommentingRules;
|
||||||
|
use App\Modules\Comments\CommentScope;
|
||||||
use App\Modules\Files\Access\DownloadAllowance;
|
use App\Modules\Files\Access\DownloadAllowance;
|
||||||
|
use App\Modules\Files\DownloadLimitScope;
|
||||||
|
use App\Modules\Files\Editing\ApplyFileEdits;
|
||||||
|
use App\Modules\Files\Editing\FileExpiry;
|
||||||
use App\Modules\Files\Folders\BreadcrumbBuilder;
|
use App\Modules\Files\Folders\BreadcrumbBuilder;
|
||||||
use App\Modules\Files\Models\Category;
|
use App\Modules\Files\Models\Category;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
@@ -22,6 +28,7 @@ use App\Modules\Platform\Settings\Settings;
|
|||||||
use App\Modules\Platform\Theming\PublicThemeRegistry;
|
use App\Modules\Platform\Theming\PublicThemeRegistry;
|
||||||
use App\Support\ConcatenatedPagination;
|
use App\Support\ConcatenatedPagination;
|
||||||
use App\Support\Pagination;
|
use App\Support\Pagination;
|
||||||
|
use App\Support\Rules;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -66,6 +73,9 @@ class MyFilesController extends Controller
|
|||||||
private readonly DownloadAllowance $allowance,
|
private readonly DownloadAllowance $allowance,
|
||||||
private readonly FileVersions $versions,
|
private readonly FileVersions $versions,
|
||||||
private readonly FileVersionLinks $versionLinks,
|
private readonly FileVersionLinks $versionLinks,
|
||||||
|
private readonly ApplyFileEdits $fileEdits,
|
||||||
|
private readonly FileExpiry $expiry,
|
||||||
|
private readonly ActivityLogger $activity,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): Response|RedirectResponse
|
public function index(Request $request): Response|RedirectResponse
|
||||||
@@ -207,9 +217,11 @@ class MyFilesController extends Controller
|
|||||||
$fileRows = $sliced['items']['files'];
|
$fileRows = $sliced['items']['files'];
|
||||||
|
|
||||||
$commentCounts = $this->comments->countsFor($client, $fileRows);
|
$commentCounts = $this->comments->countsFor($client, $fileRows);
|
||||||
// Two queries for the page, not two per row. No URL resolver: the
|
// Two queries for the page, not two per row. Still no URL resolver:
|
||||||
// portal has no per-file page to link to, so a counterpart is named
|
// the portal's per-file page is an *editor* for a client's own
|
||||||
// and not linked (see docs/theming-files-checklist.md).
|
// uploads, and a version counterpart is frequently neither theirs
|
||||||
|
// nor editable — so a counterpart stays named and not linked (see
|
||||||
|
// docs/theming-files-checklist.md).
|
||||||
$versions = $this->versionLinks->forMany($fileRows, $client);
|
$versions = $this->versionLinks->forMany($fileRows, $client);
|
||||||
$unreadComments = $this->comments->unreadCountsFor($client, array_values(array_map(intval(...), $fileRows->pluck('id')->all())));
|
$unreadComments = $this->comments->unreadCountsFor($client, array_values(array_map(intval(...), $fileRows->pluck('id')->all())));
|
||||||
|
|
||||||
@@ -237,6 +249,14 @@ class MyFilesController extends Controller
|
|||||||
'size' => $file->size,
|
'size' => $file->size,
|
||||||
'created_at' => $file->created_at?->toIso8601String(),
|
'created_at' => $file->created_at?->toIso8601String(),
|
||||||
'is_mine' => $file->uploaded_by === $client->id,
|
'is_mine' => $file->uploaded_by === $client->id,
|
||||||
|
// Decided per row by FilePolicy, exactly as the folder rows
|
||||||
|
// above are: a client's own uploads are theirs to manage
|
||||||
|
// and files shared with them are not, and both kinds sit in
|
||||||
|
// the same list. A theme reads these and never works them
|
||||||
|
// out from is_mine — holding the file is only half of it,
|
||||||
|
// the role's keys are the other half.
|
||||||
|
'can_update' => Gate::forUser($client)->allows('update', $file),
|
||||||
|
'can_delete' => Gate::forUser($client)->allows('delete', $file),
|
||||||
// Effective status (own flag or inherited from a public
|
// Effective status (own flag or inherited from a public
|
||||||
// folder) — same "will visitors on the public site see
|
// folder) — same "will visitors on the public site see
|
||||||
// this" badge as the staff library shows.
|
// this" badge as the staff library shows.
|
||||||
@@ -306,6 +326,200 @@ class MyFilesController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The editor page for a file this client uploaded.
|
||||||
|
*
|
||||||
|
* One page for every theme, not one per theme — the same shape
|
||||||
|
* `upload()` uses, and for the same reason: this is a form, and a form
|
||||||
|
* rebuilt four times is four places for a field to go missing. The
|
||||||
|
* `theme` prop picks the shell (see portal/edit-file.tsx), which is the
|
||||||
|
* only part that differs.
|
||||||
|
*
|
||||||
|
* Every `can_*` prop below is the *same* question ApplyFileEdits will
|
||||||
|
* ask when the form posts. A control this page hides is not a control
|
||||||
|
* the server then trusts: hiding it is a courtesy so a client is not
|
||||||
|
* shown a switch that will silently do nothing, and the refusal is
|
||||||
|
* server-side either way.
|
||||||
|
*/
|
||||||
|
public function edit(Request $request, File $file): Response
|
||||||
|
{
|
||||||
|
$client = $request->user();
|
||||||
|
abort_unless($client !== null && $client->isClient(), 404);
|
||||||
|
|
||||||
|
Gate::authorize('update', $file);
|
||||||
|
|
||||||
|
$file->loadMissing('categories');
|
||||||
|
|
||||||
|
return Inertia::render('portal/edit-file', [
|
||||||
|
'theme' => $this->themeKey(),
|
||||||
|
'file' => [
|
||||||
|
'id' => $file->id,
|
||||||
|
'name' => $file->name,
|
||||||
|
'description' => $file->description,
|
||||||
|
'original_name' => $file->original_name,
|
||||||
|
'size' => $file->size,
|
||||||
|
'public' => $file->public,
|
||||||
|
'commentable' => $file->commentable,
|
||||||
|
// The stored instant as the calendar day this client's own
|
||||||
|
// zone shows — the value the form posts back untouched, and
|
||||||
|
// the one update() compares against to tell a real change
|
||||||
|
// from a date that merely came along with a rename.
|
||||||
|
'expires_at' => $this->expiry->asShown($file, $client),
|
||||||
|
'download_limit' => $file->download_limit,
|
||||||
|
'download_limit_scope' => ($file->download_limit_scope ?? DownloadLimitScope::Total)->value,
|
||||||
|
'folder_id' => $file->folder_id,
|
||||||
|
'categories' => $file->categories->pluck('id')->all(),
|
||||||
|
],
|
||||||
|
'can_delete' => Gate::forUser($client)->allows('delete', $file),
|
||||||
|
'can_publish' => $client->can('upload_public'),
|
||||||
|
'can_set_expiration' => $client->can('set_file_expiration_date'),
|
||||||
|
'can_set_categories' => $client->can('set_file_categories'),
|
||||||
|
'can_limit_downloads' => $client->can('limit_downloads'),
|
||||||
|
// Only while the installation asks per file; otherwise the
|
||||||
|
// setting decides and the switch would be a lie.
|
||||||
|
'can_set_commentable' => $this->commenting->scope() === CommentScope::SelectedFiles,
|
||||||
|
'categories' => Category::query()->orderBy('name')->get(['id', 'name', 'color'])
|
||||||
|
->map(fn (Category $category): array => [
|
||||||
|
'id' => $category->id, 'name' => $category->name, 'color' => $category->color,
|
||||||
|
])->all(),
|
||||||
|
// Somewhere this client could have uploaded it in the first
|
||||||
|
// place — the same rule update() enforces, so the picker cannot
|
||||||
|
// offer a destination the save would refuse.
|
||||||
|
'folders' => Folder::query()->visibleToClient($client)->orderBy('name')->get()
|
||||||
|
->filter(fn (Folder $folder): bool => Folder::uploadableBy($client, $folder))
|
||||||
|
->map(fn (Folder $folder): array => [
|
||||||
|
'id' => $folder->id,
|
||||||
|
'name' => $folder->name,
|
||||||
|
// A destination can publish the file without the public
|
||||||
|
// switch being touched: File::isEffectivelyPublic() is
|
||||||
|
// "my own flag OR my folder's", and a client holding
|
||||||
|
// upload_to_public_folders may move into a public
|
||||||
|
// folder without holding upload_public. That is the
|
||||||
|
// established meaning of the two keys, and it is what
|
||||||
|
// uploading there has always done — but in a picker of
|
||||||
|
// bare names it would be invisible, so the name carries
|
||||||
|
// the consequence with it.
|
||||||
|
'public' => $folder->isEffectivelyPublic(),
|
||||||
|
])
|
||||||
|
->values()->all(),
|
||||||
|
// Public files are reachable at the installation's one public
|
||||||
|
// slug; without it configured, publishing shows nowhere and the
|
||||||
|
// page says so rather than offering a switch that does nothing
|
||||||
|
// visible.
|
||||||
|
'public_listing_slug' => $this->settings->get(Setting::PublicListingSlug),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit a file this client uploaded.
|
||||||
|
*
|
||||||
|
* The client portal's counterpart to the staff file editor, and
|
||||||
|
* deliberately a separate route rather than the staff one opened up:
|
||||||
|
* `files.*` renders assignments, share links, activity and download
|
||||||
|
* history, which are staff surfaces, and its folder guard asks
|
||||||
|
* StaffLibraryScope — which answers "allowed" for every client (see
|
||||||
|
* FilePolicy::update()).
|
||||||
|
*
|
||||||
|
* Who may edit at all is FilePolicy: the file must be this client's own
|
||||||
|
* upload and they must hold `edit_files`. Which *fields* they may
|
||||||
|
* write is ApplyFileEdits, the same decision the staff editor and the
|
||||||
|
* API get, so a client holding `set_file_categories` but not
|
||||||
|
* `upload_public` gets exactly what those keys say and nothing is
|
||||||
|
* decided twice.
|
||||||
|
*/
|
||||||
|
public function update(Request $request, File $file): RedirectResponse
|
||||||
|
{
|
||||||
|
$client = $request->user();
|
||||||
|
abort_unless($client !== null && $client->isClient(), 404);
|
||||||
|
|
||||||
|
Gate::authorize('update', $file);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'description' => ['nullable', 'string', 'max:2000'],
|
||||||
|
'folder_id' => Rules::folderId(),
|
||||||
|
'public' => ['sometimes', 'boolean'],
|
||||||
|
'commentable' => ['sometimes', 'boolean'],
|
||||||
|
'categories' => ['array'],
|
||||||
|
'categories.*' => ['integer', 'exists:categories,id'],
|
||||||
|
'expires_at' => ['nullable', 'date'],
|
||||||
|
'download_limit' => ['nullable', 'integer', 'min:1'],
|
||||||
|
'download_limit_scope' => ['nullable', Rule::enum(DownloadLimitScope::class)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// No `slug`, on purpose, and its absence is what makes
|
||||||
|
// ApplyFileEdits derive one from the name. An installation-wide
|
||||||
|
// unique slug that a client picks is a name to squat and an
|
||||||
|
// existence oracle to probe against every file on the
|
||||||
|
// installation, for nothing a derived slug does not already give
|
||||||
|
// them.
|
||||||
|
|
||||||
|
$folderId = isset($validated['folder_id']) ? (int) $validated['folder_id'] : null;
|
||||||
|
|
||||||
|
// The client rule, not the staff one: somewhere they could have
|
||||||
|
// uploaded it in the first place. Same check the upload path makes,
|
||||||
|
// so moving a file cannot reach a folder that uploading it could
|
||||||
|
// not. Only when the folder actually changes, so re-saving a file
|
||||||
|
// that already sits somewhere unusual still works.
|
||||||
|
if ($folderId !== null && $folderId !== $file->folder_id) {
|
||||||
|
$folder = Folder::query()->visibleToClient($client)->find($folderId);
|
||||||
|
|
||||||
|
abort_unless($folder !== null && Folder::uploadableBy($client, $folder), 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$changes = [
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'description' => $validated['description'] ?? null,
|
||||||
|
'folder_id' => $folderId,
|
||||||
|
'commentable' => $validated['commentable'] ?? $file->commentable,
|
||||||
|
'download_limit' => $validated['download_limit'] ?? null,
|
||||||
|
'download_limit_scope' => $validated['download_limit_scope'] ?? DownloadLimitScope::Total->value,
|
||||||
|
'public' => $validated['public'] ?? $file->public,
|
||||||
|
'categories' => $validated['categories'] ?? [],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Only when the date actually moved — the form posts back what it
|
||||||
|
// was rendered with, and re-deriving it on every save would shift
|
||||||
|
// the expiry by a timezone difference each time somebody renamed
|
||||||
|
// the file. See FileExpiry.
|
||||||
|
$posted = $validated['expires_at'] ?? null;
|
||||||
|
|
||||||
|
if ($posted !== $this->expiry->asShown($file, $client)) {
|
||||||
|
$changes['expires_at'] = $this->expiry->instant($posted, $client);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->fileEdits->apply($client, $file, $changes);
|
||||||
|
|
||||||
|
return back()->with('success', __('File updated.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a file this client uploaded.
|
||||||
|
*
|
||||||
|
* Their own upload and `delete_files`, both settled by
|
||||||
|
* FilePolicy::delete(). A file merely shared with them is not theirs to
|
||||||
|
* remove, and no permission changes that.
|
||||||
|
*
|
||||||
|
* The row is soft-deleted and the bytes are not: File::booted()'s
|
||||||
|
* `deleted` hook removes the upload and every cached rendition on
|
||||||
|
* commit, so the client's storage quota — which sums untrashed rows —
|
||||||
|
* frees up by exactly what the disk does.
|
||||||
|
*/
|
||||||
|
public function destroy(Request $request, File $file): RedirectResponse
|
||||||
|
{
|
||||||
|
$client = $request->user();
|
||||||
|
abort_unless($client !== null && $client->isClient(), 404);
|
||||||
|
|
||||||
|
Gate::authorize('delete', $file);
|
||||||
|
|
||||||
|
$name = $file->name;
|
||||||
|
$file->delete();
|
||||||
|
|
||||||
|
$this->activity->log(Action::FileDeleted, context: ['name' => $name]);
|
||||||
|
|
||||||
|
return redirect()->route('my-files.index')->with('success', __('File deleted.'));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Files this client may name as the previous version of what they are
|
* Files this client may name as the previous version of what they are
|
||||||
* uploading — THEIR OWN UPLOADS ONLY.
|
* uploading — THEIR OWN UPLOADS ONLY.
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ use App\Modules\Files\Models\Category;
|
|||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
use App\Modules\Files\Models\ShareLink;
|
use App\Modules\Files\Models\ShareLink;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Response;
|
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response as InertiaResponse;
|
use Inertia\Response as InertiaResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The public, unauthenticated side of a share link: no Gate/policy is
|
* The public, unauthenticated side of a share link: no Gate/policy is
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
|
use App\Modules\Files\Delivery\FileDelivery;
|
||||||
use App\Modules\Files\Access\DownloadAllowance;
|
use App\Modules\Files\Access\DownloadAllowance;
|
||||||
use App\Modules\Files\Access\ViewableFileScope;
|
use App\Modules\Files\Access\ViewableFileScope;
|
||||||
use App\Modules\Files\Jobs\BuildZipDownloadJob;
|
use App\Modules\Files\Jobs\BuildZipDownloadJob;
|
||||||
@@ -21,10 +22,10 @@ use App\Support\ContentDisposition;
|
|||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Number;
|
use Illuminate\Support\Number;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A folder's "Download as zip" button and the file listing's multi-select
|
* A folder's "Download as zip" button and the file listing's multi-select
|
||||||
@@ -45,6 +46,7 @@ class ZipDownloadsController extends Controller
|
|||||||
private readonly ViewableFileScope $viewable,
|
private readonly ViewableFileScope $viewable,
|
||||||
private readonly DownloadAllowance $allowance,
|
private readonly DownloadAllowance $allowance,
|
||||||
private readonly Settings $settings,
|
private readonly Settings $settings,
|
||||||
|
private readonly FileDelivery $delivery,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
@@ -186,12 +188,12 @@ class ZipDownloadsController extends Controller
|
|||||||
|
|
||||||
$size = Storage::disk('files')->size($path);
|
$size = Storage::disk('files')->size($path);
|
||||||
|
|
||||||
return response('', 200, [
|
return $this->delivery->serve(
|
||||||
'X-Accel-Redirect' => '/protected-files/'.$path,
|
$path,
|
||||||
'Content-Type' => 'application/zip',
|
'application/zip',
|
||||||
'Content-Disposition' => ContentDisposition::attachment($this->filenameFor($zipDownload)),
|
ContentDisposition::attachment($this->filenameFor($zipDownload)),
|
||||||
'Content-Length' => (string) $size,
|
$size,
|
||||||
]);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Modules\Files\Http\Resources\Api;
|
namespace App\Modules\Files\Http\Resources\Api;
|
||||||
|
|
||||||
|
use App\Modules\Files\Access\ClientIdentityScope;
|
||||||
use App\Modules\Files\DownloadLimitScope;
|
use App\Modules\Files\DownloadLimitScope;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
use App\Modules\Files\Models\FileAssignment;
|
use App\Modules\Files\Models\FileAssignment;
|
||||||
@@ -26,6 +27,23 @@ use Illuminate\Http\Resources\Json\JsonResource;
|
|||||||
* - `checksum` is included deliberately, since verifying an integration's
|
* - `checksum` is included deliberately, since verifying an integration's
|
||||||
* own download is a real use case, and it reveals nothing about
|
* own download is a real use case, and it reveals nothing about
|
||||||
* location.
|
* location.
|
||||||
|
*
|
||||||
|
* Two fields are narrowed to the caller: the uploader and the assignment
|
||||||
|
* list both name clients, and a client-scoped account may hold a file whose
|
||||||
|
* uploader or co-recipients are clients off their own roster — the file is
|
||||||
|
* theirs to read, those names are not theirs to see. ClientIdentityScope is
|
||||||
|
* the rule; a name dropped here is dropped to null or out of the list, and
|
||||||
|
* an unscoped account is unaffected.
|
||||||
|
*
|
||||||
|
* That narrowing happens here rather than in the controllers, which is the opposite of how the version counterparts are
|
||||||
|
* handled a few files over — and deliberately so. Whether a counterpart may
|
||||||
|
* be named is a set-shaped question with a query to express it, so it is
|
||||||
|
* asked once in the caller's eager load. Whether a client may be named is a
|
||||||
|
* per-row check against the viewer's roster with no query to fold it into,
|
||||||
|
* and this resource is built at eight call sites across four controllers,
|
||||||
|
* two of them re-loading `assignments.assignable` after a write. Asking at
|
||||||
|
* the point of serialisation is the only version of this rule that cannot
|
||||||
|
* be forgotten by the ninth caller.
|
||||||
*/
|
*/
|
||||||
class FileResource extends JsonResource
|
class FileResource extends JsonResource
|
||||||
{
|
{
|
||||||
@@ -34,6 +52,15 @@ class FileResource extends JsonResource
|
|||||||
*/
|
*/
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
|
$viewer = $request->user();
|
||||||
|
$identity = app(ClientIdentityScope::class);
|
||||||
|
|
||||||
|
// The morph class rather than ::class, matching ShareTargets: with
|
||||||
|
// a morph map registered the two disagree, and this line now
|
||||||
|
// decides which roster an entry is checked against, so getting it
|
||||||
|
// wrong would mean checking a group id against the client list.
|
||||||
|
$groupMorph = (new Group)->getMorphClass();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
@@ -96,11 +123,16 @@ class FileResource extends JsonResource
|
|||||||
]),
|
]),
|
||||||
|
|
||||||
// Name only. The uploader is a user record; their email address
|
// Name only. The uploader is a user record; their email address
|
||||||
// is not part of what "this file exists" needs to say.
|
// is not part of what "this file exists" needs to say. Null
|
||||||
'uploaded_by' => $this->whenLoaded('uploader', fn (): ?array => $this->uploader === null ? null : [
|
// when the uploader is a client the token's owner is not
|
||||||
'id' => $this->uploader->id,
|
// scoped to; an unscoped account always gets the name.
|
||||||
'name' => $this->uploader->name,
|
'uploaded_by' => $this->whenLoaded(
|
||||||
]),
|
'uploader',
|
||||||
|
fn (): ?array => $identity->permits($viewer, $this->uploader) && $this->uploader !== null ? [
|
||||||
|
'id' => $this->uploader->id,
|
||||||
|
'name' => $this->uploader->name,
|
||||||
|
] : null,
|
||||||
|
),
|
||||||
|
|
||||||
'categories' => $this->whenLoaded('categories', fn (): array => $this->categories
|
'categories' => $this->whenLoaded('categories', fn (): array => $this->categories
|
||||||
->map(fn ($category): array => [
|
->map(fn ($category): array => [
|
||||||
@@ -109,15 +141,22 @@ class FileResource extends JsonResource
|
|||||||
])
|
])
|
||||||
->all()),
|
->all()),
|
||||||
|
|
||||||
|
// Who the file is shared with, as far as this caller is
|
||||||
|
// concerned: a recipient the token's owner is not scoped to is
|
||||||
|
// left out rather than returned without a name.
|
||||||
'assignments' => $this->whenLoaded('assignments', fn (): array => $this->assignments
|
'assignments' => $this->whenLoaded('assignments', fn (): array => $this->assignments
|
||||||
|
->filter(fn (FileAssignment $assignment): bool => $assignment->assignable_type === $groupMorph
|
||||||
|
? $identity->permitsGroupId($viewer, (int) $assignment->assignable_id)
|
||||||
|
: $identity->permitsClientId($viewer, (int) $assignment->assignable_id))
|
||||||
->map(fn (FileAssignment $assignment): array => [
|
->map(fn (FileAssignment $assignment): array => [
|
||||||
'type' => $assignment->assignable_type === Group::class ? 'group' : 'client',
|
'type' => $assignment->assignable_type === $groupMorph ? 'group' : 'client',
|
||||||
'id' => $assignment->assignable_id,
|
'id' => $assignment->assignable_id,
|
||||||
// getAttribute() rather than ->name: the relation is a
|
// getAttribute() rather than ->name: the relation is a
|
||||||
// MorphTo over User|Group, so the property is only
|
// MorphTo over User|Group, so the property is only
|
||||||
// knowable at runtime. Both targets carry a name.
|
// knowable at runtime. Both targets carry a name.
|
||||||
'name' => $assignment->assignable?->getAttribute('name'),
|
'name' => $assignment->assignable?->getAttribute('name'),
|
||||||
])
|
])
|
||||||
|
->values()
|
||||||
->all()),
|
->all()),
|
||||||
|
|
||||||
'links' => [
|
'links' => [
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ use Throwable;
|
|||||||
*/
|
*/
|
||||||
class LocalPartStore
|
class LocalPartStore
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Said twice, because a full temp volume can announce itself in the
|
||||||
|
* middle of the copy or only when the last buffer is flushed.
|
||||||
|
*/
|
||||||
|
private const WRITE_FAILED = 'Could not assemble the upload: writing to the temporary directory failed.';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The route name is a parameter because the same flow is mounted twice:
|
* The route name is a parameter because the same flow is mounted twice:
|
||||||
* once on the session-authenticated web routes for the browser, once on
|
* once on the session-authenticated web routes for the browser, once on
|
||||||
@@ -140,9 +146,21 @@ class LocalPartStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream-append parts in order onto the files disk, hashing as we
|
* Stream-append parts in order onto the files disk, hashing as we go.
|
||||||
* go. Peak temp usage ≈ file size + one part (parts are unlinked
|
*
|
||||||
* as they are consumed).
|
* The parts stay on disk until the assembled bytes are safely on the
|
||||||
|
* target disk. ChunkedUploadsController's completion lock promises that
|
||||||
|
* "a later retry still works", and everything that can fail after the
|
||||||
|
* concatenation — reopening the copy, a disk refusing the write, the
|
||||||
|
* File row itself — happens while the client has nothing but this
|
||||||
|
* session to retry with. Unlinking each part as it was consumed left
|
||||||
|
* listParts() empty, so every later complete() answered "Upload is
|
||||||
|
* incomplete: missing parts" for good.
|
||||||
|
*
|
||||||
|
* The cost is temp space: peak usage is the whole file twice over
|
||||||
|
* (every part, plus the assembled copy) rather than the file plus one
|
||||||
|
* part. Both are freed by the abort() below the moment the write lands,
|
||||||
|
* and by the failure path the moment it does not.
|
||||||
*
|
*
|
||||||
* @return array{path: string, disk: string, size: int, checksum: string}
|
* @return array{path: string, disk: string, size: int, checksum: string}
|
||||||
*/
|
*/
|
||||||
@@ -158,6 +176,93 @@ class LocalPartStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
$assembledPath = $this->directory($session).'/assembled';
|
$assembledPath = $this->directory($session).'/assembled';
|
||||||
|
|
||||||
|
try {
|
||||||
|
[$size, $checksum] = $this->concatenate($session, $parts, $assembledPath);
|
||||||
|
|
||||||
|
$readStream = fopen($assembledPath, 'rb');
|
||||||
|
|
||||||
|
if ($readStream === false) {
|
||||||
|
throw new RuntimeException('Could not reopen assembled file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$diskEvent = new ResolvingUploadDisk($session->user);
|
||||||
|
Event::dispatch($diskEvent);
|
||||||
|
$disk = $diskEvent->disk;
|
||||||
|
|
||||||
|
$written = Storage::disk($disk)->writeStream($targetPath, $readStream);
|
||||||
|
|
||||||
|
if (is_resource($readStream)) {
|
||||||
|
fclose($readStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The disks are configured with 'throw' => false, so a refused
|
||||||
|
// write is a `false` return rather than an exception — and the
|
||||||
|
// caller goes on to record a File row for bytes that were never
|
||||||
|
// stored. Losing an upload silently is worse than failing it, and
|
||||||
|
// this is the only place that can tell the difference: a real
|
||||||
|
// instance of it was a GCS bucket rejecting the adapter's ACL,
|
||||||
|
// which looked exactly like a successful upload.
|
||||||
|
if ($written === false) {
|
||||||
|
// The reason is lost by the time it gets here — 'throw' => false
|
||||||
|
// means Flysystem swallowed the exception rather than passing it
|
||||||
|
// on — so log what was attempted. Which bucket it was is the
|
||||||
|
// difference between reading this as "my credentials expired"
|
||||||
|
// and "I typed the wrong bucket name", and only the log can say
|
||||||
|
// it: the message below is shown to whoever was uploading, which
|
||||||
|
// includes clients, and a bucket name is not theirs to see.
|
||||||
|
Log::error('Upload could not be written to storage.', [
|
||||||
|
'disk' => $disk,
|
||||||
|
'bucket' => config('filesystems.disks.'.$disk.'.bucket'),
|
||||||
|
'driver' => config('filesystems.disks.'.$disk.'.driver'),
|
||||||
|
'path' => $targetPath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw new RuntimeException(
|
||||||
|
'Could not write the assembled upload to the "'.$disk.'" disk. '
|
||||||
|
.'Check the storage backend is reachable and its credentials are still valid.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (Throwable $failure) {
|
||||||
|
// The half-written copy belongs to this attempt and the next one
|
||||||
|
// makes its own; the parts belong to the client, and they are
|
||||||
|
// what a retry needs. Deleting the copy here is also the only
|
||||||
|
// thing that removes it at all on this path — it used to sit in
|
||||||
|
// the session directory until the sweeper came round.
|
||||||
|
FileSystem::delete($assembledPath);
|
||||||
|
|
||||||
|
throw $failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->abort($session);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'path' => $targetPath,
|
||||||
|
'disk' => $disk,
|
||||||
|
'size' => $size,
|
||||||
|
'checksum' => $checksum,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concatenate the parts into $assembledPath, returning the byte count
|
||||||
|
* and the sha256 of what was written.
|
||||||
|
*
|
||||||
|
* Every read and every write is checked. They were not, and while a
|
||||||
|
* failing fwrite on a full volume is loud in practice — Laravel's
|
||||||
|
* error handler turns the warning into an ErrorException — loud there
|
||||||
|
* means a 500 carrying a PHP message, where the disk-refused-the-write
|
||||||
|
* case a few lines above becomes a sentence the person uploading can
|
||||||
|
* act on. A short write arriving without a warning would be worse
|
||||||
|
* still: $size and the hash describe the buffer that was read, so an
|
||||||
|
* unchecked one yields a truncated file with a checksum matching bytes
|
||||||
|
* that were never stored.
|
||||||
|
*
|
||||||
|
* @param list<array{PartNumber: int, Size: int, ETag: string}> $parts
|
||||||
|
* @return array{0: int, 1: string}
|
||||||
|
*/
|
||||||
|
private function concatenate(UploadSession $session, array $parts, string $assembledPath): array
|
||||||
|
{
|
||||||
$out = fopen($assembledPath, 'wb');
|
$out = fopen($assembledPath, 'wb');
|
||||||
|
|
||||||
if ($out === false) {
|
if ($out === false) {
|
||||||
@@ -167,85 +272,46 @@ class LocalPartStore
|
|||||||
$hash = hash_init('sha256');
|
$hash = hash_init('sha256');
|
||||||
$size = 0;
|
$size = 0;
|
||||||
|
|
||||||
foreach ($parts as $part) {
|
try {
|
||||||
$partPath = $this->partPath($session, $part['PartNumber']);
|
foreach ($parts as $part) {
|
||||||
$in = fopen($partPath, 'rb');
|
$in = fopen($this->partPath($session, $part['PartNumber']), 'rb');
|
||||||
|
|
||||||
if ($in === false) {
|
if ($in === false) {
|
||||||
fclose($out);
|
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
|
||||||
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
|
|
||||||
}
|
|
||||||
|
|
||||||
while (! feof($in)) {
|
|
||||||
$buffer = fread($in, 1024 * 1024);
|
|
||||||
|
|
||||||
if ($buffer === false) {
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fwrite($out, $buffer);
|
try {
|
||||||
hash_update($hash, $buffer);
|
while (! feof($in)) {
|
||||||
$size += strlen($buffer);
|
$buffer = fread($in, 1024 * 1024);
|
||||||
|
|
||||||
|
if ($buffer === false) {
|
||||||
|
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($buffer !== '' && @fwrite($out, $buffer) !== strlen($buffer)) {
|
||||||
|
throw new RuntimeException(self::WRITE_FAILED);
|
||||||
|
}
|
||||||
|
|
||||||
|
hash_update($hash, $buffer);
|
||||||
|
$size += strlen($buffer);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fclose($in);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (Throwable $failure) {
|
||||||
|
fclose($out);
|
||||||
|
|
||||||
fclose($in);
|
throw $failure;
|
||||||
unlink($partPath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fclose($out);
|
// fclose flushes, so a volume that filled up on the last buffer
|
||||||
|
// fails here rather than in the loop.
|
||||||
$readStream = fopen($assembledPath, 'rb');
|
if (! fclose($out)) {
|
||||||
|
throw new RuntimeException(self::WRITE_FAILED);
|
||||||
if ($readStream === false) {
|
|
||||||
throw new RuntimeException('Could not reopen assembled file.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$diskEvent = new ResolvingUploadDisk($session->user);
|
return [$size, hash_final($hash)];
|
||||||
Event::dispatch($diskEvent);
|
|
||||||
$disk = $diskEvent->disk;
|
|
||||||
|
|
||||||
$written = Storage::disk($disk)->writeStream($targetPath, $readStream);
|
|
||||||
|
|
||||||
if (is_resource($readStream)) {
|
|
||||||
fclose($readStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The disks are configured with 'throw' => false, so a refused
|
|
||||||
// write is a `false` return rather than an exception — and the
|
|
||||||
// caller goes on to record a File row for bytes that were never
|
|
||||||
// stored. Losing an upload silently is worse than failing it, and
|
|
||||||
// this is the only place that can tell the difference: a real
|
|
||||||
// instance of it was a GCS bucket rejecting the adapter's ACL,
|
|
||||||
// which looked exactly like a successful upload.
|
|
||||||
if ($written === false) {
|
|
||||||
// The reason is lost by the time it gets here — 'throw' => false
|
|
||||||
// means Flysystem swallowed the exception rather than passing it
|
|
||||||
// on — so log what was attempted. Which bucket it was is the
|
|
||||||
// difference between reading this as "my credentials expired"
|
|
||||||
// and "I typed the wrong bucket name", and only the log can say
|
|
||||||
// it: the message below is shown to whoever was uploading, which
|
|
||||||
// includes clients, and a bucket name is not theirs to see.
|
|
||||||
Log::error('Upload could not be written to storage.', [
|
|
||||||
'disk' => $disk,
|
|
||||||
'bucket' => config('filesystems.disks.'.$disk.'.bucket'),
|
|
||||||
'driver' => config('filesystems.disks.'.$disk.'.driver'),
|
|
||||||
'path' => $targetPath,
|
|
||||||
]);
|
|
||||||
|
|
||||||
throw new RuntimeException(
|
|
||||||
'Could not write the assembled upload to the "'.$disk.'" disk. '
|
|
||||||
.'Check the storage backend is reachable and its credentials are still valid.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->abort($session);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'path' => $targetPath,
|
|
||||||
'disk' => $disk,
|
|
||||||
'size' => $size,
|
|
||||||
'checksum' => hash_final($hash),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function abort(UploadSession $session): void
|
public function abort(UploadSession $session): void
|
||||||
|
|||||||
@@ -96,8 +96,9 @@ class FileVersions
|
|||||||
DB::transaction(function () use ($file, $previous, $root, $actor): void {
|
DB::transaction(function () use ($file, $previous, $root, $actor): void {
|
||||||
// Move, never drop: a revision holds no recipients of its
|
// Move, never drop: a revision holds no recipients of its
|
||||||
// own, but the people who already had this file must not
|
// own, but the people who already had this file must not
|
||||||
// lose it. Through FileSharing so each target still gets
|
// lose it. Through FileSharing, so a target the root does
|
||||||
// its activity entry, notification and digest.
|
// not hold yet still gets its activity entry, notification
|
||||||
|
// and digest — and only such a target, see below.
|
||||||
$this->moveAssignmentsToRoot($file, $root);
|
$this->moveAssignmentsToRoot($file, $root);
|
||||||
|
|
||||||
$file->update([
|
$file->update([
|
||||||
@@ -544,8 +545,27 @@ class FileVersions
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// firstOrCreate inside, so a target the root already has is a
|
// A target the root already holds gains nothing here, so it
|
||||||
// no-op rather than a duplicate notification.
|
// is skipped rather than handed to FileSharing::assign().
|
||||||
|
// That method's firstOrCreate makes the assignment row
|
||||||
|
// idempotent but not the three side effects under it, so such
|
||||||
|
// a target was told a file had been shared with it about a
|
||||||
|
// file it already had — on top of the file_new_version it
|
||||||
|
// gets from sharedAudience(), which is exactly the two
|
||||||
|
// notifications for one action link() resolves that audience
|
||||||
|
// early to avoid. copyAssignmentsFrom() below states the rule
|
||||||
|
// outright for its own case: nobody is gaining access, so the
|
||||||
|
// notification would be a lie.
|
||||||
|
$alreadyOnRoot = FileAssignment::query()
|
||||||
|
->where('file_id', $root->id)
|
||||||
|
->where('assignable_type', $target->getMorphClass())
|
||||||
|
->where('assignable_id', $target->getKey())
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($alreadyOnRoot) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$this->sharing->assign($root, $target, $target->name);
|
$this->sharing->assign($root, $target, $target->name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Modules\Audit\ActivityLogger;
|
|||||||
use App\Modules\Files\Access\StaffLibraryScope;
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
use App\Modules\Groups\Http\Resources\Api\GroupResource;
|
use App\Modules\Groups\Http\Resources\Api\GroupResource;
|
||||||
use App\Modules\Groups\Models\Group;
|
use App\Modules\Groups\Models\Group;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ class GroupMembersController extends Controller
|
|||||||
|
|
||||||
$this->activity->log(Action::GroupMemberAdded, subject: $group, context: ['member' => $client->name]);
|
$this->activity->log(Action::GroupMemberAdded, subject: $group, context: ['member' => $client->name]);
|
||||||
|
|
||||||
return new GroupResource($group->loadCount('members')->load('members'));
|
return $this->response($group, $actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Request $request, Group $group, User $member): GroupResource
|
public function destroy(Request $request, Group $group, User $member): GroupResource
|
||||||
@@ -72,6 +73,28 @@ class GroupMembersController extends Controller
|
|||||||
|
|
||||||
$this->activity->log(Action::GroupMemberRemoved, subject: $group, context: ['member' => $member->name]);
|
$this->activity->log(Action::GroupMemberRemoved, subject: $group, context: ['member' => $member->name]);
|
||||||
|
|
||||||
return new GroupResource($group->loadCount('members')->load('members'));
|
return $this->response($group, $actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The group as this actor may see it.
|
||||||
|
*
|
||||||
|
* GroupResource carries a name and an email per member, and its own
|
||||||
|
* docblock puts the boundary here: "the controller loading this
|
||||||
|
* relation is where that narrowing is applied". Api\GroupsController
|
||||||
|
* ::show() applies it for the read of the same group; changing the
|
||||||
|
* membership is not a reason to be told more than reading it, so both
|
||||||
|
* halves narrow by the same query.
|
||||||
|
*
|
||||||
|
* The count is deliberately not narrowed. members_count is the size of
|
||||||
|
* the group, which is a fact about the group rather than about who is
|
||||||
|
* in it, and the web screen shows the same total.
|
||||||
|
*/
|
||||||
|
private function response(Group $group, User $actor): GroupResource
|
||||||
|
{
|
||||||
|
return new GroupResource($group->loadCount('members')->load([
|
||||||
|
'members' => fn (BelongsToMany $members) => $members
|
||||||
|
->whereIn('users.id', $this->scope->clients($actor)->select('id')),
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Platform\Announcements\Events;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single message a package wants put in front of staff.
|
||||||
|
*
|
||||||
|
* Shown twice, from one source: a band across the top of the dashboard,
|
||||||
|
* and an icon beside the notification bell that opens the same words on
|
||||||
|
* every other page. One event rather than two because "the same message"
|
||||||
|
* is the requirement — two props would drift the day somebody edits one.
|
||||||
|
*
|
||||||
|
* Not a widget, on purpose. The widget grid is a closed list of keys that
|
||||||
|
* dashboard.tsx renders one by one, and each viewer arranges it — so a
|
||||||
|
* message that matters would sit wherever somebody happened to drag it,
|
||||||
|
* or under a fold, or switched off. A band above the grid is seen without
|
||||||
|
* competing with the columns for space.
|
||||||
|
*
|
||||||
|
* **Core knows nothing about what it says.** Title, body, the label on the
|
||||||
|
* button and where the button goes all come from the listener. The first
|
||||||
|
* caller is the hosted edition telling a free instance what a paid plan
|
||||||
|
* would give it, which is commercial copy belonging to one offering and
|
||||||
|
* has no place in the public repository.
|
||||||
|
*
|
||||||
|
* One at a time, deliberately. A dashboard that can accumulate banners
|
||||||
|
* accumulates them, and the second one is what teaches people to skip the
|
||||||
|
* first. A listener that finds one already set should leave it alone
|
||||||
|
* rather than overwrite it.
|
||||||
|
*/
|
||||||
|
class ResolvingAnnouncement
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array{title: string, body: string, action_label: string|null, action_url: string|null, tone: string}|null
|
||||||
|
*/
|
||||||
|
public ?array $announcement = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
/** Whether the viewer is a staff account. */
|
||||||
|
public readonly bool $isStaff,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `tone` picks the accent the band is drawn in. Two values, because
|
||||||
|
* two is what the difference is worth: `info` for something worth
|
||||||
|
* knowing, `warning` for something worth acting on. Anything else
|
||||||
|
* falls back to `info` rather than rendering unstyled.
|
||||||
|
*/
|
||||||
|
public function show(string $title, string $body, ?string $actionLabel = null, ?string $actionUrl = null, string $tone = 'info'): void
|
||||||
|
{
|
||||||
|
if ($this->announcement !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->announcement = [
|
||||||
|
'title' => $title,
|
||||||
|
'body' => $body,
|
||||||
|
'action_label' => $actionLabel,
|
||||||
|
'action_url' => $actionUrl,
|
||||||
|
'tone' => in_array($tone, ['info', 'warning'], true) ? $tone : 'info',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Platform\Branding;
|
||||||
|
|
||||||
|
use App\Modules\Api\Events\RegisteringApiModules;
|
||||||
|
use App\Modules\Files\Thumbnails\Events\RenderingImage;
|
||||||
|
use App\Modules\Files\Thumbnails\Events\ResolvingImageRendering;
|
||||||
|
use App\Modules\Platform\Branding\Models\BrandingSetting;
|
||||||
|
use App\Modules\Platform\Branding\Watermark\ThumbnailWatermarker;
|
||||||
|
use App\Modules\Platform\Capabilities\Capability;
|
||||||
|
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An installation dressed in its own logo, and a watermark on what its
|
||||||
|
* clients and visitors see.
|
||||||
|
*
|
||||||
|
* Lived in the private cloud-modules package until 2026-08-28, gated
|
||||||
|
* Cloud-only. That was a fact about where the code had been written
|
||||||
|
* rather than about who should have it: nothing here needs a hosted
|
||||||
|
* platform, and a self-hosted installation wanting its own mark on the
|
||||||
|
* pages it serves is the ordinary case rather than the exotic one.
|
||||||
|
*
|
||||||
|
* **What did not move.** Hiding the "Powered by ProjectSend" line is the
|
||||||
|
* white-label half, and white-labelling is one of the things a hosted
|
||||||
|
* customer pays for. Its listener still ships only in cloud-modules, so
|
||||||
|
* an installation without that package has no code able to answer "hide
|
||||||
|
* it" — flipping an edition variable buys nothing. This module carries
|
||||||
|
* the column, because it owns the table, and no way to set it.
|
||||||
|
*
|
||||||
|
* **What a plan withholds is a separate question.** A free hosted plan
|
||||||
|
* has branding subtracted from its environment, which the capability
|
||||||
|
* registry applies; see PROJECTSEND_CAPABILITIES_DISABLED. The row is
|
||||||
|
* never deleted by that, so a plan that lapses and resumes restores what
|
||||||
|
* the customer had rather than asking them to build it again.
|
||||||
|
*/
|
||||||
|
class BrandingServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
// Registered unconditionally. The listeners ask whether branding
|
||||||
|
// is available each time they fire, so an edition change, or a
|
||||||
|
// plan change that subtracts the capability, takes effect on the
|
||||||
|
// next request rather than needing a restart.
|
||||||
|
// Through the module registry rather than routes/api.php, so the
|
||||||
|
// URL stays /api/v1/modules/branding/* exactly as it was when this
|
||||||
|
// shipped in a package. A caller's integration does not care which
|
||||||
|
// repository the code moved to, and moving the path would be a
|
||||||
|
// breaking change dressed up as a refactor.
|
||||||
|
Event::listen(RegisteringApiModules::class, function (RegisteringApiModules $event): void {
|
||||||
|
$event->register(
|
||||||
|
slug: 'branding',
|
||||||
|
routes: __DIR__.'/api-routes.php',
|
||||||
|
capability: Capability::Branding->value,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Event::listen(RenderingImage::class, [ThumbnailWatermarker::class, 'handle']);
|
||||||
|
Event::listen(ResolvingImageRendering::class, [ThumbnailWatermarker::class, 'resolve']);
|
||||||
|
|
||||||
|
// Gated like the screen that sets it. Without the capability there
|
||||||
|
// is no branding page to reach, so a row that outlived a gate
|
||||||
|
// change — a downgraded plan, a restored backup — would put
|
||||||
|
// somebody's logo on every page of an installation offering no way
|
||||||
|
// to see it, change it or take it off. Evaluated per request, so
|
||||||
|
// uploading a logo or changing plan takes effect on the next one.
|
||||||
|
Inertia::share('branding', fn (): array => [
|
||||||
|
'logo_url' => $this->available()
|
||||||
|
? BrandingSetting::query()->first()?->logoUrl()
|
||||||
|
: null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function available(): bool
|
||||||
|
{
|
||||||
|
return $this->app->make(CapabilityRegistry::class)->has(Capability::Branding);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Platform\Branding\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Routing\Controller;
|
||||||
|
use App\Modules\Platform\Branding\Models\BrandingSetting;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This installation's branding — its logo and its thumbnail watermark —
|
||||||
|
* over the host's API.
|
||||||
|
*
|
||||||
|
* Read-only on purpose: uploading an image is a multipart flow with
|
||||||
|
* content-sniffing rules that only make sense with a file picker in front
|
||||||
|
* of them (see the web controller), and nothing has asked to automate it.
|
||||||
|
* An integration that wants to render this installation's branding — an
|
||||||
|
* email builder, a status page — only needs to read it.
|
||||||
|
*
|
||||||
|
* This controller knows nothing about authentication, rate limiting,
|
||||||
|
* error formats or which edition it is running in. The host supplies all
|
||||||
|
* of that: the module is registered through RegisteringApiModules, which
|
||||||
|
* mounts these routes inside the API's own auth stack and behind
|
||||||
|
* `capability:branding.customize`. That is the whole point of the seam —
|
||||||
|
* a package declares paths and controllers, and nothing else.
|
||||||
|
*/
|
||||||
|
class BrandingController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get this installation's logo.
|
||||||
|
*
|
||||||
|
* Returns a null `logo_url` when no logo has been uploaded, which is
|
||||||
|
* the normal state rather than an error.
|
||||||
|
*/
|
||||||
|
public function show(): JsonResponse
|
||||||
|
{
|
||||||
|
$setting = BrandingSetting::query()->first();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => [
|
||||||
|
'logo_url' => $setting?->logoUrl(),
|
||||||
|
'updated_at' => $setting?->updated_at?->toIso8601String(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the watermark applied to this installation's rendered images.
|
||||||
|
*
|
||||||
|
* Applies to the thumbnails and previews clients and anonymous
|
||||||
|
* public visitors see; what this installation's own staff see is
|
||||||
|
* never marked. The stored files, and every download of them, are
|
||||||
|
* never altered either way.
|
||||||
|
*
|
||||||
|
* `enabled` is false whenever no watermark is being drawn, including
|
||||||
|
* when the toggle is on but its image has since been removed —
|
||||||
|
* it answers "is this installation watermarking?", not "which way is
|
||||||
|
* the switch pointing?". `position` is one of `top-left`,
|
||||||
|
* `top-center`, `top-right`, `middle-left`, `center`, `middle-right`,
|
||||||
|
* `bottom-left`, `bottom-center`, `bottom-right`; `size` is the
|
||||||
|
* percentage of the image the mark is fitted into, and `opacity`
|
||||||
|
* a percentage.
|
||||||
|
*
|
||||||
|
* Read-only, same as the logo: an integration rendering its own
|
||||||
|
* derivative images can reproduce the mark, but uploading one is a
|
||||||
|
* multipart flow with content-sniffing rules that only make sense
|
||||||
|
* behind a file picker.
|
||||||
|
*/
|
||||||
|
public function watermark(): JsonResponse
|
||||||
|
{
|
||||||
|
// Falls back to an unsaved instance so an installation that has
|
||||||
|
// never opened the branding screen answers with the defaults it
|
||||||
|
// would start from, rather than a payload of nulls a caller would
|
||||||
|
// have to invent its own meaning for.
|
||||||
|
$setting = BrandingSetting::query()->first() ?? new BrandingSetting;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => [
|
||||||
|
'enabled' => $setting->watermarksThumbnails(),
|
||||||
|
'image_url' => $setting->watermarkUrl(),
|
||||||
|
'position' => $setting->watermark_position->value,
|
||||||
|
'size' => $setting->watermark_size,
|
||||||
|
'opacity' => $setting->watermark_opacity,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Platform\Branding\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use App\Modules\Files\Thumbnails\Events\ImageRenderingChanged;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Routing\Controller;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
use App\Modules\Platform\Branding\Models\BrandingSetting;
|
||||||
|
use App\Modules\Platform\Branding\Watermark\WatermarkPosition;
|
||||||
|
use App\Modules\Platform\Branding\Watermark\WatermarkSample;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two site-wide pieces of artwork: the logo shown in the sidebar in
|
||||||
|
* place of the default icon, and the mark stamped onto the thumbnails
|
||||||
|
* and previews clients and public visitors see. Every route here is gated end-to-end by the host's
|
||||||
|
* `capability:branding.customize` middleware (see routes.php) — this
|
||||||
|
* module has no idea what edition it's running in, it just trusts the
|
||||||
|
* gate.
|
||||||
|
*/
|
||||||
|
class BrandingController extends Controller
|
||||||
|
{
|
||||||
|
public function edit(): Response
|
||||||
|
{
|
||||||
|
// An unsaved instance rather than `current()`: rendering a settings
|
||||||
|
// screen must not write a row, and the model carries the same
|
||||||
|
// defaults the table does (see its $attributes) so the form starts
|
||||||
|
// on the values a first save would produce.
|
||||||
|
$setting = BrandingSetting::query()->first() ?? new BrandingSetting;
|
||||||
|
|
||||||
|
return Inertia::render('branding/edit', [
|
||||||
|
'logo_url' => $setting->logoUrl(),
|
||||||
|
// Read, never written here. Hiding attribution is the
|
||||||
|
// white-label half and stays a hosted feature: the switch is
|
||||||
|
// rendered only where Capability::AttributionHide is held, and
|
||||||
|
// the route that saves it is registered by cloud-modules. Core
|
||||||
|
// carries the column because it owns the table, and carries no
|
||||||
|
// way to set it.
|
||||||
|
'hide_attribution' => $setting->hide_attribution,
|
||||||
|
'watermark' => [
|
||||||
|
'enabled' => $setting->watermark_enabled,
|
||||||
|
'image_url' => $setting->watermarkUrl(),
|
||||||
|
'position' => $setting->watermark_position->value,
|
||||||
|
'size' => $setting->watermark_size,
|
||||||
|
'opacity' => $setting->watermark_opacity,
|
||||||
|
],
|
||||||
|
'watermark_positions' => WatermarkPosition::values(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'logo' => ['required', 'image', 'max:2048'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @var UploadedFile $upload */
|
||||||
|
$upload = $validated['logo'];
|
||||||
|
|
||||||
|
$setting = BrandingSetting::current();
|
||||||
|
|
||||||
|
if ($setting->logo_path !== null) {
|
||||||
|
Storage::disk('public')->delete($setting->logo_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$setting->update(['logo_path' => $this->storeImage($upload)]);
|
||||||
|
|
||||||
|
return back()->with('success', __('Logo updated.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(): RedirectResponse
|
||||||
|
{
|
||||||
|
$setting = BrandingSetting::query()->first();
|
||||||
|
|
||||||
|
if ($setting?->logo_path !== null) {
|
||||||
|
Storage::disk('public')->delete($setting->logo_path);
|
||||||
|
$setting->update(['logo_path' => null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', __('Logo removed.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the whole watermark form at once — toggle, artwork, placement,
|
||||||
|
* scale and opacity. One endpoint rather than one per field because
|
||||||
|
* they are only meaningful together: turning it on without an image,
|
||||||
|
* or changing the size without seeing the position, are not states
|
||||||
|
* worth being able to save.
|
||||||
|
*/
|
||||||
|
public function updateWatermark(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$setting = BrandingSetting::current();
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
// The image is optional on every save *except* the one that
|
||||||
|
// turns watermarking on with nothing stored yet — otherwise
|
||||||
|
// adjusting the opacity would mean re-picking the file each
|
||||||
|
// time. `exclude_if` keeps the rule off the payload entirely
|
||||||
|
// rather than requiring a re-upload.
|
||||||
|
'image' => [
|
||||||
|
$setting->watermark_path === null && $request->boolean('enabled') ? 'required' : 'nullable',
|
||||||
|
'image',
|
||||||
|
'max:2048',
|
||||||
|
],
|
||||||
|
'enabled' => ['required', 'boolean'],
|
||||||
|
'position' => ['required', Rule::in(WatermarkPosition::values())],
|
||||||
|
'size' => ['required', 'integer', 'min:5', 'max:100'],
|
||||||
|
'opacity' => ['required', 'integer', 'min:1', 'max:100'],
|
||||||
|
], [
|
||||||
|
'image.required' => __('Choose the image to use as the watermark.'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$attributes = [
|
||||||
|
'watermark_enabled' => (bool) $validated['enabled'],
|
||||||
|
'watermark_position' => $validated['position'],
|
||||||
|
'watermark_size' => (int) $validated['size'],
|
||||||
|
'watermark_opacity' => (int) $validated['opacity'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (($validated['image'] ?? null) instanceof UploadedFile) {
|
||||||
|
if ($setting->watermark_path !== null) {
|
||||||
|
Storage::disk('public')->delete($setting->watermark_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$attributes['watermark_path'] = $this->storeImage($validated['image']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$setting->update($attributes);
|
||||||
|
|
||||||
|
$this->forgetRenderedImages();
|
||||||
|
|
||||||
|
return back()->with('success', __('Watermark settings saved.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stand-in photograph with the mark drawn on it, so the settings
|
||||||
|
* screen can show what a client will actually see. Staff surfaces are
|
||||||
|
* never watermarked, so without this an administrator has no way to
|
||||||
|
* judge their own settings short of signing in as a client.
|
||||||
|
*
|
||||||
|
* Takes placement, scale and opacity from the *query string* rather
|
||||||
|
* than from the saved row: the point is to answer "what would this
|
||||||
|
* look like" while the form is still being adjusted. The artwork
|
||||||
|
* itself has to be the stored one — an unsaved file lives in the
|
||||||
|
* browser, not on this server — which is why the screen tells you to
|
||||||
|
* save after choosing a new image.
|
||||||
|
*
|
||||||
|
* Drawn by the same WatermarkPainter that renders the real thing, so
|
||||||
|
* the sample cannot flatter the settings.
|
||||||
|
*/
|
||||||
|
public function watermarkSample(Request $request, WatermarkSample $sample): SymfonyResponse
|
||||||
|
{
|
||||||
|
$setting = BrandingSetting::query()->first();
|
||||||
|
$markPath = $setting?->watermark_path;
|
||||||
|
|
||||||
|
// Not 404 for "you have not uploaded one yet" — the screen asks for
|
||||||
|
// this image before there is anything to draw, and a broken <img>
|
||||||
|
// is a worse answer than none. It hides the sample instead.
|
||||||
|
abort_if($markPath === null || ! Storage::disk('public')->exists($markPath), 404);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'position' => ['required', Rule::in(WatermarkPosition::values())],
|
||||||
|
'size' => ['required', 'integer', 'min:5', 'max:100'],
|
||||||
|
'opacity' => ['required', 'integer', 'min:1', 'max:100'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$image = $sample->render(
|
||||||
|
Storage::disk('public')->path($markPath),
|
||||||
|
WatermarkPosition::from($validated['position']),
|
||||||
|
(int) $validated['size'],
|
||||||
|
(int) $validated['opacity'],
|
||||||
|
);
|
||||||
|
|
||||||
|
return new SymfonyResponse($image->toString('image/png'), 200, [
|
||||||
|
'Content-Type' => 'image/png',
|
||||||
|
// Every request has different parameters and the artwork behind
|
||||||
|
// it can be replaced at any moment; a cached sample would show
|
||||||
|
// an administrator the settings they had a minute ago.
|
||||||
|
'Cache-Control' => 'no-store, max-age=0',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop the artwork and switch watermarking off with it — an "enabled"
|
||||||
|
* that no image backs is not a state this screen can leave behind.
|
||||||
|
*/
|
||||||
|
public function destroyWatermark(): RedirectResponse
|
||||||
|
{
|
||||||
|
$setting = BrandingSetting::query()->first();
|
||||||
|
|
||||||
|
if ($setting === null) {
|
||||||
|
return back();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($setting->watermark_path !== null) {
|
||||||
|
Storage::disk('public')->delete($setting->watermark_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$setting->update([
|
||||||
|
'watermark_path' => null,
|
||||||
|
'watermark_enabled' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->forgetRenderedImages();
|
||||||
|
|
||||||
|
return back()->with('success', __('Watermark removed.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The host caches every image it renders and never revisits it, so
|
||||||
|
* without this a settings change would only reach files nobody has
|
||||||
|
* looked at yet.
|
||||||
|
*
|
||||||
|
* Dispatched by *string* class name: the host's event class cannot be
|
||||||
|
* constructed from here (this package builds with no host present),
|
||||||
|
* and it carries no payload precisely so that it doesn't have to be.
|
||||||
|
* With no host listening this is an inert no-op.
|
||||||
|
*/
|
||||||
|
private function forgetRenderedImages(): void
|
||||||
|
{
|
||||||
|
Event::dispatch(new ImageRenderingChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The extension comes from the *content*, never from the uploaded
|
||||||
|
* filename. This disk is web-served (public/storage is symlinked into
|
||||||
|
* the document root and nginx serves it as a static file), and the
|
||||||
|
* `image` rule only inspects the sniffed content — so a GIF whose
|
||||||
|
* filename says ".html" passes validation and would then be stored,
|
||||||
|
* and served back, as text/html: stored XSS on this app's own origin,
|
||||||
|
* from any account that can reach this page. guessExtension() is
|
||||||
|
* derived from the same sniffed mime type the validator just
|
||||||
|
* accepted, so the two can no longer disagree.
|
||||||
|
*/
|
||||||
|
private function storeImage(UploadedFile $upload): string
|
||||||
|
{
|
||||||
|
$extension = $upload->guessExtension() ?? 'bin';
|
||||||
|
|
||||||
|
$path = $upload->storeAs('branding', Str::uuid().'.'.$extension, 'public');
|
||||||
|
|
||||||
|
if ($path === false) {
|
||||||
|
throw new RuntimeException('Could not store the uploaded image.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Platform\Branding\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use App\Modules\Platform\Branding\Watermark\WatermarkPosition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single-row settings table — see the migration's comment for why no
|
||||||
|
* tenant/owner column is needed.
|
||||||
|
*
|
||||||
|
* @property int $id
|
||||||
|
* @property string|null $logo_path
|
||||||
|
* @property bool $watermark_enabled
|
||||||
|
* @property string|null $watermark_path
|
||||||
|
* @property WatermarkPosition $watermark_position
|
||||||
|
* @property int $watermark_size
|
||||||
|
* @property int $watermark_opacity
|
||||||
|
* @property bool $hide_attribution
|
||||||
|
* @property \Illuminate\Support\Carbon|null $created_at
|
||||||
|
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||||
|
*/
|
||||||
|
class BrandingSetting extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'branding_settings';
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'watermark_enabled' => 'boolean',
|
||||||
|
'watermark_position' => WatermarkPosition::class,
|
||||||
|
'watermark_size' => 'integer',
|
||||||
|
'watermark_opacity' => 'integer',
|
||||||
|
'hide_attribution' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the migration's column defaults, so an unsaved instance
|
||||||
|
* answers the same as a freshly created row would. That is what lets
|
||||||
|
* the settings screen render `new BrandingSetting` on an install that
|
||||||
|
* has never touched branding, instead of either creating a row on a
|
||||||
|
* GET or restating these numbers a second time in the controller.
|
||||||
|
*/
|
||||||
|
protected $attributes = [
|
||||||
|
'watermark_enabled' => false,
|
||||||
|
'watermark_position' => 'bottom-right',
|
||||||
|
'watermark_size' => 30,
|
||||||
|
'watermark_opacity' => 60,
|
||||||
|
'hide_attribution' => false,
|
||||||
|
];
|
||||||
|
|
||||||
|
public static function current(): self
|
||||||
|
{
|
||||||
|
return static::query()->firstOrCreate([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logoUrl(): ?string
|
||||||
|
{
|
||||||
|
return $this->logo_path === null ? null : Storage::disk('public')->url($this->logo_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function watermarkUrl(): ?string
|
||||||
|
{
|
||||||
|
return $this->watermark_path === null ? null : Storage::disk('public')->url($this->watermark_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The artwork to stamp on a thumbnail being rendered right now, or
|
||||||
|
* null when this installation is not watermarking.
|
||||||
|
*
|
||||||
|
* Phrased as "which image, if any" rather than as a boolean because
|
||||||
|
* the toggle alone is not enough to act on: removing the image
|
||||||
|
* leaves the toggle standing, and a row restored from a backup can
|
||||||
|
* carry an `enabled` that its file no longer backs. Answering both
|
||||||
|
* halves at once means a caller cannot check one and use the other.
|
||||||
|
*/
|
||||||
|
public function activeWatermarkPath(): ?string
|
||||||
|
{
|
||||||
|
return $this->watermark_enabled ? $this->watermark_path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function watermarksThumbnails(): bool
|
||||||
|
{
|
||||||
|
return $this->activeWatermarkPath() !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Platform\Branding\Watermark;
|
||||||
|
|
||||||
|
use claviska\SimpleImage;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use App\Modules\Files\Thumbnails\Events\RenderingImage;
|
||||||
|
use App\Modules\Files\Thumbnails\Events\ResolvingImageRendering;
|
||||||
|
use App\Modules\Files\Thumbnails\ImageAudience;
|
||||||
|
use App\Modules\Platform\Capabilities\Capability;
|
||||||
|
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||||
|
use App\Modules\Platform\Branding\Models\BrandingSetting;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stamps the configured mark onto an image the host is about to render,
|
||||||
|
* for both of the host's rendering hooks:
|
||||||
|
*
|
||||||
|
* - `RenderingImage` — the drawing itself, on a thumbnail or a preview.
|
||||||
|
* - `ResolvingImageRendering` — the host asking, before it decodes
|
||||||
|
* anything, whether this viewer has to be served a rendering at all.
|
||||||
|
* Answering yes is what turns a client's preview from the stored file
|
||||||
|
* into a watermarked copy; leaving it alone is what keeps previews
|
||||||
|
* free on installations that do not watermark.
|
||||||
|
*
|
||||||
|
* Both events are duck-typed (`object`, `$event->audience`) rather than
|
||||||
|
* imported: this package is built and tested with no host application
|
||||||
|
* present, so `use App\Modules\Files\...` would not resolve. See the
|
||||||
|
* host's own docblocks and docs/extension-points-architecture.md in the
|
||||||
|
* host repo.
|
||||||
|
*
|
||||||
|
* Nothing here throws. The host deliberately does not wrap listeners in
|
||||||
|
* a try/catch — a listener that fails takes the request down with it —
|
||||||
|
* and for a decoration that is the wrong trade: an unreadable or
|
||||||
|
* since-deleted watermark file must degrade to a plain image, not to a
|
||||||
|
* broken one on every listing row in the app. Failures are logged so the
|
||||||
|
* setting can be fixed rather than silently doing nothing.
|
||||||
|
*
|
||||||
|
* The one asymmetry worth knowing: `wouldMark()` and `apply()` ask the
|
||||||
|
* same question a moment apart, so a watermark switched off between the
|
||||||
|
* two would yield a rendered-but-unmarked preview. That is a plain copy
|
||||||
|
* of the original at preview size — the correct content, reached by a
|
||||||
|
* slower path — and it self-corrects on the next request, since saving
|
||||||
|
* the setting flushes the cache anyway.
|
||||||
|
*/
|
||||||
|
class ThumbnailWatermarker
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly WatermarkPainter $painter,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The audience whose images go unmarked: this installation's own
|
||||||
|
* staff. Watermarking exists for the copies that leave the building —
|
||||||
|
* clients in the portal, anonymous visitors on a public listing — and
|
||||||
|
* stamping the staff file manager and file editor too would only
|
||||||
|
* obscure the originals from the people who uploaded them.
|
||||||
|
*/
|
||||||
|
public function handle(RenderingImage $event): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if ($event->audience === ImageAudience::Staff) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->apply($event->image);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Log::warning('Could not watermark a rendered image: '.$exception->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an image must be rendered rather than served as stored.
|
||||||
|
* Only ever sets the flag — never clears it, since another listener's
|
||||||
|
* yes is not this one's to overrule.
|
||||||
|
*/
|
||||||
|
public function resolve(ResolvingImageRendering $event): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if ($event->audience === ImageAudience::Staff) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->wouldMark()) {
|
||||||
|
$event->required = true;
|
||||||
|
}
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
// Leaves the host on its fast path, which serves the original
|
||||||
|
// — the behaviour of every installation that does not
|
||||||
|
// watermark, and never a failed request.
|
||||||
|
Log::warning('Could not decide whether to watermark a preview: '.$exception->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether there is a mark to draw at all: switched on, with an image
|
||||||
|
* that is still on disk. Deliberately the same three conditions
|
||||||
|
* apply() checks, so the host is never told to render something this
|
||||||
|
* listener would then decline to touch.
|
||||||
|
*/
|
||||||
|
private function wouldMark(): bool
|
||||||
|
{
|
||||||
|
if (! $this->capabilityAvailable()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$markPath = BrandingSetting::query()->first()?->activeWatermarkPath();
|
||||||
|
|
||||||
|
return $markPath !== null && Storage::disk('public')->exists($markPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function apply(SimpleImage $canvas): void
|
||||||
|
{
|
||||||
|
if (! $this->capabilityAvailable()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$setting = BrandingSetting::query()->first();
|
||||||
|
|
||||||
|
if ($setting === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$markPath = $setting->activeWatermarkPath();
|
||||||
|
|
||||||
|
if ($markPath === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$disk = Storage::disk('public');
|
||||||
|
|
||||||
|
if (! $disk->exists($markPath)) {
|
||||||
|
Log::warning('Watermarking is on but its image is missing from disk: '.$markPath);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->painter->paint(
|
||||||
|
$canvas,
|
||||||
|
$disk->path($markPath),
|
||||||
|
$setting->watermark_position,
|
||||||
|
$setting->watermark_size,
|
||||||
|
$setting->watermark_opacity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watermarking is part of the Cloud-exclusive Branding capability, so
|
||||||
|
* it renders nothing where that capability is absent — the same
|
||||||
|
* "no capability, no output" stance the host takes for Custom Assets.
|
||||||
|
* The capability registry holds the one definition of
|
||||||
|
* the check; the shared logo answers to it too.
|
||||||
|
*/
|
||||||
|
private function capabilityAvailable(): bool
|
||||||
|
{
|
||||||
|
return app(CapabilityRegistry::class)->has(Capability::Branding);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Platform\Branding\Watermark;
|
||||||
|
|
||||||
|
use claviska\SimpleImage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws the mark onto a canvas. The only place that decides how a
|
||||||
|
* watermark is positioned, scaled and blended.
|
||||||
|
*
|
||||||
|
* Extracted so the settings screen's live sample and the real rendering
|
||||||
|
* pipeline cannot drift: an administrator tuning the size slider against
|
||||||
|
* a preview drawn by *different* code would be tuning against a lie, and
|
||||||
|
* the lie would be discovered on a client's screen. The sample and the
|
||||||
|
* thumbnail a client actually gets are the same function, called with
|
||||||
|
* different arguments.
|
||||||
|
*
|
||||||
|
* Takes its settings as arguments rather than reading BrandingSetting,
|
||||||
|
* for the same reason: the sample renders values that are still unsaved
|
||||||
|
* in a form.
|
||||||
|
*/
|
||||||
|
class WatermarkPainter
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The mark's clearance from the edge it is anchored to, as a fraction
|
||||||
|
* of the canvas's shorter side. A fraction rather than a pixel count
|
||||||
|
* so a 300px thumbnail and a 1600px preview look like the same
|
||||||
|
* design. Not a setting: the difference between "flush against the
|
||||||
|
* edge" and "a few pixels in" is the whole of the visual judgement,
|
||||||
|
* and there is no useful second answer to offer an administrator.
|
||||||
|
*/
|
||||||
|
private const EDGE_INSET_RATIO = 0.04;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $markPath an absolute local path to the artwork
|
||||||
|
* @param int $size percentage of the canvas the mark is fitted into
|
||||||
|
* @param int $opacity percentage
|
||||||
|
*/
|
||||||
|
public function paint(
|
||||||
|
SimpleImage $canvas,
|
||||||
|
string $markPath,
|
||||||
|
WatermarkPosition $position,
|
||||||
|
int $size,
|
||||||
|
int $opacity,
|
||||||
|
): void {
|
||||||
|
$width = $canvas->getWidth();
|
||||||
|
$height = $canvas->getHeight();
|
||||||
|
|
||||||
|
// A box that is `size`% of *both* dimensions, so the setting reads
|
||||||
|
// the same on a portrait and a landscape canvas and a wide mark
|
||||||
|
// can never overflow a narrow one.
|
||||||
|
$mark = new SimpleImage($markPath);
|
||||||
|
|
||||||
|
$scale = min(
|
||||||
|
$width * $size / 100 / $mark->getWidth(),
|
||||||
|
$height * $size / 100 / $mark->getHeight(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Scaled by hand rather than with bestFit(), which returns early
|
||||||
|
// when the image already fits: a small logo would then keep its
|
||||||
|
// native size and the size setting would silently do nothing above
|
||||||
|
// whatever percentage happened to match it. Enlarging a small mark
|
||||||
|
// is soft, but it is what was asked for — a control that only works
|
||||||
|
// in one direction is worse than a slightly blurry one.
|
||||||
|
$mark->resize(
|
||||||
|
max(1, (int) round($mark->getWidth() * $scale)),
|
||||||
|
max(1, (int) round($mark->getHeight() * $scale)),
|
||||||
|
);
|
||||||
|
|
||||||
|
$inset = max(1, (int) round(min($width, $height) * self::EDGE_INSET_RATIO));
|
||||||
|
|
||||||
|
$canvas->overlay(
|
||||||
|
$mark,
|
||||||
|
$position->anchor(),
|
||||||
|
$opacity / 100,
|
||||||
|
$inset,
|
||||||
|
$inset,
|
||||||
|
// Offsets measured inward from whichever edge the anchor names,
|
||||||
|
// so one inset value works for all eight edge positions instead
|
||||||
|
// of needing its sign flipped per corner. Centre ignores them.
|
||||||
|
calculateOffsetFromEdge: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Platform\Branding\Watermark;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the watermark sits on a thumbnail: the four corners, the
|
||||||
|
* midpoint of each of the four edges, and the centre.
|
||||||
|
*
|
||||||
|
* The stored values are this module's own vocabulary, deliberately not
|
||||||
|
* SimpleImage's anchor strings — `anchor()` translates. SimpleImage
|
||||||
|
* decides an anchor by substring-matching 'top'/'bottom'/'left'/'right',
|
||||||
|
* so 'center' means "neither" on an axis and the two vocabularies happen
|
||||||
|
* to overlap today; storing its spelling in our database would make that
|
||||||
|
* coincidence a schema commitment.
|
||||||
|
*/
|
||||||
|
enum WatermarkPosition: string
|
||||||
|
{
|
||||||
|
case TopLeft = 'top-left';
|
||||||
|
case TopCenter = 'top-center';
|
||||||
|
case TopRight = 'top-right';
|
||||||
|
case MiddleLeft = 'middle-left';
|
||||||
|
case Center = 'center';
|
||||||
|
case MiddleRight = 'middle-right';
|
||||||
|
case BottomLeft = 'bottom-left';
|
||||||
|
case BottomCenter = 'bottom-center';
|
||||||
|
case BottomRight = 'bottom-right';
|
||||||
|
|
||||||
|
public function anchor(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::TopLeft => 'top left',
|
||||||
|
self::TopCenter => 'top',
|
||||||
|
self::TopRight => 'top right',
|
||||||
|
self::MiddleLeft => 'left',
|
||||||
|
self::Center => 'center',
|
||||||
|
self::MiddleRight => 'right',
|
||||||
|
self::BottomLeft => 'bottom left',
|
||||||
|
self::BottomCenter => 'bottom',
|
||||||
|
self::BottomRight => 'bottom right',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function values(): array
|
||||||
|
{
|
||||||
|
return array_map(fn (self $case): string => $case->value, self::cases());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Platform\Branding\Watermark;
|
||||||
|
|
||||||
|
use claviska\SimpleImage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stand-in photograph the settings screen draws the mark on, so an
|
||||||
|
* administrator can judge placement, scale and opacity without going to
|
||||||
|
* find a client account and a real file.
|
||||||
|
*
|
||||||
|
* Drawn rather than shipped as an asset: a stock photograph would be a
|
||||||
|
* licensing question and a binary in a git repository, and would only
|
||||||
|
* ever exercise whatever tones that one picture happens to contain. This
|
||||||
|
* is built to answer the question the sample exists for — "will my mark
|
||||||
|
* still read?" — with a full dark-to-light ramp under it, plus a couple
|
||||||
|
* of hard edges, so a too-transparent or too-small mark is obvious
|
||||||
|
* against at least one part of it.
|
||||||
|
*/
|
||||||
|
class WatermarkSample
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Roughly the proportions of a landscape photograph, and about the
|
||||||
|
* size the settings screen shows it at — big enough to judge, small
|
||||||
|
* enough to re-render on every keystroke.
|
||||||
|
*/
|
||||||
|
private const WIDTH = 480;
|
||||||
|
|
||||||
|
private const HEIGHT = 300;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly WatermarkPainter $painter,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $markPath an absolute local path to the artwork
|
||||||
|
*/
|
||||||
|
public function render(string $markPath, WatermarkPosition $position, int $size, int $opacity): SimpleImage
|
||||||
|
{
|
||||||
|
$canvas = $this->backdrop();
|
||||||
|
|
||||||
|
$this->painter->paint($canvas, $markPath, $position, $size, $opacity);
|
||||||
|
|
||||||
|
return $canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function backdrop(): SimpleImage
|
||||||
|
{
|
||||||
|
$canvas = (new SimpleImage())->fromNew(self::WIDTH, self::HEIGHT, '#1f2937');
|
||||||
|
|
||||||
|
// A left-to-right ramp, one column at a time — GD has no gradient
|
||||||
|
// primitive, and 480 lines is imperceptible next to the encode
|
||||||
|
// that follows.
|
||||||
|
for ($x = 0; $x < self::WIDTH; $x++) {
|
||||||
|
$shade = (int) round(24 + ($x / self::WIDTH) * 210);
|
||||||
|
|
||||||
|
// alpha 1 is *opaque* in SimpleImage's vocabulary — 0 is the
|
||||||
|
// fully transparent one ('transparent' normalizes to alpha 0).
|
||||||
|
// Getting that backwards draws the whole ramp invisibly, which
|
||||||
|
// no assertion about the mark itself would ever have caught.
|
||||||
|
$canvas->line($x, 0, $x, self::HEIGHT, [
|
||||||
|
'red' => $shade, 'green' => $shade, 'blue' => $shade, 'alpha' => 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two blocks at the extremes of the ramp, so every corner and edge
|
||||||
|
// the position picker offers has both a light and a dark
|
||||||
|
// neighbourhood somewhere near it.
|
||||||
|
$this->fill($canvas, 0, 0, (int) (self::WIDTH * 0.28), (int) (self::HEIGHT * 0.34), '#f8fafc');
|
||||||
|
$this->fill($canvas, (int) (self::WIDTH * 0.68), (int) (self::HEIGHT * 0.62), self::WIDTH, self::HEIGHT, '#0b1120');
|
||||||
|
|
||||||
|
return $canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A filled rectangle, drawn as a run of vertical lines.
|
||||||
|
*
|
||||||
|
* `rectangle(..., 'filled')` does exist and would be the obvious call,
|
||||||
|
* but SimpleImage's own docblock types that parameter `integer|array`,
|
||||||
|
* so passing its documented magic string fails static analysis. Lines
|
||||||
|
* cost nothing here and keep the analyser honest instead of teaching
|
||||||
|
* it to ignore a whole category of argument-type error in this file.
|
||||||
|
*
|
||||||
|
* @param string|array<string, int> $color
|
||||||
|
*/
|
||||||
|
private function fill(SimpleImage $canvas, int $x1, int $y1, int $x2, int $y2, string|array $color): void
|
||||||
|
{
|
||||||
|
for ($x = $x1; $x <= $x2; $x++) {
|
||||||
|
$canvas->line($x, $y1, $x, $y2, $color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use App\Modules\Platform\Branding\Http\Controllers\Api\BrandingController;
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Branding — module API routes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Mounted by the host at /api/v1/modules/branding, inside the API's auth
|
||||||
|
| stack (bearer token, active staff account) and behind
|
||||||
|
| `capability:branding.customize`. None of that is restated here — the host
|
||||||
|
| applies it, which is why these are plain relative paths.
|
||||||
|
|
|
||||||
|
| `token-can:` names a permission from the *host's* vocabulary. A module
|
||||||
|
| cannot invent ability strings: they have to reach the token-issuance UI
|
||||||
|
| and the reserved-namespace invariant, so they belong in the host's
|
||||||
|
| Permission enum. `edit_settings` is the same key the web branding routes
|
||||||
|
| use, so the API boundary mirrors the web one rather than inventing a
|
||||||
|
| second answer to "who may see the logo".
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
Route::get('logo', [BrandingController::class, 'show'])
|
||||||
|
->middleware('token-can:edit_settings')
|
||||||
|
->name('logo.show');
|
||||||
|
|
||||||
|
Route::get('watermark', [BrandingController::class, 'watermark'])
|
||||||
|
->middleware('token-can:edit_settings')
|
||||||
|
->name('watermark.show');
|
||||||
@@ -32,6 +32,24 @@ enum Capability: string
|
|||||||
case EmailTransportConfigure = 'email.transport.configure';
|
case EmailTransportConfigure = 'email.transport.configure';
|
||||||
case SystemUpdates = 'system.updates';
|
case SystemUpdates = 'system.updates';
|
||||||
|
|
||||||
|
// Community-only — whether this installation may switch off the
|
||||||
|
// project news on its dashboard.
|
||||||
|
//
|
||||||
|
// Note what is Community-only: the *choice*, not the news. A managed
|
||||||
|
// instance still fetches and still shows it, and cannot be made to
|
||||||
|
// stop. That is the difference from SystemUpdates beside it, and it
|
||||||
|
// is worth stating because the two look alike and are opposites. An
|
||||||
|
// update notice is useless on a hosted tenant — they cannot act on
|
||||||
|
// it, the image is ours — so the check does not run at all there.
|
||||||
|
// News is the reverse: announcements about the product are exactly
|
||||||
|
// what a hosted customer should be told, and an administrator
|
||||||
|
// switching them off for everybody on that instance is not a
|
||||||
|
// preference we meant to hand over.
|
||||||
|
//
|
||||||
|
// A self-hosted operator keeps the switch, because there nobody else
|
||||||
|
// decides what their installation reaches out for.
|
||||||
|
case NewsConfigure = 'news.configure';
|
||||||
|
|
||||||
// Community-only — scheduled-task run history and failed-queue-job
|
// Community-only — scheduled-task run history and failed-queue-job
|
||||||
// visibility. Cut on managed installations, where infrastructure
|
// visibility. Cut on managed installations, where infrastructure
|
||||||
// monitoring happens outside this application; a transient failure
|
// monitoring happens outside this application; a transient failure
|
||||||
@@ -46,10 +64,30 @@ enum Capability: string
|
|||||||
// cloud-modules below.
|
// cloud-modules below.
|
||||||
case CustomAssets = 'custom_assets.manage';
|
case CustomAssets = 'custom_assets.manage';
|
||||||
|
|
||||||
// Cloud-only — code lives in the private projectsend/cloud-modules
|
// Both editions. An installation dressing itself in its own logo, and
|
||||||
// package (github.com/projectsend/cloud-modules), never in this repo.
|
// watermarking what its clients and visitors see, is not a hosted
|
||||||
|
// concern -- it was Cloud-only because the code happened to live in
|
||||||
|
// the private package, which is a fact about where somebody typed it
|
||||||
|
// rather than about who should have it. Moved into core 2026-08-28.
|
||||||
|
//
|
||||||
|
// What a *plan* withholds is a different question from what an
|
||||||
|
// edition has, and it is answered by subtracting this key from an
|
||||||
|
// instance's environment rather than by moving it back. See
|
||||||
|
// CapabilityRegistry.
|
||||||
case Branding = 'branding.customize';
|
case Branding = 'branding.customize';
|
||||||
|
|
||||||
|
// Cloud-only, and deliberately not part of Branding above: taking
|
||||||
|
// ProjectSend's name off the pages somebody's own visitors see is the
|
||||||
|
// white-label half, and white-labelling is one of the things a hosted
|
||||||
|
// customer pays for.
|
||||||
|
//
|
||||||
|
// The gate is not this key. It is that the only code able to answer
|
||||||
|
// "hide it" ships in the private package, so an installation without
|
||||||
|
// that package has no listener to run and flipping an edition
|
||||||
|
// variable buys nothing. This key exists so a screen knows whether to
|
||||||
|
// offer the switch at all. See ResolvingAttribution.
|
||||||
|
case AttributionHide = 'attribution.hide';
|
||||||
|
|
||||||
// Cloud-only — the storage backend is ours, supplied by the
|
// Cloud-only — the storage backend is ours, supplied by the
|
||||||
// environment when the instance is provisioned and not the customer's
|
// environment when the instance is provisioned and not the customer's
|
||||||
// to see or change. The counterpart of StorageConfigure above rather
|
// to see or change. The counterpart of StorageConfigure above rather
|
||||||
@@ -59,11 +97,36 @@ enum Capability: string
|
|||||||
// simply inert and files stay on local disk.
|
// simply inert and files stay on local disk.
|
||||||
case StorageManaged = 'storage.managed';
|
case StorageManaged = 'storage.managed';
|
||||||
|
|
||||||
|
// Both editions, and present by default: a self-hosted installation
|
||||||
|
// has this screen today and needs it, because nobody else is going to
|
||||||
|
// supply its keys. It exists as a key so a managed platform can
|
||||||
|
// subtract it, and the reason to subtract it is narrower than the
|
||||||
|
// reason LDAP and social login stayed ungated.
|
||||||
|
//
|
||||||
|
// On a managed installation the administrator and the host are the
|
||||||
|
// same person, but the *reputation* is not theirs. Every tenant is a
|
||||||
|
// name under one shared domain, sending mail from one shared pool. An
|
||||||
|
// administrator who sets the provider to none, or who leaves the keys
|
||||||
|
// alone and just unticks the four per-form switches, turns their own
|
||||||
|
// public forms into an open door and spends everybody else's
|
||||||
|
// deliverability doing it. That is the same shape as Storage: not a
|
||||||
|
// feature somebody paid for, but a setting whose blast radius reaches
|
||||||
|
// past the installation that holds it.
|
||||||
|
//
|
||||||
|
// All-or-nothing on the route, read included, exactly as Storage and
|
||||||
|
// Branding are. Per-field gating in the controller would not do:
|
||||||
|
// switching the CAPTCHA off does not need the key fields at all, so
|
||||||
|
// the PATCH has to be closed too, and the middleware closes both
|
||||||
|
// verbs at once.
|
||||||
|
case CaptchaConfigure = 'captcha.configure';
|
||||||
|
|
||||||
// Cloud-only — managed installations supply CAPTCHA keys centrally, so
|
// Cloud-only — managed installations supply CAPTCHA keys centrally, so
|
||||||
// protection is on before anybody finds the settings screen. The
|
// protection is on before anybody finds the settings screen. The
|
||||||
// feature itself is in both editions and behind no capability: this
|
// feature itself is in both editions: this covers only the option of
|
||||||
// covers only the option of using *our* credentials, which cannot ship
|
// using *our* credentials, which cannot ship inside a self-hosted
|
||||||
// inside a self-hosted package.
|
// package. Distinct from CaptchaConfigure above — that one says
|
||||||
|
// whether the screen opens at all, this one says what it may offer
|
||||||
|
// once it does.
|
||||||
case CaptchaManagedKeys = 'captcha.managed_keys';
|
case CaptchaManagedKeys = 'captcha.managed_keys';
|
||||||
|
|
||||||
// Cloud-only — letting an AI assistant act on this installation on
|
// Cloud-only — letting an AI assistant act on this installation on
|
||||||
@@ -105,12 +168,15 @@ enum Capability: string
|
|||||||
self::StorageConfigure,
|
self::StorageConfigure,
|
||||||
self::EmailTransportConfigure,
|
self::EmailTransportConfigure,
|
||||||
self::SystemUpdates,
|
self::SystemUpdates,
|
||||||
|
self::NewsConfigure,
|
||||||
self::SchedulerMonitoring,
|
self::SchedulerMonitoring,
|
||||||
self::CustomAssets => [Edition::Community],
|
self::CustomAssets => [Edition::Community],
|
||||||
|
|
||||||
self::UsersManage => [Edition::Community, Edition::Cloud],
|
self::UsersManage,
|
||||||
|
self::CaptchaConfigure,
|
||||||
|
self::Branding => [Edition::Community, Edition::Cloud],
|
||||||
|
|
||||||
self::Branding,
|
self::AttributionHide,
|
||||||
self::StorageManaged,
|
self::StorageManaged,
|
||||||
self::CaptchaManagedKeys,
|
self::CaptchaManagedKeys,
|
||||||
self::PlatformManaged,
|
self::PlatformManaged,
|
||||||
|
|||||||
@@ -4,11 +4,63 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Modules\Platform\Capabilities;
|
namespace App\Modules\Platform\Capabilities;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this installation may do.
|
||||||
|
*
|
||||||
|
* An edition grants a set of capabilities; an operator may take some of
|
||||||
|
* them away. Those are different questions and the asymmetry between them
|
||||||
|
* is the whole design:
|
||||||
|
*
|
||||||
|
* **Subtraction only.** `PROJECTSEND_CAPABILITIES_DISABLED` can remove a
|
||||||
|
* key the edition grants. Nothing can add one. An environment variable
|
||||||
|
* that could grant a capability would put the proprietary screens of the
|
||||||
|
* hosted edition one line of `.env` away on every self-hosted install,
|
||||||
|
* which is not a gate at all — so the list is read, intersected with what
|
||||||
|
* the edition already allows, and can only ever make the answer smaller.
|
||||||
|
*
|
||||||
|
* **Why it exists.** A plan is not an edition. There are no billing tiers
|
||||||
|
* in this application to key off, and inventing one here would be a claim
|
||||||
|
* the rest of the codebase cannot back up — the same objection
|
||||||
|
* config/api.php makes about installation-level rate limits. This is not
|
||||||
|
* that: it is the operator telling the installation a fact about itself,
|
||||||
|
* exactly as PROJECTSEND_PLATFORM_MAX_STAFF_USERS does for seats. The
|
||||||
|
* platform knows what it sold; the installation is told, and enforces.
|
||||||
|
*
|
||||||
|
* **Unknown keys are ignored, not fatal.** A variable outlives the plan
|
||||||
|
* that wrote it and the release that named the key. An instance that
|
||||||
|
* refuses to boot because it was told to disable something that no longer
|
||||||
|
* exists would be a self-inflicted outage on upgrade day.
|
||||||
|
*/
|
||||||
class CapabilityRegistry
|
class CapabilityRegistry
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private readonly array $disabled;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string>|string|null $disabled keys this installation
|
||||||
|
* has been told it may not
|
||||||
|
* use; a comma-separated
|
||||||
|
* string is what the
|
||||||
|
* environment supplies
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Edition $edition,
|
private readonly Edition $edition,
|
||||||
) {}
|
array|string|null $disabled = [],
|
||||||
|
) {
|
||||||
|
// Parsed here rather than read from config(), so the registry stays
|
||||||
|
// a value object that can be constructed with nothing but its two
|
||||||
|
// facts -- which is what lets it be unit-tested without booting an
|
||||||
|
// application, and what stops the edition and the subtraction being
|
||||||
|
// read from two different places at two different times.
|
||||||
|
$this->disabled = is_array($disabled)
|
||||||
|
? $disabled
|
||||||
|
: array_values(array_filter(
|
||||||
|
array_map(trim(...), explode(',', (string) $disabled)),
|
||||||
|
fn (string $key): bool => $key !== '',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
public function edition(): Edition
|
public function edition(): Edition
|
||||||
{
|
{
|
||||||
@@ -17,7 +69,8 @@ class CapabilityRegistry
|
|||||||
|
|
||||||
public function has(Capability $capability): bool
|
public function has(Capability $capability): bool
|
||||||
{
|
{
|
||||||
return $capability->availableIn($this->edition);
|
return $capability->availableIn($this->edition)
|
||||||
|
&& ! in_array($capability->value, $this->disabled, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,8 +20,12 @@ use Illuminate\Console\Command;
|
|||||||
* administrator editing a database table by hand, guessing which of
|
* administrator editing a database table by hand, guessing which of
|
||||||
* several rows matters.
|
* several rows matters.
|
||||||
*
|
*
|
||||||
* PROJECTSEND_CAPTCHA_DISABLED does the same thing for anyone who would
|
* PROJECTSEND_CAPTCHA_DISABLED is the other half of the same escape
|
||||||
* rather touch .env than run artisan.
|
* hatch, and not merely the .env spelling of this one: it is checked
|
||||||
|
* first, ahead of the key source, so it is the only one of the two that
|
||||||
|
* works on an installation running the platform's managed keys. This
|
||||||
|
* command writes a setting those installations never read, and says so
|
||||||
|
* rather than reporting a success it did not have.
|
||||||
*/
|
*/
|
||||||
class DisableCaptchaCommand extends Command
|
class DisableCaptchaCommand extends Command
|
||||||
{
|
{
|
||||||
@@ -29,7 +33,7 @@ class DisableCaptchaCommand extends Command
|
|||||||
|
|
||||||
protected $description = 'Switch off the CAPTCHA on public forms';
|
protected $description = 'Switch off the CAPTCHA on public forms';
|
||||||
|
|
||||||
public function handle(Settings $settings): int
|
public function handle(Settings $settings, Captcha $captcha): int
|
||||||
{
|
{
|
||||||
$settings->set(Setting::CaptchaProvider, 'none');
|
$settings->set(Setting::CaptchaProvider, 'none');
|
||||||
|
|
||||||
@@ -38,6 +42,24 @@ class DisableCaptchaCommand extends Command
|
|||||||
Captcha::forgetDisplayCache();
|
Captcha::forgetDisplayCache();
|
||||||
CaptchaVerifier::forgetOutage();
|
CaptchaVerifier::forgetOutage();
|
||||||
|
|
||||||
|
// Managed keys are not this setting. Captcha::resolve() reaches
|
||||||
|
// them from config and returns before it ever looks at
|
||||||
|
// Setting::CaptchaProvider, so on an installation using them the
|
||||||
|
// write above changed a value nothing reads. Saying "CAPTCHA is
|
||||||
|
// off" there would be false, and false in the worst direction: an
|
||||||
|
// operator who is still being challenged would stop looking,
|
||||||
|
// having just been told the thing challenging them is gone.
|
||||||
|
//
|
||||||
|
// Read after the write rather than before it, because the write is
|
||||||
|
// what makes the answer meaningful — if this still resolves to
|
||||||
|
// something, the something is not ours to switch off.
|
||||||
|
if ($captcha->managedKeysSelected()) {
|
||||||
|
$this->warn('Nothing changed. This installation uses CAPTCHA keys supplied by the platform, and those do not come from the setting this command writes.');
|
||||||
|
$this->line('Set PROJECTSEND_CAPTCHA_DISABLED=true in the environment and restart to switch it off.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
$this->info('CAPTCHA is off. Your keys are still stored — switch it back on at /system/settings/captcha.');
|
$this->info('CAPTCHA is off. Your keys are still stored — switch it back on at /system/settings/captcha.');
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
|
|||||||
@@ -24,13 +24,18 @@ use Inertia\Response;
|
|||||||
/**
|
/**
|
||||||
* Configuring the CAPTCHA on public forms.
|
* Configuring the CAPTCHA on public forms.
|
||||||
*
|
*
|
||||||
* Available in **both** editions and behind no capability, for the reason
|
* Available in **both** editions, and behind Capability::CaptchaConfigure
|
||||||
* LDAP settled and social login repeated: this is an administrator's
|
* — present by default, so a self-hosted installation keeps the screen,
|
||||||
* setting, not an edition difference. What *is* an edition difference is
|
* and removable by an operator whose tenants share a domain and a sending
|
||||||
* the option of using the platform's own keys, and that is enforced per
|
* reputation. Enforced entirely by the `capability:captcha.configure`
|
||||||
* field rather than on the route — the shape EmailSettingsController uses
|
* route middleware, which covers the PATCH as well as the GET: turning
|
||||||
* for SMTP, so a hand-crafted PATCH cannot select a key source this
|
* the CAPTCHA off needs no gated field at all, so nothing short of
|
||||||
* installation has no keys for.
|
* closing the write would have closed it.
|
||||||
|
*
|
||||||
|
* Which keys this installation may point at is a second question, and
|
||||||
|
* that one is still enforced per field below rather than on the route —
|
||||||
|
* the shape EmailSettingsController uses for SMTP, so a hand-crafted
|
||||||
|
* PATCH cannot select a key source this installation has no keys for.
|
||||||
*
|
*
|
||||||
* The secret key follows the pattern MailProviderSettings established and
|
* The secret key follows the pattern MailProviderSettings established and
|
||||||
* LdapSettings and SocialSettings repeated: it is never sent to the
|
* LdapSettings and SocialSettings repeated: it is never sent to the
|
||||||
|
|||||||
@@ -162,8 +162,9 @@ class EmailOAuthController extends Controller
|
|||||||
'refresh_token' => null,
|
'refresh_token' => null,
|
||||||
'token_expires_at' => null,
|
'token_expires_at' => null,
|
||||||
'account_email' => null,
|
'account_email' => null,
|
||||||
'last_error' => null,
|
]);
|
||||||
])->save();
|
$connection->clearFailure();
|
||||||
|
$connection->save();
|
||||||
|
|
||||||
$this->activateConnection();
|
$this->activateConnection();
|
||||||
|
|
||||||
|
|||||||
@@ -185,8 +185,8 @@ class EmailSettingsController extends Controller
|
|||||||
'refresh_token' => null,
|
'refresh_token' => null,
|
||||||
'token_expires_at' => null,
|
'token_expires_at' => null,
|
||||||
'account_email' => null,
|
'account_email' => null,
|
||||||
'last_error' => null,
|
|
||||||
]);
|
]);
|
||||||
|
$connection->clearFailure();
|
||||||
}
|
}
|
||||||
|
|
||||||
$connection->save();
|
$connection->save();
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ class PrivacySettingsController extends Controller
|
|||||||
'account_erasure_grace_days' => $this->settings->get(Setting::AccountErasureGraceDays),
|
'account_erasure_grace_days' => $this->settings->get(Setting::AccountErasureGraceDays),
|
||||||
'account_erasure_content_action' => $this->settings->get(Setting::AccountErasureContentAction),
|
'account_erasure_content_action' => $this->settings->get(Setting::AccountErasureContentAction),
|
||||||
'account_erasure_reassign_to' => $this->settings->get(Setting::AccountErasureReassignTo),
|
'account_erasure_reassign_to' => $this->settings->get(Setting::AccountErasureReassignTo),
|
||||||
'reassign_candidates' => $this->accountDeletion->candidates(),
|
// Installation-wide on purpose: this is the default every
|
||||||
|
// erasure will use, stored once for everybody, and the page is
|
||||||
|
// already behind edit_settings.
|
||||||
|
'reassign_candidates' => $this->accountDeletion->candidates(null),
|
||||||
'api_request_log_retention_days' => $this->settings->get(Setting::ApiRequestLogRetentionDays),
|
'api_request_log_retention_days' => $this->settings->get(Setting::ApiRequestLogRetentionDays),
|
||||||
'discourage_search_indexing' => $this->settings->get(Setting::DiscourageSearchIndexing),
|
'discourage_search_indexing' => $this->settings->get(Setting::DiscourageSearchIndexing),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ class SystemSettingsController extends Controller
|
|||||||
{
|
{
|
||||||
$canManageUpdates = $this->capabilities->has(Capability::SystemUpdates)
|
$canManageUpdates = $this->capabilities->has(Capability::SystemUpdates)
|
||||||
&& $request->user()?->can('manage_updates') === true;
|
&& $request->user()?->can('manage_updates') === true;
|
||||||
|
// No permission beside it, unlike updates: turning the news card
|
||||||
|
// off is an ordinary settings change, and edit_settings already
|
||||||
|
// gates this whole screen.
|
||||||
|
$canConfigureNews = $this->capabilities->has(Capability::NewsConfigure);
|
||||||
|
|
||||||
return Inertia::render('system/settings/general', [
|
return Inertia::render('system/settings/general', [
|
||||||
'site_name' => $this->settings->get(Setting::SiteName),
|
'site_name' => $this->settings->get(Setting::SiteName),
|
||||||
@@ -62,6 +66,15 @@ class SystemSettingsController extends Controller
|
|||||||
'viewer_timezone' => $request->user()?->timezone,
|
'viewer_timezone' => $request->user()?->timezone,
|
||||||
'can_manage_updates' => $canManageUpdates,
|
'can_manage_updates' => $canManageUpdates,
|
||||||
'check_for_updates' => $canManageUpdates ? $this->settings->get(Setting::CheckForUpdates) : null,
|
'check_for_updates' => $canManageUpdates ? $this->settings->get(Setting::CheckForUpdates) : null,
|
||||||
|
// Its own capability, and deliberately not $canManageUpdates:
|
||||||
|
// the update block disappears on a managed instance because
|
||||||
|
// nobody there can act on it, while this one disappears
|
||||||
|
// because the news must keep arriving whether or not the
|
||||||
|
// instance's administrator would have chosen it. Null where
|
||||||
|
// the choice is not theirs, so the page renders no switch
|
||||||
|
// rather than a switch that would do nothing.
|
||||||
|
'can_configure_news' => $canConfigureNews,
|
||||||
|
'fetch_news' => $canConfigureNews ? $this->settings->get(Setting::FetchNews) : null,
|
||||||
'last_checked_at' => $canManageUpdates ? $this->lastCheckedAt()?->toIso8601String() : null,
|
'last_checked_at' => $canManageUpdates ? $this->lastCheckedAt()?->toIso8601String() : null,
|
||||||
'check_result' => $request->session()->get('update_check_result'),
|
'check_result' => $request->session()->get('update_check_result'),
|
||||||
]);
|
]);
|
||||||
@@ -123,6 +136,10 @@ class SystemSettingsController extends Controller
|
|||||||
{
|
{
|
||||||
$canManageUpdates = $this->capabilities->has(Capability::SystemUpdates)
|
$canManageUpdates = $this->capabilities->has(Capability::SystemUpdates)
|
||||||
&& $request->user()?->can('manage_updates') === true;
|
&& $request->user()?->can('manage_updates') === true;
|
||||||
|
// No permission beside it, unlike updates: turning the news card
|
||||||
|
// off is an ordinary settings change, and edit_settings already
|
||||||
|
// gates this whole screen.
|
||||||
|
$canConfigureNews = $this->capabilities->has(Capability::NewsConfigure);
|
||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
'site_name' => ['required', 'string', 'max:255'],
|
'site_name' => ['required', 'string', 'max:255'],
|
||||||
@@ -133,6 +150,10 @@ class SystemSettingsController extends Controller
|
|||||||
// "follow APP_TIMEZONE", and only a fresh install has that.
|
// "follow APP_TIMEZONE", and only a fresh install has that.
|
||||||
'timezone' => ['sometimes', 'string', 'timezone', Rule::in($this->timezones->all())],
|
'timezone' => ['sometimes', 'string', 'timezone', Rule::in($this->timezones->all())],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if ($canConfigureNews) {
|
||||||
|
$rules['fetch_news'] = ['sometimes', 'boolean'];
|
||||||
|
}
|
||||||
if ($canManageUpdates) {
|
if ($canManageUpdates) {
|
||||||
// Omitting the field (any caller not sending it, not just this
|
// Omitting the field (any caller not sending it, not just this
|
||||||
// page's own form) leaves the current value alone rather than
|
// page's own form) leaves the current value alone rather than
|
||||||
@@ -156,6 +177,13 @@ class SystemSettingsController extends Controller
|
|||||||
$this->settings->set(Setting::CheckForUpdates, $validated['check_for_updates']);
|
$this->settings->set(Setting::CheckForUpdates, $validated['check_for_updates']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Never read where the choice is not this installation's, so a
|
||||||
|
// hand-crafted PATCH cannot switch off the news on a managed
|
||||||
|
// instance any more than the absent checkbox could.
|
||||||
|
if ($canConfigureNews && array_key_exists('fetch_news', $validated)) {
|
||||||
|
$this->settings->set(Setting::FetchNews, $validated['fetch_news']);
|
||||||
|
}
|
||||||
|
|
||||||
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'general']);
|
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'general']);
|
||||||
|
|
||||||
return back();
|
return back();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Modules\Platform\Http\Middleware;
|
|||||||
use App\Modules\Platform\Capabilities\Capability;
|
use App\Modules\Platform\Capabilities\Capability;
|
||||||
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||||
use App\Modules\Platform\Capabilities\CapabilityUnavailable;
|
use App\Modules\Platform\Capabilities\CapabilityUnavailable;
|
||||||
|
use App\Support\ApiSurface;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
@@ -17,6 +18,12 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
* API requests get a machine-readable 403; web requests get a 404 so
|
* API requests get a machine-readable 403; web requests get a 404 so
|
||||||
* unavailable features are absent, not teased.
|
* unavailable features are absent, not teased.
|
||||||
*
|
*
|
||||||
|
* Which of the two a request is comes from the route (see ApiSurface), not
|
||||||
|
* from its Accept header. Whether an endpoint exists in this edition is a
|
||||||
|
* property of the installation; deciding it from what the caller is
|
||||||
|
* willing to parse answered the same API route 403 or 404 depending on
|
||||||
|
* nothing but a header, and routes/api.php promises the 403.
|
||||||
|
*
|
||||||
* The API half throws CapabilityUnavailable rather than returning a body,
|
* The API half throws CapabilityUnavailable rather than returning a body,
|
||||||
* so the refusal goes through ProblemDetails like every other API error
|
* so the refusal goes through ProblemDetails like every other API error
|
||||||
* instead of being the one response shaped differently from the rest.
|
* instead of being the one response shaped differently from the rest.
|
||||||
@@ -35,7 +42,7 @@ class EnsureCapability
|
|||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->expectsJson()) {
|
if (ApiSurface::matches($request)) {
|
||||||
throw new CapabilityUnavailable($capability, $this->capabilities->edition());
|
throw new CapabilityUnavailable($capability, $this->capabilities->edition());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Platform\Navigation\Events;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extra links a package wants in the sidebar.
|
||||||
|
*
|
||||||
|
* The sidebar is built from a hardcoded array in app-sidebar.tsx, which
|
||||||
|
* means a package could not contribute to it at all — the nav link was a
|
||||||
|
* separate manual edit every time a package grew a screen, and being
|
||||||
|
* manual it was forgotten. This is the seam that fixes that, in the shape
|
||||||
|
* the extension-points document settles on: core dispatches
|
||||||
|
* unconditionally, listeners add or do not, and with no listener the
|
||||||
|
* default (no extra links) holds.
|
||||||
|
*
|
||||||
|
* **Core deliberately learns nothing about what is added.** A link's
|
||||||
|
* label, its URL and the reason it exists all arrive from whoever
|
||||||
|
* registers it. That is not fastidiousness: the first caller is the
|
||||||
|
* hosted edition's link to its own customer portal, and a product URL
|
||||||
|
* belonging to one commercial offering has no business sitting in the
|
||||||
|
* public repository just because the sidebar happens to live here.
|
||||||
|
*
|
||||||
|
* Staff only, and enforced here rather than trusted to each listener:
|
||||||
|
* these appear in the administration area, and a client's portal shows
|
||||||
|
* only their own files.
|
||||||
|
*/
|
||||||
|
class ResolvingNavigationLinks
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var list<array{title: string, url: string, external: bool, icon: string|null}>
|
||||||
|
*/
|
||||||
|
public array $links = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
/** Whether the viewer is a staff account. Listeners that only make
|
||||||
|
* sense for staff should check this rather than assume. */
|
||||||
|
public readonly bool $isStaff,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `external` opens in a new tab and marks the link as leaving this
|
||||||
|
* installation — a link that navigates a person away from the app
|
||||||
|
* they are working in should say so before they click it, not after.
|
||||||
|
*/
|
||||||
|
public function add(string $title, string $url, bool $external = false, ?string $icon = null): void
|
||||||
|
{
|
||||||
|
$this->links[] = [
|
||||||
|
'title' => $title,
|
||||||
|
'url' => $url,
|
||||||
|
'external' => $external,
|
||||||
|
'icon' => $icon,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Modules\Platform\News\Console;
|
namespace App\Modules\Platform\News\Console;
|
||||||
|
|
||||||
|
use App\Modules\Platform\Capabilities\Capability;
|
||||||
|
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||||
use App\Modules\Platform\Settings\Setting;
|
use App\Modules\Platform\Settings\Setting;
|
||||||
use App\Modules\Platform\Settings\Settings;
|
use App\Modules\Platform\Settings\Settings;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
@@ -12,9 +14,13 @@ use Illuminate\Support\Facades\Http;
|
|||||||
use Stevebauman\Purify\Facades\Purify;
|
use Stevebauman\Purify\Facades\Purify;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Both editions — unlike CheckForUpdatesCommand, this isn't gated on any
|
* Both editions, and on a managed instance not switchable off — unlike
|
||||||
* Capability: dashboard news is informational content, not an update
|
* CheckForUpdatesCommand, which does not run there at all. Dashboard news
|
||||||
* action, so Cloud tenants see it too.
|
* is informational content rather than an update action, so hosted
|
||||||
|
* customers see it too, and see it whether their administrator would have
|
||||||
|
* chosen to or not. Capability::NewsConfigure is what a self-hosted
|
||||||
|
* installation holds and a managed one does not: the choice is the
|
||||||
|
* edition difference, not the news.
|
||||||
*
|
*
|
||||||
* The feed returns raw HTML in `content` (links, paragraphs) — sanitized
|
* The feed returns raw HTML in `content` (links, paragraphs) — sanitized
|
||||||
* here, once, before it's ever cached or sent to the frontend, so the
|
* here, once, before it's ever cached or sent to the frontend, so the
|
||||||
@@ -34,12 +40,36 @@ class FetchNewsCommand extends Command
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Settings $settings,
|
private readonly Settings $settings,
|
||||||
|
private readonly CapabilityRegistry $capabilities,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
|
// The setting only decides where the installation is allowed to
|
||||||
|
// make that choice. On a managed instance it is not: announcements
|
||||||
|
// about the product are what a hosted customer should be told, and
|
||||||
|
// one administrator switching them off for everybody on that
|
||||||
|
// instance is not a decision the platform hands over. So the news
|
||||||
|
// runs there regardless of what any row says — including a row
|
||||||
|
// left behind by an instance that used to be self-hosted.
|
||||||
|
//
|
||||||
|
// The opposite of the update check, which does not run on a
|
||||||
|
// managed instance at all because nobody there could act on it.
|
||||||
|
// The two look alike and point in different directions.
|
||||||
|
//
|
||||||
|
// Returns success rather than failure: a scheduled task that was
|
||||||
|
// asked not to run has not failed, and reporting it as a failure
|
||||||
|
// would put a red line in the scheduler history every night for
|
||||||
|
// an installation that is behaving exactly as configured.
|
||||||
|
if ($this->capabilities->has(Capability::NewsConfigure)
|
||||||
|
&& $this->settings->get(Setting::FetchNews) !== true) {
|
||||||
|
$this->info('The news feed is switched off for this installation.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
$response = Http::withHeaders(['User-Agent' => 'ProjectSend'])
|
$response = Http::withHeaders(['User-Agent' => 'ProjectSend'])
|
||||||
->timeout(10)
|
->timeout(10)
|
||||||
->get(self::FEED_URL);
|
->get(self::FEED_URL);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -249,6 +249,17 @@ enum Setting: string
|
|||||||
// is allowed to tell the admin a newer release exists.
|
// is allowed to tell the admin a newer release exists.
|
||||||
case CheckForUpdates = 'check_for_updates';
|
case CheckForUpdates = 'check_for_updates';
|
||||||
|
|
||||||
|
// Whether this installation fetches the project's news feed for the
|
||||||
|
// dashboard card. Its own key rather than riding on CheckForUpdates
|
||||||
|
// above, because they are two different wants: "do not tell me about
|
||||||
|
// releases" and "do not show me the project's news" are asked
|
||||||
|
// separately, and an installation with no outbound access at all
|
||||||
|
// wants both off while an ordinary one may want updates and no feed.
|
||||||
|
//
|
||||||
|
// On by default, so nothing changes for an installation that has
|
||||||
|
// never seen this switch.
|
||||||
|
case FetchNews = 'fetch_news';
|
||||||
|
|
||||||
// Cached result of the last update check — never written directly by
|
// Cached result of the last update check — never written directly by
|
||||||
// a settings form, only by CheckForUpdatesCommand. Empty string means
|
// a settings form, only by CheckForUpdatesCommand. Empty string means
|
||||||
// "no successful check yet" (fresh install, or checks disabled).
|
// "no successful check yet" (fresh install, or checks disabled).
|
||||||
@@ -367,6 +378,7 @@ enum Setting: string
|
|||||||
self::ClientsCanPreviewFiles,
|
self::ClientsCanPreviewFiles,
|
||||||
self::PublicListingPreviewEnabled,
|
self::PublicListingPreviewEnabled,
|
||||||
self::CheckForUpdates,
|
self::CheckForUpdates,
|
||||||
|
self::FetchNews,
|
||||||
self::ExpiredFilesAutoDeleteEnabled,
|
self::ExpiredFilesAutoDeleteEnabled,
|
||||||
self::PublicCommentsEnabled,
|
self::PublicCommentsEnabled,
|
||||||
self::CommentsGuestModeration,
|
self::CommentsGuestModeration,
|
||||||
@@ -433,6 +445,7 @@ enum Setting: string
|
|||||||
self::OrphanFilesAutoDeleteEnabled => false,
|
self::OrphanFilesAutoDeleteEnabled => false,
|
||||||
|
|
||||||
self::CheckForUpdates,
|
self::CheckForUpdates,
|
||||||
|
self::FetchNews,
|
||||||
self::CommentsGuestModeration,
|
self::CommentsGuestModeration,
|
||||||
// On, so that an installation updating into these switches
|
// On, so that an installation updating into these switches
|
||||||
// keeps the preview it already had rather than losing it to a
|
// keeps the preview it already had rather than losing it to a
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Modules\Platform\Updates\Console;
|
namespace App\Modules\Platform\Updates\Console;
|
||||||
|
|
||||||
|
use App\Modules\Platform\Capabilities\Capability;
|
||||||
|
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||||
use App\Modules\Platform\Settings\Setting;
|
use App\Modules\Platform\Settings\Setting;
|
||||||
use App\Modules\Platform\Settings\Settings;
|
use App\Modules\Platform\Settings\Settings;
|
||||||
use App\Modules\Platform\Updates\CheckForUpdates;
|
use App\Modules\Platform\Updates\CheckForUpdates;
|
||||||
@@ -32,12 +34,37 @@ class CheckForUpdatesCommand extends Command
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Settings $settings,
|
private readonly Settings $settings,
|
||||||
private readonly CheckForUpdates $check,
|
private readonly CheckForUpdates $check,
|
||||||
|
private readonly CapabilityRegistry $capabilities,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
|
// Ahead of the setting, and deliberately not a setting itself.
|
||||||
|
//
|
||||||
|
// A Setting says "the operator does not want this". The true
|
||||||
|
// statement on a managed installation is "there is nowhere for
|
||||||
|
// this to appear and nothing they could do about it": the
|
||||||
|
// dashboard's System card is gated on Capability::SystemUpdates
|
||||||
|
// (DashboardController), which is Community-only, so the answer
|
||||||
|
// this command fetches cannot be drawn on any screen — and the
|
||||||
|
// update UI is closed by the same capability, so it could not be
|
||||||
|
// acted on if it were. The image is chosen by whoever provisioned
|
||||||
|
// the instance.
|
||||||
|
//
|
||||||
|
// Encoding that as a preference would leave it switchable back on
|
||||||
|
// per tenant, which buys a nightly call to GitHub for a number
|
||||||
|
// nobody can see, and would leave the reason in a provisioning
|
||||||
|
// script rather than beside the code. A self-hosted installation
|
||||||
|
// holds the capability and loses nothing: its own setting below
|
||||||
|
// still decides.
|
||||||
|
if (! $this->capabilities->has(Capability::SystemUpdates)) {
|
||||||
|
$this->info('Update checks do not apply to this installation.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->settings->get(Setting::CheckForUpdates) !== true) {
|
if ($this->settings->get(Setting::CheckForUpdates) !== true) {
|
||||||
$this->info('Update checks are disabled.');
|
$this->info('Update checks are disabled.');
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user