mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-04 13:33:22 +00:00
Compare commits
144 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2029309126 | |||
| d83d2d9acb | |||
| 06c364d29a | |||
| 046be36861 | |||
| 4a35c25894 | |||
| 58497ef776 | |||
| d751314196 | |||
| 00d118559d | |||
| abaca20261 | |||
| b16d780ebe | |||
| 602c7bed94 | |||
| 76f79d53a0 | |||
| 3f81dd5eab | |||
| 1cefdee610 | |||
| f2e7820f5c | |||
| a92feed3ad | |||
| 73d93495c9 | |||
| 2eb23dbc07 | |||
| 13b56186f4 | |||
| e272f19045 | |||
| 28e18497b5 | |||
| b44c6bf098 | |||
| eade690f73 | |||
| 3e15237f90 | |||
| 9cc469b111 | |||
| 4469648d82 | |||
| 26205082c2 | |||
| ab6e9eecf3 | |||
| 1dc274e896 | |||
| 7045da7450 | |||
| d58e48301f | |||
| c49811f3c0 | |||
| 351da21e8d | |||
| c172d0d645 | |||
| 01f41860e6 | |||
| 0999779864 | |||
| 34c34b2fd4 | |||
| ddde779aea | |||
| 7cbffefb01 | |||
| d2901e8304 | |||
| 787e9ec189 | |||
| ac691387e8 | |||
| 463e86f82b | |||
| 623ad686da | |||
| c05927c190 | |||
| 3a7800cc52 | |||
| ffbde4bea2 | |||
| 4c5c956a26 | |||
| 553f5fd2bf | |||
| 5d99ab94fd | |||
| 3b51c5308c | |||
| 0f81b74f9e | |||
| 1760dc70f8 | |||
| ef822f2103 | |||
| 4cb46c954f | |||
| f12692520a | |||
| dacf2b3eda | |||
| e4cd56f5d6 | |||
| 9b3f7023d0 | |||
| 09efad2d8c | |||
| 9fc5042f4e | |||
| 835943e1b6 | |||
| c5d32c06f6 | |||
| d36abd73ba | |||
| e815ac8be5 | |||
| ebe4550fa6 | |||
| ad4d75d8fe | |||
| 12a8ebe380 | |||
| 6a5c9e55aa | |||
| c8078f65c5 | |||
| 7c5af8570a | |||
| 92a132d74f | |||
| eb2917f5ff | |||
| 41b4e477b5 | |||
| d7e639b7af | |||
| 8d896191e4 | |||
| 93d5b21c6a | |||
| 187d599d5f | |||
| 99b92a7e28 | |||
| a78f01f989 | |||
| e7b5b6a757 | |||
| 4b8220a250 | |||
| bc33933432 | |||
| 350a7b3073 | |||
| f1b35cc9f6 | |||
| 93d22378c4 | |||
| 2d83f139c8 | |||
| 18e4e014e6 | |||
| 67e9204654 | |||
| 5fb98f4785 | |||
| 0a8b609e8b | |||
| 98c01aed3f | |||
| 706ebf6166 | |||
| 8a6543073b | |||
| 5242169bb0 | |||
| 7ebc9b0905 | |||
| 7727ad7616 | |||
| b9f826282a | |||
| 4806b81dc3 | |||
| 2c2b86ffa1 | |||
| e3554bdd39 | |||
| 3af8235729 | |||
| acda732ff4 | |||
| a8b1987e2c | |||
| 16787cf697 | |||
| 073101d184 | |||
| 65e7f37d36 | |||
| 862765643b | |||
| d19ec11970 | |||
| b832f6bc3d | |||
| 3439537efe | |||
| 5c00d189e4 | |||
| 7646e99f33 | |||
| 3f27c8dbf8 | |||
| 61c385e423 | |||
| eade83a576 | |||
| ff7758a31a | |||
| 329aef98e0 | |||
| e6dc271f27 | |||
| 0c8518f7b7 | |||
| 67f340d23d | |||
| 21fdf98981 | |||
| 71d6b8937e | |||
| 6ab90aee79 | |||
| 91d34b204c | |||
| 73533910b0 | |||
| 7059ee293a | |||
| 5e3ea5a48b | |||
| eda8091aef | |||
| 6147b2721e | |||
| 4f4fb92b85 | |||
| 1b3abd28b7 | |||
| a14ff0c837 | |||
| 6086821d6c | |||
| 30f08fdd3b | |||
| a8f5fa5e10 | |||
| ce487da19a | |||
| a86017f2c2 | |||
| 4737849ec5 | |||
| 8b59bb2a5a | |||
| 6a8a287984 | |||
| 19d34ef38b | |||
| 4eb8cf915a | |||
| 933eaa2ba4 |
@@ -27,10 +27,28 @@ permissions:
|
||||
|
||||
jobs:
|
||||
cla:
|
||||
# This condition belongs to the job, not to the step below it, and moving
|
||||
# it back down would quietly cost money. A step that is skipped has still
|
||||
# had a runner allocated for it; a job that is skipped never gets one, and
|
||||
# Actions bills per job that runs. `issue_comment` fires on every comment
|
||||
# in the repository, so with the check one level lower every "thanks,
|
||||
# merged" on a pull request — and every comment on a plain issue — spun up
|
||||
# a machine to decide it had nothing to do.
|
||||
#
|
||||
# GitHub cannot filter `issue_comment` by body at the `on:` level, so this
|
||||
# is the only place the decision can be made.
|
||||
#
|
||||
# `issue.pull_request` is present only when the comment is on a pull
|
||||
# request; comments on ordinary issues have nothing for this action to
|
||||
# check.
|
||||
if: >-
|
||||
github.event_name == 'pull_request_target'
|
||||
|| (github.event.issue.pull_request
|
||||
&& (github.event.comment.body == 'recheck'
|
||||
|| github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: CLA check
|
||||
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
|
||||
uses: contributor-assistant/github-action@v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -44,10 +44,21 @@ on:
|
||||
- 'docker/production/dockerhub-overview.md'
|
||||
- '.github/screenshots/**'
|
||||
|
||||
# A second push supersedes the first: there is no value in finishing a run
|
||||
# for a commit nobody will look at again.
|
||||
# A second push supersedes the first — the later run covers a superset of
|
||||
# what the earlier one was checking, so finishing both buys nothing and
|
||||
# costs a runner.
|
||||
#
|
||||
# Keyed on the ref, so `main` and a branch never cancel each other. A push
|
||||
# to a branch that also has a pull request open produces two events with
|
||||
# two different refs, which is why they do not fight either.
|
||||
#
|
||||
# The tradeoff worth naming: on `main` this means an intermediate commit
|
||||
# can end up with no run of its own when two pushes land together. That is
|
||||
# accepted here — what is being verified is the state of the branch, and
|
||||
# the run that survives is the one that includes both commits. If a commit
|
||||
# ever needs its own green tick (a bisect, a release audit), push it alone.
|
||||
concurrency:
|
||||
group: tests-${{ github.workflow }}-${{ github.ref }}
|
||||
group: tests-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -39,3 +39,10 @@ yarn-error.log
|
||||
/database/seeders/DevDataSeeder.php
|
||||
/docs/*.md
|
||||
!/docs/api-guide.md
|
||||
!/docs/email-oauth.md
|
||||
!/docs/api-zapier.md
|
||||
|
||||
# ── Local-only dev TLS ──
|
||||
# mkcert certificates and the nginx config that terminates HTTPS on the
|
||||
# dev `web` container. Machine-specific, and one of them is a private key.
|
||||
/docker/web/local/
|
||||
|
||||
+121
-96
@@ -13,114 +13,139 @@ Anything under **Upgrade notes** is something you have to do, not something we d
|
||||
This section collects changes as they land; the release process turns it into a numbered entry when
|
||||
a version is cut.
|
||||
|
||||
### Added
|
||||
## 2.2.1 — 28 August 2026
|
||||
|
||||
- **Google Cloud Storage as a storage backend.** External storage used to mean S3 and nothing else.
|
||||
The Storage settings screen now asks which provider you are using first, and offers Google Cloud
|
||||
Storage alongside the S3-compatible option: choose it, paste a service account key with read and
|
||||
write access to your bucket, and new uploads go there. The key is stored encrypted and never shown
|
||||
again, and **Test connection** checks it can actually reach the bucket before you switch anything
|
||||
over — using a probe that works with a least-privilege key, rather than one that needs permission
|
||||
to read the bucket's own settings. Downloads and previews are handed to the visitor as a
|
||||
short-lived signed link, exactly as they already were for S3.
|
||||
A security release. Most of it closes ways somebody could reach past a boundary the rest of the
|
||||
application already enforced — including two that could lock you out of your own installation.
|
||||
|
||||
Nothing changes for an existing installation. Configurations saved before this release are S3, are
|
||||
still S3, and are not asked to say so. Files already stored stay where they are — the setting
|
||||
applies to new uploads, and there is still no migration between backends.
|
||||
**Merged**
|
||||
|
||||
### Fixed
|
||||
- [#1708](https://github.com/projectsend/projectsend/pull/1708) — Let an enforced user reach the far side of the confirm-password screen
|
||||
- [#1716](https://github.com/projectsend/projectsend/pull/1716) — Refuse the last administrator deleting themselves, and keep setup shut
|
||||
- [#1710](https://github.com/projectsend/projectsend/pull/1710) — Stop a folder deleting the files inside it that its owner may not delete
|
||||
- [#1714](https://github.com/projectsend/projectsend/pull/1714) — Hold the group edit screen to the same library boundary as the rest
|
||||
- [#1717](https://github.com/projectsend/projectsend/pull/1717) — Keep a private reply private after the client is deleted
|
||||
- [#1713](https://github.com/projectsend/projectsend/pull/1713) — Refuse self-deactivation over the API however the boolean is written
|
||||
- [#1709](https://github.com/projectsend/projectsend/pull/1709) — Ask the seat cap where a pending client is approved through edit()
|
||||
- [#1715](https://github.com/projectsend/projectsend/pull/1715) — Add a file to a zip once, however many ways the selection reaches it
|
||||
- [#1707](https://github.com/projectsend/projectsend/pull/1707) — Leave the test workflow one concurrency block, so it parses again
|
||||
- [#1711](https://github.com/projectsend/projectsend/pull/1711) — Stop the update tests emptying bootstrap/cache for every other worker
|
||||
- [#1712](https://github.com/projectsend/projectsend/pull/1712) — Make the storage durability dashboard test assert the verdict
|
||||
|
||||
- **An upload that cannot be stored now fails instead of disappearing.** When files are kept in
|
||||
object storage and the storage backend refuses a write — an expired key, a bucket that has been
|
||||
renamed or removed, a permission that changed underneath you — the upload used to report success
|
||||
and record the file anyway. The entry appeared in the file list, and the download it promised was
|
||||
never going to work, because the bytes had gone nowhere. The upload now stops and says so, and no
|
||||
file is recorded. Installations keeping files on local disk were never affected.
|
||||
**Also fixed**
|
||||
|
||||
- **Downloads and thumbnails for installations using external storage.** Two places assumed every
|
||||
file sat on the server's own disk, which stopped being true the moment S3-compatible storage was
|
||||
switched on. A share link to a file held in a bucket produced a broken download, and a public
|
||||
listing could not draw a thumbnail for one at all — while the same file downloaded and previewed
|
||||
correctly everywhere else, which made it look like the share link or the listing was at fault
|
||||
rather than where the file lived. Both now read the file from wherever it actually is. Nothing
|
||||
changes for installations keeping files on local disk, which is most of them.
|
||||
- The plain-text version of an email no longer shows the link twice, wrapped in brackets.
|
||||
- The message you get when an account would exceed a limit no longer reads "limited to 1 staff
|
||||
accounts".
|
||||
|
||||
- **One confirmation message instead of two.** Saving a new client, system user or role showed the
|
||||
same green "Client created." twice, stacked. So did deleting one. It was only ever cosmetic —
|
||||
nothing happened twice — but it read as though something had, which is the last thing a
|
||||
confirmation should do. Saves that stay on the same screen, such as the email settings, were never
|
||||
affected.
|
||||
([#1675](https://github.com/projectsend/projectsend/issues/1675), reported and diagnosed by
|
||||
[@denkfabrik-li](https://github.com/denkfabrik-li))
|
||||
### Upgrade notes
|
||||
|
||||
- **Connecting a provider to an account that already has one.** Signing in with Google, Microsoft or
|
||||
a custom provider worked, but attaching one to an existing account did not: the **Connect** button
|
||||
on Settings → Connected accounts appeared to do nothing at all. The button asks the server in the
|
||||
background, and the server answered by redirecting to the provider — a redirect a browser will not
|
||||
follow out of a background request to another site. The page sat there with no consent screen and
|
||||
no error to explain it, so the only reading available was that the button was dead. The server now
|
||||
tells the browser to go to the provider itself, and the flow starts as it should. Signing in from
|
||||
the login page was never affected, and neither is it now.
|
||||
([#1676](https://github.com/projectsend/projectsend/pull/1676), found and fixed by
|
||||
[@denkfabrik-li](https://github.com/denkfabrik-li))
|
||||
- **Nothing to do.** Drop in the new files and run `php artisan migrate` as usual; this release adds
|
||||
no migrations, no settings and no new environment values.
|
||||
|
||||
- **Downloads on a host where the web server is not PHP's user.** A download is not served by PHP:
|
||||
PHP checks permissions and then hands the web server the path to stream. Where the two run as
|
||||
different users — cPanel and Plesk commonly arrange it that way — the web server could not open
|
||||
the file, because uploads are written readable only by the account that wrote them. The rest of
|
||||
the site gave no sign of it: uploading worked, the library listed everything, and only downloads
|
||||
failed, in the browser as `ERR_INVALID_RESPONSE`. Setting `FILES_WEB_SERVER_READABLE=true` now
|
||||
writes uploads so the web server can read them. It is opt-in, and deliberately so — the modes it
|
||||
uses are readable by every account on the machine, which is the wrong trade on a server where the
|
||||
web server and PHP are the same user, as they are in the Docker image and on most servers people
|
||||
set up themselves. The install guide has the full procedure, including the one thing no
|
||||
application setting can fix: a PHP-FPM pool with a restrictive umask, which caps new directories
|
||||
no matter what ProjectSend asks for.
|
||||
([#1668](https://github.com/projectsend/projectsend/issues/1668), reported by
|
||||
[@denkfabrik-li](https://github.com/denkfabrik-li))
|
||||
- **One thing changes behaviour.** If somebody on your team has been deleting a folder as a way of
|
||||
clearing out files other people uploaded, that now refuses and says how many files are in the way.
|
||||
It is the same rule the file list has always applied one screen over — the folder was the way
|
||||
around it, and what it removed was not recoverable.
|
||||
|
||||
- **An installation that builds its own containers is no longer told to pull.** ProjectSend prints
|
||||
the update instructions for the way you installed it, and it had two answers where it needed
|
||||
three: anything running in a container was handed `docker compose pull && docker compose up -d`,
|
||||
including the Compose stack that builds from a checkout of the repository. There is no image
|
||||
behind those containers to pull, so both commands ran, reported success and changed nothing — and
|
||||
the dashboard went on offering the same release. Those installations are now recognised and given
|
||||
`git pull && docker compose up -d --build` instead, with the two extra steps a checkout needs when
|
||||
a release moves its dependencies or its frontend.
|
||||
([#1661](https://github.com/projectsend/projectsend/issues/1661), reported by
|
||||
[@mueller7382](https://github.com/mueller7382))
|
||||
Thanks to [@denkfabrik-li](https://github.com/denkfabrik-li), who reported, diagnosed and fixed
|
||||
every one of the above.
|
||||
|
||||
- **The dashboard no longer fails on shared hosting.** To decide which update instructions to print,
|
||||
ProjectSend asks whether it is running inside a container by looking for a file in the root of the
|
||||
filesystem. On shared hosting PHP is usually confined to your own directory, and looking outside it
|
||||
is treated as an error rather than as a "no" — so the one page that asks the question, the
|
||||
dashboard, returned a 500 while every other page worked. It now takes the restriction as the answer
|
||||
it always was: a server that keeps PHP inside a single directory is not our container image, and
|
||||
gets the manual update instructions, which is correct for shared hosting anyway. Nothing to change
|
||||
on your side, and no setting you would have been able to change if there were.
|
||||
([#1663](https://github.com/projectsend/projectsend/issues/1663), reported by
|
||||
[@denkfabrik-li](https://github.com/denkfabrik-li))
|
||||
### Issues closed since 2.2.0
|
||||
|
||||
- **502 Bad Gateway behind a reverse proxy.** Every page carried a `Link:` header listing its
|
||||
frontend assets, duplicating tags the page already had in its `<head>` — twenty of them on the
|
||||
login screen, more on a heavier page. nginx buffers a response's headers into a single block that
|
||||
defaults to 4 KB, so the file list, at over 6 KB of headers, was refused with `upstream sent too
|
||||
big header` and the proxy answered 502. Which pages went over depended on how many assets they
|
||||
loaded, so it looked like an intermittent fault: the login screen appeared, and then the
|
||||
application did not. The duplicate header is gone — the same pages now send under 1.3 KB — and no
|
||||
browser loses anything, because the tags it actually reads were always in the document. The
|
||||
install guide gained the proxy buffer settings for anyone on an older version or behind a proxy
|
||||
holding a tighter default.
|
||||
([#1664](https://github.com/projectsend/projectsend/issues/1664), reported by
|
||||
[@denkfabrik-li](https://github.com/denkfabrik-li))
|
||||
The summary above is what changed. This is the paper trail, for anyone who wants to read the
|
||||
original report.
|
||||
|
||||
- **`docker logs` now shows the web server's log.** The container runs nginx, PHP-FPM, the queue
|
||||
worker and the scheduler, and all of them reported to Docker except the one you need when a
|
||||
request fails: nginx opened the log files named in its own configuration and wrote to them inside
|
||||
the container, where nothing looks. The effect was that a proxy problem produced no logs on either
|
||||
side — the reason for every 502 and every 403 existed, in a file nobody knew to open. Both its
|
||||
access and error logs now go to the container's output, and the Docker guide has a section on
|
||||
running behind a reverse proxy that says which side a given message points at.
|
||||
- [#1706](https://github.com/projectsend/projectsend/issues/1706) — V1 migration imports $2a$ bcrypt hashes that cause HTTP 500 on login
|
||||
|
||||
## 2.2.0 — 27 August 2026
|
||||
|
||||
A big release. Most of it closes holes in who can see what. The rest is a handful of new things.
|
||||
|
||||
**New**
|
||||
|
||||
- Google Cloud Storage can hold your files, alongside S3.
|
||||
- You can set a maximum size for a zip download.
|
||||
- Zip downloads no longer hold up your email.
|
||||
- ProjectSend warns you if nothing is building your zip downloads.
|
||||
- A deleted account's email address can be used again.
|
||||
|
||||
**Closed holes in who can see what**
|
||||
|
||||
- A staff member limited to their own clients now stays limited everywhere: client records, file
|
||||
names, groups, comment moderation, and the dashboard's activity and expired-file lists.
|
||||
- Private notes on a publicly shared file stay private.
|
||||
- Clients no longer see the names of folders they cannot open.
|
||||
- The maximum file size now applies to large uploads too.
|
||||
- A download limit now holds when a zip is collected.
|
||||
- A large upload cannot be finished twice at once.
|
||||
- A two-factor recovery code can only be used once.
|
||||
- Your notification settings accept only the switches the screen offers.
|
||||
|
||||
**Fixed**
|
||||
|
||||
- People whose accounts came from ProjectSend v1 can sign in again.
|
||||
- Sessions no longer break behind a reverse proxy.
|
||||
- No more 502 Bad Gateway behind a reverse proxy.
|
||||
- Saving after your session expires takes you to the login page, not an error.
|
||||
- An upload that cannot be stored now fails instead of vanishing.
|
||||
- Downloads, thumbnails and share links work when files are kept in cloud storage.
|
||||
- A zip download is never offered when the archive was not actually written.
|
||||
- Deleting an account either finishes completely or does nothing at all.
|
||||
- A file can no longer be put into a folder that has been deleted.
|
||||
- Declining a group membership request now happens once, not twice.
|
||||
- Creating something with a create-only role no longer ends in an error page.
|
||||
- Connecting Google or Microsoft to an account you already have now works.
|
||||
- Downloads work on cPanel and Plesk, where the web server is not PHP's user.
|
||||
- The dashboard no longer fails on shared hosting.
|
||||
- An installation built from source is no longer told to pull an image.
|
||||
- `docker logs` now shows the web server's log.
|
||||
- The repair tool no longer mistakes cached previews for stray files.
|
||||
- One confirmation message instead of two.
|
||||
|
||||
**Before you upgrade, read the two notes below.** One of them needs you to do something if you
|
||||
installed ProjectSend by hand.
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
- **Manual installs: your background worker needs one more queue.** Building a zip download now runs
|
||||
on its own queue, so a worker started before this version watches the wrong one — it will keep
|
||||
sending email perfectly while no zip download ever finishes, and nothing in any log will say why.
|
||||
`update.sh` spots this and offers to fix the service file for you, keeping a copy of the old one,
|
||||
so for most people there is nothing to do but say yes. If you upgrade by hand, change the
|
||||
`ExecStart` line in `/etc/systemd/system/projectsend-worker.service` to read
|
||||
`queue:work --queue=default,zips …`, then `sudo systemctl daemon-reload && sudo systemctl restart
|
||||
projectsend-worker`. INSTALL.md has the full file, and the two-worker setup if you would rather
|
||||
keep the two kinds of work apart. Docker installations need no change.
|
||||
|
||||
If it is ever missed, ProjectSend now says so on screen: staff who can see system information get
|
||||
a banner naming the problem and the fix.
|
||||
|
||||
- **If you run behind a reverse proxy, check `TRUSTED_PROXIES`.** It is now read correctly, which it
|
||||
was not before. Set it in `.env`, and do not run `config:cache`, which stops `.env` being read at
|
||||
all.
|
||||
|
||||
Thanks to [@denkfabrik-li](https://github.com/denkfabrik-li), who found, diagnosed and fixed most
|
||||
of the boundary work above, and to [@mstewart14](https://github.com/mstewart14),
|
||||
[@elibrachas](https://github.com/elibrachas), [@mueller7382](https://github.com/mueller7382) and
|
||||
[@pabloalvarez44](https://github.com/pabloalvarez44) for reports and fixes.
|
||||
|
||||
### Issues closed since 2.1.0
|
||||
|
||||
The summary above is what changed. This is the paper trail, for anyone who wants to read the
|
||||
original report.
|
||||
|
||||
- [#1627](https://github.com/projectsend/projectsend/issues/1627) — Errors while installing via Docker
|
||||
- [#1648](https://github.com/projectsend/projectsend/issues/1648) — A deleted account's email address can never be used again
|
||||
- [#1661](https://github.com/projectsend/projectsend/issues/1661) — Docker update instructions do not update ProjectSend when using official Compose setup
|
||||
- [#1662](https://github.com/projectsend/projectsend/issues/1662) — Preview files not available on v2.1.0
|
||||
- [#1663](https://github.com/projectsend/projectsend/issues/1663) — Dashboard 500s on shared hosting: container detection trips open_basedir
|
||||
- [#1664](https://github.com/projectsend/projectsend/issues/1664) — INSTALL.md: the nginx-in-front-of-Apache path needs the buffer advice too
|
||||
- [#1668](https://github.com/projectsend/projectsend/issues/1668) — INSTALL.md: X-Accel downloads fail when nginx and PHP-FPM run as different users
|
||||
- [#1672](https://github.com/projectsend/projectsend/issues/1672) — Projectsend 2 behind Traefik issues 419 when logging in or hitting an error?
|
||||
- [#1673](https://github.com/projectsend/projectsend/issues/1673) — Projectsend 2: Setting Widget Columns throws error
|
||||
- [#1675](https://github.com/projectsend/projectsend/issues/1675) — Success toast shows twice after create/delete redirects
|
||||
- [#1706](https://github.com/projectsend/projectsend/issues/1706) — V1 migration imports $2a$ bcrypt hashes that cause HTTP 500 on login
|
||||
|
||||
## 2.1.0 — 18 August 2026
|
||||
|
||||
|
||||
+10
-1
@@ -384,7 +384,7 @@ User=www-data
|
||||
Group=www-data
|
||||
Restart=always
|
||||
WorkingDirectory=/var/www/projectsend
|
||||
ExecStart=/usr/bin/php artisan queue:work --tries=3 --backoff=3
|
||||
ExecStart=/usr/bin/php artisan queue:work --queue=default,zips --tries=3 --backoff=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -396,6 +396,15 @@ Then:
|
||||
sudo systemctl enable --now projectsend-worker
|
||||
```
|
||||
|
||||
`--queue=default,zips` matters. Building a zip runs on its own queue, so a worker that is not told
|
||||
to watch `zips` will send email happily and never finish a single zip download — with nothing in any
|
||||
log to say why. One worker watching both is fine for most installations; ordinary work is taken
|
||||
first, and a large zip simply holds the worker while it runs.
|
||||
|
||||
If zip downloads are heavily used and you would rather they never delayed email, run a second unit
|
||||
with `--queue=zips` and narrow the first one to `--queue=default`. That is what the Docker images
|
||||
do.
|
||||
|
||||
**Without this, no email is ever sent** and zip downloads never finish. `Restart=always` matters
|
||||
too: saving your email settings restarts the worker so it picks up the new values, and it needs to
|
||||
come back on its own.
|
||||
|
||||
@@ -8,6 +8,8 @@ use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Clients\ClientFieldContext;
|
||||
use App\Modules\Clients\ClientPortalCustomFields;
|
||||
use App\Modules\Identity\Erasure\ErasureSchedule;
|
||||
use App\Modules\Identity\StaffAccounts;
|
||||
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
@@ -23,6 +25,7 @@ class ProfileController extends Controller
|
||||
public function __construct(
|
||||
private readonly ClientPortalCustomFields $customFields,
|
||||
private readonly TimezoneRegistry $timezones,
|
||||
private readonly StaffAccounts $accounts,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -103,12 +106,24 @@ class ProfileController extends Controller
|
||||
$user = $request->user();
|
||||
assert($user !== null);
|
||||
|
||||
// The rule every other door into this already asks: Staff update(),
|
||||
// guardDeletable(), and both role-conversion directions. This one
|
||||
// did not, and self-deletion is the one door where the account
|
||||
// being removed is certainly signed in — so the last active
|
||||
// administrator could take themselves out, leaving no live staff
|
||||
// row at all. EnsureSetupIsComplete then reopens first-run setup to
|
||||
// anybody who asks, which is the other half of this and is closed
|
||||
// below.
|
||||
$this->accounts->guardLastAdministrator(
|
||||
$user,
|
||||
removesAdmin: $this->accounts->isAdministratorRole($user->role_id),
|
||||
);
|
||||
|
||||
Auth::logout();
|
||||
|
||||
// Self-deletion: soft delete now, permanent GDPR erasure after
|
||||
// the disclosed grace period (Setting::AccountErasureGraceDays).
|
||||
$graceDays = (int) app(Settings::class)->get(Setting::AccountErasureGraceDays);
|
||||
$user->forceFill(['erase_after' => now()->addDays($graceDays)])->save();
|
||||
app(ErasureSchedule::class)->apply($user);
|
||||
$user->delete();
|
||||
|
||||
app(ActivityLogger::class)->log(Action::UserDeleted, $user, context: ['name' => $user->name]);
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Modules\Platform\Attribution\Attribution;
|
||||
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||
use App\Modules\Platform\Captcha\Captcha;
|
||||
use App\Modules\Platform\Installation\Installation;
|
||||
use App\Modules\Files\Queue\StalledZipBuilds;
|
||||
use App\Modules\Platform\Localization\LocaleRegistry;
|
||||
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||
use App\Modules\Platform\OfficialLinks;
|
||||
@@ -102,6 +103,7 @@ class HandleInertiaRequests extends Middleware
|
||||
'pending' => $this->pendingCounts($request),
|
||||
'update_notice' => $this->updateNotice($request),
|
||||
'code_notice' => $this->codeNotice($request),
|
||||
'worker_notice' => $this->workerNotice($request),
|
||||
'locale' => app()->getLocale(),
|
||||
// The clock this viewer reads dates by, and whether it is a
|
||||
// choice or a fallback. The frontend needs both: the first to
|
||||
@@ -145,10 +147,14 @@ class HandleInertiaRequests extends Middleware
|
||||
}
|
||||
|
||||
if ($checker->allows($user, Permission::ApproveGroupsMembershipsRequests)) {
|
||||
// Narrowed like the queue it badges, and by the same scope —
|
||||
// a client-scoped staff member is not shown a number they
|
||||
// cannot act on. Same rule the comments badge below states.
|
||||
$counts['membership_requests'] = MembershipRequest::query()
|
||||
->pending()
|
||||
->whereHas('user')
|
||||
->whereHas('group')
|
||||
->approvableBy($user)
|
||||
->count();
|
||||
}
|
||||
|
||||
@@ -245,6 +251,29 @@ class HandleInertiaRequests extends Middleware
|
||||
return app(RunningCodeState::class)->current();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether anything is serving the queue zip builds run on.
|
||||
*
|
||||
* Gated on view_system_info for the reason codeNotice() gives above:
|
||||
* a background worker that is not picking work up is a fact about the
|
||||
* machine rather than a feature of an edition. Same audience, same
|
||||
* banner slot, one question further along.
|
||||
*
|
||||
* @return array{waiting_since: string}|null
|
||||
*/
|
||||
protected function workerNotice(Request $request): ?array
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user === null || ! app(PermissionChecker::class)->allows($user, Permission::ViewSystemInfo)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$waitingSince = app(StalledZipBuilds::class)->oldestUnstarted();
|
||||
|
||||
return $waitingSince === null ? null : ['waiting_since' => $waitingSince->toIso8601String()];
|
||||
}
|
||||
|
||||
/**
|
||||
* App strings use English text as the translation key, so "en" ships no
|
||||
* messages — the key itself is the fallback.
|
||||
|
||||
@@ -16,12 +16,12 @@ use League\CommonMark\MarkdownConverter;
|
||||
/**
|
||||
* The API reference, inside the admin UI.
|
||||
*
|
||||
* Rendered from the two files that are already the source of truth — the
|
||||
* committed OpenAPI document and docs/api-guide.md — rather than embedding
|
||||
* a third-party documentation UI. An iframe or a CDN-hosted renderer would
|
||||
* mean a page that ignores the app's theme, breaks its links, and goes
|
||||
* blank on an install with no outbound internet access, which self-hosted
|
||||
* installations regularly are.
|
||||
* Rendered from the files that are already the source of truth — the
|
||||
* committed OpenAPI document, docs/api-guide.md and docs/api-zapier.md —
|
||||
* rather than embedding a third-party documentation UI. An iframe or a
|
||||
* CDN-hosted renderer would mean a page that ignores the app's theme,
|
||||
* breaks its links, and goes blank on an install with no outbound internet
|
||||
* access, which self-hosted installations regularly are.
|
||||
*
|
||||
* The markdown is converted server-side with league/commonmark, already a
|
||||
* framework dependency, so no JavaScript renderer joins the bundle.
|
||||
@@ -31,7 +31,11 @@ class ApiDocsController extends Controller
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
return Inertia::render('api/docs', [
|
||||
'guide_html' => $this->guideHtml(),
|
||||
'guide_html' => $this->markdown('docs/api-guide.md'),
|
||||
// A second page rather than a section of the guide: the guide
|
||||
// is written for someone building against the API, this is
|
||||
// written for someone wiring up a Zap and reading nothing else.
|
||||
'zapier_html' => $this->markdown('docs/api-zapier.md'),
|
||||
'endpoints' => $this->endpoints(),
|
||||
'spec_url' => route('api.openapi'),
|
||||
'version' => $this->spec()['info']['version'] ?? null,
|
||||
@@ -96,16 +100,20 @@ class ApiDocsController extends Controller
|
||||
return $position === false ? '' : substr($description, $position);
|
||||
}
|
||||
|
||||
private function guideHtml(): string
|
||||
/**
|
||||
* @param string $file repository-relative path to a markdown file
|
||||
* shipped with the application
|
||||
*/
|
||||
private function markdown(string $file): string
|
||||
{
|
||||
$path = base_path('docs/api-guide.md');
|
||||
$path = base_path($file);
|
||||
|
||||
if (! is_file($path)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// A deliberately small extension set. The guide is a file shipped
|
||||
// with the application, not user input — but rendering it with the
|
||||
// A deliberately small extension set. These are files shipped with
|
||||
// the application, not user input — but rendering them with the
|
||||
// narrowest converter that does the job keeps it that way even if
|
||||
// someone later points this at something less trustworthy.
|
||||
$environment = new Environment([
|
||||
|
||||
@@ -29,6 +29,13 @@ use Illuminate\Support\Carbon;
|
||||
* one forever. The cost is re-seeing the boundary row, which a client
|
||||
* de-duplicates by id — the safe direction of the trade.
|
||||
*
|
||||
* Some tables have no `updated_at` because their rows are never edited —
|
||||
* the activity log is one. They pass their own column instead. The
|
||||
* *parameter* stays `updated_since` for every endpoint even so: the
|
||||
* shape being learned once is worth more than a second name that would
|
||||
* behave identically, since on an append-only table the two timestamps
|
||||
* are the same thing.
|
||||
*
|
||||
* Known limitation, documented rather than papered over: polling cannot
|
||||
* observe deletions. A soft-deleted row simply stops appearing. Webhooks
|
||||
* are the fix, and are deliberately a later phase.
|
||||
@@ -39,9 +46,11 @@ class PollingQuery
|
||||
* @template TModel of Model
|
||||
*
|
||||
* @param Builder<TModel> $query
|
||||
* @param string $column the timestamp to walk, for a table whose
|
||||
* rows are appended rather than edited
|
||||
* @return CursorPaginator<int, TModel>
|
||||
*/
|
||||
public function paginate(Request $request, Builder $query, string $table): CursorPaginator
|
||||
public function paginate(Request $request, Builder $query, string $table, string $column = 'updated_at'): CursorPaginator
|
||||
{
|
||||
$since = $request->query('updated_since');
|
||||
|
||||
@@ -53,11 +62,11 @@ class PollingQuery
|
||||
// a polling client would see an empty result forever instead of
|
||||
// an error. Carbon also normalises the offset into the app's
|
||||
// timezone, so a caller in any timezone gets the same rows.
|
||||
$query->where("{$table}.updated_at", '>=', Carbon::parse($since)->timezone(config('app.timezone')))
|
||||
->orderBy("{$table}.updated_at")
|
||||
$query->where("{$table}.{$column}", '>=', Carbon::parse($since)->timezone(config('app.timezone')))
|
||||
->orderBy("{$table}.{$column}")
|
||||
->orderBy("{$table}.id");
|
||||
} else {
|
||||
$query->orderByDesc("{$table}.updated_at")
|
||||
$query->orderByDesc("{$table}.{$column}")
|
||||
->orderByDesc("{$table}.id");
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,12 @@ declare(strict_types=1);
|
||||
namespace App\Modules\Audit;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Events\ResolvingActivityOrigin;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
|
||||
class ActivityLogger
|
||||
{
|
||||
@@ -35,16 +37,20 @@ class ActivityLogger
|
||||
// gaps. Reading the current request's credential is the same kind of
|
||||
// implicit lookup this class already does for the actor and the IP.
|
||||
$token = $user?->currentAccessToken();
|
||||
[$origin, $credentialName] = $this->originFor($user, $token);
|
||||
|
||||
ActivityLog::query()->create([
|
||||
'actor_id' => $user?->getKey(),
|
||||
'actor_name' => $user?->name,
|
||||
'actor_type' => $user?->type->value,
|
||||
'origin' => $this->originFor($user, $token),
|
||||
'origin' => $origin,
|
||||
// Only ever a personal access token's id — the column means a
|
||||
// row in that table, and a credential that is not one leaves
|
||||
// it null and identifies itself by name alone.
|
||||
'api_token_id' => $token?->getKey(),
|
||||
// Snapshotted beside the id for the same reason actor_name is:
|
||||
// a revoked token must not leave its entries pointing at nothing.
|
||||
'api_token_name' => $token?->getAttribute('name'),
|
||||
'api_token_name' => $credentialName,
|
||||
'action' => $action,
|
||||
'subject_type' => $subject?->getMorphClass(),
|
||||
'subject_id' => $subject?->getKey(),
|
||||
@@ -77,18 +83,34 @@ class ActivityLogger
|
||||
* untestable — the failure mode being that it looks right in
|
||||
* production and nothing proves it. A console command and a queued job
|
||||
* have no route; a request does.
|
||||
*
|
||||
* @return array{ActivityOrigin, ?string} the origin, and what to
|
||||
* record the credential as —
|
||||
* null when there is no
|
||||
* credential to name
|
||||
*/
|
||||
private function originFor(?User $actor, mixed $token): ActivityOrigin
|
||||
private function originFor(?User $actor, mixed $token): array
|
||||
{
|
||||
if ($token !== null) {
|
||||
return ActivityOrigin::Api;
|
||||
$name = $token->getAttribute('name');
|
||||
|
||||
return [ActivityOrigin::Api, is_string($name) ? $name : null];
|
||||
}
|
||||
|
||||
if ($actor !== null) {
|
||||
return ActivityOrigin::Ui;
|
||||
if ($actor === null) {
|
||||
return [request()->route() === null ? ActivityOrigin::System : ActivityOrigin::Public, null];
|
||||
}
|
||||
|
||||
return request()->route() === null ? ActivityOrigin::System : ActivityOrigin::Public;
|
||||
// An actor and no personal access token has always meant a browser
|
||||
// session, and for a long time nothing else could authenticate a
|
||||
// request. Ask before assuming it: a credential core does not know
|
||||
// about would otherwise be recorded as a person clicking, which is
|
||||
// the one thing this column exists not to get wrong. Nothing
|
||||
// listens on a stock installation, so the answer stays Ui.
|
||||
$asking = new ResolvingActivityOrigin($actor);
|
||||
Event::dispatch($asking);
|
||||
|
||||
return [$asking->origin ?? ActivityOrigin::Ui, $asking->credentialName];
|
||||
}
|
||||
|
||||
private function shouldRecordIp(Action $action, ?User $actor): bool
|
||||
|
||||
@@ -38,6 +38,18 @@ enum ActivityOrigin: string
|
||||
/** Scheduled tasks and console commands. */
|
||||
case System = 'system';
|
||||
|
||||
/**
|
||||
* An AI assistant acting for a signed-in person, through a connector
|
||||
* they authorised — the code that can produce this ships in
|
||||
* projectsend/cloud-modules and nowhere else.
|
||||
*
|
||||
* The person stays the actor: they authorised it, and an audit trail
|
||||
* that named the assistant instead would lose the only fact that
|
||||
* matters when something unexpected shows up. What the connector was
|
||||
* called goes beside the entry, the way an API token's name does.
|
||||
*/
|
||||
case Mcp = 'mcp';
|
||||
|
||||
/**
|
||||
* English label — also the translation key.
|
||||
*/
|
||||
@@ -48,6 +60,7 @@ enum ActivityOrigin: string
|
||||
self::Api => 'API',
|
||||
self::Public => 'Not signed in',
|
||||
self::System => 'System',
|
||||
self::Mcp => 'AI assistant',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Audit\Events;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\ActivityOrigin;
|
||||
|
||||
/**
|
||||
* "This request has a signed-in actor and no personal access token — was
|
||||
* it really a browser?"
|
||||
*
|
||||
* Asked only in that one ambiguous case. A request carrying a Sanctum
|
||||
* token is the API, a request with nobody signed in is public or system,
|
||||
* and neither is in any doubt — so neither is offered here.
|
||||
*
|
||||
* The doubt exists because "no token" has always meant "a session", and
|
||||
* that stops being true the moment anything else can authenticate a
|
||||
* request. `ActivityOrigin` is a closed enum a package cannot extend, so
|
||||
* core has to publish both the case and this hook before a package can
|
||||
* say "that was mine". Without it a new credential would be recorded as
|
||||
* a person clicking in a browser — silently, and in the one table whose
|
||||
* whole purpose is answering "did I do that, or did something acting for
|
||||
* me?"
|
||||
*
|
||||
* Set `$origin` only if you recognise the credential on the current
|
||||
* request. Leaving it null means "not mine", which is the honest answer
|
||||
* for every listener that is not looking at its own guard.
|
||||
*
|
||||
* Listened to by *string* class name from a package, same as every other
|
||||
* hook here — see docs/extension-points-architecture.md.
|
||||
*/
|
||||
final class ResolvingActivityOrigin
|
||||
{
|
||||
/**
|
||||
* What actually authenticated this request. Null until a listener
|
||||
* claims it, after which core stops assuming a browser session.
|
||||
*/
|
||||
public ?ActivityOrigin $origin = null;
|
||||
|
||||
/**
|
||||
* What to show beside the entry — the name of the connector or
|
||||
* application acting, not the person. Snapshotted into the same
|
||||
* column an API token's name goes in, for the same reason: revoking
|
||||
* the credential must not leave the entry pointing at nothing.
|
||||
*/
|
||||
public ?string $credentialName = null;
|
||||
|
||||
public function __construct(
|
||||
public readonly User $actor,
|
||||
) {}
|
||||
}
|
||||
@@ -76,10 +76,19 @@ class ActivityLogController extends Controller
|
||||
'key' => $action->value,
|
||||
'description' => $action->description(),
|
||||
], Action::cases()),
|
||||
'origins' => array_map(fn (ActivityOrigin $origin): array => [
|
||||
'key' => $origin->value,
|
||||
'label' => $origin->label(),
|
||||
], ActivityOrigin::cases()),
|
||||
// Every origin this installation could actually produce.
|
||||
// Offering a filter that can only ever return nothing would
|
||||
// be dangling a feature this edition does not have, which is
|
||||
// the one thing the edition boundary is meant not to do.
|
||||
'origins' => collect(ActivityOrigin::cases())
|
||||
->reject(fn (ActivityOrigin $origin): bool => $origin === ActivityOrigin::Mcp
|
||||
&& ! $this->capabilities->has(Capability::AiConnector))
|
||||
->map(fn (ActivityOrigin $origin): array => [
|
||||
'key' => $origin->value,
|
||||
'label' => $origin->label(),
|
||||
])
|
||||
->values()
|
||||
->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Audit\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Api\Support\PollingQuery;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Audit\ActivityLogScope;
|
||||
use App\Modules\Audit\Http\Resources\Api\ActivityResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* What has happened in this installation.
|
||||
*
|
||||
* The endpoint automation tools actually need. Every other list here
|
||||
* answers "what is there now"; a caller that wants to *react* — post to
|
||||
* Slack when a file is shared, add a row when a client downloads one —
|
||||
* needs to know that something happened, and the shape of the thing
|
||||
* afterwards does not say. Sharing a file writes an assignment row and
|
||||
* never touches the file, so polling the file list cannot see it at all.
|
||||
*
|
||||
* One feed rather than one endpoint per event, because the log already
|
||||
* records every one of them and a caller filtering by `action` gets any
|
||||
* event the application ever grows without waiting for an endpoint.
|
||||
*/
|
||||
class ActivityController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PollingQuery $polling,
|
||||
private readonly ActivityLogScope $scope,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* List activity, newest first.
|
||||
*
|
||||
* Filter by `action` — repeat the parameter for more than one, as
|
||||
* `?action[]=file.assigned&action[]=file.downloaded`. `subject_type`
|
||||
* narrows to one kind of thing (`file`, `user`, `group`, …).
|
||||
*
|
||||
* Entries are never edited, so `updated_since` walks the moment each
|
||||
* one was recorded. Everything else about polling is the shape every
|
||||
* list endpoint here shares.
|
||||
*
|
||||
* Scoped to what the caller may read: a staff member limited to their
|
||||
* assigned clients sees entries about their own library and their own
|
||||
* actions, never the whole installation's.
|
||||
*/
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$filters = $request->validate($this->polling->rules() + [
|
||||
'action' => ['nullable', 'array'],
|
||||
'action.*' => [Rule::enum(Action::class)],
|
||||
'subject_type' => ['nullable', 'string', 'max:64'],
|
||||
]);
|
||||
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
$query = $this->scope->apply(ActivityLog::query(), $viewer);
|
||||
|
||||
if (($filters['action'] ?? []) !== []) {
|
||||
$query->whereIn('action', $filters['action']);
|
||||
}
|
||||
|
||||
if (($filters['subject_type'] ?? null) !== null) {
|
||||
$query->where('subject_type', $this->subjectClass($filters['subject_type']));
|
||||
}
|
||||
|
||||
// created_at, not updated_at: the log is appended to and never
|
||||
// edited, and has no updated_at column to walk.
|
||||
return ActivityResource::collection(
|
||||
$this->polling->paginate($request, $query, 'activity_log', 'created_at')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The public name for a kind of subject, back to the class the column
|
||||
* actually holds. An unknown name matches nothing rather than
|
||||
* everything — a filter that silently ignores what it was given would
|
||||
* hand back the whole log to a caller who asked for one slice of it.
|
||||
*/
|
||||
private function subjectClass(string $type): string
|
||||
{
|
||||
return array_search($type, ActivityResource::subjects(), true) ?: '__no_such_subject__';
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,11 @@ use App\Models\User;
|
||||
use App\Modules\Api\ApiUsage;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Audit\ActivityLogScope;
|
||||
use App\Modules\Audit\ActivityPresenter;
|
||||
use App\Modules\Audit\DashboardWidgetPreferences;
|
||||
use App\Modules\Clients\ClientStorageUsage;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use App\Modules\Identity\UserType;
|
||||
@@ -24,6 +27,7 @@ use App\Modules\Platform\Settings\Settings;
|
||||
use App\Modules\Platform\Storage\StorageDurability;
|
||||
use App\Modules\Platform\System\SystemEnvironment;
|
||||
use App\Modules\Platform\Updates\LatestReleaseInfo;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -50,6 +54,9 @@ class DashboardController extends Controller
|
||||
private readonly Installation $installation,
|
||||
private readonly TimezoneRegistry $timezones,
|
||||
private readonly SystemEnvironment $environment,
|
||||
private readonly ActivityPresenter $presenter,
|
||||
private readonly ActivityLogScope $scope,
|
||||
private readonly StaffLibraryScope $library,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): Response
|
||||
@@ -81,10 +88,10 @@ class DashboardController extends Controller
|
||||
? ['preset' => $preset, 'from' => $from->toDateString(), 'to' => $to->toDateString()]
|
||||
: null,
|
||||
'top_clients_by_storage' => $canStatistics && $prefs->isEnabled($user, 'top_clients_by_storage')
|
||||
? $this->topClientsByStorage()
|
||||
? $this->topClientsByStorage($user)
|
||||
: null,
|
||||
'largest_files' => $canStatistics && $prefs->isEnabled($user, 'largest_files') ? $this->largestFiles($user) : null,
|
||||
'recent' => $canActionsLog && $prefs->isEnabled($user, 'recent') ? $this->recentActivity() : null,
|
||||
'recent' => $canActionsLog && $prefs->isEnabled($user, 'recent') ? $this->recentActivity($user) : null,
|
||||
'system' => $canSystem && $prefs->isEnabled($user, 'system') ? $this->systemInfo() : null,
|
||||
// Both editions — informational content, not an update action,
|
||||
// so no Capability check alongside the permission (unlike
|
||||
@@ -206,6 +213,12 @@ class DashboardController extends Controller
|
||||
*/
|
||||
private function counters(): array
|
||||
{
|
||||
// Deliberately installation-wide, unlike the three widgets below.
|
||||
// A total carries no names — "417 files" tells a scoped viewer
|
||||
// nothing about whose they are — and the same reasoning leaves
|
||||
// transferSeries() alone. If that ever stops being the line, both
|
||||
// move together.
|
||||
|
||||
return [
|
||||
'files' => File::query()->count(),
|
||||
'files_bytes' => (int) File::query()->sum('size'),
|
||||
@@ -276,11 +289,21 @@ class DashboardController extends Controller
|
||||
*
|
||||
* @return list<array{id: int, name: string, used_bytes: int, quota_mb: int}>
|
||||
*/
|
||||
private function topClientsByStorage(): array
|
||||
private function topClientsByStorage(User $viewer): array
|
||||
{
|
||||
// Narrowed by roster, not by library. This widget names *clients*,
|
||||
// and files() is the wrong lens for that: a stranger client's
|
||||
// upload can be inside a scoped viewer's library — shared with a
|
||||
// group one of their own clients is in — which put the stranger's
|
||||
// name on the widget. Measured: a client called "Stranger Client
|
||||
// Ltd", on nobody's roster, ranked on a scoped dashboard.
|
||||
// assignableClientIds is the question actually being asked.
|
||||
$clientIds = $this->library->assignableClientIds($viewer);
|
||||
|
||||
$rows = File::query()
|
||||
->select('uploaded_by', DB::raw('SUM(size) as total_bytes'))
|
||||
->whereHas('uploader', fn ($query) => $query->where('type', UserType::Client))
|
||||
->when($clientIds !== null, fn (Builder $query) => $query->whereIn('uploaded_by', $clientIds))
|
||||
->groupBy('uploaded_by')
|
||||
->orderByDesc('total_bytes')
|
||||
->limit(5)
|
||||
@@ -338,7 +361,14 @@ class DashboardController extends Controller
|
||||
$staffModule = $this->capabilities->has(Capability::UsersManage) && $viewer->can('manage_users');
|
||||
$canStaffUsers = $staffModule && $viewer->can('edit_users');
|
||||
|
||||
return array_values(File::query()
|
||||
// Narrowed to the viewer's library, not just its links. The
|
||||
// note above is about a link that 403s; a row that should not be
|
||||
// here at all is a different problem, and the file's *name* is
|
||||
// the part that leaks — "Q3 delinquent accounts" says plenty
|
||||
// without being downloadable. Scoping the query costs one call:
|
||||
// StaffLibraryScope builds a scoped user's query once per
|
||||
// request, so this is not a per-row check.
|
||||
return array_values($this->library->files($viewer)
|
||||
->with('uploader:id,name,type')
|
||||
->orderByDesc('size')
|
||||
->limit(10)
|
||||
@@ -377,8 +407,21 @@ class DashboardController extends Controller
|
||||
$canFiles = $viewer->can('upload') || $viewer->can('edit_files') || $viewer->can('edit_others_files');
|
||||
|
||||
return [
|
||||
'count' => File::query()->expired()->count(),
|
||||
'files' => array_values(File::query()->expired()->orderBy('expires_at')->limit(10)
|
||||
// Both the count and the list read the viewer's library, so
|
||||
// the number cannot describe files the list is not allowed to
|
||||
// name. Same reason largestFiles() is scoped.
|
||||
//
|
||||
// Narrower than it looks for a client-scoped viewer:
|
||||
// File::scopeVisibleToClient ends in notExpired(), so an
|
||||
// expired file belonging to one of their clients is not in
|
||||
// their library, and only their own expired uploads reach
|
||||
// this list. Rather than widen the boundary — which would
|
||||
// mean a library query that keeps expired rows, and
|
||||
// scopeVisibleToClient is the single source of truth for
|
||||
// client file access — the widget says what it is showing.
|
||||
// `scoped` is how it knows to.
|
||||
'count' => $this->library->files($viewer)->expired()->count(),
|
||||
'files' => array_values($this->library->files($viewer)->expired()->orderBy('expires_at')->limit(10)
|
||||
->get(['id', 'name', 'expires_at'])
|
||||
->map(fn (File $file): array => [
|
||||
'id' => $file->id,
|
||||
@@ -386,6 +429,10 @@ class DashboardController extends Controller
|
||||
'expires_at' => $file->expires_at?->toIso8601String(),
|
||||
'edit_url' => $canFiles ? route('files.edit', $file->id, false) : null,
|
||||
])->all()),
|
||||
// Whether this list is "everything expired" or "everything of
|
||||
// yours that expired" — a widget whose whole job is warning
|
||||
// about what is due to be deleted has to say which it means.
|
||||
'scoped' => $viewer->isClientScoped(),
|
||||
'auto_delete_enabled' => (bool) $this->settings->get(Setting::ExpiredFilesAutoDeleteEnabled),
|
||||
// Schedule::command('projectsend:purge-expired-files')->daily()
|
||||
// runs at 00:00 — always "tonight" from whenever this loads.
|
||||
@@ -396,28 +443,27 @@ class DashboardController extends Controller
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function recentActivity(): array
|
||||
private function recentActivity(User $viewer): array
|
||||
{
|
||||
return ActivityLog::query()
|
||||
// Narrowed through ActivityLogScope, exactly as the activity page and
|
||||
// the download history are. `view_actions_log` is not the whole
|
||||
// answer for a client-scoped viewer: a log entry carries the
|
||||
// subject's name, so an unscoped one reads out the name of every
|
||||
// file in the installation and who touched it, to somebody who gets
|
||||
// a 403 on the files themselves. The Client Manager role ships with
|
||||
// the permission, so this is the default configuration.
|
||||
//
|
||||
// Presented through the shared ActivityPresenter, not rebuilt inline —
|
||||
// the same sentence-ready shape the activity page and detail panels
|
||||
// use. Rebuilding it here once dropped `origin`, which is the only
|
||||
// thing that tells an actorless "Anonymous" entry from a "System" one.
|
||||
return $this->scope->apply(ActivityLog::query(), $viewer)
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id')
|
||||
->limit(8)
|
||||
->get()
|
||||
->map(fn (ActivityLog $entry): array => [
|
||||
'id' => $entry->id,
|
||||
'created_at' => $entry->created_at->toIso8601String(),
|
||||
'actor_name' => $entry->actor_name,
|
||||
'actor_type' => $entry->actor_type,
|
||||
'template' => $entry->action->template(),
|
||||
'replacements' => [
|
||||
'subject' => $entry->subject_name
|
||||
?? ($entry->subject_id !== null ? __('(deleted account)') : ''),
|
||||
...collect($entry->context ?? [])
|
||||
->filter(fn ($value): bool => is_scalar($value))
|
||||
->map(fn ($value): string => (string) $value)
|
||||
->all(),
|
||||
],
|
||||
])->all();
|
||||
->map(fn (ActivityLog $entry): array => $this->presenter->present($entry))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Audit\Http\Resources\Api;
|
||||
|
||||
use App\Modules\Audit\ActivityLog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* One entry in the activity log, as an integration reads it.
|
||||
*
|
||||
* Deliberately not ActivityPresenter's shape. That one exists to render a
|
||||
* sentence, so it hands back a template and the words to slot into it —
|
||||
* right for a screen, useless to a caller that wants to branch on what
|
||||
* happened. Here the action is a key, the subject is an object, and the
|
||||
* specifics stay in `context`.
|
||||
*
|
||||
* @mixin ActivityLog
|
||||
*/
|
||||
class ActivityResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Class names are internal structure and must never reach the wire:
|
||||
* moving a model between namespaces would otherwise be a breaking API
|
||||
* change, and `/api/v1` is a frozen contract. These strings are the
|
||||
* contract instead — add to this map when a new kind of thing becomes
|
||||
* a subject, and never rename an entry in it.
|
||||
*
|
||||
* @var array<class-string, string>
|
||||
*/
|
||||
private const SUBJECTS = [
|
||||
\App\Models\User::class => 'user',
|
||||
\App\Modules\Files\Models\File::class => 'file',
|
||||
\App\Modules\Files\Models\Folder::class => 'folder',
|
||||
\App\Modules\Files\Models\Category::class => 'category',
|
||||
\App\Modules\Groups\Models\Group::class => 'group',
|
||||
\App\Modules\Identity\Models\Role::class => 'role',
|
||||
\App\Modules\Clients\Models\ClientCustomField::class => 'client_custom_field',
|
||||
];
|
||||
|
||||
/**
|
||||
* The map, for the controller's reverse lookup.
|
||||
*
|
||||
* @return array<class-string, string>
|
||||
*/
|
||||
public static function subjects(): array
|
||||
{
|
||||
return self::SUBJECTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'action' => $this->action->value,
|
||||
'created_at' => $this->created_at->toIso8601String(),
|
||||
|
||||
// Snapshots, not joins. The actor may since have been deleted,
|
||||
// and the entry still has to say who it was.
|
||||
'actor' => $this->actor_id === null && $this->actor_name === null ? null : [
|
||||
'id' => $this->actor_id,
|
||||
'name' => $this->actor_name,
|
||||
'type' => $this->actor_type,
|
||||
],
|
||||
|
||||
// How it arrived: a person in the browser, an integration, a
|
||||
// visitor with no account, or the installation itself.
|
||||
'origin' => $this->origin->value,
|
||||
|
||||
'subject' => $this->subject_type === null ? null : [
|
||||
'type' => self::SUBJECTS[$this->subject_type] ?? 'other',
|
||||
'id' => $this->subject_id,
|
||||
'name' => $this->subject_name,
|
||||
],
|
||||
|
||||
// Whatever the action recorded beyond its subject — who a file
|
||||
// was shared with, how many files a cascade removed. Shape
|
||||
// varies by action and is documented per action rather than
|
||||
// here.
|
||||
'context' => $this->context ?? [],
|
||||
|
||||
// ip_address is deliberately absent. It is stored for some
|
||||
// actions and shown on the activity screen, but handing a
|
||||
// client's IP to an automation tool is a privacy expansion
|
||||
// with no matching use — see docs/api-todo.md.
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use App\Modules\Identity\Models\Role;
|
||||
use App\Modules\Identity\Permissions\SystemRole;
|
||||
use App\Modules\Identity\UserType;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Seats\SeatAllowance;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
@@ -36,6 +37,7 @@ class ClientProvisioning
|
||||
public function __construct(
|
||||
private readonly Settings $settings,
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly SeatAllowance $seats,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -70,6 +72,15 @@ class ClientProvisioning
|
||||
): User {
|
||||
$autoApprove ??= $this->autoApproves();
|
||||
|
||||
// Only when the account arrives already approved. A request that
|
||||
// still needs a decision is not yet a client this installation has
|
||||
// taken on, and counting one would let a stranger exhaust a paid
|
||||
// limit from the registration form — see SeatAllowance. The guard
|
||||
// for those sits on approval instead.
|
||||
if ($autoApprove) {
|
||||
$this->seats->guardClient();
|
||||
}
|
||||
|
||||
$client = User::create([
|
||||
'type' => UserType::Client,
|
||||
'active' => $autoApprove,
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Modules\Clients\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Platform\Seats\SeatAllowance;
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
@@ -28,6 +29,7 @@ use Inertia\Response;
|
||||
class AccountRequestsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SeatAllowance $seats,
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly Settings $settings,
|
||||
) {}
|
||||
@@ -67,6 +69,12 @@ class AccountRequestsController extends Controller
|
||||
{
|
||||
abort_unless($client->isClient() && $client->account_requested, 404);
|
||||
|
||||
// The moment a request becomes a client this installation has taken
|
||||
// on, which is where the seat is spent — provisioning deliberately
|
||||
// does not count a pending one, so that a stranger at the
|
||||
// registration form cannot exhaust a paid limit. See SeatAllowance.
|
||||
$this->seats->guardClient();
|
||||
|
||||
$client->forceFill([
|
||||
'active' => true,
|
||||
'account_requested' => false,
|
||||
|
||||
@@ -11,6 +11,8 @@ use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Clients\ClientCustomFieldType;
|
||||
use App\Modules\Clients\ClientStorageUsage;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Platform\Seats\SeatAllowance;
|
||||
use App\Modules\Clients\Http\Resources\Api\ClientResource;
|
||||
use App\Modules\Clients\Models\ClientCustomField;
|
||||
use App\Modules\Clients\Models\ClientCustomFieldValue;
|
||||
@@ -18,6 +20,8 @@ use App\Modules\Clients\Notifications\ClientAccountEditedNotification;
|
||||
use App\Modules\Clients\Notifications\ClientWelcomeNotification;
|
||||
use App\Modules\Files\DeletedAccountContent;
|
||||
use App\Modules\Identity\AccountContentDeletion;
|
||||
use App\Modules\Identity\Erasure\AvailableEmailRule;
|
||||
use App\Modules\Identity\Erasure\ErasureSchedule;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use App\Modules\Identity\Permissions\SystemRole;
|
||||
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
|
||||
@@ -28,6 +32,7 @@ use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
@@ -54,6 +59,9 @@ class ClientsController extends Controller
|
||||
private readonly ClientStorageUsage $storageUsage,
|
||||
private readonly DeletedAccountContent $accountContent,
|
||||
private readonly AccountContentDeletion $accountDeletion,
|
||||
private readonly StaffLibraryScope $scope,
|
||||
private readonly SeatAllowance $seats,
|
||||
private readonly ErasureSchedule $erasure,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
@@ -63,7 +71,12 @@ class ClientsController extends Controller
|
||||
'status' => ['nullable', Rule::in(['active', 'inactive'])],
|
||||
]);
|
||||
|
||||
$query = User::query()->where('type', UserType::Client);
|
||||
// Narrowed the same way the web listing is, and by the same
|
||||
// rule the object routes below are guarded with.
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
$query = $this->scope->clients($viewer);
|
||||
|
||||
if (($filters['search'] ?? null) !== null) {
|
||||
$search = $filters['search'];
|
||||
@@ -79,18 +92,36 @@ class ClientsController extends Controller
|
||||
return ClientResource::collection($this->polling->paginate($request, $query, 'users'));
|
||||
}
|
||||
|
||||
public function show(User $client): ClientResource
|
||||
/**
|
||||
* Mirrors the web controller's guard, as every API twin here does:
|
||||
* the token's `edit_clients` says its owner manages clients, not
|
||||
* that they manage *this* one.
|
||||
*/
|
||||
private function guardTarget(Request $request, User $client): void
|
||||
{
|
||||
abort_unless($client->isClient(), 404);
|
||||
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
abort_unless($this->scope->canAssignClient($viewer, $client), 404);
|
||||
}
|
||||
|
||||
public function show(Request $request, User $client): ClientResource
|
||||
{
|
||||
$this->guardTarget($request, $client);
|
||||
|
||||
|
||||
return $this->resourceFor($client);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->seats->guardClient();
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users,email'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
|
||||
// No `confirmed`: repeating a password is a defence against a
|
||||
// human mistyping into a form, and an API caller has no second
|
||||
// field to mistype. This installation's password policy still
|
||||
@@ -133,7 +164,8 @@ class ClientsController extends Controller
|
||||
|
||||
public function update(Request $request, User $client): ClientResource
|
||||
{
|
||||
abort_unless($client->isClient(), 404);
|
||||
$this->guardTarget($request, $client);
|
||||
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['sometimes', 'string', 'max:255'],
|
||||
@@ -159,7 +191,11 @@ class ClientsController extends Controller
|
||||
$client->storage_quota_mb = $validated['storage_quota_mb'] ?? 0;
|
||||
}
|
||||
|
||||
// Approval, and so the moment the seat is spent — same rule the
|
||||
// web edit screen and approve() answer to. Inside the branch, so a
|
||||
// capped installation can still edit a client it already holds.
|
||||
if (($validated['active'] ?? false) && $client->account_requested) {
|
||||
$this->seats->guardClient('active');
|
||||
$client->account_requested = false;
|
||||
}
|
||||
|
||||
@@ -203,9 +239,10 @@ class ClientsController extends Controller
|
||||
* in the activity log against the caller. Answers 204 whether or not a
|
||||
* second factor was actually in force.
|
||||
*/
|
||||
public function destroyTwoFactor(User $client, TwoFactorAdministration $twoFactor): JsonResponse
|
||||
public function destroyTwoFactor(Request $request, User $client, TwoFactorAdministration $twoFactor): JsonResponse
|
||||
{
|
||||
abort_unless($client->isClient(), 404);
|
||||
$this->guardTarget($request, $client);
|
||||
|
||||
|
||||
$twoFactor->reset($client);
|
||||
|
||||
@@ -230,16 +267,29 @@ class ClientsController extends Controller
|
||||
*/
|
||||
public function destroy(Request $request, User $client): JsonResponse
|
||||
{
|
||||
abort_unless($client->isClient(), 404);
|
||||
$this->guardTarget($request, $client);
|
||||
|
||||
|
||||
$validated = $this->accountDeletion->validate($request, $client);
|
||||
|
||||
$name = $client->name;
|
||||
$client->delete();
|
||||
// Soft-deleting the account and disposing of its files are two
|
||||
// separate writes; keep them in one transaction so a failure in the
|
||||
// second (e.g. the reassignment target deleted between validation
|
||||
// and apply()'s findOrFail) cannot leave the account deleted with
|
||||
// its content still pointing at it.
|
||||
//
|
||||
// The erasure stamp goes inside for the same reason: a deletion
|
||||
// that rolls back must not leave a live account carrying a date
|
||||
// on which it would be erased.
|
||||
DB::transaction(function () use ($validated, $client): void {
|
||||
$name = $client->name;
|
||||
$this->erasure->apply($client);
|
||||
$client->delete();
|
||||
|
||||
$this->activity->log(Action::UserDeleted, context: ['name' => $name]);
|
||||
$this->activity->log(Action::UserDeleted, context: ['name' => $name]);
|
||||
|
||||
$this->accountDeletion->apply($validated, $client, $name);
|
||||
$this->accountDeletion->apply($validated, $client, $name);
|
||||
});
|
||||
|
||||
return response()->json(status: 204);
|
||||
}
|
||||
|
||||
@@ -10,12 +10,16 @@ use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Clients\ClientCustomFieldType;
|
||||
use App\Modules\Clients\ClientStorageUsage;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Platform\Seats\SeatAllowance;
|
||||
use App\Modules\Clients\Models\ClientCustomField;
|
||||
use App\Modules\Clients\Models\ClientCustomFieldValue;
|
||||
use App\Modules\Clients\Notifications\ClientAccountEditedNotification;
|
||||
use App\Modules\Clients\Notifications\ClientWelcomeNotification;
|
||||
use App\Modules\Files\DeletedAccountContent;
|
||||
use App\Modules\Identity\AccountContentDeletion;
|
||||
use App\Modules\Identity\Erasure\AvailableEmailRule;
|
||||
use App\Modules\Identity\Erasure\ErasureSchedule;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use App\Modules\Identity\Permissions\SystemRole;
|
||||
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
|
||||
@@ -26,6 +30,7 @@ use App\Support\Pagination;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Inertia\Inertia;
|
||||
@@ -44,6 +49,9 @@ class ClientsController extends Controller
|
||||
private readonly ClientStorageUsage $storageUsage,
|
||||
private readonly DeletedAccountContent $accountContent,
|
||||
private readonly AccountContentDeletion $accountDeletion,
|
||||
private readonly StaffLibraryScope $scope,
|
||||
private readonly SeatAllowance $seats,
|
||||
private readonly ErasureSchedule $erasure,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
@@ -58,8 +66,14 @@ class ClientsController extends Controller
|
||||
'status' => $validated['status'] ?? null,
|
||||
];
|
||||
|
||||
$clients = User::query()
|
||||
->where('type', UserType::Client)
|
||||
// Narrowed by the same rule the buttons on each row are guarded
|
||||
// with. A client-scoped staff member is not shown the name and
|
||||
// email of somebody they can reach nothing of — the same thing
|
||||
// MembershipRequest::approvableBy does for its queue.
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
$clients = $this->scope->clients($viewer)
|
||||
->when($filters['search'], fn (Builder $query, string $search) => $query->where(fn (Builder $q) => $q
|
||||
->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%")))
|
||||
@@ -85,11 +99,23 @@ class ClientsController extends Controller
|
||||
'pagination' => Pagination::meta($clients),
|
||||
'filters' => $filters,
|
||||
'reassign_candidates' => $this->accountDeletion->candidates(),
|
||||
// Null on a self-hosted install: no limit, nothing to say.
|
||||
'seats' => $this->seats->clientState(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): Response
|
||||
public function create(): RedirectResponse|Response
|
||||
{
|
||||
// The same courtesy UsersController::create() does: a full
|
||||
// installation is an ordinary state on a managed plan, so say so
|
||||
// before somebody fills in a form that cannot be submitted. The
|
||||
// guard in store() is still the rule; this is only the door.
|
||||
$seats = $this->seats->clientState();
|
||||
|
||||
if ($seats !== null && $seats['full']) {
|
||||
return redirect()->route('clients.index')->with('error', $seats['message']);
|
||||
}
|
||||
|
||||
return Inertia::render('clients/create', [
|
||||
'custom_fields' => $this->customFieldDefinitions(),
|
||||
'default_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
|
||||
@@ -98,9 +124,13 @@ class ClientsController extends Controller
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
// A client created here is approved by construction, so it counts
|
||||
// immediately — unlike a self-registration awaiting a decision.
|
||||
$this->seats->guardClient();
|
||||
|
||||
$validated = $request->validate(array_merge([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users,email'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
|
||||
], $this->customFieldRules()));
|
||||
@@ -130,13 +160,43 @@ class ClientsController extends Controller
|
||||
$client->notify(new ClientWelcomeNotification);
|
||||
}
|
||||
|
||||
return redirect()->route('clients.edit', $client)->with('success', __('Client created.'));
|
||||
// A role can hold create_clients without edit_clients, and the edit
|
||||
// page this used to land on unconditionally answers such a role
|
||||
// with a 403 — after the client was created, logged and welcomed.
|
||||
// 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
|
||||
// the success toast shows there.
|
||||
$target = $request->user()?->can('edit_clients')
|
||||
? redirect()->route('clients.edit', $client)
|
||||
: redirect()->route('clients.create');
|
||||
|
||||
return $target->with('success', __('Client created.'));
|
||||
}
|
||||
|
||||
public function edit(User $client): Response
|
||||
/**
|
||||
* The one question every route binding a client has to ask.
|
||||
*
|
||||
* A permission is not a boundary: `edit_clients` says this staff
|
||||
* member manages clients, not that they manage *this* one — the same
|
||||
* rule ClientFilesController::index applies one route over. 404
|
||||
* rather than 403, so a client outside the roster is not
|
||||
* distinguishable from one that is not there.
|
||||
*/
|
||||
private function guardTarget(Request $request, User $client): void
|
||||
{
|
||||
abort_unless($client->isClient(), 404);
|
||||
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
abort_unless($this->scope->canAssignClient($viewer, $client), 404);
|
||||
}
|
||||
|
||||
public function edit(Request $request, User $client): Response
|
||||
{
|
||||
$this->guardTarget($request, $client);
|
||||
|
||||
|
||||
return Inertia::render('clients/edit', [
|
||||
'client' => [
|
||||
'id' => $client->id,
|
||||
@@ -160,7 +220,8 @@ class ClientsController extends Controller
|
||||
|
||||
public function update(Request $request, User $client): RedirectResponse
|
||||
{
|
||||
abort_unless($client->isClient(), 404);
|
||||
$this->guardTarget($request, $client);
|
||||
|
||||
|
||||
$validated = $request->validate(array_merge([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
@@ -185,8 +246,13 @@ class ClientsController extends Controller
|
||||
]);
|
||||
|
||||
// Activating a pending account through the edit screen counts as
|
||||
// approval and clears the request flag.
|
||||
// approval and clears the request flag — which is the moment a
|
||||
// seat is spent, so the cap is asked here for the same reason
|
||||
// AccountRequestsController::approve() asks it one screen over.
|
||||
// Inside the branch, not above it: an installation at its cap must
|
||||
// still be able to rename a client it already has.
|
||||
if ($client->account_requested && $validated['active']) {
|
||||
$this->seats->guardClient('active');
|
||||
$client->account_requested = false;
|
||||
}
|
||||
|
||||
@@ -219,9 +285,10 @@ class ClientsController extends Controller
|
||||
* Remove this account's second factor, for the client who has lost
|
||||
* their authenticator and their recovery codes.
|
||||
*/
|
||||
public function destroyTwoFactor(User $client, TwoFactorAdministration $twoFactor): RedirectResponse
|
||||
public function destroyTwoFactor(Request $request, User $client, TwoFactorAdministration $twoFactor): RedirectResponse
|
||||
{
|
||||
abort_unless($client->isClient(), 404);
|
||||
$this->guardTarget($request, $client);
|
||||
|
||||
|
||||
$twoFactor->reset($client);
|
||||
|
||||
@@ -230,16 +297,29 @@ class ClientsController extends Controller
|
||||
|
||||
public function destroy(Request $request, User $client): RedirectResponse
|
||||
{
|
||||
abort_unless($client->isClient(), 404);
|
||||
$this->guardTarget($request, $client);
|
||||
|
||||
|
||||
$validated = $this->accountDeletion->validate($request, $client);
|
||||
|
||||
$name = $client->name;
|
||||
$client->delete();
|
||||
// Soft-deleting the account and disposing of its files are two
|
||||
// separate writes; keep them in one transaction so a failure in the
|
||||
// second (e.g. the reassignment target deleted between validation
|
||||
// and apply()'s findOrFail) cannot leave the account deleted with
|
||||
// its content still pointing at it.
|
||||
//
|
||||
// The erasure stamp goes inside for the same reason: a deletion
|
||||
// that rolls back must not leave a live account carrying a date
|
||||
// on which it would be erased.
|
||||
DB::transaction(function () use ($validated, $client): void {
|
||||
$name = $client->name;
|
||||
$this->erasure->apply($client);
|
||||
$client->delete();
|
||||
|
||||
$this->activity->log(Action::UserDeleted, context: ['name' => $name]);
|
||||
$this->activity->log(Action::UserDeleted, context: ['name' => $name]);
|
||||
|
||||
$this->accountDeletion->apply($validated, $client, $name);
|
||||
$this->accountDeletion->apply($validated, $client, $name);
|
||||
});
|
||||
|
||||
return redirect()->route('clients.index')->with('success', __('Client deleted.'));
|
||||
}
|
||||
|
||||
@@ -68,6 +68,50 @@ class VisibleCommentScope
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The thread as somebody reading the public listing sees it: what a
|
||||
* visitor is shown, plus their own comments if they happen to be
|
||||
* signed in.
|
||||
*
|
||||
* for() above is the authenticated reading, and it assumes what the
|
||||
* top of this class demands — that the caller established the viewer
|
||||
* may see the *file*. The public listing establishes only the other
|
||||
* half of that, namely that the file is reachable without logging in,
|
||||
* which is the whole of it for a visitor and not nearly enough for an
|
||||
* account: for() hands any staff member the file's StaffOnly notes and
|
||||
* any client the messages addressed to all clients on it.
|
||||
*
|
||||
* So a signed-in reader is answered as a visitor is, widened by their
|
||||
* own writing — which is what "being logged in should not show you
|
||||
* less than a stranger sees, and their own comments should be theirs
|
||||
* to edit" asks for and all it asks for. A reader the file's own gate
|
||||
* would admit is not narrowed at all; the caller sends them through
|
||||
* for() instead.
|
||||
*
|
||||
* The held-comment rule is not restated here — hideUnapproved() is the
|
||||
* same one for()'s readings get, so a comment waiting for a moderator
|
||||
* stays exactly as visible, or invisible, as it was.
|
||||
*
|
||||
* @return Builder<FileComment>
|
||||
*/
|
||||
public function forPublicReader(?User $viewer, File $file): Builder
|
||||
{
|
||||
$query = FileComment::query()->where('file_id', $file->id);
|
||||
|
||||
if ($viewer === null) {
|
||||
return $this->applyVisibility($query, null, $file->isEffectivelyPublic());
|
||||
}
|
||||
|
||||
$this->hideUnapproved($query, $viewer);
|
||||
|
||||
return $query->where(fn (Builder $outer) => $outer
|
||||
->where('author_id', $viewer->id)
|
||||
->when(
|
||||
$file->isEffectivelyPublic(),
|
||||
fn (Builder $stranger) => $stranger->orWhere('visibility', CommentVisibility::Everyone),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every comment this staff member may read, across their whole library
|
||||
* — the management screen's query, rather than one file's thread.
|
||||
@@ -171,25 +215,40 @@ class VisibleCommentScope
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* A comment awaiting moderation exists only for those who can act on
|
||||
* it — and for whoever wrote it, who would otherwise watch their own
|
||||
* comment vanish on posting and conclude it had failed. A visitor is
|
||||
* recognised by their session (see GuestCommentIdentity); that is weak
|
||||
* on purpose, and only ever widens what somebody sees of their own
|
||||
* writing.
|
||||
*
|
||||
* Its own method because forPublicReader() answers a different
|
||||
* audience question and the same held-comment one, and a rule this
|
||||
* sharp stated twice is a rule that drifts.
|
||||
*
|
||||
* @param Builder<FileComment> $query
|
||||
*/
|
||||
private function hideUnapproved(Builder $query, ?User $viewer): void
|
||||
{
|
||||
if ($viewer !== null && $viewer->can('moderate_comments')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ownPending = $viewer === null ? $this->guests->ownCommentIds() : [];
|
||||
|
||||
$query->where(fn (Builder $visible) => $visible
|
||||
->whereNotNull('approved_at')
|
||||
->when($ownPending !== [], fn (Builder $mine) => $mine->orWhereIn('id', $ownPending)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<FileComment> $query
|
||||
* @return Builder<FileComment>
|
||||
*/
|
||||
private function applyVisibility(Builder $query, ?User $viewer, bool $isPublic): Builder
|
||||
{
|
||||
// A comment awaiting moderation exists only for those who can act
|
||||
// on it — and for whoever wrote it, who would otherwise watch their
|
||||
// own comment vanish on posting and conclude it had failed. A
|
||||
// visitor is recognised by their session (see GuestCommentIdentity);
|
||||
// that is weak on purpose, and only ever widens what somebody sees
|
||||
// of their own writing.
|
||||
if ($viewer === null || ! $viewer->can('moderate_comments')) {
|
||||
$ownPending = $viewer === null ? $this->guests->ownCommentIds() : [];
|
||||
|
||||
$query->where(fn (Builder $visible) => $visible
|
||||
->whereNotNull('approved_at')
|
||||
->when($ownPending !== [], fn (Builder $mine) => $mine->orWhereIn('id', $ownPending)));
|
||||
}
|
||||
$this->hideUnapproved($query, $viewer);
|
||||
|
||||
if ($viewer === null) {
|
||||
// Publicness is re-derived here on every read rather than
|
||||
|
||||
@@ -9,12 +9,17 @@ use App\Modules\Identity\UserType;
|
||||
/**
|
||||
* Who may write a comment (Setting::CommentsAuthors).
|
||||
*
|
||||
* This is a setting rather than a permission on purpose. Roles are only
|
||||
* editable in the community edition — the cloud edition gates the whole
|
||||
* roles screen behind Capability::UsersManage — so a permission key would
|
||||
* be unconfigurable for half our installs. It also expresses something a
|
||||
* permission structurally cannot: `Everyone` includes anonymous visitors,
|
||||
* who have no account and therefore no role to hold a key.
|
||||
* This is a setting rather than a permission on purpose, and one of the
|
||||
* two reasons has since expired. It used to be that roles were editable
|
||||
* only in the community edition — the cloud edition gated the whole roles
|
||||
* screen behind Capability::UsersManage — so a permission key would have
|
||||
* been unconfigurable for half our installs. That stopped being true in
|
||||
* 2.2.0, when users.manage opened on both editions.
|
||||
*
|
||||
* The reason that carries it now is the one a permission structurally
|
||||
* cannot express: `Everyone` includes anonymous visitors, who have no
|
||||
* account and therefore no role to hold a key. That was always the
|
||||
* stronger half; it is now the whole of it.
|
||||
*/
|
||||
enum CommentAuthors: string
|
||||
{
|
||||
|
||||
@@ -31,13 +31,23 @@ class CommentPresenter
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The whole payload for one file's thread.
|
||||
*
|
||||
* $viewerMaySeeFile is the precondition VisibleCommentScope states at
|
||||
* the top of its class: the caller must already have established that
|
||||
* this viewer may see the file. A caller that has not says so, and the
|
||||
* thread is narrowed to the public reading instead of the
|
||||
* authenticated one.
|
||||
*
|
||||
* @return array{comments: list<array<string, mixed>>, can_comment: bool, cannot_comment_reason: string|null, is_guest: bool, guest_moderated: bool, captcha_required: bool, visibilities: list<array<string, mixed>>, default_visibility: string|null, edit_window_minutes: int}
|
||||
*/
|
||||
public function thread(?User $viewer, File $file): array
|
||||
public function thread(?User $viewer, File $file, bool $viewerMaySeeFile = true): array
|
||||
{
|
||||
$forStaff = $viewer?->isStaff() === true;
|
||||
|
||||
$comments = $this->scope->for($viewer, $file)
|
||||
$comments = ($viewerMaySeeFile
|
||||
? $this->scope->for($viewer, $file)
|
||||
: $this->scope->forPublicReader($viewer, $file))
|
||||
->with(['author', 'clientContext'])
|
||||
->orderBy('created_at')
|
||||
->orderBy('id')
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Modules\Comments;
|
||||
use App\Models\User;
|
||||
use App\Modules\Comments\Access\VisibleCommentScope;
|
||||
use App\Modules\Comments\Models\FileComment;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
/**
|
||||
@@ -20,6 +21,7 @@ class FileCommentPolicy
|
||||
public function __construct(
|
||||
private readonly VisibleCommentScope $scope,
|
||||
private readonly CommentingRules $rules,
|
||||
private readonly StaffLibraryScope $library,
|
||||
) {}
|
||||
|
||||
public function view(User $user, FileComment $comment): bool
|
||||
@@ -44,16 +46,38 @@ class FileCommentPolicy
|
||||
|
||||
public function delete(User $user, FileComment $comment): bool
|
||||
{
|
||||
if ($this->moderate($user)) {
|
||||
if ($this->moderate($user, $comment)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $comment->author_id === $user->id && $this->withinEditWindow($comment);
|
||||
}
|
||||
|
||||
public function moderate(User $user): bool
|
||||
/**
|
||||
* Called both ways: with a comment, to decide about that one, and
|
||||
* against the class, to ask whether this user moderates at all (the
|
||||
* queue's own gate, and the affordances that offer it).
|
||||
*
|
||||
* The library boundary belongs here rather than in each caller. Named
|
||||
* against the class it cannot be applied — there is no file to weigh —
|
||||
* so that form answers the coarser question and every caller holding a
|
||||
* comment should pass it.
|
||||
*/
|
||||
public function moderate(User $user, ?FileComment $comment = null): bool
|
||||
{
|
||||
return $user->isStaff() && $user->can('moderate_comments');
|
||||
if (! $user->isStaff() || ! $user->can('moderate_comments')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($comment === null || ! $user->isClientScoped()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// By file id rather than through the relation: a file soft-deleted
|
||||
// out from under its comments resolves to null there, and the
|
||||
// answer for a scoped moderator is the same either way — it is not
|
||||
// in their library. Unscoped staff never reach this line.
|
||||
return $this->library->files($user)->whereKey($comment->file_id)->exists();
|
||||
}
|
||||
|
||||
private function withinEditWindow(FileComment $comment): bool
|
||||
|
||||
@@ -220,10 +220,27 @@ class FileComments
|
||||
return null;
|
||||
}
|
||||
|
||||
// Asked of the column, not of the relation. client_context_id is
|
||||
// cascadeOnDelete, but a user is soft-deleted, so the cascade
|
||||
// never fires: the column goes on pointing at a row that is still
|
||||
// there while the relation resolves to null. Branching on the
|
||||
// relation therefore read "this is Alice's conversation" as "this
|
||||
// has no conversation" — and a null context on a Clients comment
|
||||
// is the branch every client on the file reads (see
|
||||
// VisibleCommentScope's opening rule). A private reply became a
|
||||
// circular, and canAssignClient below was skipped on the way.
|
||||
if ($replyTo->client_context_id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$client = $replyTo->clientContext;
|
||||
|
||||
if ($client === null) {
|
||||
return null;
|
||||
// The column points at somebody, and that somebody is gone.
|
||||
// There is nobody to answer, and the one outcome that must
|
||||
// not follow from a filled column is the broadcast above, so
|
||||
// this refuses rather than falling through to it.
|
||||
throw new AuthorizationException('You cannot reply in this conversation.');
|
||||
}
|
||||
|
||||
if (! $this->library->canAssignClient($author, $client)) {
|
||||
|
||||
@@ -70,9 +70,9 @@ class CommentModerationController extends Controller
|
||||
{
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
Gate::forUser($viewer)->authorize('moderate', FileComment::class);
|
||||
// Moderation rights are not a way around the library boundary.
|
||||
abort_unless($this->library->allowsFile($viewer, $comment->file), 403);
|
||||
// Moderation rights are not a way around the library boundary; the
|
||||
// policy weighs the comment's file, so name the comment.
|
||||
Gate::authorize('moderate', $comment);
|
||||
|
||||
$this->comments->approve($comment, $viewer);
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ use App\Modules\Comments\CommentPresenter;
|
||||
use App\Modules\Comments\CommentVisibility;
|
||||
use App\Modules\Comments\FileComments;
|
||||
use App\Modules\Comments\Models\FileComment;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Platform\Localization\LocalDay;
|
||||
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||
use Carbon\Carbon;
|
||||
@@ -44,7 +43,6 @@ class CommentsController extends Controller
|
||||
|
||||
public function __construct(
|
||||
private readonly FileComments $comments,
|
||||
private readonly StaffLibraryScope $library,
|
||||
private readonly CommentPresenter $presenter,
|
||||
private readonly VisibleCommentScope $scope,
|
||||
private readonly TimezoneRegistry $timezones,
|
||||
@@ -95,9 +93,9 @@ class CommentsController extends Controller
|
||||
{
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
Gate::forUser($viewer)->authorize('moderate', FileComment::class);
|
||||
// Moderation rights are not a way around the library boundary.
|
||||
abort_unless($this->library->allowsFile($viewer, $comment->file), 403);
|
||||
// Moderation rights are not a way around the library boundary; the
|
||||
// policy weighs the comment's file, so name the comment.
|
||||
Gate::forUser($viewer)->authorize('moderate', $comment);
|
||||
|
||||
$this->comments->approve($comment, $viewer);
|
||||
|
||||
@@ -113,7 +111,6 @@ class CommentsController extends Controller
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
Gate::forUser($viewer)->authorize('delete', $comment);
|
||||
abort_unless($this->library->allowsFile($viewer, $comment->file), 403);
|
||||
|
||||
$this->comments->remove($comment);
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ class FileCommentsController extends Controller
|
||||
|
||||
$this->comments->edit($comment, $validated['body']);
|
||||
|
||||
return response()->json($this->payload($viewer, $comment->file));
|
||||
return response()->json($this->payloadAfterChange($viewer, $comment->file));
|
||||
}
|
||||
|
||||
public function destroy(Request $request, FileComment $comment): JsonResponse
|
||||
@@ -90,10 +90,13 @@ class FileCommentsController extends Controller
|
||||
|
||||
$this->comments->remove($comment);
|
||||
|
||||
return response()->json($this->payload($viewer, $file));
|
||||
return response()->json($this->payloadAfterChange($viewer, $file));
|
||||
}
|
||||
|
||||
/**
|
||||
* The thread for the two routes that bind a file, after their own
|
||||
* `view` authorization has passed.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function payload(User $viewer, File $file): array
|
||||
@@ -101,6 +104,28 @@ class FileCommentsController extends Controller
|
||||
return $this->presenter->thread($viewer, $file);
|
||||
}
|
||||
|
||||
/**
|
||||
* The thread that goes back with a change to one comment.
|
||||
*
|
||||
* update() and destroy() bind a comment rather than a file, so nothing
|
||||
* in the request has established that this viewer may read the file's
|
||||
* conversation — only that this one comment is theirs to change.
|
||||
* Somebody who commented through the public listing is exactly that
|
||||
* person, and refusing them on their own edit would be wrong, so the
|
||||
* reading they get back is the one the file's own gate allows them:
|
||||
* the public page's, if that is how they arrived.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function payloadAfterChange(User $viewer, File $file): array
|
||||
{
|
||||
return $this->presenter->thread(
|
||||
$viewer,
|
||||
$file,
|
||||
viewerMaySeeFile: Gate::forUser($viewer)->allows('view', $file),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The comment being answered, resolved through the same scope that
|
||||
* decided what this viewer may read. A reply can therefore only ever
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Modules\Comments\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\Comments\CommentingRules;
|
||||
use App\Modules\Comments\CommentPresenter;
|
||||
use App\Modules\Comments\CommentVisibility;
|
||||
@@ -17,6 +18,7 @@ use App\Modules\Platform\Settings\Settings;
|
||||
use App\Support\Rules;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
/**
|
||||
* Comments on a publicly-listed file, for visitors who are not logged in.
|
||||
@@ -46,7 +48,7 @@ class PublicFileCommentsController extends Controller
|
||||
{
|
||||
$this->guard($publicSlug, $file);
|
||||
|
||||
return response()->json($this->presenter->thread($request->user(), $file));
|
||||
return response()->json($this->thread($request->user(), $file));
|
||||
}
|
||||
|
||||
public function store(Request $request, string $publicSlug, File $file): JsonResponse
|
||||
@@ -86,7 +88,31 @@ class PublicFileCommentsController extends Controller
|
||||
$this->guests->remember($comment->id);
|
||||
}
|
||||
|
||||
return response()->json($this->presenter->thread($viewer, $file), 201);
|
||||
return response()->json($this->thread($viewer, $file), 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* The thread as this endpoint may serve it.
|
||||
*
|
||||
* guard() establishes the guest half of VisibleCommentScope's
|
||||
* precondition — the file is reachable without logging in — and that
|
||||
* is the whole of it for a visitor. It says nothing about an account,
|
||||
* and handing a signed-in viewer to the authenticated reading anyway
|
||||
* is what let any staff account read a public file's StaffOnly notes
|
||||
* and any client account read the messages addressed to that file's
|
||||
* clients. The file's own gate decides which reading applies; the one
|
||||
* it does not admit still reads what a visitor reads plus their own
|
||||
* comments, which is what this endpoint has always promised them.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function thread(?User $viewer, File $file): array
|
||||
{
|
||||
return $this->presenter->thread(
|
||||
$viewer,
|
||||
$file,
|
||||
viewerMaySeeFile: $viewer !== null && Gate::forUser($viewer)->allows('view', $file),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -113,18 +113,32 @@ class FileComment extends Model
|
||||
* The name to show. Snapshotted for guests at write time; read live
|
||||
* for accounts so a rename is reflected everywhere at once.
|
||||
*
|
||||
* author_id cascades on delete, so a row that has one always has the
|
||||
* account behind it — there is no deleted-author case to snapshot
|
||||
* against, unlike the activity log's actor_name.
|
||||
* A deleted account is still read. author_id cascades on delete, but
|
||||
* a user is soft-deleted and the cascade never fires, so the row
|
||||
* behind a deleted commenter is still there — and reading it through
|
||||
* the plain relation returned null, which sent a named client's
|
||||
* comment out as "Anonymous". That is what a guest comment looks
|
||||
* like, and a guest comment is governed by different rules; the two
|
||||
* must not be able to look the same. Whether the author is a guest is
|
||||
* decided by author_id alone, which is also what isFromGuest() asks.
|
||||
*/
|
||||
public function authorName(): string
|
||||
{
|
||||
if ($this->author_id === null) {
|
||||
return $this->guest_name ?? (string) __('Anonymous');
|
||||
}
|
||||
|
||||
$author = $this->author;
|
||||
|
||||
if ($author !== null) {
|
||||
return $author->name;
|
||||
}
|
||||
|
||||
return $this->guest_name ?? (string) __('Anonymous');
|
||||
// Trashed: the row is still there, the relation simply will not
|
||||
// hand it over. Nothing comes back only once the grace-period
|
||||
// erasure has removed the row for real.
|
||||
$name = $this->author()->withTrashed()->value('name');
|
||||
|
||||
return is_string($name) ? $name : (string) __('Anonymous');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,11 @@ namespace App\Modules\Files\Access;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\FileAssignment;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Files\Models\FolderAssignment;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use App\Modules\Identity\UserType;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
@@ -26,10 +29,37 @@ use Illuminate\Database\Eloquent\Builder;
|
||||
*/
|
||||
class StaffLibraryScope
|
||||
{
|
||||
/**
|
||||
* Built queries, by user id. Building one is not free: it walks the
|
||||
* assigned clients and File::scopeVisibleToClient runs four immediate
|
||||
* lookups for each of them, none of which depend on the query being
|
||||
* built. Callers ask over and over — the policies ask once per row on
|
||||
* a listing, and Gate resolves a fresh policy for every check — so the
|
||||
* same handful of lookups were being repeated per row.
|
||||
*
|
||||
* A clone goes back rather than the query itself, since every caller
|
||||
* adds to it. Registered with the container as `scoped`, so the memo
|
||||
* lasts a request and is dropped between queue jobs.
|
||||
*
|
||||
* @var array<int, Builder<File>>
|
||||
*/
|
||||
private array $files = [];
|
||||
|
||||
/** @var array<int, Builder<Folder>> */
|
||||
private array $folders = [];
|
||||
|
||||
/**
|
||||
* @return Builder<File>
|
||||
*/
|
||||
public function files(User $user): Builder
|
||||
{
|
||||
return clone ($this->files[$user->id] ??= $this->buildFiles($user));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<File>
|
||||
*/
|
||||
private function buildFiles(User $user): Builder
|
||||
{
|
||||
$query = File::query();
|
||||
|
||||
@@ -53,6 +83,14 @@ class StaffLibraryScope
|
||||
* @return Builder<Folder>
|
||||
*/
|
||||
public function folders(User $user): Builder
|
||||
{
|
||||
return clone ($this->folders[$user->id] ??= $this->buildFolders($user));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<Folder>
|
||||
*/
|
||||
private function buildFolders(User $user): Builder
|
||||
{
|
||||
$query = Folder::query();
|
||||
|
||||
@@ -139,10 +177,124 @@ class StaffLibraryScope
|
||||
return $ids === null || in_array($client->id, $ids, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every client this staff member may act on, as a query.
|
||||
*
|
||||
* The listing half of canAssignClient(), so a screen narrows by the
|
||||
* same rule its buttons are guarded with rather than restating it —
|
||||
* which is how ClientsController came to list every client on the
|
||||
* installation, name and email, to a viewer who could reach nothing
|
||||
* of theirs. An unscoped user gets the whole roster, unchanged.
|
||||
*
|
||||
* @return Builder<User>
|
||||
*/
|
||||
public function clients(User $user): Builder
|
||||
{
|
||||
$query = User::query()->where('type', UserType::Client);
|
||||
$ids = $this->assignableClientIds($user);
|
||||
|
||||
return $ids === null ? $query : $query->whereIn('id', $ids);
|
||||
}
|
||||
|
||||
public function canAssignGroup(User $user, Group $group): bool
|
||||
{
|
||||
$ids = $this->assignableGroupIds($user);
|
||||
|
||||
return $ids === null || in_array($group->id, $ids, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a staff member may put a client into a group, or take one
|
||||
* out again.
|
||||
*
|
||||
* Not canAssignGroup(): that answers "may I share with this group",
|
||||
* and it answers it *from* the membership — a group counts as the
|
||||
* user's because one of their clients is in it. Deciding membership
|
||||
* with a predicate derived from membership means whoever may edit
|
||||
* the list also decides what the list entitles them to, which is not
|
||||
* a boundary at all. It is also the wrong answer here in the other
|
||||
* direction: a group nobody has joined yet belongs to nobody, so a
|
||||
* scoped staff member could never put the first member into a group
|
||||
* they had just created.
|
||||
*
|
||||
* The question membership actually asks is about reach. Joining a
|
||||
* group hands the new member everything shared with it, and — when
|
||||
* that member is one of the actor's own clients — hands the actor
|
||||
* the same content back through File::scopeVisibleToClient, which is
|
||||
* what StaffLibraryScope::files() is built on. So both sides have to
|
||||
* hold: the client must be one this staff member holds, and the
|
||||
* group must not already reach beyond their library. A group with
|
||||
* nothing shared with it passes trivially, which is what keeps a
|
||||
* newly created one usable.
|
||||
*
|
||||
* Unscoped staff are unaffected — both halves are true for them by
|
||||
* construction.
|
||||
*/
|
||||
public function allowsGroupMembership(User $user, Group $group, User $client): bool
|
||||
{
|
||||
return $this->canAssignClient($user, $client) && $this->groupReachesNoFurther($user, $group);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this staff member may change a group itself — rename it,
|
||||
* make it public, delete it.
|
||||
*
|
||||
* The reach half of allowsGroupMembership, on its own because there
|
||||
* is no client in the question. Deleting a group is the destructive
|
||||
* end of it: an assignment to a group is how its members reach a
|
||||
* file, so removing the group takes that access away from every one
|
||||
* of them. A staff member who may not add somebody to a group out of
|
||||
* their reach should not be able to delete it out from under the
|
||||
* people already in it.
|
||||
*/
|
||||
public function allowsGroupChange(User $user, Group $group): bool
|
||||
{
|
||||
return $this->groupReachesNoFurther($user, $group);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether everything shared with this group is already inside the
|
||||
* user's library — files assigned to it, and the folders whose
|
||||
* subtrees it can browse.
|
||||
*
|
||||
* Asked as "is anything shared with this group outside my library",
|
||||
* rather than by counting assignment rows against library rows. An
|
||||
* assignment outlives the thing it points at: nothing clears these
|
||||
* rows when a file or folder is deleted, and a deleted one can never
|
||||
* appear in files()/folders(), which exclude trashed rows. Counting
|
||||
* therefore never balanced again, and the group became permanently
|
||||
* unmanageable for a scoped staff member — including for their own
|
||||
* clients, and including removing somebody. Starting from the live
|
||||
* row rather than from the assignment ignores the dead ones by
|
||||
* construction, which is also the right answer: a deleted file is
|
||||
* not reach, because nobody can reach it.
|
||||
*/
|
||||
private function groupReachesNoFurther(User $user, Group $group): bool
|
||||
{
|
||||
if (! $user->isClientScoped()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$morph = $group->getMorphClass();
|
||||
|
||||
$assignedFiles = FileAssignment::query()->select('file_id')
|
||||
->where('assignable_type', $morph)->where('assignable_id', $group->id);
|
||||
|
||||
$outside = File::query()
|
||||
->whereIn('id', $assignedFiles)
|
||||
->whereNotIn('id', $this->files($user)->select('id'))
|
||||
->exists();
|
||||
|
||||
if ($outside) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$assignedFolders = FolderAssignment::query()->select('folder_id')
|
||||
->where('assignable_type', $morph)->where('assignable_id', $group->id);
|
||||
|
||||
return ! Folder::query()
|
||||
->whereIn('id', $assignedFolders)
|
||||
->whereNotIn('id', $this->folders($user)->select('id'))
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Modules\Files\Console;
|
||||
use App\Modules\Files\Models\ZipDownload;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use League\Flysystem\UnableToRetrieveMetadata;
|
||||
|
||||
class PurgeZipDownloadsCommand extends Command
|
||||
{
|
||||
@@ -18,16 +19,80 @@ class PurgeZipDownloadsCommand extends Command
|
||||
{
|
||||
$stale = ZipDownload::query()->where('created_at', '<', now()->subDay())->get();
|
||||
|
||||
// Listed once up front: the loop only deletes, so nothing it does
|
||||
// changes what a later row would match.
|
||||
$builtZips = collect(Storage::disk('files')->files('zips'));
|
||||
|
||||
foreach ($stale as $zipDownload) {
|
||||
// Every artifact tied to this row's id, not just the recorded
|
||||
// path: a build killed before it finished (worker timeout, disk
|
||||
// full) leaves a partial archive — and libzip's temp file
|
||||
// alongside it — with no path ever written back to the row.
|
||||
$artifacts = $builtZips
|
||||
->filter(fn (string $path): bool => str_starts_with(basename($path), $zipDownload->id.'.zip'))
|
||||
->all();
|
||||
|
||||
if ($zipDownload->path !== null) {
|
||||
Storage::disk('files')->delete($zipDownload->path);
|
||||
$artifacts[] = $zipDownload->path;
|
||||
}
|
||||
|
||||
Storage::disk('files')->delete(array_values(array_unique($artifacts)));
|
||||
|
||||
$zipDownload->delete();
|
||||
}
|
||||
|
||||
$this->info("Purged {$stale->count()} stale zip download(s).");
|
||||
$swept = $this->sweepUnreferenced();
|
||||
|
||||
$this->info("Purged {$stale->count()} stale zip download(s) and {$swept} unreferenced file(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows are what the loop above cleans by, so a file whose row is gone
|
||||
* is invisible to it — and a row can vanish without its files:
|
||||
* zip_downloads.requested_by cascades on delete, so removing a user
|
||||
* takes their rows with it and leaves every archive they built behind.
|
||||
* Anything already stranded that way before this command learned to
|
||||
* look is in the same position.
|
||||
*
|
||||
* OrphanFileScanner skips zips/ on purpose — this command owns that
|
||||
* directory, so closing the gap belongs here.
|
||||
*/
|
||||
private function sweepUnreferenced(): int
|
||||
{
|
||||
$disk = Storage::disk('files');
|
||||
$cutoff = now()->subDay()->getTimestamp();
|
||||
$live = array_flip(ZipDownload::query()->pluck('id')->all());
|
||||
$unreferenced = [];
|
||||
|
||||
foreach ($disk->files('zips') as $path) {
|
||||
// Both an archive (12.zip) and libzip's temp beside it
|
||||
// (12.zip.aB3xY9) lead with the row id they belong to.
|
||||
$id = explode('.', basename($path))[0];
|
||||
|
||||
if (ctype_digit($id) && isset($live[(int) $id])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// A day's grace before deleting something no row explains.
|
||||
// Nothing here should outlive its row by design, so the
|
||||
// wait costs nothing — and it means a file another process
|
||||
// has only just put there is never taken out from under it.
|
||||
if ($disk->lastModified($path) >= $cutoff) {
|
||||
continue;
|
||||
}
|
||||
} catch (UnableToRetrieveMetadata) {
|
||||
// Gone between listing the directory and asking about it.
|
||||
continue;
|
||||
}
|
||||
|
||||
$unreferenced[] = $path;
|
||||
}
|
||||
|
||||
$disk->delete($unreferenced);
|
||||
|
||||
return count($unreferenced);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files;
|
||||
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Files\Notifications\FileShareDigestNotification;
|
||||
@@ -20,6 +21,17 @@ use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class FilesServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
// Scoped rather than transient: the library query it builds costs
|
||||
// several lookups per assigned client, and the policies ask for it
|
||||
// once per row on a listing — Gate resolves a fresh policy for
|
||||
// every check, so without this the instance memo would never be
|
||||
// reached twice. Scoped rather than a singleton so a long-lived
|
||||
// queue worker starts each job with an empty memo.
|
||||
$this->app->scoped(StaffLibraryScope::class);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
Gate::policy(File::class, FilePolicy::class);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Folders;
|
||||
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
/**
|
||||
* An id naming a folder that is actually there.
|
||||
*
|
||||
* A rule object rather than `Rule::exists(...)->whereNull('deleted_at')`
|
||||
* for one reason: the message. The generic form says "The selected folder
|
||||
* id is invalid", which tells somebody nothing when the real answer is
|
||||
* that the folder they picked has since been deleted — and that is the
|
||||
* usual way to meet this rule, since a live id they chose from a list is
|
||||
* how they got here. It matters most on the chunked upload path, which is
|
||||
* the one place a request that used to succeed now fails.
|
||||
*
|
||||
* Carrying the message on the rule keeps the single definition
|
||||
* Rules::folderId() exists for: a `messages()` array would have to be
|
||||
* repeated at every call site, which is how the plain `exists:folders,id`
|
||||
* it replaced came to mean two different things in ten places.
|
||||
*/
|
||||
class FolderExistsRule implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* @param Closure(string, string|null=): \Illuminate\Translation\PotentiallyTranslatedString $fail
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_numeric($value)) {
|
||||
// Reached only when a caller drops `integer`; the message
|
||||
// still has to make sense to whoever sees it.
|
||||
$fail(__('That folder could not be found.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Folder::query() honours the soft delete, which is the whole
|
||||
// point — the table-level `exists` rule this replaces does not.
|
||||
if (Folder::query()->whereKey((int) $value)->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fail(__('That folder no longer exists. Pick another one and try again.'));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Clients\ClientStorageUsage;
|
||||
use App\Modules\Comments\CommentingRules;
|
||||
use App\Modules\Comments\CommentScope;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Access\ViewableFileScope;
|
||||
use App\Modules\Files\DownloadLimitScope;
|
||||
use App\Modules\Files\Http\Resources\Api\FileResource;
|
||||
@@ -57,6 +58,7 @@ class FilesController extends Controller
|
||||
private readonly ClientStorageUsage $storageUsage,
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly CommentingRules $commenting,
|
||||
private readonly StaffLibraryScope $scope,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -174,7 +176,7 @@ class FilesController extends Controller
|
||||
'file' => ['required', 'file'],
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:2000'],
|
||||
'folder_id' => ['nullable', 'integer', 'exists:folders,id'],
|
||||
'folder_id' => Rules::folderId(),
|
||||
]);
|
||||
|
||||
/** @var UploadedFile $upload */
|
||||
@@ -275,7 +277,7 @@ class FilesController extends Controller
|
||||
$validated = $request->validate([
|
||||
'name' => ['sometimes', 'string', 'max:255'],
|
||||
'description' => ['sometimes', 'nullable', 'string', 'max:2000'],
|
||||
'folder_id' => ['sometimes', 'nullable', 'integer', 'exists:folders,id'],
|
||||
'folder_id' => ['sometimes', ...Rules::folderId()],
|
||||
'public' => ['sometimes', 'boolean'],
|
||||
'commentable' => ['sometimes', 'boolean'],
|
||||
'slug' => Rules::slug('files', $file->id),
|
||||
@@ -286,6 +288,21 @@ class FilesController extends Controller
|
||||
'download_limit_scope' => ['sometimes', Rule::enum(DownloadLimitScope::class)],
|
||||
]);
|
||||
|
||||
// Reparenting through update() must respect the same library scope as
|
||||
// the web move()/bulkUpdate() paths: the destination folder must be
|
||||
// one this user can see. Only enforced when folder_id actually
|
||||
// changes, so re-saving a file that already sits in an out-of-scope
|
||||
// folder (reachable via a direct client share) still works. The
|
||||
// integer rule admits numeric strings, so cast before the strict
|
||||
// change comparison.
|
||||
if (array_key_exists('folder_id', $validated) && $validated['folder_id'] !== null) {
|
||||
$validated['folder_id'] = (int) $validated['folder_id'];
|
||||
|
||||
if ($validated['folder_id'] !== $file->folder_id) {
|
||||
$this->scope->folders($user)->findOrFail($validated['folder_id']);
|
||||
}
|
||||
}
|
||||
|
||||
$attributes = array_intersect_key($validated, array_flip(['name', 'description', 'folder_id']));
|
||||
|
||||
if (array_key_exists('expires_at', $validated) && $user->can('set_file_expiration_date')) {
|
||||
|
||||
@@ -81,7 +81,14 @@ class CategoriesController extends Controller
|
||||
|
||||
$this->activity->log(Action::CategoryCreated, subject: $category);
|
||||
|
||||
return redirect()->route('categories.edit', $category)->with('success', __('Category created.'));
|
||||
// Same create-without-edit rule as ClientsController::store() — and
|
||||
// the most reachable case of it: the sidebar shows Categories from
|
||||
// create_categories alone, with no manage tier in between.
|
||||
$target = $request->user()?->can('edit_categories')
|
||||
? redirect()->route('categories.edit', $category)
|
||||
: redirect()->route('categories.create');
|
||||
|
||||
return $target->with('success', __('Category created.'));
|
||||
}
|
||||
|
||||
public function edit(Category $category): Response
|
||||
|
||||
@@ -24,11 +24,13 @@ use App\Modules\Identity\UserType;
|
||||
use App\Modules\Notifications\Notifier;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use App\Support\Rules;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -71,7 +73,7 @@ class ChunkedUploadsController extends Controller
|
||||
'size' => ['required', 'integer', 'min:1'],
|
||||
'type' => ['nullable', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:2000'],
|
||||
'folder_id' => ['nullable', 'integer', 'exists:folders,id'],
|
||||
'folder_id' => Rules::folderId(),
|
||||
'previous_file_id' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
@@ -240,6 +242,33 @@ class ChunkedUploadsController extends Controller
|
||||
$user = $request->user();
|
||||
assert($user !== null);
|
||||
|
||||
// Serialise completion per session: two concurrent completes (an Uppy
|
||||
// retry, a double submit, a lost-connection resend) would otherwise
|
||||
// both assemble into the one target file and create two File rows.
|
||||
// The lock's TTL releases the claim if a completion dies mid-flight,
|
||||
// so a later retry still works.
|
||||
$lock = Cache::lock('upload-complete:'.$session->id, 120);
|
||||
|
||||
if (! $lock->get()) {
|
||||
throw ValidationException::withMessages([
|
||||
'parts' => __('This upload is already being finalised.'),
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->finalise($session, $user);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble a session's received parts into a stored File. Runs under
|
||||
* complete()'s per-session lock, so it is the single writer to the
|
||||
* session's target path and the only creator of its File row.
|
||||
*/
|
||||
private function finalise(UploadSession $session, User $user): JsonResponse
|
||||
{
|
||||
$extension = strtolower(pathinfo($session->original_name, PATHINFO_EXTENSION));
|
||||
$targetPath = now()->format('Y/m').'/'.Str::uuid()->toString().($extension !== '' ? '.'.$extension : '');
|
||||
|
||||
@@ -249,6 +278,22 @@ class ChunkedUploadsController extends Controller
|
||||
throw ValidationException::withMessages(['parts' => $exception->getMessage()]);
|
||||
}
|
||||
|
||||
// store()'s size check ran against the client-declared, unverified
|
||||
// size, so a small declared size would otherwise let an upload of any
|
||||
// size through here. Re-check the real assembled byte count against
|
||||
// the same limit store() applies to everyone. No File row exists yet
|
||||
// at this point, so cleanup only needs to undo what assemble() wrote.
|
||||
$maxMb = (int) $this->settings->get(Setting::MaxFileSizeMb);
|
||||
|
||||
if ($maxMb > 0 && $assembled['size'] > $maxMb * 1024 * 1024) {
|
||||
Storage::disk($assembled['disk'])->delete($assembled['path']);
|
||||
$session->delete();
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'size' => __('This file exceeds the maximum allowed size of :max MB.', ['max' => (string) $maxMb]),
|
||||
]);
|
||||
}
|
||||
|
||||
// store()'s quota check used a client-declared, unverified size —
|
||||
// re-check against the real assembled byte count before this
|
||||
// becomes a File row. No File row exists yet at this point, so
|
||||
@@ -272,6 +317,23 @@ class ChunkedUploadsController extends Controller
|
||||
// the previewer's browser. Detect the real mime type from the assembled bytes.
|
||||
$mimeType = Storage::disk($assembled['disk'])->mimeType($assembled['path']) ?: 'application/octet-stream';
|
||||
|
||||
// Re-resolved rather than taken from the session. A chunked
|
||||
// upload is two requests, and store()'s rule only ever sees the
|
||||
// first: delete the folder while the bytes are in flight and the
|
||||
// recorded id names a folder whose own deletion already removed
|
||||
// every file in it. Filing into it would recreate exactly the
|
||||
// state Rules::folderId() exists to prevent.
|
||||
//
|
||||
// The root, rather than a refusal, because the two moments cost
|
||||
// different things. At store() nothing has been sent, so refusing
|
||||
// is free and honest. Here the bytes are already uploaded, and
|
||||
// throwing away somebody's finished transfer over a folder that
|
||||
// vanished underneath them is the harsher of the two surprises —
|
||||
// the file lands somewhere they can see it and move it.
|
||||
$folderId = $session->folder_id !== null && Folder::query()->whereKey($session->folder_id)->exists()
|
||||
? $session->folder_id
|
||||
: null;
|
||||
|
||||
$file = $this->storeFile->create(
|
||||
uploader: $user,
|
||||
originalName: $session->original_name,
|
||||
@@ -280,7 +342,7 @@ class ChunkedUploadsController extends Controller
|
||||
size: $assembled['size'],
|
||||
checksum: $assembled['checksum'],
|
||||
description: $session->description,
|
||||
folderId: $session->folder_id,
|
||||
folderId: $folderId,
|
||||
disk: $assembled['disk'],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
/**
|
||||
* Limits that apply when files leave the installation rather than when
|
||||
* they arrive. Only the zip cap for now — consumed by
|
||||
* ZipDownloadsController and BuildZipDownloadJob.
|
||||
*/
|
||||
class DownloadSettingsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Settings $settings,
|
||||
private readonly ActivityLogger $activity,
|
||||
) {}
|
||||
|
||||
public function edit(): Response
|
||||
{
|
||||
return Inertia::render('system/settings/downloads', [
|
||||
'max_zip_download_size_mb' => $this->settings->get(Setting::MaxZipDownloadSizeMb),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
// Same ceiling as the upload size field: a megabyte figure
|
||||
// large enough to be meaningless is the same as unlimited,
|
||||
// which 0 already says more clearly.
|
||||
'max_zip_download_size_mb' => ['required', 'integer', 'min:0', 'max:1048576'],
|
||||
]);
|
||||
|
||||
$this->settings->set(Setting::MaxZipDownloadSizeMb, (int) $validated['max_zip_download_size_mb']);
|
||||
|
||||
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'downloads']);
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ class FilesController extends Controller
|
||||
'file' => ['required', 'file', 'max:102400'],
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:2000'],
|
||||
'folder_id' => ['nullable', 'integer', 'exists:folders,id'],
|
||||
'folder_id' => Rules::folderId(),
|
||||
]);
|
||||
|
||||
/** @var UploadedFile $upload */
|
||||
@@ -93,6 +93,17 @@ class FilesController extends Controller
|
||||
$user = $request->user();
|
||||
assert($user !== null);
|
||||
|
||||
// The check the other two upload paths make and this one did not:
|
||||
// a folder outside the uploader's library is not a place to put a
|
||||
// file. Without it this route reached any folder on the
|
||||
// installation, and File::scopeVisibleToClient then hands the file
|
||||
// to whoever that folder's subtree is shared with.
|
||||
$folder = isset($validated['folder_id'])
|
||||
? Folder::query()->whereKey($validated['folder_id'])->first()
|
||||
: null;
|
||||
|
||||
abort_unless(Folder::uploadableBy($user, $folder), 403);
|
||||
|
||||
if (! app(UploadExtensionPolicy::class)->isAllowed($user, $upload->getClientOriginalName())) {
|
||||
throw ValidationException::withMessages([
|
||||
'file' => __('This file type is not allowed for upload.'),
|
||||
@@ -197,7 +208,11 @@ class FilesController extends Controller
|
||||
'url' => route('files.edit', $member, false),
|
||||
'is_current' => $member->id === $file->id,
|
||||
])->values(),
|
||||
'folder_options' => Folder::query()->orderBy('path')->orderBy('name')->get()
|
||||
// Narrowed like every other folder listing: an unscoped staff
|
||||
// member gets the whole tree, a client-scoped one only their
|
||||
// own. Unfiltered this handed a scoped staffer every folder
|
||||
// name and id on the installation.
|
||||
'folder_options' => $this->scope->folders($viewer)->orderBy('path')->orderBy('name')->get()
|
||||
->map(fn (Folder $folder): array => ['id' => $folder->id, 'name' => $folder->name])->all(),
|
||||
'categories' => Category::query()->orderBy('name')->get(['id', 'name', 'color'])
|
||||
->map(fn (Category $category): array => ['id' => $category->id, 'name' => $category->name, 'color' => $category->color])->all(),
|
||||
@@ -242,7 +257,7 @@ class FilesController extends Controller
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:2000'],
|
||||
'folder_id' => ['nullable', 'integer', 'exists:folders,id'],
|
||||
'folder_id' => Rules::folderId(),
|
||||
'public' => ['sometimes', 'boolean'],
|
||||
'commentable' => ['sometimes', 'boolean'],
|
||||
// The slug only matters (and is only shown) once a file is
|
||||
@@ -255,10 +270,25 @@ class FilesController extends Controller
|
||||
'download_limit_scope' => ['nullable', Rule::enum(DownloadLimitScope::class)],
|
||||
]);
|
||||
|
||||
// The edit form posts folder_id as a string; cast so the strict
|
||||
// change comparison below matches the model's int.
|
||||
$folderId = isset($validated['folder_id']) ? (int) $validated['folder_id'] : null;
|
||||
$user = $request->user();
|
||||
|
||||
// Reparenting through update() is the same privileged write as
|
||||
// move()/bulkUpdate(), so it needs the same guard: the destination
|
||||
// must be a folder this user can actually see. Only checked when the
|
||||
// folder actually changes, so re-saving a file that already sits in
|
||||
// an out-of-scope folder (reachable via a direct client share) still
|
||||
// works.
|
||||
if ($folderId !== null && $folderId !== $file->folder_id && $user !== null) {
|
||||
$this->scope->folders($user)->findOrFail($folderId);
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'folder_id' => $validated['folder_id'] ?? null,
|
||||
'folder_id' => $folderId,
|
||||
];
|
||||
|
||||
// Only meaningful while the comment scope is `selected`, and only
|
||||
@@ -327,7 +357,7 @@ class FilesController extends Controller
|
||||
Gate::authorize('update', $file);
|
||||
|
||||
$validated = $request->validate([
|
||||
'folder_id' => ['nullable', 'integer', 'exists:folders,id'],
|
||||
'folder_id' => Rules::folderId(),
|
||||
]);
|
||||
|
||||
$folderId = $validated['folder_id'] ?? null;
|
||||
@@ -363,7 +393,7 @@ class FilesController extends Controller
|
||||
'file_ids.*' => ['integer', 'distinct'],
|
||||
|
||||
'folder_action' => ['required', Rule::in(['no_change', 'move'])],
|
||||
'folder_id' => ['nullable', 'integer', 'exists:folders,id'],
|
||||
'folder_id' => Rules::folderId(),
|
||||
|
||||
'description_action' => ['required', Rule::in(['no_change', 'set'])],
|
||||
'description' => ['nullable', 'string', 'max:2000'],
|
||||
|
||||
@@ -186,7 +186,11 @@ class FoldersController extends Controller
|
||||
'expired' => $expired,
|
||||
'categories' => Category::query()->orderBy('name')->get(['id', 'name', 'color'])
|
||||
->map(fn (Category $category): array => ['id' => $category->id, 'name' => $category->name, 'color' => $category->color])->all(),
|
||||
'folder_options' => Folder::query()->orderBy('path')->orderBy('name')->get()
|
||||
// Narrowed like every other folder listing on this screen: an
|
||||
// unscoped staff member gets the whole tree, a client-scoped
|
||||
// one only their own. Unfiltered this handed a scoped staffer
|
||||
// every folder name and id on the installation.
|
||||
'folder_options' => $this->scope->folders($user)->orderBy('path')->orderBy('name')->get()
|
||||
->map(fn (Folder $folder): array => ['id' => $folder->id, 'name' => $folder->name])->all(),
|
||||
'can_create_folders' => $user->can('create_own_folders'),
|
||||
'can_upload' => $user->can('upload'),
|
||||
@@ -305,7 +309,7 @@ class FoldersController extends Controller
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'parent_id' => ['nullable', 'integer', 'exists:folders,id'],
|
||||
'parent_id' => Rules::folderId(),
|
||||
'public' => ['sometimes', 'boolean'],
|
||||
'slug' => Rules::slug('folders'),
|
||||
'allow_client_uploads' => ['sometimes', 'boolean'],
|
||||
@@ -389,7 +393,7 @@ class FoldersController extends Controller
|
||||
Gate::authorize('update', $folder);
|
||||
|
||||
$validated = $request->validate([
|
||||
'parent_id' => ['nullable', 'integer', 'exists:folders,id'],
|
||||
'parent_id' => Rules::folderId(),
|
||||
]);
|
||||
|
||||
$newParent = $this->resolveParent($request->user(), $validated['parent_id'] ?? null);
|
||||
@@ -401,10 +405,32 @@ class FoldersController extends Controller
|
||||
return back();
|
||||
}
|
||||
|
||||
public function destroy(Folder $folder): RedirectResponse
|
||||
public function destroy(Request $request, Folder $folder): RedirectResponse
|
||||
{
|
||||
Gate::authorize('delete', $folder);
|
||||
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
// Deleting a folder cascades to every file in its subtree, and a
|
||||
// File's `deleted` hook removes the bytes from disk — there is no
|
||||
// restore. Authorizing the folder is not authorizing its contents:
|
||||
// FilePolicy::delete asks for `delete_others_files` on somebody
|
||||
// else's upload, and for the library boundary on top of that, and
|
||||
// neither question is asked anywhere on this path.
|
||||
//
|
||||
// MyFoldersController::destroy already refuses for the client half
|
||||
// of the same cascade, in the same words. This is the staff half.
|
||||
$blocked = $this->undeletableFileCount($viewer, $folder);
|
||||
|
||||
if ($blocked > 0) {
|
||||
return back()->with('error', trans_choice(
|
||||
'This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.',
|
||||
$blocked,
|
||||
['count' => (string) $blocked],
|
||||
));
|
||||
}
|
||||
|
||||
$name = $folder->name;
|
||||
$parentId = $folder->parent_id;
|
||||
|
||||
@@ -415,6 +441,50 @@ class FoldersController extends Controller
|
||||
return redirect()->route('files.index', $parentId !== null ? ['folder' => $parentId] : [])->with('success', __('Folder deleted.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* How many files in this folder's subtree the viewer may not delete.
|
||||
*
|
||||
* Asked as one count rather than FilePolicy::delete per file: a folder
|
||||
* can hold thousands, Gate resolves a fresh policy for every check, and
|
||||
* a per-row policy check on a listing is the cost 0a8b609e went to
|
||||
* some trouble to remove. The two halves of FilePolicy::delete are
|
||||
* expressible in SQL — the permission half is constant for this
|
||||
* viewer, and the library half is the query StaffLibraryScope already
|
||||
* memoises per request.
|
||||
*
|
||||
* Somebody holding both delete permissions and no library scope can
|
||||
* delete anything in the subtree by construction, so they never pay for
|
||||
* the query at all.
|
||||
*/
|
||||
private function undeletableFileCount(User $viewer, Folder $folder): int
|
||||
{
|
||||
$mayDeleteOwn = $viewer->can('delete_files');
|
||||
$mayDeleteOthers = $viewer->can('delete_others_files');
|
||||
$scoped = $viewer->isClientScoped();
|
||||
|
||||
if ($mayDeleteOwn && $mayDeleteOthers && ! $scoped) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return File::query()
|
||||
->whereIn('folder_id', $folder->subtreeFolderIds())
|
||||
->where(function (Builder $outer) use ($viewer, $mayDeleteOwn, $mayDeleteOthers, $scoped): void {
|
||||
if (! $mayDeleteOwn) {
|
||||
$outer->orWhere('uploaded_by', $viewer->id);
|
||||
}
|
||||
|
||||
if (! $mayDeleteOthers) {
|
||||
$outer->orWhere(fn (Builder $others): Builder => $others
|
||||
->whereNull('uploaded_by')->orWhere('uploaded_by', '!=', $viewer->id));
|
||||
}
|
||||
|
||||
if ($scoped) {
|
||||
$outer->orWhereNotIn('id', $this->scope->files($viewer)->select('id'));
|
||||
}
|
||||
})
|
||||
->count();
|
||||
}
|
||||
|
||||
private function resolveParent(?User $user, ?int $parentId): ?Folder
|
||||
{
|
||||
if ($user === null || $parentId === null) {
|
||||
|
||||
@@ -122,15 +122,23 @@ class MyFilesController extends Controller
|
||||
|
||||
// Subfolders: at root, every visible folder whose parent isn't
|
||||
// itself visible (top of each shared subtree, or a client-owned
|
||||
// folder with no visible parent); inside a folder, its direct
|
||||
// children.
|
||||
// folder with no visible parent); inside a folder, the visible
|
||||
// ones among its direct children.
|
||||
//
|
||||
// Both branches narrow to $visibleIds. Being handed a folder is
|
||||
// not permission to read the names of everything filed inside
|
||||
// it: a client-created folder is visible through created_by,
|
||||
// which says nothing about a subfolder staff later put there.
|
||||
if ($current === null) {
|
||||
$folders = Folder::query()
|
||||
->whereIn('id', $visibleIds)
|
||||
->where(fn ($q) => $q->whereNull('parent_id')->orWhereNotIn('parent_id', $visibleIds))
|
||||
->orderBy('name');
|
||||
} else {
|
||||
$folders = Folder::query()->where('parent_id', $current->id)->orderBy('name');
|
||||
$folders = Folder::query()
|
||||
->whereIn('id', $visibleIds)
|
||||
->where('parent_id', $current->id)
|
||||
->orderBy('name');
|
||||
}
|
||||
|
||||
// Files: inside a folder, that folder's files; at root, only
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Folders\FolderService;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Support\Rules;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
@@ -43,7 +44,7 @@ class MyFoldersController extends Controller
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'parent_id' => ['nullable', 'integer', 'exists:folders,id'],
|
||||
'parent_id' => Rules::folderId(),
|
||||
]);
|
||||
|
||||
$parent = null;
|
||||
|
||||
@@ -15,12 +15,16 @@ use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Files\Models\ZipDownload;
|
||||
use App\Modules\Files\Uploads\StoreUploadedFile;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use App\Support\ContentDisposition;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Number;
|
||||
|
||||
/**
|
||||
* A folder's "Download as zip" button and the file listing's multi-select
|
||||
@@ -40,6 +44,7 @@ class ZipDownloadsController extends Controller
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly ViewableFileScope $viewable,
|
||||
private readonly DownloadAllowance $allowance,
|
||||
private readonly Settings $settings,
|
||||
) {}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
@@ -47,6 +52,22 @@ class ZipDownloadsController extends Controller
|
||||
$user = $request->user();
|
||||
assert($user !== null);
|
||||
|
||||
// One build at a time per requester. A zip holds the queue worker
|
||||
// for as long as it takes to write, and everything else — every
|
||||
// notification email — waits behind it, so a queue of them from
|
||||
// one person is everyone else's outage. An hour old is treated as
|
||||
// abandoned rather than in progress: BuildZipDownloadJob::failed()
|
||||
// resolves a row the worker gave up on, but a worker killed hard
|
||||
// enough never runs it, and nobody should be locked out forever by
|
||||
// a row nothing will ever finish.
|
||||
$inFlight = ZipDownload::query()
|
||||
->where('requested_by', $user->id)
|
||||
->where('status', ZipDownload::STATUS_PENDING)
|
||||
->where('created_at', '>', now()->subHour())
|
||||
->exists();
|
||||
|
||||
abort_if($inFlight, 429, __('A zip download is already being prepared. Wait for that one to finish before starting another.'));
|
||||
|
||||
$validated = $request->validate([
|
||||
'file_ids' => ['array'],
|
||||
'file_ids.*' => ['integer'],
|
||||
@@ -98,9 +119,32 @@ class ZipDownloadsController extends Controller
|
||||
fn (Folder $folder): int => (clone $visible)->whereIn('folder_id', $folder->subtreeFolderIds())->count(),
|
||||
);
|
||||
|
||||
// Measured the same way, and deliberately without the allowance
|
||||
// filter the loose-file branch applies: a folder's total can only
|
||||
// come out at or above what the archive will really weigh, and an
|
||||
// over-estimate is the safe direction for a cap.
|
||||
$totalSize = (int) $files->sum('size') + (int) $folders->sum(
|
||||
fn (Folder $folder): int => (int) (clone $visible)->whereIn('folder_id', $folder->subtreeFolderIds())->sum('size'),
|
||||
);
|
||||
|
||||
abort_if($fileCount === 0, 422, __('The selected folders are empty.'));
|
||||
abort_if($fileCount > self::MAX_FILES, 422, __('Too many files selected. Choose a smaller selection and try again.'));
|
||||
|
||||
// Bytes, not file count, are what a build costs — worker time, the
|
||||
// temp copies a remote disk needs, and the archive on disk. The
|
||||
// message names both numbers because "too big" without them leaves
|
||||
// someone guessing how much to deselect.
|
||||
$maxBytes = (int) $this->settings->get(Setting::MaxZipDownloadSizeMb) * 1024 * 1024;
|
||||
|
||||
abort_if(
|
||||
$maxBytes > 0 && $totalSize > $maxBytes,
|
||||
422,
|
||||
__('That selection is :size. Zip downloads are limited to :limit — select fewer files and try again.', [
|
||||
'size' => Number::fileSize($totalSize, precision: 1),
|
||||
'limit' => Number::fileSize($maxBytes),
|
||||
]),
|
||||
);
|
||||
|
||||
$zipDownload = ZipDownload::query()->create([
|
||||
'requested_by' => $user->id,
|
||||
'status' => ZipDownload::STATUS_PENDING,
|
||||
@@ -137,8 +181,7 @@ class ZipDownloadsController extends Controller
|
||||
// Only the first time. Re-fetching one prepared archive is the
|
||||
// same delivery, not a fresh download of everything inside it.
|
||||
if ($zipDownload->delivered_at === null) {
|
||||
$this->logContainedDownloads($zipDownload, $user);
|
||||
$zipDownload->forceFill(['delivered_at' => now()])->save();
|
||||
$this->deliverOnce($zipDownload, $user);
|
||||
}
|
||||
|
||||
$size = Storage::disk('files')->size($path);
|
||||
@@ -152,14 +195,81 @@ class ZipDownloadsController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Every file actually bundled gets a FileDownloaded entry — otherwise
|
||||
* a file's download history/count would silently miss zip downloads.
|
||||
* Hand the archive over, once: refuse it if anything inside is out of
|
||||
* allowance, otherwise count everything it holds as downloaded.
|
||||
*
|
||||
* This is the only point that spends a download limit, which is why
|
||||
* it also has to be the point that enforces it. Building an archive
|
||||
* takes nothing, so ordering the same limited file into any number of
|
||||
* archives passes every check on the way — store() and the job both
|
||||
* look at an allowance nothing has drawn on yet — and collecting them
|
||||
* all afterwards would hand over more copies than the limit allows.
|
||||
*
|
||||
* One refused file refuses the whole delivery, because nothing can be
|
||||
* taken out of a finished archive without building it again. Ordering
|
||||
* the same selection afresh is the way through: the build leaves the
|
||||
* spent file out and names it in skipped_files.
|
||||
*
|
||||
* An archive from before the job recorded its contents is handed over
|
||||
* the way it always was, without this check. Its contents can only be
|
||||
* guessed at by resolving the selection again, and guessing is exactly
|
||||
* what must not decide a refusal: the same reconstruction both refuses
|
||||
* over files the archive does not hold and misses files it does. Those
|
||||
* rows stop existing within a day or two of an upgrade, and until then
|
||||
* they behave as they did before this change rather than worse.
|
||||
*/
|
||||
private function logContainedDownloads(ZipDownload $zipDownload, User $requester): void
|
||||
private function deliverOnce(ZipDownload $zipDownload, User $requester): void
|
||||
{
|
||||
$recorded = $zipDownload->contained_file_ids;
|
||||
|
||||
// What the job wrote down, read back as it stands — deliberately
|
||||
// not filtered by what the requester may see today. The bytes are
|
||||
// in the archive already, so a file that has since expired or left
|
||||
// their scope is still being given to them, and a count that
|
||||
// quietly dropped it would understate what was taken.
|
||||
$contained = $recorded === null
|
||||
? $this->resolveSelection($zipDownload, $requester)
|
||||
: File::query()->whereIn('id', $recorded)->get();
|
||||
|
||||
abort_if(
|
||||
$recorded !== null
|
||||
&& $contained->contains(fn (File $file): bool => ! $this->allowance->allows($file, $requester)),
|
||||
403,
|
||||
__('Those files have reached their download limit.'),
|
||||
);
|
||||
|
||||
// Atomic, so two fetches arriving together are still one delivery:
|
||||
// only the request that actually moves delivered_at logs anything.
|
||||
// Same reasoning as the conditional increment guarding a share
|
||||
// link's max_downloads in PublicShareController. The other request
|
||||
// still receives the archive — that is the re-fetch rule above.
|
||||
$claimed = ZipDownload::query()
|
||||
->whereKey($zipDownload->id)
|
||||
->whereNull('delivered_at')
|
||||
->update(['delivered_at' => now()]);
|
||||
|
||||
if ($claimed === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Every file actually bundled gets a FileDownloaded entry —
|
||||
// otherwise a file's download history/count would silently miss
|
||||
// zip downloads.
|
||||
foreach ($contained as $file) {
|
||||
$this->activity->log(Action::FileDownloaded, subject: $file);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What an archive built before the job recorded its contents is taken
|
||||
* to hold: the selection, resolved again, which is how this worked
|
||||
* throughout. Only reachable for rows written by an older release,
|
||||
* and PurgeZipDownloadsCommand removes those within a day.
|
||||
*
|
||||
* @return Collection<int, File>
|
||||
*/
|
||||
private function resolveSelection(ZipDownload $zipDownload, User $requester): Collection
|
||||
{
|
||||
// Same per-file filter the job used to decide what actually went
|
||||
// into the archive, so the log records what was really downloaded
|
||||
// rather than everything that happened to sit in the folder.
|
||||
$visible = $this->viewable->for($requester);
|
||||
$fileIds = collect($zipDownload->file_ids);
|
||||
|
||||
@@ -177,9 +287,7 @@ class ZipDownloadsController extends Controller
|
||||
// further past it.
|
||||
$skipped = collect($zipDownload->skipped_files ?? [])->pluck('id')->all();
|
||||
|
||||
foreach ((clone $visible)->whereIn('id', $fileIds->unique())->whereNotIn('id', $skipped)->get() as $file) {
|
||||
$this->activity->log(Action::FileDownloaded, subject: $file);
|
||||
}
|
||||
return (clone $visible)->whereIn('id', $fileIds->unique())->whereNotIn('id', $skipped)->get();
|
||||
}
|
||||
|
||||
private function filenameFor(ZipDownload $zipDownload): string
|
||||
|
||||
@@ -10,6 +10,8 @@ use App\Modules\Files\Access\ViewableFileScope;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Files\Models\ZipDownload;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -17,6 +19,7 @@ use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Throwable;
|
||||
use ZipArchive;
|
||||
@@ -40,9 +43,40 @@ class BuildZipDownloadJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* A zip build is not usefully retryable — a source file that went
|
||||
* missing mid-build, or an allowance spent while the job waited, makes
|
||||
* a second attempt no likelier to succeed — so a failure is recorded
|
||||
* once and surfaced to the requester rather than silently retried.
|
||||
*/
|
||||
public int $tries = 1;
|
||||
|
||||
/**
|
||||
* Building the archive is the whole job, and a large selection (up to
|
||||
* ZipDownloadsController::MAX_FILES sources, some stream-copied from a
|
||||
* remote disk) runs well past the queue worker's default 60s timeout.
|
||||
* Without room the worker kills the process mid-build before the catch
|
||||
* can run, stranding the row as PENDING forever; failed() is the
|
||||
* backstop for when the kill lands anyway.
|
||||
*/
|
||||
public int $timeout = 3600;
|
||||
|
||||
public function __construct(
|
||||
private readonly int $zipDownloadId,
|
||||
) {}
|
||||
) {
|
||||
// Its own queue, because $timeout is an hour and every shipped
|
||||
// topology runs one worker: on the default queue a single large
|
||||
// build holds up every notification email behind it. Set in the
|
||||
// constructor rather than at the dispatch site so a second caller
|
||||
// cannot forget it.
|
||||
//
|
||||
// A worker has to be listening. The images run a second one; a
|
||||
// manual install whose worker command still says plain
|
||||
// `queue:work` consumes `default` only, so INSTALL.md documents
|
||||
// `--queue=default,zips` for the single-worker case — see the
|
||||
// upgrade note in CHANGELOG.md.
|
||||
$this->onQueue('zips');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
@@ -52,6 +86,14 @@ class BuildZipDownloadJob implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
// Stamped before any of the work, because the only thing this is
|
||||
// for is telling "a worker has this in hand" apart from "nobody
|
||||
// is listening to the zips queue". A build that waits and never
|
||||
// starts is the second, which is what a manual install whose
|
||||
// worker command predates that queue looks like from here. See
|
||||
// StalledZipBuilds.
|
||||
$zipDownload->forceFill(['started_at' => now()])->save();
|
||||
|
||||
// Authorization is re-derived here, against the requester, rather
|
||||
// than trusted from what the controller stored: a folder id only
|
||||
// says "this user may open this folder", never "this user may read
|
||||
@@ -88,9 +130,19 @@ class BuildZipDownloadJob implements ShouldQueue
|
||||
$tempFiles = [];
|
||||
$skipped = [];
|
||||
|
||||
// Counted rather than derived from $usedNames, which also
|
||||
// holds the folder entry names.
|
||||
$added = 0;
|
||||
// Collected rather than derived from $usedNames, which also
|
||||
// holds the folder entry names. Recording the ids, not just a
|
||||
// count, is what lets the download action log exactly what it
|
||||
// hands over instead of resolving the selection a second time
|
||||
// against a scope that may have moved since.
|
||||
//
|
||||
// Keyed by id rather than appended to a list, because it is
|
||||
// also what keeps a file out of the archive twice. The loose
|
||||
// selection cannot repeat itself — one whereIn on the primary
|
||||
// key — but a selected folder can hold a file that was also
|
||||
// named loosely, and the cap is 10000 sources, so the check
|
||||
// has to be a lookup rather than a scan.
|
||||
$added = [];
|
||||
|
||||
foreach ((clone $visible)->whereIn('id', $zipDownload->file_ids)->get() as $file) {
|
||||
// Re-checked here for the same reason visibility is: the
|
||||
@@ -105,24 +157,87 @@ class BuildZipDownloadJob implements ShouldQueue
|
||||
$entryName = $this->dedupeName($usedNames, $this->entrySegment($file->original_name));
|
||||
$zip->addFile($this->localPathFor($file, $tempFiles), $entryName);
|
||||
$totalSize += $file->size;
|
||||
$added++;
|
||||
$added[$file->id] = true;
|
||||
}
|
||||
|
||||
foreach (Folder::query()->whereIn('id', $zipDownload->folder_ids)->get() as $folder) {
|
||||
foreach ($this->outermostFolders($zipDownload->folder_ids) as $folder) {
|
||||
$totalSize += $this->addFolder($zip, $folder, $requester, $usedNames, $tempFiles, $visible, $skipped, $added);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
// Re-checked here, not only in ZipDownloadsController: the
|
||||
// selection is re-derived at build time, so a folder that grew
|
||||
// while the job sat in the queue could otherwise fill the disk
|
||||
// with an archive nobody is allowed to ask for. unchangeAll()
|
||||
// drops every pending entry, so close() writes nothing rather
|
||||
// than writing an archive we would delete a line later.
|
||||
$maxBytes = (int) app(Settings::class)->get(Setting::MaxZipDownloadSizeMb) * 1024 * 1024;
|
||||
|
||||
if ($maxBytes > 0 && $totalSize > $maxBytes) {
|
||||
$zip->unchangeAll();
|
||||
@$zip->close();
|
||||
|
||||
foreach ($tempFiles as $tempFile) {
|
||||
@unlink($tempFile);
|
||||
}
|
||||
|
||||
$this->fail($zipDownload, $relativePath, 'The selection grew past the maximum zip download size before the archive could be built.', $skipped);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// ZipArchive defers every write to close(): a source file
|
||||
// deleted after its addFile() (a concurrent staff delete runs
|
||||
// FileDiskCleanup at once) or a full disk only surfaces here,
|
||||
// as a false return. Its low-level warning is silenced (as with
|
||||
// the @unlink cleanup below) so the return value is the signal
|
||||
// we act on, deterministically, rather than an exception whose
|
||||
// firing depends on the error_reporting level. An archive that
|
||||
// ended up with no entries is the same kind of non-result —
|
||||
// libzip writes no file for one at all, even though close()
|
||||
// still returns true. Either way there is nothing to serve, so
|
||||
// the row must not be marked ready over a missing or empty
|
||||
// archive: the download controller would X-Accel a file that
|
||||
// isn't there.
|
||||
$written = @$zip->close();
|
||||
|
||||
foreach ($tempFiles as $tempFile) {
|
||||
@unlink($tempFile);
|
||||
}
|
||||
|
||||
if ($written !== true || $added === []) {
|
||||
if ($written !== true) {
|
||||
// What the requester sees stays generic: a libzip
|
||||
// string means nothing to them and can name a server
|
||||
// path. An operator needs the opposite — "disk full"
|
||||
// and "the source file vanished" are different
|
||||
// problems — so the reason goes to the log instead.
|
||||
Log::error('A zip download could not be written.', [
|
||||
'zip_download_id' => $zipDownload->id,
|
||||
'reason' => $zip->getStatusString(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Nothing written is told apart from nothing added, and
|
||||
// "every file had already been downloaded as often as it
|
||||
// was meant to be" from "there was nothing left to send".
|
||||
// They are different problems for the person who asked,
|
||||
// and fail() carries the skipped list either way, so
|
||||
// "which files?" stays answerable from the row.
|
||||
$this->fail($zipDownload, $relativePath, match (true) {
|
||||
$written !== true => 'The zip archive could not be written.',
|
||||
$skipped !== [] => 'Every selected file had already reached its download limit.',
|
||||
default => 'None of the selected files were available to add to the archive.',
|
||||
}, $skipped);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$zipDownload->update([
|
||||
'status' => ZipDownload::STATUS_READY,
|
||||
'path' => $relativePath,
|
||||
'total_size' => $totalSize,
|
||||
'file_count' => $added,
|
||||
'file_count' => count($added),
|
||||
'contained_file_ids' => array_keys($added),
|
||||
'skipped_files' => $skipped === [] ? null : $skipped,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
@@ -137,6 +252,45 @@ class BuildZipDownloadJob implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One way out for every build that cannot produce an archive: drop
|
||||
* whatever landed on disk, and leave the row saying what happened and
|
||||
* what was left out.
|
||||
*
|
||||
* @param list<array{id: int, name: string}> $skipped
|
||||
*/
|
||||
private function fail(ZipDownload $zipDownload, string $relativePath, string $message, array $skipped): void
|
||||
{
|
||||
Storage::disk('files')->delete($relativePath);
|
||||
|
||||
$zipDownload->update([
|
||||
'status' => ZipDownload::STATUS_FAILED,
|
||||
'error' => $message,
|
||||
'skipped_files' => $skipped === [] ? null : $skipped,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs when the queue gives up on the job — most importantly when the
|
||||
* worker kills it for exceeding $timeout, which skips handle()'s own
|
||||
* catch and would otherwise leave the row PENDING forever, polled by
|
||||
* the frontend with no end. Only a row still pending is touched: a
|
||||
* build that already resolved itself (ready or failed) is left alone.
|
||||
*/
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
$zipDownload = ZipDownload::query()->find($this->zipDownloadId);
|
||||
|
||||
if ($zipDownload === null || $zipDownload->status !== ZipDownload::STATUS_PENDING) {
|
||||
return;
|
||||
}
|
||||
|
||||
$zipDownload->update([
|
||||
'status' => ZipDownload::STATUS_FAILED,
|
||||
'error' => 'The zip archive could not be built.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A local-disk file is added by its real path (fast path). Anything
|
||||
* else gets stream-copied to a temp file first — ZipArchive::addFile()
|
||||
@@ -182,8 +336,9 @@ class BuildZipDownloadJob implements ShouldQueue
|
||||
* @param array<int, string> $tempFiles
|
||||
* @param Builder<File> $visible every file the requester may read
|
||||
* @param list<array{id: int, name: string}> $skipped
|
||||
* @param array<int, true> $added every file really written into the archive, keyed by id
|
||||
*/
|
||||
private function addFolder(ZipArchive $zip, Folder $folder, User $requester, array &$usedNames, array &$tempFiles, Builder $visible, array &$skipped, int &$added): int
|
||||
private function addFolder(ZipArchive $zip, Folder $folder, User $requester, array &$usedNames, array &$tempFiles, Builder $visible, array &$skipped, array &$added): int
|
||||
{
|
||||
$allowance = app(DownloadAllowance::class);
|
||||
|
||||
@@ -194,6 +349,15 @@ class BuildZipDownloadJob implements ShouldQueue
|
||||
$totalSize = 0;
|
||||
|
||||
foreach ((clone $visible)->whereIn('folder_id', $subtreeIds)->get() as $file) {
|
||||
// Already in the archive under another part of the selection —
|
||||
// named loosely, or inside a folder selected before this one.
|
||||
// Skipped rather than added again: a second entry is a second
|
||||
// copy of the same bytes, and delivery charges one download
|
||||
// however many copies went out.
|
||||
if (isset($added[$file->id])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Holding the folder does not entitle the requester to a file
|
||||
// inside it whose own allowance is spent — same reason the
|
||||
// per-file visibility filter is re-derived rather than
|
||||
@@ -209,12 +373,38 @@ class BuildZipDownloadJob implements ShouldQueue
|
||||
$entryPath = $this->dedupeName($usedNames, $entryPath);
|
||||
$zip->addFile($this->localPathFor($file, $tempFiles), $entryPath);
|
||||
$totalSize += $file->size;
|
||||
$added++;
|
||||
$added[$file->id] = true;
|
||||
}
|
||||
|
||||
return $totalSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected folders with the redundant ones dropped: one that sits
|
||||
* inside another selected folder is already covered by it.
|
||||
*
|
||||
* Zipping both would reach the same file twice, and which of the two
|
||||
* paths the surviving entry ended up under would be decided by
|
||||
* whatever order the database returned the rows in. Keeping the outer
|
||||
* folder keeps the fuller path — Reports/Q1/report.pdf rather than
|
||||
* Q1/report.pdf — and gives the same archive on every run.
|
||||
*
|
||||
* @param list<int> $folderIds
|
||||
* @return Collection<int, Folder>
|
||||
*/
|
||||
private function outermostFolders(array $folderIds): Collection
|
||||
{
|
||||
/** @var Collection<int, Folder> $folders */
|
||||
$folders = Folder::query()->whereIn('id', $folderIds)->orderBy('id')->get();
|
||||
|
||||
return $folders
|
||||
->reject(fn (Folder $folder): bool => $folders->contains(
|
||||
fn (Folder $other): bool => $other->id !== $folder->id
|
||||
&& str_starts_with($folder->path, $other->subtreePathPrefix()),
|
||||
))
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Folder> $foldersById Every folder in the root's subtree, keyed by id.
|
||||
*/
|
||||
|
||||
@@ -105,7 +105,20 @@ class File extends Model
|
||||
// because it needs the row's own pointers intact.
|
||||
app(FileVersions::class)->detachOnDelete($file);
|
||||
|
||||
app(FileDiskCleanup::class)->delete($file);
|
||||
// The bytes go once the transaction holding this row commits,
|
||||
// not alongside the row itself. A cascade — a folder subtree,
|
||||
// an account's content — deletes many rows in one transaction,
|
||||
// and anything that rolls it back afterwards puts every row
|
||||
// back while the bytes are already gone: a loss nothing can
|
||||
// undo. Deferred, the worst case is bytes left on disk with no
|
||||
// row, which OrphanFileScanner already finds and reports.
|
||||
//
|
||||
// Outside a transaction the callback runs immediately, so
|
||||
// deleting one file is unchanged. Nested transactions only fire
|
||||
// it at the outermost commit, which is the case this is for.
|
||||
$file->getConnection()->afterCommit(
|
||||
fn () => app(FileDiskCleanup::class)->delete($file)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Modules\Files\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use App\Support\Concerns\HasUniqueSlug;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -152,11 +153,19 @@ class Folder extends Model
|
||||
|
||||
/**
|
||||
* Whether $user may upload a new file directly into $folder (null =
|
||||
* loose at the root, always allowed). Staff already validate folder_id
|
||||
* through FilesController's own flow — this is the client-facing
|
||||
* check, used by ChunkedUploadsController: the client owns the
|
||||
* folder, or it's a public folder that opts into client uploads and
|
||||
* the client's role permits uploading into public folders at all.
|
||||
* loose at the root, always allowed).
|
||||
*
|
||||
* Staff are held to the library boundary they are held to everywhere
|
||||
* else: an unscoped staff member may use any folder, a client-scoped
|
||||
* one only the folders StaffLibraryScope already shows them. This is
|
||||
* the only place that decides it: every upload path — the web form,
|
||||
* the API and the chunked flow the browser actually posts to — comes
|
||||
* through here rather than checking folder_id for itself.
|
||||
*
|
||||
* For a client this is unchanged, and is still the whole of the
|
||||
* check: they own the folder, or it is a public folder that opts into
|
||||
* client uploads and their role permits uploading into public folders
|
||||
* at all.
|
||||
*/
|
||||
public static function uploadableBy(User $user, ?self $folder): bool
|
||||
{
|
||||
@@ -165,7 +174,7 @@ class Folder extends Model
|
||||
}
|
||||
|
||||
if ($user->isStaff()) {
|
||||
return true;
|
||||
return app(StaffLibraryScope::class)->allowsFolder($user, $folder);
|
||||
}
|
||||
|
||||
return $folder->isOwnedBy($user)
|
||||
|
||||
@@ -22,8 +22,10 @@ use Illuminate\Support\Carbon;
|
||||
* @property string|null $error
|
||||
* @property list<int> $file_ids
|
||||
* @property list<int> $folder_ids
|
||||
* @property list<int>|null $contained_file_ids
|
||||
* @property list<array{id: int, name: string}>|null $skipped_files
|
||||
* @property Carbon|null $delivered_at
|
||||
* @property Carbon|null $started_at
|
||||
*/
|
||||
class ZipDownload extends Model
|
||||
{
|
||||
@@ -40,8 +42,10 @@ class ZipDownload extends Model
|
||||
return [
|
||||
'file_ids' => 'array',
|
||||
'folder_ids' => 'array',
|
||||
'contained_file_ids' => 'array',
|
||||
'skipped_files' => 'array',
|
||||
'delivered_at' => 'datetime',
|
||||
'started_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Modules\Files;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Thumbnails\ImageRendition;
|
||||
use App\Modules\Files\Uploads\UploadExtensionPolicy;
|
||||
use App\Modules\Platform\Settings\ExternalStorageConfigApplier;
|
||||
use App\Modules\Platform\Settings\ExternalStorageSettings;
|
||||
@@ -20,13 +21,6 @@ use Illuminate\Support\Facades\Storage;
|
||||
*/
|
||||
class OrphanFileScanner
|
||||
{
|
||||
// Derived artifacts written by FileThumbnailController and
|
||||
// BuildZipDownloadJob respectively — never orphaned uploads, so
|
||||
// never candidates regardless of what's in the files table.
|
||||
// Thumbnails are always local; zips would be too if that job ever
|
||||
// ran against 'files_external', so the exclusion applies per-disk.
|
||||
private const EXCLUDED_PREFIXES = ['thumbnails/', 'zips/'];
|
||||
|
||||
public function __construct(
|
||||
private readonly UploadExtensionPolicy $extensionPolicy,
|
||||
private readonly ExternalStorageConfigApplier $externalStorage,
|
||||
@@ -160,9 +154,32 @@ class OrphanFileScanner
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Path prefixes that are derived artifacts, never orphaned uploads, so
|
||||
* never candidates regardless of what's in the files table: every image
|
||||
* rendition's cache directory (taken from ImageRendition so a new
|
||||
* rendition can't be forgotten here — previews used to be) plus the
|
||||
* download-bundle job's 'zips'. Thumbnails and previews are always local;
|
||||
* zips would be too if that job ever ran against 'files_external', so the
|
||||
* exclusion applies per-disk.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function excludedPrefixes(): array
|
||||
{
|
||||
$prefixes = array_map(
|
||||
static fn (ImageRendition $rendition): string => $rendition->directory().'/',
|
||||
ImageRendition::cases(),
|
||||
);
|
||||
|
||||
$prefixes[] = 'zips/';
|
||||
|
||||
return $prefixes;
|
||||
}
|
||||
|
||||
private function isExcluded(string $path): bool
|
||||
{
|
||||
foreach (self::EXCLUDED_PREFIXES as $prefix) {
|
||||
foreach ($this->excludedPrefixes() as $prefix) {
|
||||
if (str_starts_with($path, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Queue;
|
||||
|
||||
use App\Modules\Files\Models\ZipDownload;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Whether anything is consuming the `zips` queue.
|
||||
*
|
||||
* The application cannot see its own worker processes; it can only see
|
||||
* whether work gets done. So the question is asked from the other end —
|
||||
* a build that was requested a while ago and that no worker ever picked
|
||||
* up means nobody is listening to that queue.
|
||||
*
|
||||
* Which is a real configuration, not a hypothetical one. Zip building
|
||||
* moved onto its own queue, and a manual install whose worker command
|
||||
* still reads a plain `queue:work` consumes `default` and nothing else.
|
||||
* It goes on sending every email perfectly while no zip download ever
|
||||
* finishes, and nothing in any log says why — the worst shape a
|
||||
* misconfiguration can take, and the reason this is worth a banner
|
||||
* rather than a line in a release note.
|
||||
*
|
||||
* Two conditions, because one of them alone cries wolf:
|
||||
*
|
||||
* - a build has been waiting past GRACE and was never started; and
|
||||
* - no other build is in hand right now.
|
||||
*
|
||||
* The second matters because one worker builds one archive at a time. A
|
||||
* queue behind a large build is a healthy queue, and its waiting rows
|
||||
* look exactly like abandoned ones until you notice something running.
|
||||
* "In hand" is itself bounded by the job's own timeout: a build that
|
||||
* started three hours ago is not in progress, it is a worker that died
|
||||
* holding it.
|
||||
*/
|
||||
class StalledZipBuilds
|
||||
{
|
||||
/**
|
||||
* Long enough that an ordinary wait never trips it, short enough to
|
||||
* be found on the day the install is upgraded rather than the week.
|
||||
*/
|
||||
private const GRACE_MINUTES = 5;
|
||||
|
||||
/**
|
||||
* Matches BuildZipDownloadJob::$timeout. Past it, a build that
|
||||
* started is not running any more — the worker died holding it, and
|
||||
* the queue is as unattended as if it had never begun.
|
||||
*/
|
||||
private const IN_HAND_MINUTES = 60;
|
||||
|
||||
/**
|
||||
* The oldest build nothing ever picked up, or null when the queue is
|
||||
* being served.
|
||||
*/
|
||||
public function oldestUnstarted(): ?Carbon
|
||||
{
|
||||
if ($this->buildInHand()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$waiting = ZipDownload::query()
|
||||
->where('status', ZipDownload::STATUS_PENDING)
|
||||
->whereNull('started_at')
|
||||
->where('created_at', '<', now()->subMinutes(self::GRACE_MINUTES))
|
||||
->min('created_at');
|
||||
|
||||
return $waiting === null ? null : Carbon::parse($waiting);
|
||||
}
|
||||
|
||||
private function buildInHand(): bool
|
||||
{
|
||||
return ZipDownload::query()
|
||||
->where('status', ZipDownload::STATUS_PENDING)
|
||||
->whereNotNull('started_at')
|
||||
->where('started_at', '>', now()->subMinutes(self::IN_HAND_MINUTES))
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
@@ -255,7 +255,26 @@ class LocalPartStore
|
||||
|
||||
private function directory(UploadSession $session): string
|
||||
{
|
||||
return storage_path('app/uploads-tmp/'.$session->id);
|
||||
return $this->root().'/'.$session->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where part files live while a transfer is in progress.
|
||||
*
|
||||
* Configurable only so the test suite can hold it apart per parallel
|
||||
* worker. This is a real directory rather than a faked disk, and each
|
||||
* worker's database restarts session ids at 1, so two workers writing
|
||||
* parts land in the same place — and ChunkedUploadsTest's afterEach
|
||||
* deletes the whole tree, for everybody. Unset, which is every
|
||||
* installation, the path is what it has always been.
|
||||
*/
|
||||
private function root(): string
|
||||
{
|
||||
$configured = config('projectsend.uploads.parts_path');
|
||||
|
||||
return is_string($configured) && $configured !== ''
|
||||
? rtrim($configured, '/')
|
||||
: storage_path('app/uploads-tmp');
|
||||
}
|
||||
|
||||
private function partPath(UploadSession $session, int $partNumber): string
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Groups\Http\Resources\Api\GroupResource;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -17,6 +18,7 @@ class GroupMembersController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly StaffLibraryScope $scope,
|
||||
) {}
|
||||
|
||||
public function store(Request $request, Group $group): GroupResource
|
||||
@@ -37,6 +39,17 @@ class GroupMembersController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
$actor = $request->user();
|
||||
assert($actor instanceof User);
|
||||
|
||||
// Membership is a library boundary, not just a list: joining a
|
||||
// group hands the new member everything shared with it, and if
|
||||
// that member is one of the actor's own clients,
|
||||
// File::scopeVisibleToClient hands the same content back to the
|
||||
// actor. `edit_groups` in front of the route is a permission,
|
||||
// not a boundary. See StaffLibraryScope::allowsGroupMembership.
|
||||
abort_unless($this->scope->allowsGroupMembership($actor, $group, $client), 403);
|
||||
|
||||
// syncWithoutDetaching, so adding an existing member is a no-op and
|
||||
// a retried request is safe.
|
||||
$group->members()->syncWithoutDetaching([$client->id]);
|
||||
@@ -46,8 +59,15 @@ class GroupMembersController extends Controller
|
||||
return new GroupResource($group->loadCount('members')->load('members'));
|
||||
}
|
||||
|
||||
public function destroy(Group $group, User $member): GroupResource
|
||||
public function destroy(Request $request, Group $group, User $member): GroupResource
|
||||
{
|
||||
$actor = $request->user();
|
||||
assert($actor instanceof User);
|
||||
|
||||
// The same boundary as store(): taking somebody out of a group
|
||||
// is a decision about their access, and about a group.
|
||||
abort_unless($this->scope->allowsGroupMembership($actor, $group, $member), 403);
|
||||
|
||||
$group->members()->detach($member->id);
|
||||
|
||||
$this->activity->log(Action::GroupMemberRemoved, subject: $group, context: ['member' => $member->name]);
|
||||
|
||||
@@ -9,9 +9,11 @@ use App\Modules\Api\Support\PollingQuery;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Groups\Http\Resources\Api\GroupResource;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use App\Support\Rules;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
@@ -29,6 +31,7 @@ class GroupsController extends Controller
|
||||
public function __construct(
|
||||
private readonly PollingQuery $polling,
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly StaffLibraryScope $scope,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
@@ -54,9 +57,20 @@ class GroupsController extends Controller
|
||||
return GroupResource::collection($this->polling->paginate($request, $query, 'groups'));
|
||||
}
|
||||
|
||||
public function show(Group $group): GroupResource
|
||||
public function show(Request $request, Group $group): GroupResource
|
||||
{
|
||||
return new GroupResource($group->loadCount('members')->load('members'));
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
// The web edit screen's boundary, on its API twin: this is the read
|
||||
// half of the group that update() and destroy() below already refuse
|
||||
// to touch, and it hands back the membership with addresses.
|
||||
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
|
||||
|
||||
return new GroupResource($group->loadCount('members')->load([
|
||||
'members' => fn (BelongsToMany $members) => $members
|
||||
->whereIn('users.id', $this->scope->clients($viewer)->select('id')),
|
||||
]));
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
@@ -83,6 +97,14 @@ class GroupsController extends Controller
|
||||
|
||||
public function update(Request $request, Group $group): GroupResource
|
||||
{
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
// Mirrors the web controller: a group reaching past this token
|
||||
// owner's library is not theirs to change, and deleting one
|
||||
// revokes its members' access to everything assigned to it.
|
||||
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['sometimes', 'string', 'max:255'],
|
||||
'slug' => Rules::slug('groups', $group->id),
|
||||
@@ -108,8 +130,16 @@ class GroupsController extends Controller
|
||||
return new GroupResource($group->refresh()->loadCount('members'));
|
||||
}
|
||||
|
||||
public function destroy(Group $group): JsonResponse
|
||||
public function destroy(Request $request, Group $group): JsonResponse
|
||||
{
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
// Mirrors the web controller: a group reaching past this token
|
||||
// owner's library is not theirs to change, and deleting one
|
||||
// revokes its members' access to everything assigned to it.
|
||||
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
|
||||
|
||||
$name = $group->name;
|
||||
$group->delete();
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -17,6 +18,7 @@ class GroupMembersController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly StaffLibraryScope $scope,
|
||||
) {}
|
||||
|
||||
public function store(Request $request, Group $group): RedirectResponse
|
||||
@@ -35,6 +37,17 @@ class GroupMembersController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
$actor = $request->user();
|
||||
assert($actor instanceof User);
|
||||
|
||||
// Membership is a library boundary, not just a list: joining a
|
||||
// group hands the new member everything shared with it, and if
|
||||
// that member is one of the actor's own clients,
|
||||
// File::scopeVisibleToClient hands the same content back to the
|
||||
// actor. `edit_groups` in front of the route is a permission,
|
||||
// not a boundary. See StaffLibraryScope::allowsGroupMembership.
|
||||
abort_unless($this->scope->allowsGroupMembership($actor, $group, $client), 403);
|
||||
|
||||
$group->members()->syncWithoutDetaching([$client->id]);
|
||||
|
||||
$this->activity->log(Action::GroupMemberAdded, subject: $group, context: ['member' => $client->name]);
|
||||
@@ -42,8 +55,15 @@ class GroupMembersController extends Controller
|
||||
return back();
|
||||
}
|
||||
|
||||
public function destroy(Group $group, User $member): RedirectResponse
|
||||
public function destroy(Request $request, Group $group, User $member): RedirectResponse
|
||||
{
|
||||
$actor = $request->user();
|
||||
assert($actor instanceof User);
|
||||
|
||||
// The same boundary as store(): taking somebody out of a group
|
||||
// is a decision about their access, and about a group.
|
||||
abort_unless($this->scope->allowsGroupMembership($actor, $group, $member), 403);
|
||||
|
||||
$group->members()->detach($member->id);
|
||||
|
||||
$this->activity->log(Action::GroupMemberRemoved, subject: $group, context: ['member' => $member->name]);
|
||||
|
||||
@@ -8,8 +8,8 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use App\Modules\Identity\UserType;
|
||||
use App\Support\Pagination;
|
||||
use App\Support\PublicUrl;
|
||||
use App\Support\Rules;
|
||||
@@ -25,6 +25,7 @@ class GroupsController extends Controller
|
||||
public function __construct(
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly PublicUrl $publicUrl,
|
||||
private readonly StaffLibraryScope $scope,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
@@ -92,11 +93,26 @@ class GroupsController extends Controller
|
||||
$this->activity->log(Action::GroupMadePublic, subject: $group, context: ['slug' => $group->slug]);
|
||||
}
|
||||
|
||||
return redirect()->route('groups.edit', $group)->with('success', __('Group created.'));
|
||||
// Same create-without-edit rule as ClientsController::store().
|
||||
$target = $request->user()?->can('edit_groups')
|
||||
? redirect()->route('groups.edit', $group)
|
||||
: redirect()->route('groups.create');
|
||||
|
||||
return $target->with('success', __('Group created.'));
|
||||
}
|
||||
|
||||
public function edit(Group $group): Response
|
||||
public function edit(Request $request, Group $group): Response
|
||||
{
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
// The same reach question update() and destroy() ask, asked one
|
||||
// step earlier. Without it this was the one group route holding no
|
||||
// library boundary at all: a scoped staff member could open a group
|
||||
// whose contents they cannot see, read its membership off the
|
||||
// screen, and only be refused on save.
|
||||
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
|
||||
|
||||
return Inertia::render('groups/edit', [
|
||||
'group' => [
|
||||
'id' => $group->id,
|
||||
@@ -105,14 +121,24 @@ class GroupsController extends Controller
|
||||
'description' => $group->description,
|
||||
'public' => $group->public,
|
||||
],
|
||||
'members' => $group->members()->orderBy('name')->get()
|
||||
// Both lists narrow through StaffLibraryScope::clients(), which
|
||||
// is the listing half of the rule this screen's buttons are
|
||||
// already guarded with: a member outside the roster cannot be
|
||||
// removed here (allowsGroupMembership refuses it), and a client
|
||||
// outside it cannot be added. Naming them anyway, with their
|
||||
// address, was the same mistake the client list made before
|
||||
// that method existed. An unscoped viewer sees everything,
|
||||
// unchanged.
|
||||
'members' => $group->members()
|
||||
->whereIn('users.id', $this->scope->clients($viewer)->select('id'))
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (User $member): array => [
|
||||
'id' => $member->id,
|
||||
'name' => $member->name,
|
||||
'email' => $member->email,
|
||||
])->all(),
|
||||
'available_clients' => User::query()
|
||||
->where('type', UserType::Client)
|
||||
'available_clients' => $this->scope->clients($viewer)
|
||||
->whereNotIn('id', $group->members()->pluck('users.id'))
|
||||
->orderBy('name')
|
||||
->get()
|
||||
@@ -126,6 +152,19 @@ class GroupsController extends Controller
|
||||
|
||||
public function update(Request $request, Group $group): RedirectResponse
|
||||
{
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
// A group whose reach extends past this staff member's library is
|
||||
// not theirs to change. #1701 drew this line for membership; the
|
||||
// object itself needs it for the same reason and more sharply —
|
||||
// an assignment to a group is how its members reach a file, so
|
||||
// deleting one revokes that access for every member, including
|
||||
// clients outside this person's roster. Measured before this
|
||||
// guard: a scoped role deleted a stranger's group and the
|
||||
// stranger's client stopped seeing the file it carried.
|
||||
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
// The slug only matters (and is only shown) once a group is
|
||||
@@ -154,8 +193,21 @@ class GroupsController extends Controller
|
||||
return back()->with('success', __('Group updated.'));
|
||||
}
|
||||
|
||||
public function destroy(Group $group): RedirectResponse
|
||||
public function destroy(Request $request, Group $group): RedirectResponse
|
||||
{
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
// A group whose reach extends past this staff member's library is
|
||||
// not theirs to change. #1701 drew this line for membership; the
|
||||
// object itself needs it for the same reason and more sharply —
|
||||
// an assignment to a group is how its members reach a file, so
|
||||
// deleting one revokes that access for every member, including
|
||||
// clients outside this person's roster. Measured before this
|
||||
// guard: a scoped role deleted a stranger's group and the
|
||||
// stranger's client stopped seeing the file it carried.
|
||||
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
|
||||
|
||||
$name = $group->name;
|
||||
$group->delete();
|
||||
|
||||
|
||||
@@ -5,8 +5,11 @@ declare(strict_types=1);
|
||||
namespace App\Modules\Groups\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use App\Modules\Groups\Models\MembershipRequest;
|
||||
use App\Modules\Groups\Notifications\GroupMembershipDeniedNotification;
|
||||
use App\Modules\Notifications\Notifier;
|
||||
@@ -30,6 +33,7 @@ class MembershipRequestsController extends Controller
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly Settings $settings,
|
||||
private readonly Notifier $notifier,
|
||||
private readonly StaffLibraryScope $scope,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
@@ -40,12 +44,19 @@ class MembershipRequestsController extends Controller
|
||||
|
||||
$filters = ['search' => $validated['search'] ?? null];
|
||||
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
$requests = MembershipRequest::query()
|
||||
->pending()
|
||||
// A request whose client or group vanished is dead weight; excluding
|
||||
// it in SQL (not after fetching) keeps pagination counts honest.
|
||||
->whereHas('user')
|
||||
->whereHas('group')
|
||||
// Narrowed the way the buttons on each row now are — see
|
||||
// MembershipRequest::scopeApprovableBy, which the sidebar badge
|
||||
// reads too so the number and this screen agree.
|
||||
->approvableBy($viewer)
|
||||
->with(['group', 'user'])
|
||||
->when($filters['search'], fn (Builder $query, string $search) => $query->where(fn (Builder $q) => $q
|
||||
->whereHas('user', fn (Builder $u) => $u->where('name', 'like', "%{$search}%")->orWhere('email', 'like', "%{$search}%"))
|
||||
@@ -68,13 +79,15 @@ class MembershipRequestsController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function approve(MembershipRequest $membershipRequest): RedirectResponse
|
||||
public function approve(Request $request, MembershipRequest $membershipRequest): RedirectResponse
|
||||
{
|
||||
$group = $membershipRequest->group;
|
||||
$client = $membershipRequest->user;
|
||||
|
||||
abort_unless($group !== null && $client !== null && $membershipRequest->status === MembershipRequest::STATUS_PENDING, 404);
|
||||
|
||||
$this->guardRequest($request, $group, $client);
|
||||
|
||||
$group->members()->syncWithoutDetaching([$client->id]);
|
||||
$membershipRequest->delete();
|
||||
|
||||
@@ -85,11 +98,26 @@ class MembershipRequestsController extends Controller
|
||||
return back()->with('success', __('Membership request approved.'));
|
||||
}
|
||||
|
||||
public function deny(MembershipRequest $membershipRequest): RedirectResponse
|
||||
public function deny(Request $request, MembershipRequest $membershipRequest): RedirectResponse
|
||||
{
|
||||
// The half of approve()'s guard that applies here. A request that
|
||||
// has already been denied is not a decision left to make, and
|
||||
// taking it again re-stamps denied_at -- which is what the
|
||||
// client's re-request cooldown counts from, so the same request
|
||||
// repeated keeps a client out of a group indefinitely -- while
|
||||
// writing a second log entry and sending a second "your request
|
||||
// was declined" mail for one decision. The queue only ever lists
|
||||
// pending requests, so this is not reachable through the screen;
|
||||
// it is reachable by asking for the route directly.
|
||||
abort_unless($membershipRequest->status === MembershipRequest::STATUS_PENDING, 404);
|
||||
|
||||
$group = $membershipRequest->group;
|
||||
$client = $membershipRequest->user;
|
||||
|
||||
if ($group !== null && $client !== null) {
|
||||
$this->guardRequest($request, $group, $client);
|
||||
}
|
||||
|
||||
// The denied row persists: the client sees the outcome, and it
|
||||
// enforces the re-request cooldown.
|
||||
$membershipRequest->forceFill([
|
||||
@@ -107,4 +135,25 @@ class MembershipRequestsController extends Controller
|
||||
|
||||
return back()->with('success', __('Membership request denied.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Approving a request is GroupMembersController::store by another
|
||||
* door: it joins a client to a group, with the same consequence for
|
||||
* what that client -- and any staff member holding them -- can reach
|
||||
* afterwards. Denying one is a decision about somebody's client, and
|
||||
* emails them about it. Both belong inside the same boundary, and
|
||||
* `approve_groups_memberships_requests` in front of the route is a
|
||||
* permission, not one.
|
||||
*
|
||||
* 404 rather than 403, matching the guard immediately above it in
|
||||
* approve(): a request this staff member may not act on should not
|
||||
* be distinguishable from one that is not there.
|
||||
*/
|
||||
private function guardRequest(Request $request, Group $group, User $client): void
|
||||
{
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
abort_unless($this->scope->allowsGroupMembership($viewer, $group, $client), 404);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,7 +326,7 @@ class PublicGroupsController extends Controller
|
||||
return route('public.preview', [$publicSlug, $file->slug]);
|
||||
}
|
||||
|
||||
public function download(string $publicSlug, File $file): Response
|
||||
public function download(string $publicSlug, File $file): Response|RedirectResponse
|
||||
{
|
||||
$this->guardSlug($publicSlug);
|
||||
|
||||
@@ -340,11 +340,6 @@ class PublicGroupsController extends Controller
|
||||
|
||||
$this->activity->log(Action::PublicFileDownloaded, subject: $file);
|
||||
|
||||
return response('', 200, [
|
||||
'X-Accel-Redirect' => '/protected-files/'.$file->path,
|
||||
'Content-Type' => $file->mime_type,
|
||||
'Content-Disposition' => ContentDisposition::attachment($file->original_name),
|
||||
'Content-Length' => (string) $file->size,
|
||||
]);
|
||||
return $this->bytes->attachment($file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,12 @@ use Illuminate\Http\Resources\Json\JsonResource;
|
||||
* @mixin Group
|
||||
*
|
||||
* Members carry a name and an email, which is what the group edit screen
|
||||
* already shows to anyone holding `edit_groups`. They are attached only
|
||||
* when explicitly loaded, so a listing of groups does not become a bulk
|
||||
* export of every client's address.
|
||||
* shows the same viewer. That is a claim about the screen, so it holds
|
||||
* only for as long as the screen does: both narrow the list to the
|
||||
* clients the viewer may act on, and the controller loading this relation
|
||||
* is where that narrowing is applied. They are attached only when
|
||||
* explicitly loaded, so a listing of groups does not become a bulk export
|
||||
* of every client's address.
|
||||
*/
|
||||
class GroupResource extends JsonResource
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Modules\Groups\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -44,6 +45,37 @@ class MembershipRequest extends Model
|
||||
return $query->where('status', self::STATUS_PENDING);
|
||||
}
|
||||
|
||||
/**
|
||||
* The requests a staff member may actually act on.
|
||||
*
|
||||
* MembershipRequestsController guards approve() and deny() with
|
||||
* StaffLibraryScope::allowsGroupMembership, because joining a client
|
||||
* to a group decides what that client -- and any staff member
|
||||
* holding them -- can reach. This is the listing half of the same
|
||||
* rule, and both the queue and the sidebar badge read it, so the
|
||||
* number and the screen behind it cannot drift apart. That is why it
|
||||
* lives here rather than in either caller, the same reasoning
|
||||
* VisibleCommentScope::pendingTotal() gives for owning the comment
|
||||
* badge instead of leaving the middleware to count for itself.
|
||||
*
|
||||
* Narrowed on the client only. Whether the *group* is reachable is
|
||||
* the other half of allowsGroupMembership, and it depends on what is
|
||||
* shared with that group -- not a question to ask row by row in a
|
||||
* listing. So a scoped viewer may still be shown a request they
|
||||
* would be refused on; it will be one of their own clients asking to
|
||||
* join a group out of their reach, rather than a client they were
|
||||
* never meant to hear about. The names are the part that leaks.
|
||||
*
|
||||
* @param Builder<MembershipRequest> $query
|
||||
* @return Builder<MembershipRequest>
|
||||
*/
|
||||
public function scopeApprovableBy(Builder $query, User $viewer): Builder
|
||||
{
|
||||
$clientIds = app(StaffLibraryScope::class)->assignableClientIds($viewer);
|
||||
|
||||
return $clientIds === null ? $query : $query->whereIn('user_id', $clientIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Group, $this>
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,9 @@ namespace App\Modules\Identity;
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use App\Modules\Platform\Seats\SeatAllowance;
|
||||
use App\Modules\Identity\Permissions\SystemRole;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -36,6 +38,8 @@ class AccountConversion
|
||||
public function __construct(
|
||||
private readonly StaffAccounts $accounts,
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly StaffLibraryScope $library,
|
||||
private readonly SeatAllowance $seats,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -43,6 +47,10 @@ class AccountConversion
|
||||
*/
|
||||
public function guardToClient(User $actor, User $target): void
|
||||
{
|
||||
// The mirror of the promotion above: a demotion takes a client
|
||||
// seat and frees a staff one.
|
||||
$this->seats->guardClient();
|
||||
|
||||
$this->guardSelf($actor, $target);
|
||||
|
||||
// Only on this direction. "Could the actor have granted the
|
||||
@@ -84,11 +92,31 @@ class AccountConversion
|
||||
{
|
||||
$this->guardSelf($actor, $target);
|
||||
|
||||
// No guardTarget here — see guardToClient(). What actually limits
|
||||
// a promotion is the role being granted, and that is enforced by
|
||||
// the caller validating role_id against
|
||||
// StaffAccounts::assignableRoleIds(): nobody hands out authority
|
||||
// they do not hold.
|
||||
// A promotion takes a staff seat. It frees a client one at the same
|
||||
// moment, so the two caps move in opposite directions and only the
|
||||
// one being filled can refuse. Asked in the guard rather than in
|
||||
// toStaff() so a refusal happens before the transaction opens.
|
||||
$this->seats->guardStaff();
|
||||
|
||||
// No guardTarget here — see guardToClient(). It asks "could the
|
||||
// actor have granted the target's role", which is meaningless of
|
||||
// a client; what limits a promotion is the role being *granted*,
|
||||
// and the caller enforces that by validating role_id against
|
||||
// StaffAccounts::assignableRoleIds().
|
||||
//
|
||||
// That answers the question about the role. It does not answer
|
||||
// the one about the target, and the target here is a client
|
||||
// account: the same object every other route that binds one
|
||||
// holds to the actor's own roster. A promotion is the most
|
||||
// far-reaching thing that can be done to a client — it takes
|
||||
// their portal access away, makes their assignments inert, and
|
||||
// leaves them holding staff permissions the actor chose — so
|
||||
// reaching one outside that roster through this door and no
|
||||
// other is not a rule, it is a gap. 404 rather than 403, like
|
||||
// the clients routes and like the isClient() check the caller
|
||||
// makes on the way in: a client this staff member may not manage
|
||||
// should not be distinguishable from one that is not there.
|
||||
abort_unless($this->library->canAssignClient($actor, $target), 404);
|
||||
|
||||
// An account request is not an account yet. Approving one is a
|
||||
// deliberate decision with its own screen and its own audit entry;
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Modules\Identity\Console;
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Identity\Erasure\AvailableEmailRule;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use App\Modules\Identity\Permissions\SystemRole;
|
||||
use App\Modules\Identity\UserType;
|
||||
@@ -43,7 +44,7 @@ class CreateAdminCommand extends Command
|
||||
['name' => $name, 'email' => $email, 'password' => $password],
|
||||
[
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', new AvailableEmailRule],
|
||||
'password' => ['required', Password::defaults()],
|
||||
],
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ class PurgeErasuresCommand extends Command
|
||||
{
|
||||
protected $signature = 'projectsend:purge-erasures';
|
||||
|
||||
protected $description = 'Permanently erase self-deleted accounts whose grace period has passed (runs daily)';
|
||||
protected $description = 'Permanently erase deleted accounts whose grace period has passed (runs daily)';
|
||||
|
||||
public function handle(AccountEraser $eraser): int
|
||||
{
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Identity\Erasure;
|
||||
|
||||
use App\Models\User;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Translation\PotentiallyTranslatedString;
|
||||
|
||||
/**
|
||||
* `unique:users,email` with an answer for the case that rule cannot
|
||||
* explain: the address is held by a soft-deleted account.
|
||||
*
|
||||
* The unique index on users.email spans trashed rows on purpose — an
|
||||
* email address is a login identity, and it must not become
|
||||
* re-registerable while the account holding it is merely pending erasure.
|
||||
* But the stock message ("has already been taken") then names a conflict
|
||||
* the person at the form cannot see or clear from any screen (#1648).
|
||||
* This rule keeps the refusal and explains it: when the address frees
|
||||
* itself, or — for accounts deleted before erasure scheduling existed —
|
||||
* which command frees it.
|
||||
*
|
||||
* Staff surfaces only. Public registration keeps the stock rule
|
||||
* deliberately: telling an anonymous visitor "this address belongs to a
|
||||
* deleted account" confirms the address had an account here, which is
|
||||
* exactly the disclosure the generic message avoids.
|
||||
*/
|
||||
class AvailableEmailRule implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* @param Closure(string, string|null=): PotentiallyTranslatedString $fail
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! is_string($value) || $value === '') {
|
||||
// required/string/email own that refusal.
|
||||
return;
|
||||
}
|
||||
|
||||
$holder = User::withTrashed()->where('email', $value)->first();
|
||||
|
||||
if ($holder === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $holder->trashed()) {
|
||||
// A living account: the stock unique message said all there
|
||||
// is to say.
|
||||
$fail('validation.unique')->translate();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($holder->erase_after !== null) {
|
||||
$fail(__('This email address belongs to a deleted account that is scheduled for permanent erasure. The address becomes available on :date. To free it sooner, erase the account with the projectsend:erase-account console command.', [
|
||||
'date' => $holder->erase_after->toFormattedDateString(),
|
||||
]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Deleted before erasure scheduling existed, so no purge will ever
|
||||
// reach it — only the operator command can free the address.
|
||||
$fail(__('This email address belongs to a deleted account that has no erasure scheduled. Run the projectsend:erase-account console command to erase it and free the address.'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Identity\Erasure;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
|
||||
/**
|
||||
* Stamps the moment a soft-deleted account graduates to permanent
|
||||
* erasure: now plus the installation's grace period.
|
||||
*
|
||||
* Every deletion path calls this right before delete(), self-service and
|
||||
* administrative alike, so PurgeErasuresCommand eventually reaches every
|
||||
* deleted account — and the email address its row keeps reserved under
|
||||
* the unique index is freed. Only self-deletion did this at first, which
|
||||
* left admin-deleted accounts trashed forever and their addresses
|
||||
* unusable (#1648).
|
||||
*/
|
||||
class ErasureSchedule
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Settings $settings,
|
||||
) {}
|
||||
|
||||
public function apply(User $user): void
|
||||
{
|
||||
$graceDays = (int) $this->settings->get(Setting::AccountErasureGraceDays);
|
||||
|
||||
$user->forceFill(['erase_after' => now()->addDays($graceDays)])->save();
|
||||
}
|
||||
}
|
||||
@@ -26,10 +26,12 @@ use Inertia\Response;
|
||||
/**
|
||||
* Moving an account between staff and clients.
|
||||
*
|
||||
* Community edition only, by the same route group as every other
|
||||
* staff-account screen — managed installations create staff accounts
|
||||
* outside the application, so a converter there would be a second,
|
||||
* unmanaged way to create one.
|
||||
* Both editions since 2.2.0, by the same route group as every other
|
||||
* staff-account screen: whoever may create a staff account may promote
|
||||
* one, and a managed installation limits that by seats rather than by
|
||||
* closing the screen — AccountConversion asks SeatAllowance on both
|
||||
* directions, because a promotion spends a staff seat and a demotion
|
||||
* spends a client one.
|
||||
*
|
||||
* The rules live in AccountConversion, which calls StaffAccounts for the
|
||||
* authority questions. This controller is the request shape and the
|
||||
@@ -120,7 +122,9 @@ class AccountConversionController extends Controller
|
||||
'is_system' => $role->is_system,
|
||||
'client_scoped' => $role->client_scoped,
|
||||
])->all(),
|
||||
'clients' => User::query()->where('type', UserType::Client)->orderBy('name')->get()
|
||||
// Narrowed like `roles` beside it: the picker offers what this
|
||||
// actor may hand out, which is what store() will accept.
|
||||
'clients' => User::query()->whereIn('id', $this->accounts->assignableClientIds($actor))->orderBy('name')->get()
|
||||
->map(fn (User $client): array => ['id' => $client->id, 'name' => $client->name])
|
||||
->values()->all(),
|
||||
]);
|
||||
@@ -152,7 +156,8 @@ class AccountConversionController extends Controller
|
||||
'assigned_clients' => ['array'],
|
||||
'assigned_clients.*' => [
|
||||
'integer',
|
||||
Rule::exists('users', 'id')->where('type', UserType::Client->value),
|
||||
// Reach, not a label: see StaffAccounts::assignableClientIds.
|
||||
Rule::in($this->accounts->assignableClientIds($actor)),
|
||||
Rule::notIn([$user->id]),
|
||||
],
|
||||
// Required only for an account whose credential lives in the
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\User;
|
||||
use App\Modules\Api\Support\PollingQuery;
|
||||
use App\Modules\Files\DeletedAccountContent;
|
||||
use App\Modules\Identity\AccountContentDeletion;
|
||||
use App\Modules\Identity\Erasure\AvailableEmailRule;
|
||||
use App\Modules\Identity\Http\Resources\Api\StaffUserResource;
|
||||
use App\Modules\Identity\StaffAccounts;
|
||||
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
|
||||
@@ -17,6 +18,7 @@ use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -24,12 +26,18 @@ use Illuminate\Validation\ValidationException;
|
||||
/**
|
||||
* Staff accounts over the API — the API twin of the /users screens.
|
||||
*
|
||||
* **Community only.** Every route is behind `capability:users.manage`, so
|
||||
* a cloud install answers 403 `capability_unavailable`: managed
|
||||
* installations create staff accounts outside the application, and an API
|
||||
* that could mint them there would be a second, unmanaged door into the
|
||||
* same thing.
|
||||
* The routes are still registered in every edition so the committed
|
||||
* Both editions since 2.2.0. Every route is behind
|
||||
* `capability:users.manage`, which cloud installations now hold as well:
|
||||
* a platform sells staff seats and the tenant fills them, so an API that
|
||||
* creates one is the same door the screen is, not a second unmanaged one
|
||||
* (see Capability::UsersManage). How many it may create is
|
||||
* SeatAllowance's question, asked here through StaffAccounts, and an
|
||||
* installation at its limit answers 422 rather than 403.
|
||||
*
|
||||
* The capability stays in front of the routes rather than being dropped:
|
||||
* it is the seam an edition difference would have to travel through, and
|
||||
* an installation without it answers 403 `capability_unavailable`. The
|
||||
* routes are registered in every edition either way, so the committed
|
||||
* OpenAPI document is identical everywhere — the middleware refuses, the
|
||||
* route table does not lie.
|
||||
*
|
||||
@@ -118,14 +126,18 @@ class UsersController extends Controller
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users,email'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
|
||||
'role_id' => ['required', 'integer', Rule::in($this->accounts->assignableRoleIds($actor))],
|
||||
// No `confirmed`: repeating a password defends against a human
|
||||
// mistyping into a form, and an API caller has no second field
|
||||
// to mistype. Password::defaults() still applies.
|
||||
'password' => ['required', Password::defaults()],
|
||||
'assigned_clients' => ['array'],
|
||||
'assigned_clients.*' => ['integer', Rule::exists('users', 'id')->where('type', UserType::Client->value)],
|
||||
// Only clients you can reach yourself: an unrestricted account may
|
||||
// assign any client, a client-scoped one only the clients already
|
||||
// assigned to it. Assigning a client hands over everything that
|
||||
// client can see, so it follows the same rule as role_id above.
|
||||
'assigned_clients.*' => ['integer', Rule::in($this->accounts->assignableClientIds($actor))],
|
||||
]);
|
||||
|
||||
$user = $this->accounts->create([
|
||||
@@ -163,18 +175,34 @@ class UsersController extends Controller
|
||||
'active' => ['sometimes', 'boolean'],
|
||||
'password' => ['sometimes', 'nullable', Password::defaults()],
|
||||
'assigned_clients' => ['sometimes', 'array'],
|
||||
'assigned_clients.*' => ['integer', Rule::exists('users', 'id')->where('type', UserType::Client->value)],
|
||||
// Only clients you can reach yourself: an unrestricted account may
|
||||
// assign any client, a client-scoped one only the clients already
|
||||
// assigned to it. Assigning a client hands over everything that
|
||||
// client can see, so it follows the same rule as role_id above.
|
||||
'assigned_clients.*' => ['integer', Rule::in($this->accounts->assignableClientIds($actor))],
|
||||
]);
|
||||
|
||||
// Read through Request::boolean() rather than off the validated
|
||||
// array, for the reason RolesController::guardScopeRemoval spells
|
||||
// out: the `boolean` rule accepts 0 and "0" as well as false but
|
||||
// does not cast, so a strict comparison lets through a value the
|
||||
// model's own `boolean` cast then stores as false anyway. The same
|
||||
// value goes to the guard and to the write.
|
||||
$deactivating = array_key_exists('active', $validated) && ! $request->boolean('active');
|
||||
|
||||
// The same refusal the web screen makes, and for the same reason:
|
||||
// locking yourself out is never what was meant.
|
||||
if ($user->is($actor) && ($validated['active'] ?? true) === false) {
|
||||
if ($user->is($actor) && $deactivating) {
|
||||
throw ValidationException::withMessages([
|
||||
'active' => __('You cannot deactivate your own account.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$attributes = array_intersect_key($validated, array_flip(['name', 'email', 'active', 'password']));
|
||||
$attributes = array_intersect_key($validated, array_flip(['name', 'email', 'password']));
|
||||
|
||||
if (array_key_exists('active', $validated)) {
|
||||
$attributes['active'] = $request->boolean('active');
|
||||
}
|
||||
|
||||
if (array_key_exists('role_id', $validated)) {
|
||||
$attributes['role_id'] = (int) $validated['role_id'];
|
||||
@@ -215,9 +243,15 @@ class UsersController extends Controller
|
||||
|
||||
$validated = $this->accountDeletion->validate($request, $user);
|
||||
|
||||
$name = $this->accounts->delete($user);
|
||||
|
||||
$this->accountDeletion->apply($validated, $user, $name);
|
||||
// Soft-deleting the account and disposing of its files are two
|
||||
// separate writes; keep them in one transaction so a failure in the
|
||||
// second (e.g. the reassignment target deleted between validation
|
||||
// and apply()'s findOrFail) cannot leave the account deleted with
|
||||
// its content still pointing at it.
|
||||
DB::transaction(function () use ($validated, $user): void {
|
||||
$name = $this->accounts->delete($user);
|
||||
$this->accountDeletion->apply($validated, $user, $name);
|
||||
});
|
||||
|
||||
return response()->json(status: 204);
|
||||
}
|
||||
|
||||
@@ -89,9 +89,12 @@ class RolesController extends Controller
|
||||
|
||||
$this->guardGrantablePermissions($request, $validated['permissions'] ?? []);
|
||||
|
||||
$clientScoped = $request->boolean('client_scoped');
|
||||
$this->guardScopeRemoval($request, removesScope: ! $clientScoped);
|
||||
|
||||
$role = Role::query()->create([
|
||||
'name' => $validated['name'],
|
||||
'client_scoped' => $validated['client_scoped'] ?? false,
|
||||
'client_scoped' => $clientScoped,
|
||||
]);
|
||||
|
||||
$this->syncPermissions($role, $validated['permissions'] ?? []);
|
||||
@@ -135,9 +138,12 @@ class RolesController extends Controller
|
||||
// Built-in roles have fixed names and a fixed scope flag; only their
|
||||
// permission set is editable. Custom roles can change name + scope.
|
||||
if (! $role->is_system) {
|
||||
$clientScoped = $request->boolean('client_scoped');
|
||||
$this->guardScopeRemoval($request, removesScope: $role->client_scoped && ! $clientScoped);
|
||||
|
||||
$role->update([
|
||||
'name' => $validated['name'],
|
||||
'client_scoped' => $validated['client_scoped'] ?? false,
|
||||
'client_scoped' => $clientScoped,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -212,6 +218,46 @@ class RolesController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same rule for the other half of what a role carries.
|
||||
*
|
||||
* `client_scoped` decides how much of the library the role reaches,
|
||||
* which makes it authority in exactly the sense the docblock above
|
||||
* describes -- and the larger part of it, since it is what stands
|
||||
* between a limited staff member and every file on the installation.
|
||||
* Both writers of the flag went through nothing at all, so
|
||||
* `manage_users` alone was enough to mint a role without the limit,
|
||||
* or to lift it off the actor's own, and then to hold it.
|
||||
*
|
||||
* Phrased as "removes the limit" rather than "is not limited", so
|
||||
* that only what this request actually changes is checked -- the same
|
||||
* reasoning that has guardGrantablePermissions look at the diff.
|
||||
* Editing an already-unlimited role's permissions is not this actor
|
||||
* lifting a limit, and StaffAccounts::mayGrant is what stops them
|
||||
* holding the result either way.
|
||||
*
|
||||
* Callers resolve the flag with Request::boolean() and hand the same
|
||||
* value to this guard and to the write, deliberately. The `boolean`
|
||||
* validation rule accepts "0" and 0 as well as false but does not
|
||||
* cast, so reading the validated array and comparing it strictly
|
||||
* would let a request through here that the model's `boolean` cast
|
||||
* then stores as false anyway -- the guard and the write disagreeing
|
||||
* about one value is exactly the shape this guard exists to prevent.
|
||||
*/
|
||||
private function guardScopeRemoval(Request $request, bool $removesScope): void
|
||||
{
|
||||
$actor = $request->user();
|
||||
assert($actor !== null);
|
||||
|
||||
if (! $removesScope || ! $actor->isClientScoped()) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'client_scoped' => __('Your own role is limited to the clients assigned to you, so a role you create or edit cannot drop that limit.'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $permissions
|
||||
*/
|
||||
|
||||
@@ -97,8 +97,16 @@ class SetupController extends Controller
|
||||
return Inertia::render('setup-success');
|
||||
}
|
||||
|
||||
/**
|
||||
* Trashed staff count, for the reason EnsureSetupIsComplete gives:
|
||||
* this asks whether the installation was ever set up, and store()
|
||||
* below is the door a stranger walks through if the answer is wrong.
|
||||
* The middleware and this must agree — one of them saying "not set
|
||||
* up" while the other says "set up" is either a redirect loop or an
|
||||
* open form.
|
||||
*/
|
||||
private function setupIsComplete(): bool
|
||||
{
|
||||
return User::query()->where('type', UserType::Staff)->exists();
|
||||
return User::query()->withTrashed()->where('type', UserType::Staff)->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,17 @@ use App\Models\User;
|
||||
use App\Modules\Api\Auth\ApiTokens;
|
||||
use App\Modules\Files\DeletedAccountContent;
|
||||
use App\Modules\Identity\AccountContentDeletion;
|
||||
use App\Modules\Identity\Erasure\AvailableEmailRule;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use App\Modules\Identity\StaffAccounts;
|
||||
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
|
||||
use App\Modules\Identity\UserType;
|
||||
use App\Modules\Platform\Seats\SeatAllowance;
|
||||
use App\Support\Pagination;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -24,9 +27,17 @@ use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
/**
|
||||
* Staff ("system users") management — community edition only; managed
|
||||
* installations create them outside the application. Clients are a different
|
||||
* population managed by the Clients module: they never appear here.
|
||||
* Staff ("system users") management. Clients are a different population
|
||||
* managed by the Clients module: they never appear here.
|
||||
*
|
||||
* Available on both editions since 2.2.0. A managed installation is sold a
|
||||
* number of seats and fills them itself — see Capability::UsersManage for
|
||||
* why capacity is the platform's and who fills it is the tenant's.
|
||||
*
|
||||
* That makes a full installation an ordinary state rather than an error,
|
||||
* so `index()` reports the seat position and `create()` refuses to open a
|
||||
* form nothing can be submitted through. SeatAllowance::guardStaff() still
|
||||
* runs in `store()`: this is the courtesy, that is the rule.
|
||||
*/
|
||||
class UsersController extends Controller
|
||||
{
|
||||
@@ -35,6 +46,7 @@ class UsersController extends Controller
|
||||
private readonly AccountContentDeletion $accountDeletion,
|
||||
private readonly ApiTokens $apiTokens,
|
||||
private readonly StaffAccounts $accounts,
|
||||
private readonly SeatAllowance $seats,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
@@ -97,11 +109,24 @@ class UsersController extends Controller
|
||||
'roles' => Role::query()->orderBy('name')->get(['id', 'name'])
|
||||
->map(fn (Role $role): array => ['id' => $role->id, 'name' => $role->name])->all(),
|
||||
'reassign_candidates' => $this->accountDeletion->candidates(),
|
||||
// Null on a self-hosted install: no limit, nothing to say.
|
||||
'seats' => $this->seats->staffState(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): Response
|
||||
public function create(): RedirectResponse|Response
|
||||
{
|
||||
// Turned away here rather than on submit. Somebody reaching this
|
||||
// by link or bookmark used to fill in a name, an email and a
|
||||
// password they had to invent, and learn the installation was full
|
||||
// from a validation error under the email field — which reads as a
|
||||
// fault with the address rather than a fact about the plan.
|
||||
$seats = $this->seats->staffState();
|
||||
|
||||
if ($seats !== null && $seats['full']) {
|
||||
return redirect()->route('users.index')->with('error', $seats['message']);
|
||||
}
|
||||
|
||||
return Inertia::render('users/create', [
|
||||
'roles' => $this->roleOptions(),
|
||||
'clients' => $this->clientOptions(),
|
||||
@@ -112,11 +137,14 @@ class UsersController extends Controller
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users,email'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
|
||||
'role_id' => ['required', 'integer', Rule::in($this->accounts->assignableRoleIds($this->actor()))],
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
'assigned_clients' => ['array'],
|
||||
'assigned_clients.*' => ['integer', Rule::exists('users', 'id')->where('type', UserType::Client->value)],
|
||||
// Reach, not a label: see StaffAccounts::assignableClientIds.
|
||||
// The list is client-typed already, so this is one rule where
|
||||
// an exists() plus a type filter used to be two.
|
||||
'assigned_clients.*' => ['integer', Rule::in($this->accounts->assignableClientIds($this->actor()))],
|
||||
]);
|
||||
|
||||
$user = $this->accounts->create([
|
||||
@@ -126,7 +154,12 @@ class UsersController extends Controller
|
||||
'password' => $validated['password'],
|
||||
], $validated['assigned_clients'] ?? []);
|
||||
|
||||
return redirect()->route('users.edit', $user)->with('success', __('User created.'));
|
||||
// Same create-without-edit rule as ClientsController::store().
|
||||
$target = $this->actor()->can('edit_users')
|
||||
? redirect()->route('users.edit', $user)
|
||||
: redirect()->route('users.create');
|
||||
|
||||
return $target->with('success', __('User created.'));
|
||||
}
|
||||
|
||||
public function edit(User $user): Response
|
||||
@@ -171,7 +204,10 @@ class UsersController extends Controller
|
||||
'active' => ['required', 'boolean'],
|
||||
'password' => ['nullable', 'confirmed', Password::defaults()],
|
||||
'assigned_clients' => ['array'],
|
||||
'assigned_clients.*' => ['integer', Rule::exists('users', 'id')->where('type', UserType::Client->value)],
|
||||
// Reach, not a label: see StaffAccounts::assignableClientIds.
|
||||
// The list is client-typed already, so this is one rule where
|
||||
// an exists() plus a type filter used to be two.
|
||||
'assigned_clients.*' => ['integer', Rule::in($this->accounts->assignableClientIds($this->actor()))],
|
||||
]);
|
||||
|
||||
// Deactivating yourself is refused here rather than in StaffAccounts
|
||||
@@ -216,9 +252,15 @@ class UsersController extends Controller
|
||||
|
||||
$validated = $this->accountDeletion->validate($request, $user);
|
||||
|
||||
$name = $this->accounts->delete($user);
|
||||
|
||||
$this->accountDeletion->apply($validated, $user, $name);
|
||||
// Soft-deleting the account and disposing of its files are two
|
||||
// separate writes; keep them in one transaction so a failure in the
|
||||
// second (e.g. the reassignment target deleted between validation
|
||||
// and apply()'s findOrFail) cannot leave the account deleted with
|
||||
// its content still pointing at it.
|
||||
DB::transaction(function () use ($validated, $user): void {
|
||||
$name = $this->accounts->delete($user);
|
||||
$this->accountDeletion->apply($validated, $user, $name);
|
||||
});
|
||||
|
||||
return redirect()->route('users.index')->with('success', __('User deleted.'));
|
||||
}
|
||||
@@ -259,13 +301,15 @@ class UsersController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* The client roster, for the assigned-clients picker.
|
||||
* The client roster, for the assigned-clients picker — narrowed to
|
||||
* what this actor may actually hand out, the same way roleOptions()
|
||||
* is narrowed to the roles they may grant.
|
||||
*
|
||||
* @return array<int, array{id: int, name: string}>
|
||||
*/
|
||||
private function clientOptions(): array
|
||||
{
|
||||
return User::query()->where('type', UserType::Client)->orderBy('name')->get()
|
||||
return User::query()->whereIn('id', $this->accounts->assignableClientIds($this->actor()))->orderBy('name')->get()
|
||||
->map(fn (User $client): array => ['id' => $client->id, 'name' => $client->name])
|
||||
->values()->all();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Modules\Identity\Http\Middleware;
|
||||
use App\Modules\Identity\TwoFactor\TwoFactorEnforcement;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use App\Support\WriteSafeRedirect;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -39,15 +40,20 @@ class EnforceTwoFactor
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// password.confirm is on this list because the two-factor mutation
|
||||
// password.confirm* is on this list because the two-factor mutation
|
||||
// routes now require it: without the exemption, enrolling would
|
||||
// redirect to the confirm-password screen, which this middleware
|
||||
// would redirect straight back to two-factor.show — a loop that
|
||||
// locks the user out of the only exit.
|
||||
if ($request->routeIs('two-factor.*', 'password.confirm', 'logout', 'locale.update')) {
|
||||
//
|
||||
// The pattern covers both halves of that screen. Naming only the
|
||||
// GET left the form rendering and its submission redirected away,
|
||||
// so the password was never confirmed and the loop stayed shut
|
||||
// one step further along than before.
|
||||
if ($request->routeIs('two-factor.*', 'password.confirm*', 'logout', 'locale.update')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
return redirect()->route('two-factor.show')->with('two_factor_enforced_notice', true);
|
||||
return WriteSafeRedirect::apply($request, redirect()->route('two-factor.show')->with('two_factor_enforced_notice', true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Identity\Http\Middleware;
|
||||
|
||||
use App\Support\WriteSafeRedirect;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -25,9 +26,9 @@ class EnsureAccountIsActive
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login')->withErrors([
|
||||
return WriteSafeRedirect::apply($request, redirect()->route('login')->withErrors([
|
||||
'email' => __('Your account has been deactivated.'),
|
||||
]);
|
||||
]));
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Modules\Identity\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\UserType;
|
||||
use App\Support\WriteSafeRedirect;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -15,6 +16,16 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
* sent to the first-run setup screen. The database is the only source of
|
||||
* truth — no install flags. Client accounts do not count: setup is about
|
||||
* having an administrator.
|
||||
*
|
||||
* Trashed staff count. "Has this installation been set up" is not the
|
||||
* same question as "does it have a working administrator right now", and
|
||||
* only the first one belongs here: a soft-deleted staff row is still
|
||||
* evidence that setup happened, and an installation that has lost its
|
||||
* last administrator needs a recovery path, not a stranger filling in
|
||||
* the first-run form. Deleting a staff account is guarded against
|
||||
* reaching zero (StaffAccounts::guardLastAdministrator), so this is the
|
||||
* second lock rather than the first — but the first one is asked at five
|
||||
* separate doors, and this one is asked once.
|
||||
*/
|
||||
class EnsureSetupIsComplete
|
||||
{
|
||||
@@ -24,10 +35,10 @@ class EnsureSetupIsComplete
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (User::query()->where('type', UserType::Staff)->exists()) {
|
||||
if (User::query()->withTrashed()->where('type', UserType::Staff)->exists()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
return redirect()->route('setup');
|
||||
return WriteSafeRedirect::apply($request, redirect()->route('setup'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ namespace App\Modules\Identity;
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Identity\Erasure\ErasureSchedule;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use App\Modules\Platform\Seats\SeatAllowance;
|
||||
use App\Modules\Identity\Permissions\PermissionChecker;
|
||||
use App\Modules\Identity\Permissions\SystemRole;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -34,6 +37,9 @@ class StaffAccounts
|
||||
public function __construct(
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly PermissionChecker $permissions,
|
||||
private readonly ErasureSchedule $erasure,
|
||||
private readonly SeatAllowance $seats,
|
||||
private readonly StaffLibraryScope $library,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -45,8 +51,17 @@ class StaffAccounts
|
||||
* permission into every permission and makes the rest of the matrix
|
||||
* decorative.
|
||||
*
|
||||
* An administrator holds every permission by construction, so this is
|
||||
* always true for them and the admin experience is unchanged.
|
||||
* A role's `client_scoped` flag is part of that authority, and the
|
||||
* larger part: a role without it reaches the whole library, while a
|
||||
* client-scoped actor reaches only the clients assigned to them. So
|
||||
* one may not hand out a role that is not client-scoped -- to a
|
||||
* colleague, to a new account, or to themselves, which is the case
|
||||
* that matters, since the role picker is how an account changes role
|
||||
* and an account may edit its own.
|
||||
*
|
||||
* An administrator holds every permission by construction and is
|
||||
* never client-scoped, so this is always true for them and the admin
|
||||
* experience is unchanged.
|
||||
*/
|
||||
public function mayGrant(User $actor, Role $role): bool
|
||||
{
|
||||
@@ -58,6 +73,10 @@ class StaffAccounts
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($actor->isClientScoped() && ! $role->client_scoped) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$held = $this->permissions->grantedKeys($actor);
|
||||
$granting = $role->permissions()->pluck('permission')->all();
|
||||
|
||||
@@ -90,6 +109,39 @@ class StaffAccounts
|
||||
return array_values($this->assignableRoles($actor)->map(fn (Role $role): int => $role->id)->all());
|
||||
}
|
||||
|
||||
/**
|
||||
* Client ids this actor may put on a staff account's roster — the same
|
||||
* rule as mayGrant(), applied to reach instead of to authority.
|
||||
*
|
||||
* An assigned client is not a label: it is everything that client can
|
||||
* see, handed to whoever holds it. So a client-scoped actor may hand
|
||||
* out the clients they hold and no others — including to themselves,
|
||||
* which is the case that matters, since guardTarget() lets anybody
|
||||
* edit their own account and `assigned_clients` was never checked
|
||||
* against the actor at all. Without this a scoped staff member with
|
||||
* `edit_users` could PATCH their own id with every client id on the
|
||||
* installation and read the whole library from then on.
|
||||
*
|
||||
* An unrestricted actor gets the full roster back rather than null, so
|
||||
* every caller can validate against one list instead of composing a
|
||||
* conditional rule. That list is already client-typed, which is why it
|
||||
* replaces the `exists:users,id where type = client` rule rather than
|
||||
* joining it.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public function assignableClientIds(User $actor): array
|
||||
{
|
||||
$ids = $this->library->assignableClientIds($actor);
|
||||
|
||||
if ($ids !== null) {
|
||||
return $ids;
|
||||
}
|
||||
|
||||
return array_values(User::query()->where('type', UserType::Client)
|
||||
->pluck('id')->map(fn ($id): int => (int) $id)->all());
|
||||
}
|
||||
|
||||
/**
|
||||
* The same rule applied to an existing account: if the actor could not
|
||||
* grant the target's role, they have no business editing or deleting
|
||||
@@ -183,6 +235,12 @@ class StaffAccounts
|
||||
*/
|
||||
public function create(array $attributes, array $assignedClients = []): User
|
||||
{
|
||||
// Before the write, so a refusal creates nothing. Both staff
|
||||
// controllers reach this, web and API; the other two doors into a
|
||||
// staff seat are AccountConversion::toStaff() and the console
|
||||
// command, which asks deliberately not to — see SeatAllowance.
|
||||
$this->seats->guardStaff();
|
||||
|
||||
$user = User::create([
|
||||
'type' => UserType::Staff,
|
||||
'active' => true,
|
||||
@@ -294,14 +352,29 @@ class StaffAccounts
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete the account and record it. Returns the name, which the
|
||||
* caller needs afterwards for the content-reassignment step — by then
|
||||
* the model is trashed and reading it back is needless ceremony.
|
||||
* Soft-delete the account, schedule its permanent erasure and record
|
||||
* it. Returns the name, which the caller needs afterwards for the
|
||||
* content-reassignment step — by then the model is trashed and reading
|
||||
* it back is needless ceremony.
|
||||
*
|
||||
* **This is only half of deleting somebody.** What happens to the
|
||||
* files and folders they own is the other half, and it lives in
|
||||
* AccountContentDeletion: validate() to make the caller choose
|
||||
* between cascading and reassigning, apply() to carry it out. A
|
||||
* caller that stops here leaves their content pointing at an account
|
||||
* that no longer exists.
|
||||
*
|
||||
* The trap is that it looks like it works. validate() returns an
|
||||
* empty array when the account owns nothing, so an account with no
|
||||
* files deletes perfectly through this method alone — and keeps
|
||||
* doing so until somebody deletes a colleague who had actually done
|
||||
* some work. Both existing callers pair the two; a new one must too.
|
||||
*/
|
||||
public function delete(User $user): string
|
||||
{
|
||||
$name = $user->name;
|
||||
|
||||
$this->erasure->apply($user);
|
||||
$user->delete();
|
||||
|
||||
$this->activity->log(Action::UserDeleted, context: ['name' => $name]);
|
||||
|
||||
@@ -14,6 +14,7 @@ use BaconQrCode\Renderer\RendererStyle\Fill;
|
||||
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
|
||||
use BaconQrCode\Writer;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
|
||||
@@ -112,20 +113,45 @@ class TwoFactorService
|
||||
|
||||
/**
|
||||
* Consume a recovery code; each code works exactly once.
|
||||
*
|
||||
* Read the list, filter it, write the whole list back is not once.
|
||||
* Two requests that both read before either writes each store their
|
||||
* own filtered copy, and the second write puts back the code the
|
||||
* first removed -- so a spent code is available again, and the same
|
||||
* code offered twice is accepted twice. Neither lets in anybody who
|
||||
* was not already holding a code, which is why this is a promise not
|
||||
* being kept rather than a door standing open. The promise is the
|
||||
* sentence above, and it is the reason recovery codes are printed
|
||||
* out and crossed off.
|
||||
*
|
||||
* Decide from the row as it stands, read back under a lock inside
|
||||
* the transaction that writes it -- the same shape
|
||||
* SendNotificationDigest uses to claim the rows it is about to
|
||||
* delete. The lock is what makes it atomic against a request
|
||||
* arriving at the same moment; the re-read is what makes the
|
||||
* decision right, and it is the half that can be demonstrated in a
|
||||
* test, since SQLite ignores lockForUpdate.
|
||||
*
|
||||
* The caller's own instance is what gets saved, so it does not walk
|
||||
* away holding a list the database no longer has.
|
||||
*/
|
||||
public function consumeRecoveryCode(User $user, string $code): bool
|
||||
{
|
||||
/** @var list<string>|null $codes */
|
||||
$codes = $user->two_factor_recovery_codes;
|
||||
return DB::transaction(function () use ($user, $code): bool {
|
||||
$locked = User::query()->whereKey($user->getKey())->lockForUpdate()->first();
|
||||
|
||||
if ($codes === null || ! in_array($code, $codes, true)) {
|
||||
return false;
|
||||
}
|
||||
/** @var list<string>|null $codes */
|
||||
$codes = $locked?->two_factor_recovery_codes;
|
||||
|
||||
$user->forceFill([
|
||||
'two_factor_recovery_codes' => array_values(array_diff($codes, [$code])),
|
||||
])->save();
|
||||
if ($codes === null || ! in_array($code, $codes, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
$user->forceFill([
|
||||
'two_factor_recovery_codes' => array_values(array_diff($codes, [$code])),
|
||||
])->save();
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@ namespace App\Modules\Notifications\Http\Controllers;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Notifications\NotificationPreference;
|
||||
use App\Modules\Notifications\NotificationPreferences;
|
||||
use App\Modules\Notifications\NotificationTypeDefinition;
|
||||
use App\Modules\Notifications\NotificationTypeRegistry;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@@ -30,21 +32,12 @@ class NotificationPreferencesController extends Controller
|
||||
$user = $request->user();
|
||||
assert($user !== null);
|
||||
|
||||
// Only types that can email at all have anything to opt in or out
|
||||
// of — a pure in-app type has no toggle to show. Either route
|
||||
// counts: Notifier sending a mail class directly, or the digest
|
||||
// buffering and sending one.
|
||||
$emailable = array_values(array_filter(
|
||||
$this->types->all(),
|
||||
fn ($type) => $type->mailNotification !== null || $type->digestMail !== null,
|
||||
));
|
||||
|
||||
return Inertia::render('settings/notifications', [
|
||||
'types' => array_map(fn ($type) => [
|
||||
'types' => array_map(fn (NotificationTypeDefinition $type): array => [
|
||||
'key' => $type->key,
|
||||
'label' => $type->label,
|
||||
'email_enabled' => $this->preferences->emailEnabledFor($user, $type),
|
||||
], $emailable),
|
||||
], $this->emailable()),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -55,7 +48,11 @@ class NotificationPreferencesController extends Controller
|
||||
|
||||
$validated = $request->validate([
|
||||
'preferences' => ['required', 'array'],
|
||||
'preferences.*.type' => ['required', 'string'],
|
||||
// Against the registry, not merely "a string": a preference row
|
||||
// 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
|
||||
// it.
|
||||
'preferences.*.type' => ['required', 'string', Rule::in($this->emailableKeys())],
|
||||
'preferences.*.email_enabled' => ['required', 'boolean'],
|
||||
]);
|
||||
|
||||
@@ -68,4 +65,31 @@ class NotificationPreferencesController extends Controller
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
/**
|
||||
* Only types that can email at all have anything to opt in or out of —
|
||||
* a pure in-app type has no toggle to show. Either route counts:
|
||||
* Notifier sending a mail class directly, or the digest buffering and
|
||||
* sending one.
|
||||
*
|
||||
* Shared by both halves on purpose, so what the screen offers and what
|
||||
* it accepts back cannot drift apart.
|
||||
*
|
||||
* @return list<NotificationTypeDefinition>
|
||||
*/
|
||||
private function emailable(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->types->all(),
|
||||
fn (NotificationTypeDefinition $type): bool => $type->mailNotification !== null || $type->digestMail !== null,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function emailableKeys(): array
|
||||
{
|
||||
return array_map(fn (NotificationTypeDefinition $type): string => $type->key, $this->emailable());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,14 @@ namespace App\Modules\Platform\Capabilities;
|
||||
*/
|
||||
enum Capability: string
|
||||
{
|
||||
// Community-only — cut where the installation is managed for you.
|
||||
// Both editions. It was Community-only while a managed installation's
|
||||
// staff accounts were expected to be created from outside — but a
|
||||
// platform does not know whether Alice should be an Account Manager,
|
||||
// any more than it knows where her files go when she leaves, and the
|
||||
// seat count it does own is enforced by PROJECTSEND_PLATFORM_MAX_STAFF_USERS
|
||||
// rather than by closing the screen. Capacity is the platform's; who
|
||||
// fills it is the tenant's. Same division managed storage already uses:
|
||||
// the bucket is provisioned, what goes in it is not.
|
||||
case UsersManage = 'users.manage';
|
||||
case StorageConfigure = 'storage.configure';
|
||||
case EmailTransportConfigure = 'email.transport.configure';
|
||||
@@ -59,22 +66,55 @@ enum Capability: string
|
||||
// inside a self-hosted package.
|
||||
case CaptchaManagedKeys = 'captcha.managed_keys';
|
||||
|
||||
// Cloud-only — letting an AI assistant act on this installation on
|
||||
// somebody's behalf. Code lives in the private
|
||||
// projectsend/cloud-modules package; without it this capability is
|
||||
// inert, which is the point: the edition boundary here is which
|
||||
// package is installed, not a flag an installation can set. Present
|
||||
// in this enum even so, because a package cannot extend a closed one
|
||||
// — core has to publish the key before anything can gate on it.
|
||||
// Cloud-only — marks an installation that a platform provisioned and
|
||||
// looks after, for the screens that have to know the difference.
|
||||
//
|
||||
// It does not close the tenant's own /users screens. It used to say
|
||||
// so, and that stopped being true when UsersManage opened on both
|
||||
// editions: a platform sells the seats, the tenant decides who sits
|
||||
// in them. Capacity is the platform's, and it arrives as
|
||||
// PROJECTSEND_PLATFORM_MAX_STAFF_USERS rather than as a shut door.
|
||||
//
|
||||
// The seat *number* deliberately does not live here. There are no
|
||||
// billing or plan tiers in this application to key off — the same
|
||||
// reason config/api.php gives for not inventing an installation-level
|
||||
// rate limit — so the limit arrives from the environment and this
|
||||
// capability only says who is in charge.
|
||||
//
|
||||
// Declared before the module that implements it exists, and that is
|
||||
// the point: a capability added after a release is invisible to every
|
||||
// image built from one, which is exactly how StorageManaged came to
|
||||
// sit unusable for a fleet that had everything else in place.
|
||||
case PlatformManaged = 'platform.managed';
|
||||
|
||||
case AiConnector = 'ai.connector';
|
||||
|
||||
/**
|
||||
* @return list<Edition>
|
||||
*/
|
||||
public function editions(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::UsersManage,
|
||||
self::StorageConfigure,
|
||||
self::EmailTransportConfigure,
|
||||
self::SystemUpdates,
|
||||
self::SchedulerMonitoring,
|
||||
self::CustomAssets => [Edition::Community],
|
||||
|
||||
self::UsersManage => [Edition::Community, Edition::Cloud],
|
||||
|
||||
self::Branding,
|
||||
self::StorageManaged,
|
||||
self::CaptchaManagedKeys => [Edition::Cloud],
|
||||
self::CaptchaManagedKeys,
|
||||
self::PlatformManaged,
|
||||
self::AiConnector => [Edition::Cloud],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Platform\Capabilities\Capability;
|
||||
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||
use App\Modules\Platform\Mail\MailOAuthBrokers;
|
||||
use App\Modules\Platform\Mail\MailOAuthConnection;
|
||||
use App\Modules\Platform\Mail\MailOAuthException;
|
||||
use App\Modules\Platform\Settings\MailConfigApplier;
|
||||
use App\Modules\Platform\Settings\MailProviderSettings;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Connecting the mailbox an OAuth mail provider sends as, and cutting it
|
||||
* loose again.
|
||||
*
|
||||
* Mirrors SocialLoginController's shape — a session marker written
|
||||
* before the redirect is what ties the callback to an exchange somebody
|
||||
* here actually started, and refuses a stray or replayed one — but with
|
||||
* its own state parameter instead of Socialite, because this flow wants
|
||||
* raw tokens with a send scope, not a user identity (see
|
||||
* MailOAuthBroker). Community-only like the rest of the transport
|
||||
* configuration: on cloud, outgoing mail is the platform's relay and
|
||||
* there is nothing to connect.
|
||||
*/
|
||||
class EmailOAuthController extends Controller
|
||||
{
|
||||
private const STATE = 'mail_oauth.state';
|
||||
|
||||
private const PROVIDER = 'mail_oauth.provider';
|
||||
|
||||
public function __construct(
|
||||
private readonly CapabilityRegistry $capabilities,
|
||||
private readonly MailOAuthBrokers $brokers,
|
||||
private readonly MailConfigApplier $mailConfig,
|
||||
private readonly ActivityLogger $activity,
|
||||
) {}
|
||||
|
||||
/** Begin connecting: off to the provider's consent screen. */
|
||||
public function connect(Request $request): RedirectResponse|Response
|
||||
{
|
||||
abort_unless($this->capabilities->has(Capability::EmailTransportConfigure), 404);
|
||||
|
||||
$provider = MailProviderSettings::current()->provider;
|
||||
|
||||
if (! $provider->isOAuth()) {
|
||||
return back()->with('error', __('The selected mail provider does not use a connected mailbox.'));
|
||||
}
|
||||
|
||||
$connection = MailOAuthConnection::for($provider);
|
||||
|
||||
if (! $connection->configured()) {
|
||||
return back()->with('error', __('Enter and save the application (client) ID and secret first.'));
|
||||
}
|
||||
|
||||
$state = Str::random(40);
|
||||
$request->session()->put(self::STATE, $state);
|
||||
$request->session()->put(self::PROVIDER, $provider->value);
|
||||
|
||||
// Inertia::location(), not redirect()->away(): the button posts
|
||||
// through Inertia's XHR, and a plain 302 to another origin makes
|
||||
// the XHR follow it into a CORS wall — the consent screen never
|
||||
// appears and the page just reloads. The 409/X-Inertia-Location
|
||||
// handshake turns it into a real top-level navigation (and falls
|
||||
// back to an ordinary redirect for a non-Inertia request).
|
||||
return Inertia::location(
|
||||
$this->brokers->for($provider)->authorizeUrl($connection, $state, route('system-settings.email.oauth.callback')),
|
||||
);
|
||||
}
|
||||
|
||||
/** The provider sent the admin's browser back with a code (or a refusal). */
|
||||
public function callback(Request $request): RedirectResponse
|
||||
{
|
||||
abort_unless($this->capabilities->has(Capability::EmailTransportConfigure), 404);
|
||||
|
||||
$expectedState = $request->session()->pull(self::STATE);
|
||||
$startedProvider = $request->session()->pull(self::PROVIDER);
|
||||
|
||||
$provider = MailProviderSettings::current()->provider;
|
||||
|
||||
// Nobody started this exchange from here — or the provider was
|
||||
// switched mid-flight, in which case the code belongs to a
|
||||
// configuration that no longer exists.
|
||||
if (! is_string($expectedState) || $startedProvider !== $provider->value || ! $provider->isOAuth()) {
|
||||
return redirect()->route('system-settings.email.edit')->with('error', __('That connection attempt could not be completed. Please try again.'));
|
||||
}
|
||||
|
||||
$state = $request->query('state');
|
||||
|
||||
if (! is_string($state) || ! hash_equals($expectedState, $state)) {
|
||||
return redirect()->route('system-settings.email.edit')->with('error', __('That connection attempt could not be completed. Please try again.'));
|
||||
}
|
||||
|
||||
// The admin clicked "Cancel" on the consent screen, or the
|
||||
// provider refused. Their description is safe to show — this is
|
||||
// an authenticated administrator on their own settings page.
|
||||
$error = $request->query('error');
|
||||
|
||||
if (is_string($error) && $error !== '') {
|
||||
$description = $request->query('error_description');
|
||||
|
||||
return redirect()->route('system-settings.email.edit')
|
||||
->with('error', __('The mailbox was not connected: :reason', [
|
||||
'reason' => is_string($description) && $description !== '' ? $description : $error,
|
||||
]));
|
||||
}
|
||||
|
||||
$code = $request->query('code');
|
||||
|
||||
if (! is_string($code) || $code === '') {
|
||||
return redirect()->route('system-settings.email.edit')->with('error', __('That connection attempt could not be completed. Please try again.'));
|
||||
}
|
||||
|
||||
$connection = MailOAuthConnection::for($provider);
|
||||
|
||||
try {
|
||||
$this->brokers->for($provider)->exchange($connection, $code, route('system-settings.email.oauth.callback'));
|
||||
} catch (MailOAuthException $e) {
|
||||
return redirect()->route('system-settings.email.edit')
|
||||
->with('error', __('The mailbox was not connected: :reason', ['reason' => $e->getMessage()]));
|
||||
}
|
||||
|
||||
$this->activateConnection();
|
||||
|
||||
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'email', 'action' => 'mailbox_connected']);
|
||||
|
||||
return redirect()->route('system-settings.email.edit')
|
||||
->with('success', __(':account connected. Outgoing email now sends as this mailbox.', [
|
||||
'account' => (string) $connection->account_email,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the tokens; keep the app registration, so reconnecting is one
|
||||
* click through the consent screen rather than a form refill.
|
||||
*/
|
||||
public function disconnect(): RedirectResponse
|
||||
{
|
||||
abort_unless($this->capabilities->has(Capability::EmailTransportConfigure), 404);
|
||||
|
||||
$provider = MailProviderSettings::current()->provider;
|
||||
|
||||
if (! $provider->isOAuth()) {
|
||||
return back()->with('error', __('The selected mail provider does not use a connected mailbox.'));
|
||||
}
|
||||
|
||||
$connection = MailOAuthConnection::for($provider);
|
||||
|
||||
$connection->fill([
|
||||
'access_token' => null,
|
||||
'refresh_token' => null,
|
||||
'token_expires_at' => null,
|
||||
'account_email' => null,
|
||||
'last_error' => null,
|
||||
])->save();
|
||||
|
||||
$this->activateConnection();
|
||||
|
||||
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'email', 'action' => 'mailbox_disconnected']);
|
||||
|
||||
return back()->with('success', __('Mailbox disconnected. Outgoing email is paused until one is connected again.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The same three steps EmailSettingsController::update() ends with,
|
||||
* for the same reason: this request must already see the new
|
||||
* transport, and the long-running queue worker must not keep sending
|
||||
* (or failing) with the old one.
|
||||
*/
|
||||
private function activateConnection(): void
|
||||
{
|
||||
$this->mailConfig->flush();
|
||||
$this->mailConfig->apply();
|
||||
|
||||
Artisan::call('queue:restart');
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Platform\Capabilities\Capability;
|
||||
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||
use App\Modules\Platform\Mail\MailOAuthConnection;
|
||||
use App\Modules\Platform\Notifications\TestEmailNotification;
|
||||
use App\Modules\Platform\Settings\MailConfigApplier;
|
||||
use App\Modules\Platform\Settings\MailProvider;
|
||||
@@ -66,7 +67,27 @@ class EmailSettingsController extends Controller
|
||||
'label' => $provider->label(),
|
||||
'host' => $provider->defaultHost(),
|
||||
'port' => $provider->defaultPort(),
|
||||
'oauth' => $provider->isOAuth(),
|
||||
'needs_tenant' => $provider->needsTenant(),
|
||||
], MailProvider::cases()),
|
||||
// Keyed by provider so the form can switch providers without
|
||||
// a round-trip; tokens and the secret never leave the server
|
||||
// — only "is one stored" and the connection's health.
|
||||
'mail_oauth_connections' => collect(MailProvider::cases())
|
||||
->filter(fn (MailProvider $provider): bool => $provider->isOAuth())
|
||||
->mapWithKeys(function (MailProvider $provider): array {
|
||||
$connection = MailOAuthConnection::for($provider);
|
||||
|
||||
return [$provider->value => [
|
||||
'client_id' => $connection->client_id ?? '',
|
||||
'has_client_secret' => $connection->client_secret !== null && $connection->client_secret !== '',
|
||||
'tenant_id' => $connection->tenant_id ?? '',
|
||||
'connected' => $connection->usable(),
|
||||
'account_email' => $connection->account_email,
|
||||
'last_refreshed_at' => $connection->last_refreshed_at?->toIso8601String(),
|
||||
'last_error' => $connection->last_error,
|
||||
]];
|
||||
}),
|
||||
'test_result' => $request->session()->get('mail_test_result'),
|
||||
]);
|
||||
}
|
||||
@@ -79,23 +100,43 @@ class EmailSettingsController extends Controller
|
||||
{
|
||||
$canConfigureTransport = $this->capabilities->has(Capability::EmailTransportConfigure);
|
||||
|
||||
// Peeked at before validation because it decides which rule set
|
||||
// the rest of the transport fields get; an unknown value falls
|
||||
// through to the SMTP rules, whose `provider` rule then rejects
|
||||
// it with the proper validation error.
|
||||
$requestedProvider = $canConfigureTransport
|
||||
? MailProvider::tryFrom((string) $request->input('provider'))
|
||||
: null;
|
||||
$wantsOAuth = $requestedProvider?->isOAuth() ?? false;
|
||||
|
||||
$rules = [
|
||||
'email_notifications_enabled' => ['required', 'boolean'],
|
||||
'admin_notification_emails' => ['required', 'array', 'min:1'],
|
||||
'admin_notification_emails.*' => ['email', 'max:255'],
|
||||
'from_address' => ['required', 'email', 'max:255'],
|
||||
// With an OAuth provider the sender is the connected mailbox,
|
||||
// not a form field — the form doesn't submit one.
|
||||
'from_address' => [$wantsOAuth ? 'nullable' : 'required', 'email', 'max:255'],
|
||||
'from_name' => ['required', 'string', 'max:255'],
|
||||
];
|
||||
|
||||
if ($canConfigureTransport) {
|
||||
$rules += [
|
||||
'provider' => ['required', Rule::in(array_map(fn (MailProvider $p): string => $p->value, MailProvider::cases()))],
|
||||
'host' => ['required', 'string', 'max:255'],
|
||||
'port' => ['required', 'integer', 'between:1,65535'],
|
||||
'username' => ['nullable', 'string', 'max:255'],
|
||||
'password' => ['nullable', 'string', 'max:255'],
|
||||
'encryption' => ['required', Rule::in(['none', 'tls', 'ssl'])],
|
||||
];
|
||||
$rules['provider'] = ['required', Rule::in(array_map(fn (MailProvider $p): string => $p->value, MailProvider::cases()))];
|
||||
|
||||
if ($wantsOAuth) {
|
||||
$rules += [
|
||||
'client_id' => ['required', 'string', 'max:255'],
|
||||
'client_secret' => ['nullable', 'string', 'max:255'],
|
||||
'tenant_id' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
} else {
|
||||
$rules += [
|
||||
'host' => ['required', 'string', 'max:255'],
|
||||
'port' => ['required', 'integer', 'between:1,65535'],
|
||||
'username' => ['nullable', 'string', 'max:255'],
|
||||
'password' => ['nullable', 'string', 'max:255'],
|
||||
'encryption' => ['required', Rule::in(['none', 'tls', 'ssl'])],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$validated = $request->validate($rules);
|
||||
@@ -109,23 +150,69 @@ class EmailSettingsController extends Controller
|
||||
// Transport fields are simply never read from the request when the
|
||||
// capability is absent — a hand-crafted PATCH can't smuggle a
|
||||
// custom relay into a cloud install through this endpoint either.
|
||||
if ($canConfigureTransport) {
|
||||
$mailProvider->fill([
|
||||
'provider' => $validated['provider'],
|
||||
'host' => $validated['host'],
|
||||
'port' => $validated['port'],
|
||||
'username' => $validated['username'] ?? null,
|
||||
'encryption' => $validated['encryption'],
|
||||
]);
|
||||
if ($canConfigureTransport && $requestedProvider !== null) {
|
||||
$mailProvider->provider = $requestedProvider;
|
||||
|
||||
// A blank password keeps whatever is already stored — the field
|
||||
// is never round-tripped to the browser (only `has_password` is).
|
||||
if (is_string($validated['password'] ?? null) && $validated['password'] !== '') {
|
||||
$mailProvider->password = $validated['password'];
|
||||
if ($wantsOAuth) {
|
||||
// The SMTP columns keep their values — switching to an
|
||||
// OAuth provider and back must lose nothing.
|
||||
$connection = MailOAuthConnection::for($requestedProvider);
|
||||
|
||||
// A different app registration invalidates tokens minted
|
||||
// by the old one (the next refresh would present the new
|
||||
// client_id against them and die) — drop them now so the
|
||||
// page honestly shows "not connected" instead of a
|
||||
// connection that fails on first send.
|
||||
$clientIdChanged = $connection->client_id !== null
|
||||
&& $connection->client_id !== ''
|
||||
&& $connection->client_id !== $validated['client_id'];
|
||||
|
||||
$connection->client_id = $validated['client_id'];
|
||||
$connection->tenant_id = ($validated['tenant_id'] ?? null) !== null && trim((string) $validated['tenant_id']) !== ''
|
||||
? trim((string) $validated['tenant_id'])
|
||||
: null;
|
||||
|
||||
// A blank secret keeps whatever is already stored, like
|
||||
// the SMTP password below (only `has_client_secret` is
|
||||
// ever round-tripped to the browser).
|
||||
if (is_string($validated['client_secret'] ?? null) && $validated['client_secret'] !== '') {
|
||||
$connection->client_secret = $validated['client_secret'];
|
||||
}
|
||||
|
||||
if ($clientIdChanged) {
|
||||
$connection->fill([
|
||||
'access_token' => null,
|
||||
'refresh_token' => null,
|
||||
'token_expires_at' => null,
|
||||
'account_email' => null,
|
||||
'last_error' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$connection->save();
|
||||
} else {
|
||||
$mailProvider->fill([
|
||||
'host' => $validated['host'],
|
||||
'port' => $validated['port'],
|
||||
'username' => $validated['username'] ?? null,
|
||||
'encryption' => $validated['encryption'],
|
||||
]);
|
||||
|
||||
// A blank password keeps whatever is already stored — the field
|
||||
// is never round-tripped to the browser (only `has_password` is).
|
||||
if (is_string($validated['password'] ?? null) && $validated['password'] !== '') {
|
||||
$mailProvider->password = $validated['password'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$mailProvider->from_address = $validated['from_address'];
|
||||
// Absent while an OAuth provider is selected (the connected
|
||||
// mailbox is the sender) — the stored value survives for a later
|
||||
// switch back to SMTP.
|
||||
if (is_string($validated['from_address'] ?? null) && $validated['from_address'] !== '') {
|
||||
$mailProvider->from_address = $validated['from_address'];
|
||||
}
|
||||
|
||||
$mailProvider->from_name = $validated['from_name'];
|
||||
$mailProvider->save();
|
||||
|
||||
@@ -157,9 +244,18 @@ class EmailSettingsController extends Controller
|
||||
'recipient' => ['required', 'email', 'max:255'],
|
||||
]);
|
||||
|
||||
$host = config('mail.mailers.smtp.host');
|
||||
$port = config('mail.mailers.smtp.port');
|
||||
$hostPort = (is_string($host) ? $host : '').':'.(is_scalar($port) ? (string) $port : '');
|
||||
// What "via" means depends on the active transport: host:port
|
||||
// only describes SMTP; an OAuth mailer is best named by its
|
||||
// mailer key (e.g. "microsoft-graph").
|
||||
$mailer = config('mail.default');
|
||||
|
||||
if ($mailer === 'smtp') {
|
||||
$host = config('mail.mailers.smtp.host');
|
||||
$port = config('mail.mailers.smtp.port');
|
||||
$hostPort = (is_string($host) ? $host : '').':'.(is_scalar($port) ? (string) $port : '');
|
||||
} else {
|
||||
$hostPort = is_string($mailer) ? $mailer : '';
|
||||
}
|
||||
|
||||
// Which of the two this is has to travel with the message rather
|
||||
// than be inferred from its text: the frontend colours the result,
|
||||
|
||||
@@ -61,6 +61,7 @@ class SchedulerMonitoringController extends Controller
|
||||
'projectsend:purge-api-request-logs' => (string) __('Purge API request logs'),
|
||||
'projectsend:purge-failed-jobs' => (string) __('Purge failed jobs'),
|
||||
'projectsend:purge-notifications' => (string) __('Purge read notifications'),
|
||||
'projectsend:refresh-mail-oauth-tokens' => (string) __('Refresh mail OAuth tokens'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Installation\Console;
|
||||
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Identity\TwoFactor\TwoFactorEnforcement;
|
||||
use App\Modules\Identity\UserType;
|
||||
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||
use App\Modules\Platform\Installation\Events\ResolvingInstallationStatus;
|
||||
use App\Modules\Platform\Seats\SeatAllowance;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Migrations\Migrator;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* What this installation is, as a fact rather than a screen.
|
||||
*
|
||||
* Written for whatever watches a managed installation from outside the
|
||||
* container. Everything here is already visible to any signed-in
|
||||
* administrator — a version, an edition, which capabilities the edition
|
||||
* grants, how many accounts exist against how many are allowed. Nothing
|
||||
* is a secret and nothing is a credential.
|
||||
*
|
||||
* ### Why a command and not a shell one-liner
|
||||
*
|
||||
* A reconciler that observes tenants has to be able to say it never sends
|
||||
* instructions, only reads state. `docker exec … php -r '…'` is an
|
||||
* instruction with the caller's argv in it, however harmless the argv;
|
||||
* a named command is an observation, the same kind of thing as reading a
|
||||
* directory size. The distinction is the whole reason this exists rather
|
||||
* than a documented incantation.
|
||||
*
|
||||
* ### The counts are the enforcing code's own
|
||||
*
|
||||
* `used` comes from SeatAllowance, which is what refuses the account past
|
||||
* the limit. Two counts that merely agree will diverge eventually — over
|
||||
* an inactive account, or a soft-deleted one — and the divergence looks
|
||||
* like a billing fault rather than a counting one. So there is one
|
||||
* definition and this reads it.
|
||||
*
|
||||
* ### Is anybody there
|
||||
*
|
||||
* `activity.last_staff_login_at` answers the one question a platform
|
||||
* cannot answer from outside: whether a human still uses this
|
||||
* installation. It is a timestamp and nothing else — no name, no address,
|
||||
* no session. Only interactive sign-ins reach it, because that is all
|
||||
* Laravel's Login event fires for: an integration polling the API every
|
||||
* hour must not make a dormant installation look busy.
|
||||
*
|
||||
* Derived from the activity log rather than denormalised onto `users`. A
|
||||
* column would need a migration, a listener change and a backfill to save
|
||||
* one indexed MAX() over a table that is small on exactly the
|
||||
* installations anybody asks this about. The log is never pruned, and
|
||||
* erasure anonymises entries rather than deleting them (`actor_type`
|
||||
* survives on purpose — see AccountEraser), so the answer does not change
|
||||
* when the person who gave it is forgotten.
|
||||
*
|
||||
* ### Storage is the application's number, not the disk's
|
||||
*
|
||||
* `storage.bytes` is what this installation holds, summed from the rows
|
||||
* that record it. Measuring the directory instead was correct until
|
||||
* external storage went live, and silently stopped being: an upload that
|
||||
* resolves to a bucket leaves nothing on the volume to measure, so a
|
||||
* figure taken from the filesystem freezes while the account keeps
|
||||
* filling. `by_disk` is the same sum split by where the bytes went, which
|
||||
* is the only way to see what is still sitting on local disk from before
|
||||
* a cutover.
|
||||
*
|
||||
* Trashed files are excluded because they hold no bytes: File's `deleted`
|
||||
* hook removes them, so a soft-deleted row is a record of something that
|
||||
* is gone rather than something still costing anything.
|
||||
*
|
||||
* ### Health is what a container cannot show from outside
|
||||
*
|
||||
* A tenant's queue worker dying is invisible to anything watching the
|
||||
* container: it is still up, and zips quietly stop building while mail
|
||||
* stops going out. Same for migrations that failed after a deploy — the
|
||||
* application answers every request and is a schema behind. Neither is a
|
||||
* secret; both are already visible to anyone who can open the database,
|
||||
* which is anyone who can run this command.
|
||||
*
|
||||
* ### What core cannot answer
|
||||
*
|
||||
* `modules` is filled by whatever packages are installed, through
|
||||
* ResolvingInstallationStatus. A platform that provisioned a bucket knows
|
||||
* what it asked for; only the installation knows what loaded.
|
||||
*/
|
||||
class StatusCommand extends Command
|
||||
{
|
||||
protected $signature = 'projectsend:status {--json : Emit machine-readable JSON on stdout}';
|
||||
|
||||
protected $description = 'Report this installation\'s version, edition, capabilities and seat usage';
|
||||
|
||||
public function handle(CapabilityRegistry $capabilities, SeatAllowance $seats, Settings $settings): int
|
||||
{
|
||||
$status = [
|
||||
'version' => (string) config('projectsend.version'),
|
||||
'edition' => $capabilities->edition()->value,
|
||||
'capabilities' => $capabilities->enabledKeys(),
|
||||
'seats' => [
|
||||
'staff' => [
|
||||
'used' => $seats->staffUsed(),
|
||||
// null is unlimited, and is emitted as null rather than
|
||||
// as 0 or as an absent key: a reader that mistook one
|
||||
// for the other would report an installation selling
|
||||
// unlimited accounts as one that may hold none.
|
||||
'limit' => $seats->staffLimit(),
|
||||
],
|
||||
'clients' => [
|
||||
'used' => $seats->clientUsed(),
|
||||
'limit' => $seats->clientLimit(),
|
||||
],
|
||||
],
|
||||
'activity' => [
|
||||
// Null means "no staff account has ever signed in here",
|
||||
// and is emitted rather than left out for the same reason
|
||||
// an unlimited seat count is: a watcher has to be able to
|
||||
// tell that apart from "we got no answer". Collapsing the
|
||||
// two is how a broken probe reads as a dormant fleet.
|
||||
'last_staff_login_at' => $this->lastStaffLoginAt(),
|
||||
],
|
||||
'storage' => $this->storage(),
|
||||
'health' => $this->health(),
|
||||
'settings' => [
|
||||
// Echoed back rather than assumed: an operator writes the
|
||||
// environment variable, and this is the installation
|
||||
// saying what it actually applied. Read the way
|
||||
// EnforceTwoFactor reads it, down to what an unreadable
|
||||
// value falls back to -- reporting a stricter answer than
|
||||
// the middleware enforces would be worse than reporting
|
||||
// none at all.
|
||||
'two_factor_enforcement' => $this->enforcement($settings),
|
||||
],
|
||||
// Cast so an installation with no packages emits {} rather
|
||||
// than [] -- an empty PHP array encodes as a list, and a
|
||||
// reader unmarshalling a map breaks on the day it happens to
|
||||
// be empty rather than on the day it is written.
|
||||
'modules' => (object) $this->modules(),
|
||||
];
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line((string) json_encode($status, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->line("ProjectSend {$status['version']} ({$status['edition']})");
|
||||
$this->line('Capabilities: '.(implode(', ', $status['capabilities']) ?: 'none'));
|
||||
$this->line('Staff seats: '.$this->seatLine($status['seats']['staff']));
|
||||
$this->line('Clients: '.$this->seatLine($status['seats']['clients']));
|
||||
$this->line('Last staff login: '.($status['activity']['last_staff_login_at'] ?? 'never'));
|
||||
$this->line('Storage: '.number_format($status['storage']['bytes']).' bytes in '.$status['storage']['files'].' files');
|
||||
$this->line('Health: '.$status['health']['pending_migrations'].' migrations pending, '
|
||||
.$status['health']['failed_jobs'].' failed jobs, '
|
||||
.array_sum(array_filter($status['health']['queues'], 'is_int')).' queued');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function enforcement(Settings $settings): string
|
||||
{
|
||||
$value = $settings->get(Setting::TwoFactorEnforcement);
|
||||
|
||||
$enforcement = (is_string($value) ? TwoFactorEnforcement::tryFrom($value) : null)
|
||||
?? TwoFactorEnforcement::None;
|
||||
|
||||
return $enforcement->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* What this installation holds, from the rows that record it.
|
||||
*
|
||||
* @return array{bytes: int, files: int, by_disk: object}
|
||||
*/
|
||||
private function storage(): array
|
||||
{
|
||||
$perDisk = File::query()
|
||||
->groupBy('disk')
|
||||
->selectRaw('disk, sum(size) as bytes, count(*) as files')
|
||||
->get();
|
||||
|
||||
return [
|
||||
'bytes' => (int) $perDisk->sum(fn (File $row): int => (int) $row->getAttribute('bytes')),
|
||||
'files' => (int) $perDisk->sum(fn (File $row): int => (int) $row->getAttribute('files')),
|
||||
// Keyed by disk name rather than a list, because the reader
|
||||
// wants one of them by name — "how much is still local" — and
|
||||
// not to walk a list looking for it.
|
||||
// Same reason as `modules`: an installation holding no files
|
||||
// at all must still answer with a map.
|
||||
'by_disk' => (object) $perDisk
|
||||
->mapWithKeys(fn (File $row): array => [
|
||||
(string) $row->getAttribute('disk') => [
|
||||
'bytes' => (int) $row->getAttribute('bytes'),
|
||||
'files' => (int) $row->getAttribute('files'),
|
||||
],
|
||||
])->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{pending_migrations: int, failed_jobs: int, queues: array<string, int|null>}
|
||||
*/
|
||||
private function health(): array
|
||||
{
|
||||
return [
|
||||
'pending_migrations' => $this->pendingMigrations(),
|
||||
'failed_jobs' => $this->failedJobs(),
|
||||
// The two this application actually runs workers for. A depth
|
||||
// is not a fault on its own -- a busy installation has one --
|
||||
// but a depth that only ever grows is a worker that died, and
|
||||
// nothing outside the container can see the difference.
|
||||
'queues' => [
|
||||
'default' => $this->queueDepth('default'),
|
||||
'zips' => $this->queueDepth('zips'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function pendingMigrations(): int
|
||||
{
|
||||
/** @var Migrator $migrator */
|
||||
$migrator = app('migrator');
|
||||
|
||||
// Every path, not just database/migrations: a package registers
|
||||
// its own, and a package migration left unrun is exactly the kind
|
||||
// of half-deploy this is here to report.
|
||||
$files = $migrator->getMigrationFiles(array_merge([database_path('migrations')], $migrator->paths()));
|
||||
|
||||
return count(array_diff(array_keys($files), $migrator->getRepository()->getRan()));
|
||||
}
|
||||
|
||||
private function failedJobs(): int
|
||||
{
|
||||
$table = config('queue.failed.table');
|
||||
|
||||
if (! is_string($table) || $table === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return DB::table($table)->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Null rather than a crash when the queue cannot be reached, and null
|
||||
* rather than zero: an unreachable Redis is not an empty queue, and a
|
||||
* reader watching for a worker that died would read the second as
|
||||
* everything being fine.
|
||||
*
|
||||
* This command is a probe, and a probe that dies on one unreachable
|
||||
* dependency tells the reader nothing about the facts it could still
|
||||
* have answered.
|
||||
*/
|
||||
private function queueDepth(string $queue): ?int
|
||||
{
|
||||
try {
|
||||
return Queue::size($queue);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string|int|bool|null>
|
||||
*/
|
||||
private function modules(): array
|
||||
{
|
||||
$event = new ResolvingInstallationStatus;
|
||||
|
||||
Event::dispatch($event);
|
||||
|
||||
return $event->facts;
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent interactive staff sign-in, or null if there has
|
||||
* never been one.
|
||||
*/
|
||||
private function lastStaffLoginAt(): ?string
|
||||
{
|
||||
$latest = ActivityLog::query()
|
||||
->where('action', Action::Login->value)
|
||||
->where('actor_type', UserType::Staff->value)
|
||||
->max('created_at');
|
||||
|
||||
// `action` and `actor_type` carry an index each, so this narrows
|
||||
// on one of them rather than reading the log.
|
||||
return $latest === null ? null : Carbon::parse($latest)->toIso8601String();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{used: int, limit: int|null} $seat
|
||||
*/
|
||||
private function seatLine(array $seat): string
|
||||
{
|
||||
return $seat['limit'] === null
|
||||
? "{$seat['used']} of unlimited"
|
||||
: "{$seat['used']} of {$seat['limit']}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Installation\Events;
|
||||
|
||||
/**
|
||||
* "What else is worth knowing about this installation?" — asked once,
|
||||
* by `projectsend:status`, of whatever packages happen to be installed.
|
||||
*
|
||||
* Core cannot answer for them. A managed installation's storage backend
|
||||
* and the version of the package providing it live in
|
||||
* projectsend/cloud-modules, which this repository is public and must
|
||||
* not reference; a control plane still has to be able to observe them,
|
||||
* and observing is exactly what that command is for.
|
||||
*
|
||||
* The distinction this exists to preserve: a platform writing eight
|
||||
* environment variables knows what it *asked for*. Only the installation
|
||||
* knows what actually loaded. Those came apart once — a bucket was
|
||||
* provisioned and a token minted while the container ignored both,
|
||||
* because its image predated the module that reads them, and the
|
||||
* configuration sitting beside the files looked perfectly correct.
|
||||
*
|
||||
* Listened to by *string* class name from a package, same as every
|
||||
* other hook here — see docs/extension-points-architecture.md.
|
||||
*/
|
||||
final class ResolvingInstallationStatus
|
||||
{
|
||||
/**
|
||||
* What listeners have reported, keyed by name.
|
||||
*
|
||||
* Scalars and null only: this is serialised to JSON for a reader
|
||||
* that is not this application, and a shape it has to walk is a
|
||||
* shape it has to be taught. Null is a real answer — "asked, and
|
||||
* the thing is not here" — and it must survive to the document
|
||||
* rather than being dropped, for the reason the whole file's null
|
||||
* handling exists: absent and "nothing to report" are different
|
||||
* facts, and a reader that cannot tell them apart guesses.
|
||||
*
|
||||
* @var array<string, string|int|bool|null>
|
||||
*/
|
||||
public array $facts = [];
|
||||
|
||||
public function report(string $key, string|int|bool|null $value): void
|
||||
{
|
||||
$this->facts[$key] = $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Mail\Console;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Permissions\Permission;
|
||||
use App\Modules\Identity\Permissions\PermissionChecker;
|
||||
use App\Modules\Identity\UserType;
|
||||
use App\Modules\Notifications\Notifier;
|
||||
use App\Modules\Platform\Mail\MailOAuthBrokers;
|
||||
use App\Modules\Platform\Mail\MailOAuthConnection;
|
||||
use App\Modules\Platform\Mail\MailOAuthException;
|
||||
use App\Modules\Platform\Settings\MailConfigApplier;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Keeps every connected OAuth mailbox able to send, and says so early
|
||||
* when one no longer can.
|
||||
*
|
||||
* Transports already refresh on demand at send time; what they cannot do
|
||||
* is refresh on an installation that sends rarely — and a delegated
|
||||
* refresh token dies of pure disuse (Microsoft's sliding inactivity
|
||||
* window). A daily refresh keeps the window sliding, and doubles as the
|
||||
* health check: the delegated flow's one real weakness is that a grant
|
||||
* can die silently (password reset, Conditional Access change), which
|
||||
* for a portal whose password-reset mails ride on this connection must
|
||||
* surface as a warning, not as a support ticket weeks later.
|
||||
*/
|
||||
class RefreshMailOAuthTokensCommand extends Command
|
||||
{
|
||||
protected $signature = 'projectsend:refresh-mail-oauth-tokens';
|
||||
|
||||
protected $description = 'Refresh connected OAuth mailbox tokens and flag connections that need to be reconnected (runs daily)';
|
||||
|
||||
public function handle(MailOAuthBrokers $brokers, Notifier $notifier, PermissionChecker $permissions, MailConfigApplier $mailConfig): int
|
||||
{
|
||||
$connections = MailOAuthConnection::query()->get()->filter(
|
||||
fn (MailOAuthConnection $connection): bool => $connection->usable(),
|
||||
);
|
||||
|
||||
if ($connections->isEmpty()) {
|
||||
$this->info('No connected OAuth mailboxes; nothing to refresh.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($connections as $connection) {
|
||||
$hadError = $connection->last_error !== null;
|
||||
|
||||
try {
|
||||
$brokers->for($connection->provider)->refresh($connection);
|
||||
|
||||
$this->info("Refreshed {$connection->provider->value} ({$connection->account_email}).");
|
||||
|
||||
// Back from the dead (an admin fixed things upstream
|
||||
// without reconnecting): the applier may have been
|
||||
// resolving "not ready" and must see the recovery.
|
||||
if ($hadError) {
|
||||
$mailConfig->flush();
|
||||
}
|
||||
} catch (MailOAuthException $e) {
|
||||
$this->error("Could not refresh {$connection->provider->value}: {$e->getMessage()}");
|
||||
|
||||
if (! $e->needsReconnect) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only on the transition into the broken state — the
|
||||
// notification would otherwise repeat daily for as long
|
||||
// as nobody reconnects, and a nagging alert trains
|
||||
// people to ignore the one that matters.
|
||||
if (! $hadError) {
|
||||
$recipients = array_values(User::query()->where('type', UserType::Staff)->get()
|
||||
->filter(fn (User $staff): bool => $permissions->allows($staff, Permission::EditSettings))
|
||||
->all());
|
||||
|
||||
$notifier->send('mail_oauth_connection_broken', $recipients, data: [
|
||||
'provider' => $connection->provider->label(),
|
||||
'account' => (string) $connection->account_email,
|
||||
]);
|
||||
}
|
||||
|
||||
$mailConfig->flush();
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Mail;
|
||||
|
||||
use App\Modules\Platform\Settings\MailProvider;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Symfony\Component\Mailer\Exception\TransportException;
|
||||
use Symfony\Component\Mailer\SentMessage;
|
||||
use Symfony\Component\Mailer\Transport\AbstractTransport;
|
||||
|
||||
/**
|
||||
* Sends through the Gmail API as the connected Google account.
|
||||
*
|
||||
* Gmail's messages.send takes the raw RFC 822 message base64url-encoded
|
||||
* — the same "ship Symfony's exact bytes" approach as
|
||||
* MicrosoftGraphTransport, and for the same reason: re-describing an
|
||||
* already-rendered message in a vendor's JSON shape is a second
|
||||
* serializer to get subtly wrong. Gmail reads recipients from the MIME
|
||||
* headers and strips Bcc on delivery.
|
||||
*
|
||||
* Gmail rewrites the From header to the authenticated account (or one
|
||||
* of its configured send-as aliases), which is why MailConfigApplier
|
||||
* pins mail.from.address to the connected account while this provider
|
||||
* is active. The connection row is read fresh on every send — a queue
|
||||
* worker holds this transport for its whole life, and tokens change
|
||||
* underneath it.
|
||||
*/
|
||||
class GmailTransport extends AbstractTransport
|
||||
{
|
||||
public function __construct(private readonly GoogleMailBroker $broker)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function doSend(SentMessage $message): void
|
||||
{
|
||||
$connection = MailOAuthConnection::for(MailProvider::Gmail);
|
||||
|
||||
if (! $connection->usable()) {
|
||||
throw new TransportException('Gmail is selected as the mail provider, but no account is connected.');
|
||||
}
|
||||
|
||||
try {
|
||||
$token = $this->broker->freshAccessToken($connection);
|
||||
} catch (MailOAuthException $e) {
|
||||
throw new TransportException('Could not get a Google access token: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
$response = Http::withToken($token)->post('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', [
|
||||
'raw' => rtrim(strtr(base64_encode($message->toString()), '+/', '-_'), '='),
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
$status = $response->json('error.status');
|
||||
$detail = $response->json('error.message');
|
||||
|
||||
throw new TransportException(
|
||||
'Gmail refused the message (HTTP '.$response->status()
|
||||
.(is_string($status) && $status !== '' ? ', '.$status : '').')'
|
||||
.(is_string($detail) && $detail !== '' ? ': '.$detail : '.'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return 'gmail-api';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Mail;
|
||||
|
||||
/**
|
||||
* Google OAuth tokens for sending through the Gmail API.
|
||||
*
|
||||
* Same delegated shape as Microsoft's: an administrator signs into the
|
||||
* Google account the installation should send as, and the token can
|
||||
* send as that account and nothing else. The app registration lives in
|
||||
* Google Cloud Console (an OAuth client of type "Web application");
|
||||
* a Workspace admin can mark it Internal, everyone else runs it in
|
||||
* testing/published status — in testing, Google expires the refresh
|
||||
* token after 7 days, which the daily health check surfaces as a
|
||||
* reconnect warning rather than silent dead mail.
|
||||
*/
|
||||
class GoogleMailBroker extends OAuthCodeFlowBroker
|
||||
{
|
||||
/**
|
||||
* gmail.send is the one permission sending needs; openid/email buy
|
||||
* the id_token the connected account's address is read from — no
|
||||
* userinfo call, no broader Gmail access.
|
||||
*/
|
||||
private const SCOPE = 'openid email https://www.googleapis.com/auth/gmail.send';
|
||||
|
||||
public function authorizeUrl(MailOAuthConnection $connection, string $state, string $redirectUri): string
|
||||
{
|
||||
// access_type=offline is what makes Google issue a refresh token
|
||||
// at all, and prompt must include 'consent' because Google only
|
||||
// hands one out while showing the consent screen — a silent
|
||||
// re-auth returns none, and this flow cannot run on borrowed
|
||||
// time. select_account for the same reason as Microsoft's: the
|
||||
// sending account is usually not the one the admin is signed
|
||||
// into.
|
||||
return 'https://accounts.google.com/o/oauth2/v2/auth?'.http_build_query([
|
||||
'client_id' => (string) $connection->client_id,
|
||||
'response_type' => 'code',
|
||||
'redirect_uri' => $redirectUri,
|
||||
'scope' => self::SCOPE,
|
||||
'state' => $state,
|
||||
'access_type' => 'offline',
|
||||
'prompt' => 'select_account consent',
|
||||
]);
|
||||
}
|
||||
|
||||
protected function tokenEndpoint(MailOAuthConnection $connection): string
|
||||
{
|
||||
return 'https://oauth2.googleapis.com/token';
|
||||
}
|
||||
|
||||
protected function scope(): string
|
||||
{
|
||||
return self::SCOPE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Mail;
|
||||
|
||||
/**
|
||||
* One OAuth mail provider's token machinery: building the consent URL,
|
||||
* turning the returned code into tokens, and keeping those tokens fresh.
|
||||
*
|
||||
* Deliberately not Socialite: a mail connection needs raw tokens with a
|
||||
* send scope, not a user identity, and Socialite's user() call would
|
||||
* drag in a userinfo permission (User.Read on Graph) that sending mail
|
||||
* does not need. Implementations write their results straight onto the
|
||||
* MailOAuthConnection row and save it.
|
||||
*/
|
||||
interface MailOAuthBroker
|
||||
{
|
||||
/** The provider consent URL the admin's browser is sent to. */
|
||||
public function authorizeUrl(MailOAuthConnection $connection, string $state, string $redirectUri): string;
|
||||
|
||||
/**
|
||||
* Exchange the callback's authorization code for tokens and record
|
||||
* them, along with the connected mailbox's address, on the connection.
|
||||
*
|
||||
* @throws MailOAuthException
|
||||
*/
|
||||
public function exchange(MailOAuthConnection $connection, string $code, string $redirectUri): void;
|
||||
|
||||
/**
|
||||
* Refresh the access token (rotating the refresh token when the
|
||||
* provider hands back a new one) and record the outcome — including
|
||||
* `last_error` on failure, so the settings page and the scheduled
|
||||
* health check read one source of truth.
|
||||
*
|
||||
* @throws MailOAuthException
|
||||
*/
|
||||
public function refresh(MailOAuthConnection $connection): void;
|
||||
|
||||
/**
|
||||
* An access token currently valid for at least a small safety margin,
|
||||
* refreshing first when needed — what transports call at send time.
|
||||
*
|
||||
* @throws MailOAuthException
|
||||
*/
|
||||
public function freshAccessToken(MailOAuthConnection $connection): string;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Mail;
|
||||
|
||||
use App\Modules\Platform\Settings\MailProvider;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Resolves the broker for an OAuth mail provider.
|
||||
*
|
||||
* A closed map rather than an open registry, for the same reason
|
||||
* SocialProvider is a closed enum: each broker encodes decisions about a
|
||||
* vendor's token semantics (rotation, what kills a grant) that somebody
|
||||
* has reasoned about.
|
||||
*/
|
||||
class MailOAuthBrokers
|
||||
{
|
||||
public function for(MailProvider $provider): MailOAuthBroker
|
||||
{
|
||||
return match ($provider) {
|
||||
MailProvider::Microsoft365 => app(MicrosoftMailBroker::class),
|
||||
MailProvider::Gmail => app(GoogleMailBroker::class),
|
||||
default => throw new InvalidArgumentException("{$provider->value} is not an OAuth mail provider."),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Mail;
|
||||
|
||||
use App\Modules\Platform\Settings\MailProvider;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* One OAuth mail provider's app registration and its connected mailbox.
|
||||
*
|
||||
* Shaped after SocialSettings, including the part that matters most:
|
||||
* `client_secret` and both tokens carry an `'encrypted'` cast, so a
|
||||
* database dump does not hand over a credential that can send mail as
|
||||
* the organization.
|
||||
*
|
||||
* The row splits into two halves with different lifetimes: the app
|
||||
* registration (client_id/client_secret/tenant_id) survives a
|
||||
* disconnect, while the connection itself (tokens, account, error state)
|
||||
* is what connecting and disconnecting write. Transports read this row
|
||||
* fresh at send time — tokens must never travel through the boot-config
|
||||
* cache (see MailConfigApplier, which caches only readiness and the
|
||||
* account address).
|
||||
*
|
||||
* @property int $id
|
||||
* @property MailProvider $provider
|
||||
* @property string|null $client_id
|
||||
* @property string|null $client_secret
|
||||
* @property string|null $tenant_id
|
||||
* @property string|null $account_email
|
||||
* @property string|null $access_token
|
||||
* @property string|null $refresh_token
|
||||
* @property Carbon|null $token_expires_at
|
||||
* @property Carbon|null $last_refreshed_at
|
||||
* @property string|null $last_error
|
||||
*/
|
||||
class MailOAuthConnection extends Model
|
||||
{
|
||||
protected $table = 'mail_oauth_connections';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'provider' => MailProvider::class,
|
||||
'client_secret' => 'encrypted',
|
||||
'access_token' => 'encrypted',
|
||||
'refresh_token' => 'encrypted',
|
||||
'token_expires_at' => 'datetime',
|
||||
'last_refreshed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public static function for(MailProvider $provider): self
|
||||
{
|
||||
return static::query()->firstOrNew(['provider' => $provider->value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the connect flow can be started: the admin has entered the
|
||||
* app registration, even if no mailbox is connected yet.
|
||||
*/
|
||||
public function configured(): bool
|
||||
{
|
||||
return $this->filled('client_id') && $this->filled('client_secret');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether transports can send through this connection. A half-torn
|
||||
* state (configured but never connected, or tokens cleared by a
|
||||
* disconnect) behaves as "not usable" rather than failing inside a
|
||||
* queued job — the same rule SocialSettings::usable() follows.
|
||||
*/
|
||||
public function usable(): bool
|
||||
{
|
||||
return $this->configured() && $this->filled('refresh_token');
|
||||
}
|
||||
|
||||
private function filled(string $attribute): bool
|
||||
{
|
||||
$value = $this->getAttribute($attribute);
|
||||
|
||||
return is_string($value) && trim($value) !== '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Mail;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* A failed token exchange or refresh.
|
||||
*
|
||||
* `needsReconnect` separates the two situations an admin can be in: the
|
||||
* grant itself is dead (revoked consent, password/Conditional-Access
|
||||
* change, expired refresh token — only re-running the connect flow
|
||||
* helps) versus a transient failure (endpoint unreachable, 5xx) where
|
||||
* the existing connection is fine and retrying is the answer.
|
||||
*/
|
||||
class MailOAuthException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
public readonly bool $needsReconnect = false,
|
||||
) {
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Mail;
|
||||
|
||||
use App\Modules\Platform\Settings\MailProvider;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Symfony\Component\Mailer\Exception\TransportException;
|
||||
use Symfony\Component\Mailer\SentMessage;
|
||||
use Symfony\Component\Mailer\Transport\AbstractTransport;
|
||||
|
||||
/**
|
||||
* Sends through Microsoft Graph's sendMail as the connected mailbox.
|
||||
*
|
||||
* Graph rather than smtp.office365.com because SMTP submission (both
|
||||
* password and XOAUTH2) is the endpoint Microsoft is winding down, while
|
||||
* Graph is where they invest — this transport is the future-proof half
|
||||
* of the Microsoft 365 provider, the connect flow in MicrosoftMailBroker
|
||||
* is the other.
|
||||
*
|
||||
* The message goes up as base64 MIME, not as Graph's JSON message shape:
|
||||
* Symfony already rendered the exact bytes (themed HTML, alternatives,
|
||||
* attachments), and re-describing them in JSON is a second
|
||||
* serializer to get subtly wrong. Exchange reads recipients from the
|
||||
* MIME headers and strips Bcc on delivery, so all three recipient kinds
|
||||
* behave. The connection row is read fresh on every send — a queue
|
||||
* worker holds this transport for its whole life, and tokens rotate
|
||||
* underneath it.
|
||||
*
|
||||
* Sending as the connected mailbox is a property of delegated Graph, not
|
||||
* a limitation of this class: the From header must be that mailbox (or
|
||||
* one it holds SendAs rights over), which is why MailConfigApplier pins
|
||||
* mail.from.address to the connected account while this provider is
|
||||
* active.
|
||||
*/
|
||||
class MicrosoftGraphTransport extends AbstractTransport
|
||||
{
|
||||
public function __construct(private readonly MicrosoftMailBroker $broker)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function doSend(SentMessage $message): void
|
||||
{
|
||||
$connection = MailOAuthConnection::for(MailProvider::Microsoft365);
|
||||
|
||||
if (! $connection->usable()) {
|
||||
throw new TransportException('Microsoft 365 is selected as the mail provider, but no mailbox is connected.');
|
||||
}
|
||||
|
||||
try {
|
||||
$token = $this->broker->freshAccessToken($connection);
|
||||
} catch (MailOAuthException $e) {
|
||||
throw new TransportException('Could not get a Microsoft 365 access token: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
$response = Http::withToken($token)
|
||||
->withBody(base64_encode($message->toString()), 'text/plain')
|
||||
->post('https://graph.microsoft.com/v1.0/me/sendMail');
|
||||
|
||||
// Graph acknowledges an accepted submission with 202 and an empty
|
||||
// body; anything else is a refusal worth the admin's attention
|
||||
// (SendAsDenied when the From header isn't the connected mailbox,
|
||||
// ErrorMessageSubmissionBlocked, throttling).
|
||||
if ($response->status() !== 202) {
|
||||
$code = $response->json('error.code');
|
||||
$detail = $response->json('error.message');
|
||||
|
||||
// The code carries the diagnosis ("ErrorQuotaExceeded",
|
||||
// "ErrorSendAsDenied"); Graph's message text is often generic
|
||||
// to the point of useless, so both go into the exception.
|
||||
throw new TransportException(
|
||||
'Microsoft Graph refused the message (HTTP '.$response->status()
|
||||
.(is_string($code) && $code !== '' ? ', '.$code : '').')'
|
||||
.(is_string($detail) && $detail !== '' ? ': '.$detail : '.'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return 'microsoft-graph';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Mail;
|
||||
|
||||
/**
|
||||
* Microsoft identity platform (v2.0) tokens for sending through Graph.
|
||||
*
|
||||
* Delegated flow on purpose: an administrator signs into the mailbox the
|
||||
* installation should send as, and the resulting token can send as that
|
||||
* mailbox and nothing else — no admin consent, no PowerShell
|
||||
* ApplicationAccessPolicy, and it works for work/school and personal
|
||||
* accounts alike. The price of delegated is that the grant can die
|
||||
* behind our back (password reset, Conditional Access change), which is
|
||||
* why refresh failures record `last_error` for the health check to
|
||||
* surface instead of letting mail stop silently.
|
||||
*/
|
||||
class MicrosoftMailBroker extends OAuthCodeFlowBroker
|
||||
{
|
||||
/**
|
||||
* Mail.Send is the one Graph permission sending needs; offline_access
|
||||
* buys the refresh token; openid/profile/email buy the id_token the
|
||||
* connected mailbox's address is read from — which is what lets the
|
||||
* whole flow avoid User.Read and a Graph /me call entirely.
|
||||
*/
|
||||
private const SCOPE = 'offline_access openid profile email https://graph.microsoft.com/Mail.Send';
|
||||
|
||||
public function authorizeUrl(MailOAuthConnection $connection, string $state, string $redirectUri): string
|
||||
{
|
||||
// select_account, always: the admin doing this is often signed
|
||||
// into their own mailbox, and the one the installation should
|
||||
// send as (noreply@, portal@) is usually a different one.
|
||||
return 'https://login.microsoftonline.com/'.$this->tenant($connection).'/oauth2/v2.0/authorize?'.http_build_query([
|
||||
'client_id' => (string) $connection->client_id,
|
||||
'response_type' => 'code',
|
||||
'redirect_uri' => $redirectUri,
|
||||
'response_mode' => 'query',
|
||||
'scope' => self::SCOPE,
|
||||
'state' => $state,
|
||||
'prompt' => 'select_account',
|
||||
]);
|
||||
}
|
||||
|
||||
protected function tokenEndpoint(MailOAuthConnection $connection): string
|
||||
{
|
||||
return 'https://login.microsoftonline.com/'.$this->tenant($connection).'/oauth2/v2.0/token';
|
||||
}
|
||||
|
||||
protected function scope(): string
|
||||
{
|
||||
return self::SCOPE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blank falls back to 'common', which admits work/school accounts of
|
||||
* any tenant plus personal accounts — the inclusive default for this
|
||||
* app's audience. 'consumers' and 'organizations' work here too; a
|
||||
* Consumer-audience app registration in fact requires 'consumers',
|
||||
* as /common refuses that userAudience outright.
|
||||
*/
|
||||
private function tenant(MailOAuthConnection $connection): string
|
||||
{
|
||||
$tenant = $connection->tenant_id;
|
||||
|
||||
return is_string($tenant) && trim($tenant) !== '' ? trim($tenant) : 'common';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Mail;
|
||||
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* The authorization-code machinery every OAuth mail provider shares:
|
||||
* exchanging the callback's code, refreshing on demand, storing what
|
||||
* came back. Providers differ only in their endpoints, their scope
|
||||
* string, and how the consent URL is parameterized — which is exactly
|
||||
* the surface the abstract methods cover.
|
||||
*
|
||||
* Plain HTTP against the token endpoints rather than vendor SDKs — the
|
||||
* project ships none, and two POST requests per provider do not justify
|
||||
* one.
|
||||
*/
|
||||
abstract class OAuthCodeFlowBroker implements MailOAuthBroker
|
||||
{
|
||||
/** Refresh when the access token has less life left than this. */
|
||||
private const EXPIRY_MARGIN_SECONDS = 120;
|
||||
|
||||
abstract public function authorizeUrl(MailOAuthConnection $connection, string $state, string $redirectUri): string;
|
||||
|
||||
/** The provider's OAuth token endpoint for this connection. */
|
||||
abstract protected function tokenEndpoint(MailOAuthConnection $connection): string;
|
||||
|
||||
/** The scope string this provider's tokens are requested with. */
|
||||
abstract protected function scope(): string;
|
||||
|
||||
public function exchange(MailOAuthConnection $connection, string $code, string $redirectUri): void
|
||||
{
|
||||
$response = Http::asForm()->post($this->tokenEndpoint($connection), [
|
||||
'client_id' => (string) $connection->client_id,
|
||||
'client_secret' => (string) $connection->client_secret,
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => $redirectUri,
|
||||
'scope' => $this->scope(),
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw $this->failure($response);
|
||||
}
|
||||
|
||||
$this->storeTokens($connection, $response);
|
||||
}
|
||||
|
||||
public function refresh(MailOAuthConnection $connection): void
|
||||
{
|
||||
$refreshToken = $connection->refresh_token;
|
||||
|
||||
if (! is_string($refreshToken) || $refreshToken === '') {
|
||||
throw new MailOAuthException('No mailbox is connected.', needsReconnect: true);
|
||||
}
|
||||
|
||||
$response = Http::asForm()->post($this->tokenEndpoint($connection), [
|
||||
'client_id' => (string) $connection->client_id,
|
||||
'client_secret' => (string) $connection->client_secret,
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $refreshToken,
|
||||
'scope' => $this->scope(),
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
$failure = $this->failure($response);
|
||||
|
||||
// Only a dead grant is worth alarming the admin over; a
|
||||
// transient endpoint problem heals on the next attempt and
|
||||
// must not paint the settings page red in the meantime.
|
||||
if ($failure->needsReconnect) {
|
||||
$connection->last_error = $failure->getMessage();
|
||||
$connection->save();
|
||||
}
|
||||
|
||||
throw $failure;
|
||||
}
|
||||
|
||||
$this->storeTokens($connection, $response);
|
||||
}
|
||||
|
||||
public function freshAccessToken(MailOAuthConnection $connection): string
|
||||
{
|
||||
if ($this->stillUsable($connection)) {
|
||||
return (string) $connection->access_token;
|
||||
}
|
||||
|
||||
// Both providers rotate the refresh token as they hand out a new
|
||||
// access token, so a refresh token is good for exactly one use.
|
||||
// Two queue workers reaching an expired token at the same moment —
|
||||
// or a worker racing the nightly refresh command — means the slower
|
||||
// one spends a token the faster one has already replaced. The
|
||||
// provider answers that with invalid_grant, which is the same thing
|
||||
// it says about a genuinely revoked grant: last_error gets written,
|
||||
// the settings page turns red, and every admin is told to go and
|
||||
// re-consent a connection that was never broken.
|
||||
//
|
||||
// So refresh one at a time per connection, and make whoever waited
|
||||
// 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
|
||||
// they can just use.
|
||||
$lock = Cache::lock('mail-oauth-refresh:'.$connection->provider->value, 30);
|
||||
|
||||
try {
|
||||
$lock->block(15);
|
||||
} catch (LockTimeoutException) {
|
||||
// Fifteen seconds means something is wrong with the lock rather
|
||||
// than with the provider. Racing is a false alarm; not sending is
|
||||
// a lost message. Take the race.
|
||||
$this->refresh($connection);
|
||||
|
||||
return (string) $connection->access_token;
|
||||
}
|
||||
|
||||
try {
|
||||
// Eloquent's refresh(), re-reading the row — not this class's,
|
||||
// which is the thing the lock exists to serialise.
|
||||
$connection->refresh();
|
||||
|
||||
if ($this->stillUsable($connection)) {
|
||||
return (string) $connection->access_token;
|
||||
}
|
||||
|
||||
$this->refresh($connection);
|
||||
|
||||
return (string) $connection->access_token;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the stored access token has enough life left to send with. */
|
||||
private function stillUsable(MailOAuthConnection $connection): bool
|
||||
{
|
||||
$token = $connection->access_token;
|
||||
$expiresAt = $connection->token_expires_at;
|
||||
|
||||
return is_string($token)
|
||||
&& $token !== ''
|
||||
&& $expiresAt !== null
|
||||
&& $expiresAt->gt(now()->addSeconds(self::EXPIRY_MARGIN_SECONDS));
|
||||
}
|
||||
|
||||
private function storeTokens(MailOAuthConnection $connection, Response $response): void
|
||||
{
|
||||
$accessToken = $response->json('access_token');
|
||||
$expiresIn = $response->json('expires_in');
|
||||
|
||||
if (! is_string($accessToken) || $accessToken === '') {
|
||||
throw new MailOAuthException('The token response did not include an access token.');
|
||||
}
|
||||
|
||||
$connection->access_token = $accessToken;
|
||||
$connection->token_expires_at = now()->addSeconds(is_numeric($expiresIn) ? (int) $expiresIn : 3600);
|
||||
|
||||
// Microsoft rotates the refresh token on every use; Google hands
|
||||
// one out only on the initial consent. Same rule covers both: a
|
||||
// response without one keeps what is already stored.
|
||||
$newRefreshToken = $response->json('refresh_token');
|
||||
if (is_string($newRefreshToken) && $newRefreshToken !== '') {
|
||||
$connection->refresh_token = $newRefreshToken;
|
||||
}
|
||||
|
||||
$email = $this->emailFromIdToken($response->json('id_token'));
|
||||
if ($email !== null) {
|
||||
$connection->account_email = $email;
|
||||
}
|
||||
|
||||
$connection->last_refreshed_at = now();
|
||||
$connection->last_error = null;
|
||||
$connection->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in mailbox's address, read from the id_token's claims
|
||||
* (`preferred_username` on Microsoft, `email` on Google).
|
||||
*
|
||||
* Deliberately without signature verification: this token arrived in
|
||||
* the token endpoint's own TLS response — not from the browser — and
|
||||
* feeds a display/From value, not an authentication decision. That
|
||||
* is the trade that lets sending work with the send scope alone,
|
||||
* with no userinfo permission.
|
||||
*/
|
||||
private function emailFromIdToken(mixed $idToken): ?string
|
||||
{
|
||||
if (! is_string($idToken) || substr_count($idToken, '.') !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[, $payload] = explode('.', $idToken);
|
||||
|
||||
$decoded = base64_decode(strtr($payload, '-_', '+/'), true);
|
||||
|
||||
if ($decoded === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$claims = json_decode($decoded, true);
|
||||
|
||||
if (! is_array($claims)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (['preferred_username', 'email'] as $claim) {
|
||||
$value = $claims[$claim] ?? null;
|
||||
|
||||
if (is_string($value) && str_contains($value, '@')) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function failure(Response $response): MailOAuthException
|
||||
{
|
||||
$error = $response->json('error');
|
||||
$description = $response->json('error_description');
|
||||
|
||||
$message = is_string($description) && $description !== ''
|
||||
? $description
|
||||
: (is_string($error) && $error !== '' ? $error : 'The token endpoint answered HTTP '.$response->status().'.');
|
||||
|
||||
// Both vendors speak RFC 6749 here. invalid_grant covers
|
||||
// everything that kills a grant: revoked consent, a password
|
||||
// reset or Conditional Access change (Microsoft), the 7-day
|
||||
// testing-status expiry (Google). invalid_client means the app
|
||||
// registration itself (secret expired?) — also unfixable by
|
||||
// retry.
|
||||
$needsReconnect = in_array($error, ['invalid_grant', 'invalid_client'], true);
|
||||
|
||||
return new MailOAuthException($message, needsReconnect: $needsReconnect);
|
||||
}
|
||||
}
|
||||
@@ -126,8 +126,11 @@ class QuickStart
|
||||
];
|
||||
}
|
||||
|
||||
// Community only, and the example the brief named: a managed
|
||||
// installation has no staff accounts of its own to hand out.
|
||||
// Both editions since 2.2.0. A managed installation was once the
|
||||
// example of a site with no staff accounts of its own to hand out;
|
||||
// it is sold seats and fills them itself now, so this step belongs
|
||||
// on its list too. The capability stays in the condition as the
|
||||
// seam an edition difference would travel through.
|
||||
if ($this->permissions->allows($user, Permission::CreateUsers)
|
||||
&& $this->capabilities->has(Capability::UsersManage)) {
|
||||
$items[] = [
|
||||
|
||||
@@ -13,9 +13,14 @@ use App\Modules\Platform\Captcha\Console\DisableCaptchaCommand;
|
||||
use App\Modules\Platform\Captcha\Console\TestCaptchaCommand;
|
||||
use App\Modules\Platform\Localization\LocaleRegistry;
|
||||
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||
use App\Modules\Platform\Mail\Console\RefreshMailOAuthTokensCommand;
|
||||
use App\Modules\Platform\Mail\GmailTransport;
|
||||
use App\Modules\Platform\Mail\MicrosoftGraphTransport;
|
||||
use App\Modules\Platform\News\Console\FetchNewsCommand;
|
||||
use App\Modules\Platform\Notifications\ThemedMailChannel;
|
||||
use App\Modules\Platform\Scheduling\Console\PurgeFailedJobsCommand;
|
||||
use App\Modules\Platform\Installation\Console\StatusCommand;
|
||||
use App\Modules\Platform\Settings\Console\SeedSettingsCommand;
|
||||
use App\Modules\Platform\Scheduling\RecordsScheduledTaskRuns;
|
||||
use App\Modules\Platform\Settings\ExternalStorageConfigApplier;
|
||||
use App\Modules\Platform\Settings\MailConfigApplier;
|
||||
@@ -30,6 +35,7 @@ use Illuminate\Console\Events\ScheduledTaskFailed;
|
||||
use Illuminate\Console\Events\ScheduledTaskFinished;
|
||||
use Illuminate\Notifications\Channels\MailChannel;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class PlatformServiceProvider extends ServiceProvider
|
||||
@@ -79,12 +85,21 @@ class PlatformServiceProvider extends ServiceProvider
|
||||
DisableCaptchaCommand::class,
|
||||
TestCaptchaCommand::class,
|
||||
PurgeFailedJobsCommand::class,
|
||||
SeedSettingsCommand::class,
|
||||
StatusCommand::class,
|
||||
RefreshMailOAuthTokensCommand::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
// Registered before apply() below can select one as the default
|
||||
// mailer. The closures resolve lazily on first send, so booting
|
||||
// never pays for a transport nobody uses.
|
||||
Mail::extend('microsoft-graph', fn (): MicrosoftGraphTransport => $this->app->make(MicrosoftGraphTransport::class));
|
||||
Mail::extend('gmail-api', fn (): GmailTransport => $this->app->make(GmailTransport::class));
|
||||
|
||||
// Every process boot (a web request, or a freshly (re)started
|
||||
// queue worker) picks up the admin-configured mail provider, if
|
||||
// any — a no-op until the Email settings page is actually saved.
|
||||
@@ -125,6 +140,17 @@ class PlatformServiceProvider extends ServiceProvider
|
||||
url: fn (array $data) => route('dashboard'),
|
||||
));
|
||||
|
||||
// In-app only, like update_available — deliberately not mail:
|
||||
// this fires precisely when outgoing mail is broken, so an email
|
||||
// companion would either vanish into the dead transport or (on
|
||||
// the scheduled check) fail the very job reporting the problem.
|
||||
$this->app->make(NotificationTypeRegistry::class)->register(new NotificationTypeDefinition(
|
||||
key: 'mail_oauth_connection_broken',
|
||||
label: 'The connected mailbox can no longer send email',
|
||||
template: 'The :provider mailbox connection (:account) stopped working and needs to be reconnected',
|
||||
url: fn (array $data) => route('system-settings.email.edit'),
|
||||
));
|
||||
|
||||
// Core's free themes — available in every edition, gated by
|
||||
// nothing. A genuinely edition-exclusive theme would instead
|
||||
// register into these same singletons from a private package's
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Seats;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\UserType;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* How many accounts this installation may hold, and how many it holds.
|
||||
*
|
||||
* Unlimited unless an operator says otherwise, which is every self-hosted
|
||||
* install. A managed one is sold a number of staff seats and a number of
|
||||
* clients, and this is the only process that can count against it — the
|
||||
* platform knows what it sold, the installation knows what exists.
|
||||
*
|
||||
* ### One definition, two consumers
|
||||
*
|
||||
* The number this refuses on is the number to display. A control plane
|
||||
* showing "2 of 3 seats used" from its own query, next to an application
|
||||
* refusing the fourth from a different one, will disagree eventually —
|
||||
* over an inactive account, or a deleted one — and the disagreement looks
|
||||
* like a billing fault rather than a counting one. So `staffUsed()` and
|
||||
* `clientUsed()` are public and are what `guard*()` reads.
|
||||
*
|
||||
* That second consumer stopped being hypothetical on 2026-08-27: the
|
||||
* hosted fleet console shows these numbers per tenant, read from
|
||||
* `projectsend:status --json`. So the rules below are load-bearing on a
|
||||
* screen support staff read, and changing one changes what they are told
|
||||
* before it changes what a customer hits.
|
||||
*
|
||||
* ### What counts
|
||||
*
|
||||
* A soft-deleted account does not. Its address stays reserved until
|
||||
* erasure (see AvailableEmailRule), so freeing a seat and re-adding the
|
||||
* same person can still be refused — that is the address rule, not this
|
||||
* one.
|
||||
*
|
||||
* An **inactive** staff account does count. Deactivating is one click from
|
||||
* reactivating, so excluding it would make deactivation a way around the
|
||||
* cap rather than a way to revoke access. The consequence is worth stating
|
||||
* because somebody has to explain it: deactivating is the safe removal and
|
||||
* does not free a seat; deleting frees it and asks what happens to the
|
||||
* files.
|
||||
*
|
||||
* A client **awaiting approval** does not count. Self-registration is open
|
||||
* to strangers, so counting a pending request would let anyone exhaust a
|
||||
* paid limit from the outside — turning a pricing tier into an
|
||||
* availability control. Approving one is the moment it becomes a client
|
||||
* the installation has taken on, and that is where the guard sits.
|
||||
*
|
||||
* ### A cap is only a cap if every door asks
|
||||
*
|
||||
* There is no single `User::create()` these funnel through, so this is
|
||||
* asked in several places and has a test per door. That is
|
||||
* DownloadAllowance's shape, for DownloadAllowance's reason: the failure
|
||||
* mode is one of them quietly not asking, and it is invisible from
|
||||
* everywhere except the door that forgot.
|
||||
*/
|
||||
class SeatAllowance
|
||||
{
|
||||
/**
|
||||
* Staff accounts allowed, or null when unlimited.
|
||||
*/
|
||||
public function staffLimit(): ?int
|
||||
{
|
||||
return $this->limit('max_staff_users');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clients allowed, or null when unlimited.
|
||||
*/
|
||||
public function clientLimit(): ?int
|
||||
{
|
||||
return $this->limit('max_clients');
|
||||
}
|
||||
|
||||
public function staffUsed(): int
|
||||
{
|
||||
return User::query()->where('type', UserType::Staff)->count();
|
||||
}
|
||||
|
||||
public function clientUsed(): int
|
||||
{
|
||||
return User::query()
|
||||
->where('type', UserType::Client)
|
||||
->where('account_requested', false)
|
||||
->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* The staff seat position, for a screen rather than a guard.
|
||||
*
|
||||
* Null on a self-hosted install: there is no limit, so there is
|
||||
* nothing for a screen to say about one.
|
||||
*
|
||||
* @return array{limit: int, used: int, full: bool, message: string|null}|null
|
||||
*/
|
||||
public function staffState(): ?array
|
||||
{
|
||||
return $this->state($this->staffLimit(), $this->staffUsed(), fn (): string => $this->staffFullMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{limit: int, used: int, full: bool, message: string|null}|null
|
||||
*/
|
||||
public function clientState(): ?array
|
||||
{
|
||||
return $this->state($this->clientLimit(), $this->clientUsed(), fn (): string => $this->clientFullMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ValidationException when one more staff account would exceed
|
||||
* what this installation may hold.
|
||||
*/
|
||||
public function guardStaff(string $field = 'email'): void
|
||||
{
|
||||
$limit = $this->staffLimit();
|
||||
|
||||
if ($limit === null || $this->staffUsed() < $limit) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([$field => $this->staffFullMessage()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ValidationException when one more client would exceed what
|
||||
* this installation may hold.
|
||||
*/
|
||||
public function guardClient(string $field = 'email'): void
|
||||
{
|
||||
$limit = $this->clientLimit();
|
||||
|
||||
if ($limit === null || $this->clientUsed() < $limit) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([$field => $this->clientFullMessage()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a screen is closed, in the words the guard would have used.
|
||||
*
|
||||
* A door that turns somebody away and a guard that refuses them are
|
||||
* the same rule met at two moments, so they say the same sentence. Two
|
||||
* wordings of one limit is how a person ends up believing there are
|
||||
* two limits.
|
||||
*/
|
||||
public function staffFullMessage(): string
|
||||
{
|
||||
return __('Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.', [
|
||||
'count' => (string) $this->staffLimit(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function clientFullMessage(): string
|
||||
{
|
||||
return __('Clients on this installation are limited to :count. Remove one, or ask for a larger plan.', [
|
||||
'count' => (string) $this->clientLimit(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* `full` is derived here rather than in each caller, and from the same
|
||||
* comparison `guard*()` refuses on. A screen that works out for itself
|
||||
* whether there is room can disagree with the guard about the edge --
|
||||
* `used > limit` after an operator lowers a limit is the obvious one --
|
||||
* and then the button is offered for a form that cannot be submitted,
|
||||
* which is the whole fault this is here to prevent.
|
||||
*
|
||||
* The message travels with the state so a screen never has to write
|
||||
* its own version of the refusal.
|
||||
*
|
||||
* @param callable(): string $message
|
||||
* @return array{limit: int, used: int, full: bool, message: string|null}|null
|
||||
*/
|
||||
private function state(?int $limit, int $used, callable $message): ?array
|
||||
{
|
||||
if ($limit === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$full = $used >= $limit;
|
||||
|
||||
return [
|
||||
'limit' => $limit,
|
||||
'used' => $used,
|
||||
'full' => $full,
|
||||
'message' => $full ? $message() : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Absent, empty and non-numeric all mean unlimited. An operator who
|
||||
* mistypes the variable gets the self-hosted behaviour rather than an
|
||||
* installation that refuses every account it is asked to create.
|
||||
*/
|
||||
private function limit(string $key): ?int
|
||||
{
|
||||
$raw = config("projectsend.platform.$key");
|
||||
|
||||
if ($raw === null || $raw === '' || ! is_numeric($raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return max(0, (int) $raw);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Settings\Console;
|
||||
|
||||
use App\Modules\Identity\TwoFactor\TwoFactorEnforcement;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use App\Modules\Platform\Settings\StoredSetting;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Seed a setting from the environment, once, at provision.
|
||||
*
|
||||
* Settings live in the database because they belong to whoever runs the
|
||||
* installation. A managed one has a moment before anybody runs it — the
|
||||
* first boot, where the entrypoint already creates the first administrator
|
||||
* from ADMIN_* — and a policy that has to exist before the account it
|
||||
* protects has to be written there or not at all.
|
||||
*
|
||||
* The case this exists for is two-factor enforcement. A platform wants it
|
||||
* on before the first seat, and the first seat is created in that same
|
||||
* boot. Left to a control plane calling in afterwards, there is a window
|
||||
* between the account existing and the policy covering it.
|
||||
*
|
||||
* ### Seeded, not overridden
|
||||
*
|
||||
* Writing only when nothing has been stored is the whole design. A value
|
||||
* that won on every boot would take the setting away from the
|
||||
* administrator it belongs to, and somebody who tightened it would find it
|
||||
* loosened again by a restart. Same shape as `projectsend:admin --if-none`,
|
||||
* which runs a line below this one in the entrypoint.
|
||||
*
|
||||
* ### Read through config, never env() directly
|
||||
*
|
||||
* `config:cache` stops `.env` being read at all, which is exactly how
|
||||
* TRUSTED_PROXIES came to have no effect on any web request while looking
|
||||
* correct in the file. Anything an operator sets has to arrive through a
|
||||
* config key or it works until somebody optimises the install.
|
||||
*
|
||||
* ### Deliberately not general
|
||||
*
|
||||
* No `PROJECTSEND_SETTING_<KEY>` mechanism. Every setting reachable from
|
||||
* outside is a setting whose value depends on where you look, and the
|
||||
* blast radius of getting that wrong is the whole settings table. One
|
||||
* named key per setting that needs it, added when it needs it.
|
||||
*/
|
||||
class SeedSettingsCommand extends Command
|
||||
{
|
||||
protected $signature = 'projectsend:seed-settings';
|
||||
|
||||
protected $description = 'Apply provisioning defaults from the environment to settings that have never been set';
|
||||
|
||||
public function handle(Settings $settings): int
|
||||
{
|
||||
$enforcement = config('projectsend.platform.two_factor_enforcement');
|
||||
|
||||
if (is_string($enforcement) && $enforcement !== '') {
|
||||
$this->seedTwoFactorEnforcement($settings, $enforcement);
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function seedTwoFactorEnforcement(Settings $settings, string $value): void
|
||||
{
|
||||
if (TwoFactorEnforcement::tryFrom($value) === null) {
|
||||
// Named rather than ignored. A typo here means a tenant
|
||||
// provisioned without the policy it was meant to have, and
|
||||
// silence would make that indistinguishable from success.
|
||||
$this->warn("PROJECTSEND_TWO_FACTOR_ENFORCEMENT='{$value}' is not one of none, staff, clients, all — leaving the setting alone.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Asked of the table rather than of Settings::get(), which cannot
|
||||
// tell a stored value apart from the enum's own default — and
|
||||
// 'none' is that default, so get() would report the thing we are
|
||||
// trying to detect the absence of.
|
||||
if (StoredSetting::query()->where('key', Setting::TwoFactorEnforcement->value)->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$settings->set(Setting::TwoFactorEnforcement, $value);
|
||||
|
||||
$this->info("Two-factor enforcement seeded to '{$value}' (first boot).");
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Modules\Platform\Settings;
|
||||
|
||||
use App\Modules\Platform\Capabilities\Capability;
|
||||
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||
use App\Modules\Platform\Mail\MailOAuthConnection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
@@ -36,7 +37,12 @@ class MailConfigApplier
|
||||
// rememberForever value under the old key would otherwise crash every
|
||||
// boot with "Undefined array key" (PlatformServiceProvider::boot()
|
||||
// calls apply() unconditionally). Bump again on any future shape change.
|
||||
private const CACHE_KEY = 'platform.mail_provider_settings.v2';
|
||||
// v3: OAuth provider fields added. Note what is deliberately NOT in
|
||||
// the cached shape: tokens. Transports read those fresh from the
|
||||
// connection row at send time — only readiness and the connected
|
||||
// address are cheap enough to be worth caching, and neither is a
|
||||
// credential.
|
||||
private const CACHE_KEY = 'platform.mail_provider_settings.v3';
|
||||
|
||||
public function __construct(
|
||||
private readonly CapabilityRegistry $capabilities,
|
||||
@@ -46,7 +52,17 @@ class MailConfigApplier
|
||||
{
|
||||
$resolved = $this->resolve();
|
||||
|
||||
if ($resolved['transport_configured'] && $this->capabilities->has(Capability::EmailTransportConfigure)) {
|
||||
if ($resolved['oauth_mailer'] !== null && $resolved['oauth_ready'] && $this->capabilities->has(Capability::EmailTransportConfigure)) {
|
||||
Config::set('mail.default', $resolved['oauth_mailer']);
|
||||
|
||||
// Delegated Graph/Gmail can only send as the mailbox that
|
||||
// consented, so the From address is pinned to it — a stored
|
||||
// from_address from an earlier SMTP setup must not survive
|
||||
// into a mode where the vendor would reject it (SendAsDenied).
|
||||
if ($resolved['oauth_account'] !== null) {
|
||||
Config::set('mail.from.address', $resolved['oauth_account']);
|
||||
}
|
||||
} elseif ($resolved['transport_configured'] && $this->capabilities->has(Capability::EmailTransportConfigure)) {
|
||||
Config::set('mail.default', 'smtp');
|
||||
Config::set('mail.mailers.smtp.host', $resolved['host']);
|
||||
Config::set('mail.mailers.smtp.port', $resolved['port']);
|
||||
@@ -55,7 +71,7 @@ class MailConfigApplier
|
||||
Config::set('mail.mailers.smtp.encryption', $resolved['encryption']);
|
||||
}
|
||||
|
||||
if ($resolved['from_address'] !== null) {
|
||||
if ($resolved['from_address'] !== null && ($resolved['oauth_mailer'] === null || ! $resolved['oauth_ready'])) {
|
||||
Config::set('mail.from.address', $resolved['from_address']);
|
||||
}
|
||||
|
||||
@@ -70,7 +86,7 @@ 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}
|
||||
* @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}
|
||||
*/
|
||||
private function resolve(): array
|
||||
{
|
||||
@@ -78,6 +94,7 @@ class MailConfigApplier
|
||||
'transport_configured' => false,
|
||||
'host' => null, 'port' => null, 'username' => null, 'password' => null, 'encryption' => null,
|
||||
'from_address' => null, 'from_name' => null,
|
||||
'oauth_mailer' => null, 'oauth_ready' => false, 'oauth_account' => null,
|
||||
];
|
||||
|
||||
// Through BootSettingsCache, not Cache directly: this runs on every
|
||||
@@ -92,8 +109,24 @@ class MailConfigApplier
|
||||
$settings = MailProviderSettings::current();
|
||||
$hasHost = $settings->host !== null && $settings->host !== '';
|
||||
|
||||
$oauthMailer = null;
|
||||
$oauthReady = false;
|
||||
$oauthAccount = null;
|
||||
|
||||
// The table guard covers an install mid-upgrade, where this
|
||||
// migration has not run yet but the settings row already
|
||||
// names an OAuth provider (it can't, but a guard beats a
|
||||
// boot-killing query on the ordering assumption).
|
||||
if ($settings->provider->isOAuth() && Schema::hasTable('mail_oauth_connections')) {
|
||||
$connection = MailOAuthConnection::for($settings->provider);
|
||||
|
||||
$oauthMailer = $settings->provider->oauthMailer();
|
||||
$oauthReady = $connection->usable();
|
||||
$oauthAccount = $connection->account_email;
|
||||
}
|
||||
|
||||
return [
|
||||
'transport_configured' => $hasHost,
|
||||
'transport_configured' => $hasHost && ! $settings->provider->isOAuth(),
|
||||
'host' => $settings->host,
|
||||
'port' => $settings->port,
|
||||
'username' => $settings->username,
|
||||
@@ -101,6 +134,9 @@ class MailConfigApplier
|
||||
'encryption' => $settings->encryption === 'none' ? null : $settings->encryption,
|
||||
'from_address' => $settings->from_address,
|
||||
'from_name' => $settings->from_name,
|
||||
'oauth_mailer' => $oauthMailer,
|
||||
'oauth_ready' => $oauthReady,
|
||||
'oauth_account' => $oauthAccount,
|
||||
];
|
||||
}, $blank);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,17 @@ declare(strict_types=1);
|
||||
namespace App\Modules\Platform\Settings;
|
||||
|
||||
/**
|
||||
* A preset picker over the single generic SMTP transport — every provider
|
||||
* here supports SMTP relay, so selecting one just pre-fills the well-known
|
||||
* host/port; the app always sends via Laravel's "smtp" mailer regardless
|
||||
* of which preset was picked. Custom covers anything else ("etc").
|
||||
* The choices in the Email settings "Provider" dropdown.
|
||||
*
|
||||
* Two kinds share the one list, distinguished by isOAuth(): the SMTP
|
||||
* presets (every one of them supports SMTP relay, so selecting one just
|
||||
* pre-fills the well-known host/port — the app sends via Laravel's "smtp"
|
||||
* mailer regardless of which was picked; Custom covers anything else),
|
||||
* and the OAuth API providers, which switch the transport itself to a
|
||||
* dedicated mailer that talks the vendor's HTTP API with tokens from
|
||||
* MailOAuthConnection instead of a password. One dropdown rather than a
|
||||
* separate screen because "where does outgoing email go" should have
|
||||
* exactly one answer.
|
||||
*/
|
||||
enum MailProvider: string
|
||||
{
|
||||
@@ -17,6 +24,8 @@ enum MailProvider: string
|
||||
case Mailgun = 'mailgun';
|
||||
case Postmark = 'postmark';
|
||||
case AmazonSes = 'ses';
|
||||
case Microsoft365 = 'microsoft365';
|
||||
case Gmail = 'gmail';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
@@ -26,13 +35,15 @@ enum MailProvider: string
|
||||
self::Mailgun => 'Mailgun',
|
||||
self::Postmark => 'Postmark',
|
||||
self::AmazonSes => 'Amazon SES',
|
||||
self::Microsoft365 => 'Microsoft 365 (OAuth)',
|
||||
self::Gmail => 'Google / Gmail (OAuth)',
|
||||
};
|
||||
}
|
||||
|
||||
public function defaultHost(): ?string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Custom => null,
|
||||
self::Custom, self::Microsoft365, self::Gmail => null,
|
||||
self::SendGrid => 'smtp.sendgrid.net',
|
||||
self::Mailgun => 'smtp.mailgun.org',
|
||||
self::Postmark => 'smtp.postmarkapp.com',
|
||||
@@ -43,8 +54,45 @@ enum MailProvider: string
|
||||
public function defaultPort(): ?int
|
||||
{
|
||||
return match ($this) {
|
||||
self::Custom => null,
|
||||
self::Custom, self::Microsoft365, self::Gmail => null,
|
||||
self::SendGrid, self::Mailgun, self::Postmark, self::AmazonSes => 587,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this provider sends through a vendor HTTP API authorized by
|
||||
* an admin-connected mailbox (MailOAuthConnection) rather than through
|
||||
* the generic SMTP transport.
|
||||
*/
|
||||
public function isOAuth(): bool
|
||||
{
|
||||
return $this === self::Microsoft365 || $this === self::Gmail;
|
||||
}
|
||||
|
||||
/**
|
||||
* The custom Laravel mailer this provider sends through — the name
|
||||
* registered via Mail::extend() and declared in config/mail.php.
|
||||
*/
|
||||
public function oauthMailer(): ?string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Microsoft365 => 'microsoft-graph',
|
||||
self::Gmail => 'gmail-api',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the connect flow needs a directory/tenant to build its
|
||||
* endpoints. Microsoft's authorize/token URLs are tenant-scoped;
|
||||
* blank falls back to 'common', which admits work/school accounts of
|
||||
* any tenant plus personal accounts — the inclusive default for this
|
||||
* app's audience. Unlike social login's tenant pinning this is not a
|
||||
* security control: the flow is started by an administrator and the
|
||||
* resulting token can only send as the one mailbox that consented.
|
||||
*/
|
||||
public function needsTenant(): bool
|
||||
{
|
||||
return $this === self::Microsoft365;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,13 @@ enum Setting: string
|
||||
// comment (0 disables editing entirely).
|
||||
case CommentsEditWindowMinutes = 'comments_edit_window_minutes';
|
||||
|
||||
// Largest zip download that can be asked for, in MB (0 = unlimited).
|
||||
// The cap is on total size rather than on file count because bytes are
|
||||
// what a build actually costs — worker time, the temp copies a remote
|
||||
// disk needs, and the archive itself. The file count has its own,
|
||||
// deliberately fixed, rail in ZipDownloadsController::MAX_FILES.
|
||||
case MaxZipDownloadSizeMb = 'max_zip_download_size_mb';
|
||||
|
||||
// Whether a download's IP is recorded in the activity log (all |
|
||||
// anonymous_only | none). Only affects Action::FileDownloaded /
|
||||
// ShareLinkDownloaded entries — see ActivityLogger::shouldRecordIp().
|
||||
@@ -130,9 +137,9 @@ enum Setting: string
|
||||
case FailedJobRetentionDays = 'failed_job_retention_days';
|
||||
case NotificationRetentionDays = 'notification_retention_days';
|
||||
|
||||
// How many days a self-deleted account is retained (soft-deleted)
|
||||
// before PurgeErasuresCommand permanently erases it. Consumed by
|
||||
// ProfileController.
|
||||
// How many days a deleted account is retained (soft-deleted) before
|
||||
// PurgeErasuresCommand permanently erases it. Consumed by
|
||||
// ErasureSchedule, which every deletion path calls.
|
||||
case AccountErasureGraceDays = 'account_erasure_grace_days';
|
||||
|
||||
// What the unattended erasure does with the files and folders a purged
|
||||
@@ -374,6 +381,7 @@ enum Setting: string
|
||||
self::ClientsAutoGroup,
|
||||
self::ClientsMembershipDenyCooldownDays,
|
||||
self::MaxFileSizeMb,
|
||||
self::MaxZipDownloadSizeMb,
|
||||
self::DefaultClientStorageQuotaMb,
|
||||
self::AccountErasureGraceDays,
|
||||
self::AccountErasureReassignTo,
|
||||
@@ -442,6 +450,7 @@ enum Setting: string
|
||||
self::ClientsAutoGroup => 0,
|
||||
self::ClientsMembershipDenyCooldownDays => 30,
|
||||
self::MaxFileSizeMb => 2048,
|
||||
self::MaxZipDownloadSizeMb => 2048,
|
||||
self::DefaultClientStorageQuotaMb => 0,
|
||||
self::AccountErasureGraceDays => 30,
|
||||
self::AccountErasureReassignTo => 0,
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Support;
|
||||
|
||||
use App\Modules\Platform\Captcha\Captcha;
|
||||
use App\Modules\Platform\Captcha\CaptchaForm;
|
||||
use App\Modules\Files\Folders\FolderExistsRule;
|
||||
use App\Modules\Platform\Captcha\CaptchaRule;
|
||||
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||
use Illuminate\Validation\Rule;
|
||||
@@ -50,6 +51,43 @@ class Rules
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule for an id naming a library folder.
|
||||
*
|
||||
* Shared because the plain `exists:folders,id` it replaces is not
|
||||
* true: Folder uses SoftDeletes, and the presence check runs against
|
||||
* the table, so a folder in the trash passes it. Every caller then
|
||||
* reads the rule as "this folder exists" and behaves accordingly —
|
||||
* and the ones that resolve the id afterwards resolve it through
|
||||
* Folder::query(), which does honour the soft delete, so the guard
|
||||
* sees no folder at all while the value that reaches the write is
|
||||
* still the id.
|
||||
*
|
||||
* FilesController::store() and Api\FilesController::store() ended up
|
||||
* filing an upload into a deleted folder that way: the guard read
|
||||
* null and allowed it as a root upload, and the row was written with
|
||||
* the id. Deleting a folder deletes every file in its subtree, so
|
||||
* that is a live file inside a folder whose deletion already removed
|
||||
* everything in it — reachable by id, in search and over the API,
|
||||
* and absent from the listing its uploader would look in.
|
||||
*
|
||||
* Making the rule mean what its readers already assume fixes those
|
||||
* and leaves the paths that resolve through StaffLibraryScope alone;
|
||||
* they refuse a trashed id today by a longer route.
|
||||
*
|
||||
* Presence is the caller's business, as with slug() above: spread it
|
||||
* behind `sometimes` where a PATCH may omit the field.
|
||||
*
|
||||
* A rule object rather than a conditional `exists`, so the refusal can
|
||||
* say why — see FolderExistsRule.
|
||||
*
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
public static function folderId(): array
|
||||
{
|
||||
return ['nullable', 'integer', new FolderExistsRule];
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule for an IANA timezone identifier.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* A redirect answering a write has to be a 303, not a 302.
|
||||
*
|
||||
* A browser follows a 302 by replaying the request method on the new
|
||||
* location — POST is the only one it downgrades to GET. So a PUT that
|
||||
* gets redirected to the login page is replayed as `PUT /login`, which
|
||||
* accepts only GET and POST, and the person is shown a 405 instead of
|
||||
* the one thing they needed to read: sign in again. 303 means "see
|
||||
* other, and follow it with GET", which is the only sensible next step
|
||||
* after a write.
|
||||
*
|
||||
* Inertia's own middleware already does this for responses that pass
|
||||
* back through it. Two kinds never do, which is the whole reason this
|
||||
* exists:
|
||||
*
|
||||
* - A redirect rendered during **exception handling** — the guest
|
||||
* redirect after AuthenticationException above all — never travels
|
||||
* back through the middleware stack at all.
|
||||
* - A redirect returned early by middleware that runs *before*
|
||||
* HandleInertiaRequests: EnsureSetupIsComplete, EnsureAccountIsActive
|
||||
* and EnforceTwoFactor. A response only unwinds through middleware it
|
||||
* already entered, and those three answer before Inertia's is reached.
|
||||
*
|
||||
* Reads are left alone. A 302 answering a GET is correct, and replaying
|
||||
* a GET is exactly the right thing to do.
|
||||
*
|
||||
* See issue #1673 and pull request #1680, which found and fixed the
|
||||
* first of the two cases; this is the same rule, kept in one place so
|
||||
* the second could not drift from it.
|
||||
*/
|
||||
final class WriteSafeRedirect
|
||||
{
|
||||
/**
|
||||
* The methods a browser replays verbatim when following a 302.
|
||||
*
|
||||
* POST is deliberately absent: browsers already downgrade it to GET,
|
||||
* which is why form posts never showed this bug and only the
|
||||
* Inertia-style PUT/PATCH/DELETE saves did.
|
||||
*/
|
||||
private const REPLAYED_METHODS = ['PUT', 'PATCH', 'DELETE'];
|
||||
|
||||
public static function apply(Request $request, Response $response): Response
|
||||
{
|
||||
if ($response->getStatusCode() === Response::HTTP_FOUND
|
||||
&& in_array($request->method(), self::REPLAYED_METHODS, true)) {
|
||||
$response->setStatusCode(Response::HTTP_SEE_OTHER);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,13 @@ use App\Modules\Identity\Http\Middleware\EnsureSetupIsComplete;
|
||||
use App\Modules\Identity\Http\Middleware\EnsureStaff;
|
||||
use App\Modules\Platform\Http\Middleware\EnsureCapability;
|
||||
use App\Modules\Platform\Http\Middleware\SetLocale;
|
||||
use App\Support\WriteSafeRedirect;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Session\Middleware\AuthenticateSession;
|
||||
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
@@ -113,4 +115,14 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
? $problems->render($request, $e)
|
||||
: null;
|
||||
});
|
||||
|
||||
// A redirect born in exception handling — the guest redirect after
|
||||
// an expired login, above all — never travels back through the
|
||||
// middleware stack, so Inertia's usual 302→303 upgrade cannot reach
|
||||
// it. WriteSafeRedirect explains why that matters and holds the
|
||||
// rule; the same three middleware that answer before Inertia's is
|
||||
// reached apply it too.
|
||||
$exceptions->respond(
|
||||
fn (SymfonyResponse $response, Throwable $e, Request $request): SymfonyResponse => WriteSafeRedirect::apply($request, $response)
|
||||
);
|
||||
})->create();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user