mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-03 21:03:17 +00:00
Compare commits
184 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 | |||
| 55e17498a2 | |||
| a459d45c87 | |||
| b22d3cf33c | |||
| f7db586c7e | |||
| 23b7dc0d11 | |||
| 4737849ec5 | |||
| daec0a877e | |||
| 57540164fa | |||
| 8b59bb2a5a | |||
| 6a8a287984 | |||
| 457fed0c86 | |||
| 4f38c9adee | |||
| f7d6fe929e | |||
| 1030f719fc | |||
| 19d34ef38b | |||
| 4eb8cf915a | |||
| 933eaa2ba4 | |||
| fbd6c3603d | |||
| 5bfc5a0883 | |||
| 94e4aa36e4 | |||
| 027e8532d2 | |||
| 2a82335e07 | |||
| 1aaab1bf66 | |||
| 503676647f | |||
| 5a7c9938dd | |||
| d1d1999216 | |||
| 51eea30dda | |||
| c18f2f0f73 | |||
| 3d6089a501 | |||
| 8f12c83d21 | |||
| 11e6876826 | |||
| 88c182cf3b | |||
| 30f66cff2b | |||
| cca3d9c314 | |||
| 7d1903f9db | |||
| 6b76c11192 | |||
| 283c79bcd6 | |||
| 1b6513f0fb | |||
| cb67a15e81 | |||
| 1c62036ed2 | |||
| 5e23474e8b | |||
| 202a1d7ad5 | |||
| 8b5d480670 | |||
| e279e83fd0 | |||
| 318550f866 | |||
| 9c991f495d |
@@ -61,6 +61,15 @@ SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
|
||||
# Set this only if your web server and PHP-FPM run as different system
|
||||
# users — common on cPanel/Plesk shared hosting. Uploaded files are
|
||||
# written 0600 in 0700 directories, which nginx cannot read, and since
|
||||
# nginx is what actually streams a download (PHP authorizes, then hands
|
||||
# it the path) every download fails while the rest of the site works.
|
||||
# Relaxes those to 0644/0755, which every account on the machine can
|
||||
# read — leave it off if your web server and PHP are the same user.
|
||||
# FILES_WEB_SERVER_READABLE=true
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
CACHE_STORE=redis
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
+45
-25
@@ -5,13 +5,33 @@ on:
|
||||
branches:
|
||||
- develop
|
||||
- main
|
||||
paths:
|
||||
# Only the frontend is actually checked here, so only the frontend
|
||||
# needs to trigger it.
|
||||
- 'resources/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'eslint.config.js'
|
||||
- '.prettierrc*'
|
||||
- 'tsconfig.json'
|
||||
- '.github/workflows/lint.yml'
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
- main
|
||||
paths:
|
||||
- 'resources/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'eslint.config.js'
|
||||
- '.prettierrc*'
|
||||
- 'tsconfig.json'
|
||||
- '.github/workflows/lint.yml'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
# A second push supersedes the first.
|
||||
concurrency:
|
||||
group: linter-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
@@ -19,32 +39,32 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
php-version: '8.4'
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
# community-modules resolves from its public GitHub repository (the vcs
|
||||
# entry in composer.json); cloud-modules is not required. COMPOSER_AUTH
|
||||
# just lifts the anonymous GitHub API rate limit for the fetch.
|
||||
- name: Install Dependencies
|
||||
env:
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GITHUB_TOKEN }}"}}'
|
||||
run: |
|
||||
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
|
||||
npm install
|
||||
|
||||
- name: Run Pint
|
||||
run: vendor/bin/pint
|
||||
|
||||
- name: Format Frontend
|
||||
run: npm run format
|
||||
run: npm ci
|
||||
|
||||
# `eslint .` rather than `npm run lint`, which is `eslint . --fix`:
|
||||
# a formatter that rewrites the checkout and throws the result away
|
||||
# cannot fail a build, so it was never a gate. This one is.
|
||||
- name: Lint Frontend
|
||||
run: npm run lint
|
||||
run: npx eslint .
|
||||
|
||||
# - name: Commit Changes
|
||||
# uses: stefanzweifel/git-auto-commit-action@v5
|
||||
# with:
|
||||
# commit_message: fix code style
|
||||
# commit_options: '--no-verify'
|
||||
# Two steps used to live here and were removed on 2026-08-23, because
|
||||
# neither could ever fail:
|
||||
#
|
||||
# - `vendor/bin/pint`, without `--test` and with the auto-commit step
|
||||
# commented out. It reformatted the runner's checkout, exited 0 and
|
||||
# threw the result away — 150 seconds of this job's 195, gating
|
||||
# nothing. Reinstating it as a real gate means `pint --test`, which
|
||||
# today reports around a hundred pre-existing failures; the honest
|
||||
# order is a formatting sweep first, then the flag.
|
||||
#
|
||||
# - `npm run format`, which is `prettier --write`, for the same reason.
|
||||
# `prettier --check` currently reports 44 files, so the same applies:
|
||||
# sweep, then switch. `npm run format:check` is the command.
|
||||
#
|
||||
# Dropping them also let the PHP toolchain go: nothing left here needs it.
|
||||
|
||||
@@ -5,10 +5,61 @@ on:
|
||||
branches:
|
||||
- develop
|
||||
- main
|
||||
paths-ignore:
|
||||
# Files no code reads and no test covers. Deliberately NOT listed:
|
||||
# CHANGELOG.md, which ReleaseNotes parses and ReleaseNotesTest
|
||||
# covers, and docs/, whose only two tracked files are served by
|
||||
# ApiDocsController and OpenApiController. A malformed edit to
|
||||
# either is exactly the thing that must not skip the suite.
|
||||
#
|
||||
# Repeated verbatim under pull_request: GitHub Actions does not
|
||||
# support YAML anchors.
|
||||
- 'README.md'
|
||||
- 'CONTRIBUTING.md'
|
||||
- 'SECURITY.md'
|
||||
- 'LICENSING.md'
|
||||
- 'CLA-ENTITY.md'
|
||||
- 'CLA-INDIVIDUAL.md'
|
||||
- 'INSTALL.md'
|
||||
- 'UPDATE.md'
|
||||
- 'DOCKER.md'
|
||||
- 'MIGRATING-FROM-V1.md'
|
||||
- 'docker/production/dockerhub-overview.md'
|
||||
- '.github/screenshots/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
- main
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
- 'CONTRIBUTING.md'
|
||||
- 'SECURITY.md'
|
||||
- 'LICENSING.md'
|
||||
- 'CLA-ENTITY.md'
|
||||
- 'CLA-INDIVIDUAL.md'
|
||||
- 'INSTALL.md'
|
||||
- 'UPDATE.md'
|
||||
- 'DOCKER.md'
|
||||
- 'MIGRATING-FROM-V1.md'
|
||||
- 'docker/production/dockerhub-overview.md'
|
||||
- '.github/screenshots/**'
|
||||
|
||||
# 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.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
@@ -87,5 +138,17 @@ jobs:
|
||||
- name: Static Analysis
|
||||
run: ./vendor/bin/phpstan analyse --no-progress
|
||||
|
||||
# `--parallel` rather than a shorter suite. One process took 191s of
|
||||
# this job's 4m30s; the same 1763 tests across the runner's cores
|
||||
# take about a third of that, with nothing skipped. paratest is
|
||||
# already a dev dependency (via Pest), so this needs no new install.
|
||||
#
|
||||
# `:memory:` explicitly: parallel testing gives each process its own
|
||||
# database, and an in-memory one per process is what the suite is
|
||||
# verified against locally. The job-level DB_DATABASE above is a file
|
||||
# path, which parallel workers would have to create and migrate
|
||||
# individually — a difference in behaviour with nothing to gain.
|
||||
- name: Tests
|
||||
run: ./vendor/bin/pest
|
||||
run: ./vendor/bin/pest --parallel
|
||||
env:
|
||||
DB_DATABASE: ':memory:'
|
||||
|
||||
@@ -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/
|
||||
|
||||
+134
@@ -13,6 +13,140 @@ 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.
|
||||
|
||||
## 2.2.1 — 28 August 2026
|
||||
|
||||
A security release. Most of it closes ways somebody could reach past a boundary the rest of the
|
||||
application already enforced — including two that could lock you out of your own installation.
|
||||
|
||||
**Merged**
|
||||
|
||||
- [#1708](https://github.com/projectsend/projectsend/pull/1708) — Let an enforced user reach the far side of the confirm-password screen
|
||||
- [#1716](https://github.com/projectsend/projectsend/pull/1716) — Refuse the last administrator deleting themselves, and keep setup shut
|
||||
- [#1710](https://github.com/projectsend/projectsend/pull/1710) — Stop a folder deleting the files inside it that its owner may not delete
|
||||
- [#1714](https://github.com/projectsend/projectsend/pull/1714) — Hold the group edit screen to the same library boundary as the rest
|
||||
- [#1717](https://github.com/projectsend/projectsend/pull/1717) — Keep a private reply private after the client is deleted
|
||||
- [#1713](https://github.com/projectsend/projectsend/pull/1713) — Refuse self-deactivation over the API however the boolean is written
|
||||
- [#1709](https://github.com/projectsend/projectsend/pull/1709) — Ask the seat cap where a pending client is approved through edit()
|
||||
- [#1715](https://github.com/projectsend/projectsend/pull/1715) — Add a file to a zip once, however many ways the selection reaches it
|
||||
- [#1707](https://github.com/projectsend/projectsend/pull/1707) — Leave the test workflow one concurrency block, so it parses again
|
||||
- [#1711](https://github.com/projectsend/projectsend/pull/1711) — Stop the update tests emptying bootstrap/cache for every other worker
|
||||
- [#1712](https://github.com/projectsend/projectsend/pull/1712) — Make the storage durability dashboard test assert the verdict
|
||||
|
||||
**Also fixed**
|
||||
|
||||
- The plain-text version of an email no longer shows the link twice, wrapped in brackets.
|
||||
- The message you get when an account would exceed a limit no longer reads "limited to 1 staff
|
||||
accounts".
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
- **Nothing to do.** Drop in the new files and run `php artisan migrate` as usual; this release adds
|
||||
no migrations, no settings and no new environment values.
|
||||
|
||||
- **One thing changes behaviour.** If somebody on your team has been deleting a folder as a way of
|
||||
clearing out files other people uploaded, that now refuses and says how many files are in the way.
|
||||
It is the same rule the file list has always applied one screen over — the folder was the way
|
||||
around it, and what it removed was not recoverable.
|
||||
|
||||
Thanks to [@denkfabrik-li](https://github.com/denkfabrik-li), who reported, diagnosed and fixed
|
||||
every one of the above.
|
||||
|
||||
### Issues closed since 2.2.0
|
||||
|
||||
The summary above is what changed. This is the paper trail, for anyone who wants to read the
|
||||
original report.
|
||||
|
||||
- [#1706](https://github.com/projectsend/projectsend/issues/1706) — V1 migration imports $2a$ bcrypt hashes that cause HTTP 500 on login
|
||||
|
||||
## 2.2.0 — 27 August 2026
|
||||
|
||||
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
|
||||
|
||||
Updating, mostly. ProjectSend now tells you when there is a new version, ends an update somewhere
|
||||
|
||||
@@ -8,20 +8,37 @@ data with it.
|
||||
|
||||
Read this before you put real files in ProjectSend, not after.
|
||||
|
||||
> Getting started with Docker in the first place is covered in [README](README.md#getting-started).
|
||||
> **This page is about the official image**, `projectsend/projectsend`, started from the
|
||||
> `compose.example.yaml` in [Getting started](README.md#getting-started). That is the supported way
|
||||
> to run it.
|
||||
>
|
||||
> A **clone of this repository is a development copy, not an installation** — it builds from source,
|
||||
> bind-mounts the working tree, and ships nothing pre-built. If that is what you are running, its
|
||||
> setup and its data layout are [CONTRIBUTING.md](CONTRIBUTING.md), not this page.
|
||||
>
|
||||
> Installing without Docker, on a plain PHP server, is [INSTALL.md](INSTALL.md).
|
||||
|
||||
---
|
||||
|
||||
## The three things that matter
|
||||
## The two things that matter
|
||||
|
||||
Everything ProjectSend cannot regenerate lives in exactly three places:
|
||||
Everything ProjectSend cannot regenerate lives in exactly two Docker volumes:
|
||||
|
||||
| What | Where it is by default | Losing it means |
|
||||
|---|---|---|
|
||||
| **The database** | A Docker *named volume*, `projectsend_db-data` | Everything except the files themselves: accounts, groups, permissions, share links, comments, the activity log |
|
||||
| **Uploaded files** | `storage/app/files/` in the project directory | The files your clients downloaded — gone |
|
||||
| **`.env`** | The project directory | `APP_KEY`, without which saved SMTP and LDAP passwords cannot be decrypted |
|
||||
| **The database** | The volume mounted at `/var/lib/mysql` — `projectsend_db-data` | Everything except the files themselves: accounts, groups, permissions, share links, comments, the activity log |
|
||||
| **Uploaded files, and `APP_KEY`** | The volume mounted at `/var/www/html/storage` — `projectsend_storage` | The files your clients downloaded, and the key that decrypts saved SMTP and LDAP passwords |
|
||||
|
||||
The second one is the one people get wrong, because it is two things in one place. The container
|
||||
generates `.env` on first boot and keeps it *on the storage volume*, at `storage/.env`, symlinked
|
||||
into place — precisely so `APP_KEY` survives the container being replaced. A key that changes
|
||||
between restarts signs everybody out and makes every encrypted column permanently unreadable, and
|
||||
nothing errors when it happens. Back up the volume and you have both halves; back up only
|
||||
`storage/app/files/` and you have the files without the key.
|
||||
|
||||
(If you set `APP_KEY` in the environment instead, Laravel reads it from there and it wins. That is
|
||||
the right move when you already manage secrets somewhere else — but then it is *that* system's
|
||||
backup you are relying on.)
|
||||
|
||||
You do not have to work out which of these you have from memory. **The dashboard's System panel
|
||||
reports where your uploaded files actually live** — a host directory, a Docker volume (named), or
|
||||
@@ -36,14 +53,14 @@ Two things you may be surprised to find you do **not** need to protect:
|
||||
everyone out and drops any not-yet-sent emails or half-built zips. Annoying; not data loss.
|
||||
- **Parts of `storage/app/files/`** are derived, not precious: `zips/` (built downloads, deleted
|
||||
automatically after a day), `thumbnails/` and `previews/` (rebuilt on demand the next time
|
||||
somebody looks at a file). They sit in the same directory as the real uploads, so the simplest
|
||||
thing is to back up all of it and not think about which is which.
|
||||
somebody looks at a file). They sit inside the volume you are backing up anyway, so the simplest
|
||||
thing is to take all of it and not think about which is which.
|
||||
|
||||
## The good news, and the one command to fear
|
||||
|
||||
Named volumes are already outside the container lifecycle. `docker compose down`,
|
||||
`docker compose up --build`, deleting and recreating every container — none of those touch
|
||||
`projectsend_db-data`. Upgrading does not lose your database, and never did.
|
||||
Named volumes are already outside the container lifecycle. `docker compose pull`,
|
||||
`docker compose down`, deleting and recreating every container — none of those touch
|
||||
`projectsend_db-data` or `projectsend_storage`. Upgrading does not lose your data, and never did.
|
||||
|
||||
The command that *does* destroy it is:
|
||||
|
||||
@@ -52,73 +69,185 @@ docker compose down -v # ← the -v deletes the named volumes
|
||||
```
|
||||
|
||||
That flag exists to clean up a development machine. On a real installation it deletes your entire
|
||||
database in about a second, with no confirmation. The same goes for `docker volume prune` and
|
||||
`docker system prune --volumes` when the stack happens to be down.
|
||||
database and every uploaded file in about a second, with no confirmation. The same goes for
|
||||
`docker volume prune` and `docker system prune --volumes` when the stack happens to be down.
|
||||
|
||||
So the actual problem with the default setup is not fragility, it is **invisibility**: your
|
||||
database is somewhere under `/var/lib/docker/volumes/`, which means most people never back it up
|
||||
and would not know where to look. The rest of this page fixes that.
|
||||
So the actual problem with the default setup is not fragility, it is **invisibility**: your data is
|
||||
somewhere under `/var/lib/docker/volumes/`, which means most people never back it up and would not
|
||||
know where to look. The rest of this page fixes that.
|
||||
|
||||
---
|
||||
|
||||
## Surviving a reboot
|
||||
|
||||
Every service needs a restart policy, or the Docker daemon will not start it again when the host
|
||||
comes back:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
restart: unless-stopped
|
||||
db:
|
||||
restart: unless-stopped
|
||||
redis:
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
`compose.example.yaml` already has this on all three. It is worth checking if you wrote your own
|
||||
compose file, because the failure is silent and delayed: the stack works perfectly until the first
|
||||
reboot or power cut, and then the site is simply down with no error anywhere. `depends_on` does not
|
||||
cover this — it applies to `docker compose up`, not to containers the daemon brings back at boot.
|
||||
|
||||
```sh
|
||||
docker compose ps -a # after a reboot, everything should be Up, not Exited (0)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Behind a reverse proxy
|
||||
|
||||
Almost nobody exposes the container directly: there is a proxy in front terminating TLS — Nginx
|
||||
Proxy Manager, Traefik, Caddy, or an nginx vhost you wrote. Two things are worth setting before you
|
||||
go looking for a bug that isn't there.
|
||||
|
||||
### Tell ProjectSend the proxy is there
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
TRUSTED_PROXIES: "*"
|
||||
```
|
||||
|
||||
Without it every visitor appears to come from the proxy. The login rate limiter then treats all of
|
||||
your users as one attacker, and the download log records the proxy's address instead of the
|
||||
person's. `compose.example.yaml` already sets this.
|
||||
|
||||
Leaving it unset does not cause a `502` — that means your proxy could not get a usable response out
|
||||
of the container at all, which is a different problem with a different fix. It does cause a **419
|
||||
"page expired"**. Without it the application never learns the proxy terminated TLS, so it builds
|
||||
its links and redirects with `http://` while the browser is on `https://`, and marks the session
|
||||
cookie as non-secure. The browser declines to send that cookie back to what it now reads as a
|
||||
different, less secure origin, the session arrives empty, and the first thing you submit — usually
|
||||
the create-your-admin-account form — is rejected as a stale token. After that you get returned to
|
||||
the login screen at random, because each redirect leaves and re-enters over the wrong scheme.
|
||||
|
||||
Your proxy also has to pass the original `Host` header through, or the links come out naming the
|
||||
container instead of your domain. Most do by default: `passHostHeader=true` in Traefik,
|
||||
`proxy_set_header Host $host;` in nginx.
|
||||
|
||||
### Give the proxy header headroom
|
||||
|
||||
If you are running a version before this one, some pages — the dashboard and the file list first —
|
||||
can send a response header block larger than the 4 KB single page nginx buffers headers into by
|
||||
default, and the proxy answers `502 Bad Gateway`. Because it depends on the page, it looks like an
|
||||
intermittent fault rather than a setting: the login screen loads, and then the application does not.
|
||||
The proxy's own error log names it exactly:
|
||||
|
||||
```
|
||||
upstream sent too big header while reading response header from upstream
|
||||
```
|
||||
|
||||
ProjectSend no longer sends headers that large. On an older version, or behind any proxy holding a
|
||||
default that tight, raise them:
|
||||
|
||||
```nginx
|
||||
proxy_buffer_size 32k;
|
||||
proxy_buffers 8 32k;
|
||||
proxy_busy_buffers_size 64k;
|
||||
```
|
||||
|
||||
In Nginx Proxy Manager that goes in the **Advanced** tab of the proxy host. Traefik and Caddy have
|
||||
their own spellings; the idea is the same.
|
||||
|
||||
### When something does go wrong, read the container's log
|
||||
|
||||
The app container logs everything — nginx, PHP-FPM, the queue worker and the scheduler — to Docker:
|
||||
|
||||
```sh
|
||||
docker compose logs -f app
|
||||
docker compose logs --since 30m app | grep -iE "error|upstream|502"
|
||||
```
|
||||
|
||||
nginx's line is the one that matters for a proxy problem, because it says which side failed.
|
||||
`connect() failed` or `upstream timed out` means the request reached the container and PHP was the
|
||||
problem. **Nothing at all**, while your proxy reports a 502, means the request never arrived — look
|
||||
at the proxy, the network between them, and the published port, not at ProjectSend.
|
||||
|
||||
The container also answers a cheap health endpoint that touches neither the database nor Redis, which
|
||||
is the quickest way to separate "the app is down" from "the proxy cannot reach the app". Run both
|
||||
during an outage, from the same machine:
|
||||
|
||||
```sh
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://<host-ip>:8080/up # straight at the container
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://files.example.com/up
|
||||
```
|
||||
|
||||
Docker records the same check every 30 seconds, so there is a history to read after the fact:
|
||||
|
||||
```sh
|
||||
docker inspect --format 'restarts={{.RestartCount}} oom={{.State.OOMKilled}} health={{.State.Health.Status}}' $(docker compose ps -q app)
|
||||
```
|
||||
|
||||
A non-zero `restarts`, or `oom=true`, means the container is dying and coming back rather than
|
||||
misbehaving — check memory. `compose.example.yaml` sets no limits, and MySQL, Redis and up to ten
|
||||
PHP-FPM workers add up on a small VPS.
|
||||
|
||||
---
|
||||
|
||||
## Putting the data where you chose
|
||||
|
||||
Bind-mount both to real paths on the host, so your data sits somewhere you picked, somewhere you
|
||||
can see in `ls`, and somewhere your existing backup tool already knows about.
|
||||
Bind-mount both volumes to real paths on the host, so your data sits somewhere you picked, somewhere
|
||||
you can see in `ls`, and somewhere your existing backup tool already knows about.
|
||||
|
||||
### 1. Make the directories
|
||||
|
||||
```sh
|
||||
sudo mkdir -p /srv/projectsend/files /srv/projectsend/mysql
|
||||
|
||||
# The app containers run as uid 1000 by default (the WWWUSER build argument).
|
||||
# If you set WWWUSER to something else in .env, use that instead.
|
||||
sudo chown -R 1000:1000 /srv/projectsend/files
|
||||
sudo mkdir -p /srv/projectsend/storage /srv/projectsend/mysql
|
||||
```
|
||||
|
||||
Leave `/srv/projectsend/mysql` owned by root — the MySQL image sets its own ownership the first
|
||||
time it starts.
|
||||
No `chown` needed for either. The ProjectSend container recreates the directory tree it needs on
|
||||
every boot and sets its own ownership (uid 1000), precisely because a bind-mounted host directory
|
||||
arrives empty where a named volume arrives seeded from the image. The MySQL image does the same for
|
||||
its own directory the first time it starts.
|
||||
|
||||
### 2. Create `compose.override.yaml`
|
||||
### 2. Point the compose file at them
|
||||
|
||||
Next to `compose.yaml`. Docker Compose reads this file automatically and merges it on top, so you
|
||||
never edit the tracked `compose.yaml` and nothing you write here is lost on the next update.
|
||||
`compose.example.yaml` is yours — you downloaded and edited it — so change the volumes in place
|
||||
rather than layering an override on top:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
# All four app containers must see the same files directory. Missing one of
|
||||
# them is the classic mistake: uploads land in one place and downloads are
|
||||
# served from another, so every download 404s. `web` is the one people
|
||||
# forget — nginx serves the bytes itself, from
|
||||
# /var/www/html/storage/app/files/, so it needs the mount just as much as
|
||||
# the container that wrote them.
|
||||
app:
|
||||
volumes:
|
||||
- /srv/projectsend/files:/var/www/html/storage/app/files
|
||||
web:
|
||||
volumes:
|
||||
- /srv/projectsend/files:/var/www/html/storage/app/files
|
||||
worker:
|
||||
volumes:
|
||||
- /srv/projectsend/files:/var/www/html/storage/app/files
|
||||
scheduler:
|
||||
volumes:
|
||||
- /srv/projectsend/files:/var/www/html/storage/app/files
|
||||
# Was: storage:/var/www/html/storage
|
||||
- /srv/projectsend/storage:/var/www/html/storage
|
||||
|
||||
db:
|
||||
volumes:
|
||||
# Was: db-data:/var/lib/mysql
|
||||
- /srv/projectsend/mysql:/var/lib/mysql
|
||||
```
|
||||
|
||||
Check the result before applying it — this prints the fully merged configuration:
|
||||
Mount the whole `storage` directory, not `storage/app/files` inside it. Uploads are only half of
|
||||
what lives there — `storage/.env` holds `APP_KEY`, and mounting one level too deep leaves the key
|
||||
back inside the container where the next `docker compose down` takes it.
|
||||
|
||||
Then drop `storage:` and `db-data:` from the `volumes:` block at the bottom, if nothing else uses
|
||||
them, and check the result before applying it — this prints the fully merged configuration:
|
||||
|
||||
```sh
|
||||
docker compose config
|
||||
```
|
||||
|
||||
### 3. Move the data you already have
|
||||
### 3. Move an existing install's data onto the new paths
|
||||
|
||||
**Skip this on a brand-new installation.** There is nothing to move; go straight to step 4.
|
||||
This step is only for an install that has **already been running** on the named volumes and is now
|
||||
moving to the host paths you just chose. It moves ProjectSend's own storage and database, nothing
|
||||
else.
|
||||
|
||||
**Skip it on a brand-new installation** — there is nothing to move; go straight to step 4. That
|
||||
includes an install you are about to migrate ProjectSend Legacy (v1) into: those files and that
|
||||
database come across later, through the migration tool, and the new install has to be empty when
|
||||
they do. See [MIGRATING-FROM-V1.md](MIGRATING-FROM-V1.md).
|
||||
|
||||
Stop everything first. Copying a database out from under a running MySQL is how you get a backup
|
||||
that restores into a corrupt table.
|
||||
@@ -127,25 +256,22 @@ that restores into a corrupt table.
|
||||
docker compose down # no -v
|
||||
```
|
||||
|
||||
Files, which are already on the host inside the project directory:
|
||||
A throwaway container is the tidy way to reach inside a named volume:
|
||||
|
||||
```sh
|
||||
sudo rsync -a storage/app/files/ /srv/projectsend/files/
|
||||
sudo chown -R 1000:1000 /srv/projectsend/files
|
||||
```
|
||||
docker run --rm \
|
||||
-v projectsend_storage:/from \
|
||||
-v /srv/projectsend/storage:/to \
|
||||
alpine sh -c 'cd /from && cp -a . /to'
|
||||
|
||||
The database, which is in the named volume. A throwaway container is the tidy way to reach inside
|
||||
one:
|
||||
|
||||
```sh
|
||||
docker run --rm \
|
||||
-v projectsend_db-data:/from \
|
||||
-v /srv/projectsend/mysql:/to \
|
||||
alpine sh -c 'cd /from && cp -a . /to'
|
||||
```
|
||||
|
||||
(`projectsend_db-data` is the volume's real name — the `db-data` from `compose.yaml` prefixed with
|
||||
the project name. `docker volume ls` will confirm it.)
|
||||
(Those are the volumes' real names — the `storage` and `db-data` from your compose file, prefixed
|
||||
with the project name. `docker volume ls` will confirm them.)
|
||||
|
||||
### 4. Start, and check
|
||||
|
||||
@@ -155,14 +281,22 @@ docker compose up -d
|
||||
|
||||
Then prove it worked rather than assuming: log in and check the dashboard's System panel — **Files
|
||||
stored on** should now read *Host directory*, and the Docker-volume warning should be gone. Then
|
||||
open a file, **download it**, and upload a new one; confirm the new upload appears in
|
||||
`/srv/projectsend/files/` on the host. A download that returns nothing means one of the four
|
||||
containers is missing the mount from step 2.
|
||||
open a file, **download it**, and upload a new one; confirm the new upload appears under
|
||||
`/srv/projectsend/storage/app/files/` on the host.
|
||||
|
||||
Once you are satisfied, and not before, you can reclaim the old volume:
|
||||
Confirm the key came across too, since that is the half nothing on screen will tell you about:
|
||||
|
||||
```sh
|
||||
docker volume rm projectsend_db-data
|
||||
grep '^APP_KEY=' /srv/projectsend/storage/.env
|
||||
```
|
||||
|
||||
If that is empty or missing while your database has saved SMTP or LDAP credentials, stop and go
|
||||
back — the container will generate a *new* key and those passwords become unreadable.
|
||||
|
||||
Once you are satisfied, and not before, you can reclaim the old volumes:
|
||||
|
||||
```sh
|
||||
docker volume rm projectsend_storage projectsend_db-data
|
||||
```
|
||||
|
||||
---
|
||||
@@ -178,37 +312,41 @@ copy of a live data directory is not a snapshot — it is a set of files capture
|
||||
different moments, and it may restore into something subtly broken. Use a dump:
|
||||
|
||||
```sh
|
||||
docker compose exec -T db \
|
||||
mysqldump -u root -p"${DB_ROOT_PASSWORD:-root}" \
|
||||
docker compose exec -T db sh -c \
|
||||
'mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" \
|
||||
--single-transaction --routines --triggers \
|
||||
projectsend > projectsend-$(date +%F).sql
|
||||
projectsend' > projectsend-$(date +%F).sql
|
||||
```
|
||||
|
||||
`--single-transaction` is what makes this safe on a running database: the dump sees one consistent
|
||||
moment in time without locking anybody out.
|
||||
moment in time without locking anybody out. Reading the password from the container's own
|
||||
environment keeps it off your shell history and off the process list on the host.
|
||||
|
||||
### The files
|
||||
### The files, and the key
|
||||
|
||||
```sh
|
||||
rsync -a /srv/projectsend/files/ /your/backup/location/files/
|
||||
rsync -a /srv/projectsend/storage/ /your/backup/location/storage/
|
||||
```
|
||||
|
||||
Ordinary files, no special handling. Restoring means copying them back and fixing ownership
|
||||
(`chown -R 1000:1000`).
|
||||
Ordinary files, no special handling — and taking the whole directory is what picks up `.env` with
|
||||
`APP_KEY` in it. That file is a few hundred bytes and it is the difference between a perfect backup
|
||||
and one where the SMTP and LDAP passwords in your database are undecryptable.
|
||||
|
||||
### `.env`
|
||||
If you kept the named volume instead of bind-mounting, the same content comes out through a
|
||||
throwaway container:
|
||||
|
||||
Copy it somewhere safe, once, and again whenever you change it. It is a few hundred bytes and it
|
||||
holds `APP_KEY` — lose that and the SMTP and LDAP passwords stored in your database become
|
||||
undecryptable, even though the rest of the backup is perfect.
|
||||
```sh
|
||||
docker run --rm -v projectsend_storage:/from -v "$PWD":/to \
|
||||
alpine tar czf /to/projectsend-storage-$(date +%F).tar.gz -C /from .
|
||||
```
|
||||
|
||||
### Restoring
|
||||
|
||||
```sh
|
||||
docker compose up -d db
|
||||
docker compose exec -T db mysql -u root -p"${DB_ROOT_PASSWORD:-root}" projectsend < projectsend-2026-08-08.sql
|
||||
sudo rsync -a /your/backup/location/files/ /srv/projectsend/files/
|
||||
sudo chown -R 1000:1000 /srv/projectsend/files
|
||||
docker compose exec -T db sh -c \
|
||||
'mysql -u root -p"$MYSQL_ROOT_PASSWORD" projectsend' < projectsend-2026-08-08.sql
|
||||
sudo rsync -a /your/backup/location/storage/ /srv/projectsend/storage/
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
@@ -222,19 +360,17 @@ restored is a hypothesis, not a backup.
|
||||
With the data outside the containers, an upgrade touches only the containers:
|
||||
|
||||
```sh
|
||||
docker compose down # again: no -v
|
||||
git pull # or unpack the new release over the directory
|
||||
docker compose up -d --build
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The app container runs `php artisan projectsend:update` itself on boot — the same command a
|
||||
manual install runs — so it migrates the database and verifies its reference data with no separate
|
||||
step. Take a database dump first anyway — migrations move forwards, not
|
||||
backwards, and the one time you skip it will be the time you want it.
|
||||
That is the whole procedure. The container runs `php artisan projectsend:update` itself on boot —
|
||||
the same command a manual install runs — so it migrates the database and verifies its reference data
|
||||
with no separate step. Take a database dump first anyway: migrations move forwards, not backwards,
|
||||
and the one time you skip it will be the time you want it.
|
||||
|
||||
If you run the published image rather than building your own, it is `docker compose pull` followed
|
||||
by `docker compose up -d`. Either way, **[UPDATE.md](UPDATE.md)** has the whole procedure: what the
|
||||
container does on its way up, how to tell it worked, and what to do when it does not.
|
||||
**[UPDATE.md](UPDATE.md)** has the rest: what the container does on its way up, how to tell it
|
||||
worked, and what to do when it does not.
|
||||
|
||||
---
|
||||
|
||||
@@ -243,10 +379,14 @@ container does on its way up, how to tell it worked, and what to do when it does
|
||||
This is the payoff for everything above, and it is worth doing once deliberately so you know it
|
||||
works:
|
||||
|
||||
1. Dump the database and copy `/srv/projectsend/`, `.env` and the dump to the new machine.
|
||||
2. Install Docker, put the project directory in place, restore both as described under
|
||||
1. Dump the database, and copy `/srv/projectsend/` (or the storage tarball) and the dump to the new
|
||||
machine.
|
||||
2. Install Docker, put your `compose.yaml` in place, restore both as described under
|
||||
[Restoring](#restoring).
|
||||
3. Point DNS at the new machine, and update `APP_URL` in `.env` if the address changed.
|
||||
3. Point DNS at the new machine, and update `APP_URL` in your compose file if the address changed.
|
||||
|
||||
No export tool, no vendor involvement, nothing that only works while the old machine is alive.
|
||||
That is the property worth protecting, and the reason this page exists.
|
||||
Bring `APP_KEY` across with the storage directory — a fresh key on the new machine leaves the site
|
||||
working and the saved mail and LDAP passwords silently broken.
|
||||
|
||||
No export tool, no vendor involvement, nothing that only works while the old machine is alive. That
|
||||
is the property worth protecting, and the reason this page exists.
|
||||
|
||||
+121
-28
@@ -68,8 +68,23 @@ instruction PHP just gave. There is no setting to change; the header names simpl
|
||||
Two ways out, if nginx really is impossible on your hosting:
|
||||
|
||||
- Put nginx in front of Apache as a reverse proxy, serving `/protected-files/` itself. This works
|
||||
but is more moving parts than just using nginx.
|
||||
- Store your files in S3-compatible object storage instead (see
|
||||
but is more moving parts than just using nginx. Give the proxy some header headroom while you are
|
||||
there — the same headroom the reference configuration in Step 6 gives PHP-FPM, in the directives a
|
||||
proxy uses instead:
|
||||
|
||||
```nginx
|
||||
proxy_buffer_size 32k;
|
||||
proxy_buffers 8 32k;
|
||||
proxy_busy_buffers_size 64k;
|
||||
```
|
||||
|
||||
nginx buffers a response's headers into a single block that defaults to one memory page — 4 KB on
|
||||
most systems — and answers `502 Bad Gateway` with `upstream sent too big header` when they do not
|
||||
fit. The page that goes over is not always the same one, so it presents as an intermittent fault
|
||||
rather than as a misconfiguration. This applies to any proxy in front of ProjectSend, not just
|
||||
this one: Nginx Proxy Manager, Traefik and a hand-written nginx vhost all ship the same default.
|
||||
([#1664](https://github.com/projectsend/projectsend/issues/1664))
|
||||
- Store your files in object storage instead — S3-compatible or Google Cloud Storage (see
|
||||
[Storing files somewhere other than this server](#storing-files-somewhere-other-than-this-server)).
|
||||
Files kept there are never on your server's disk, so downloads become a signed, expiring redirect
|
||||
to the storage provider and the web server is not involved at all. This is a genuine, supported
|
||||
@@ -182,6 +197,66 @@ sudo chown -R www-data:www-data /var/www/projectsend
|
||||
sudo chmod -R 775 /var/www/projectsend/storage /var/www/projectsend/bootstrap/cache
|
||||
```
|
||||
|
||||
### If your web server and PHP-FPM are different users
|
||||
|
||||
Check before you go further, because the symptom is misleading:
|
||||
|
||||
```sh
|
||||
ps -o user= -C nginx | sort -u # the web server's user
|
||||
ps -o user= -C php-fpm | sort -u # PHP's user
|
||||
```
|
||||
|
||||
Most servers you set up yourself run both as `www-data` and there is nothing to do here. Managed
|
||||
panels often do not — cPanel and Plesk commonly give each site its own PHP user while nginx runs as
|
||||
its own. If the two differ, add this to your `.env`:
|
||||
|
||||
```dotenv
|
||||
FILES_WEB_SERVER_READABLE=true
|
||||
```
|
||||
|
||||
Uploaded files are written `0600` inside `0700` directories, readable only by the user that wrote
|
||||
them. That is deliberate, and on a same-user server it is the safer setting. But a download is not
|
||||
served by PHP: PHP checks permissions and then hands the web server the path with `X-Accel-Redirect`
|
||||
(see [Why nginx](#why-nginx)), so the web server has to open a file PHP owns. When it cannot, **the
|
||||
whole site works and only downloads fail** — the browser reports `ERR_INVALID_RESPONSE` and the
|
||||
nginx error log says:
|
||||
|
||||
```
|
||||
open() ".../storage/app/files/..." failed (13: Permission denied)
|
||||
```
|
||||
|
||||
The setting relaxes new uploads to `0644`/`0755`. Be aware of what that means on a shared machine:
|
||||
those modes are readable by *every* account on the server, not only by the web server. The files stay
|
||||
off the web — the `internal` directive in Step 6 sees to that — but they are no longer private from
|
||||
your neighbours, so leave this off unless you need it.
|
||||
|
||||
Files already on disk keep the permissions they were written with, so fix those once:
|
||||
|
||||
```sh
|
||||
sudo find /var/www/projectsend/storage/app/files -type d -exec chmod 755 {} +
|
||||
sudo find /var/www/projectsend/storage/app/files -type f -exec chmod 644 {} +
|
||||
```
|
||||
|
||||
**Then check that new uploads keep it.** Upload a file and look at the directory it landed in:
|
||||
|
||||
```sh
|
||||
ls -ld /var/www/projectsend/storage/app/files/*/*
|
||||
```
|
||||
|
||||
If it is `drwxr-xr-x` you are done. If it is still `drwx------`, your PHP-FPM pool runs with a
|
||||
restrictive umask, and no application setting can beat it: ProjectSend asks for `0755`, but the
|
||||
directory is created by `mkdir()`, and `mkdir()` masks whatever mode it is given with the umask of
|
||||
the process. (Files are unaffected — they are set explicitly after being written, so they are `0644`
|
||||
either way.) Fix it in the pool configuration, not here:
|
||||
|
||||
```ini
|
||||
; /etc/php/8.4/fpm/pool.d/your-pool.conf — the path varies by panel
|
||||
php_admin_value[umask] = 0022
|
||||
```
|
||||
|
||||
Some panels expose this as a "umask" field instead. Restart PHP-FPM afterwards, then re-run the
|
||||
`chmod` above for anything uploaded in the meantime.
|
||||
|
||||
## Step 5 — Prepare the application
|
||||
|
||||
Three commands. Run them from the install directory, as the web server's user, so that everything
|
||||
@@ -309,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
|
||||
@@ -321,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.
|
||||
@@ -372,8 +456,20 @@ the worker afterwards.
|
||||
### Storing files somewhere other than this server
|
||||
|
||||
Out of the box, uploads live in `storage/app/files/` on this machine. You can point ProjectSend at
|
||||
S3-compatible object storage instead from **System → Settings → Storage** — useful when the files
|
||||
outgrow the server's disk.
|
||||
object storage instead from **System → Settings → Storage** — useful when the files outgrow the
|
||||
server's disk.
|
||||
|
||||
Two backends are offered. **S3-compatible** covers AWS S3 and everything speaking that API: MinIO,
|
||||
Backblaze B2, Wasabi, DigitalOcean Spaces. Leave the endpoint blank for AWS itself, or set it to the
|
||||
service's own address and turn on path-style addressing, which most of them need. **Google Cloud
|
||||
Storage** takes a service account key with read and write access to the bucket, pasted in as the JSON
|
||||
file Google issues; it is stored encrypted and never shown again.
|
||||
|
||||
Whichever you choose, use **Test connection** before switching uploads over — it checks the
|
||||
credentials actually reach the bucket, rather than leaving you to find out at the first upload.
|
||||
|
||||
The setting applies to new uploads. Files already on local disk stay there and keep working, and
|
||||
there is no migration between backends.
|
||||
|
||||
### Making it faster
|
||||
|
||||
@@ -388,28 +484,16 @@ sudo -u www-data php artisan event:cache
|
||||
You only run these once: `projectsend:update` notices they are in place and rebuilds them for you
|
||||
after every update. If you change your mind, `php artisan optimize:clear` undoes all three.
|
||||
|
||||
#### One command to skip: `config:cache`
|
||||
#### `config:cache` and your `.env`
|
||||
|
||||
Every Laravel deployment guide on the internet lists `php artisan config:cache` alongside those
|
||||
three, and `php artisan optimize` runs it for you. **Don't** — not on this application.
|
||||
Every Laravel deployment guide also lists `php artisan config:cache`, and `php artisan optimize`
|
||||
runs it for you. It is safe here, with one thing to remember.
|
||||
|
||||
Here is why. Caching the configuration writes every resolved setting into one PHP file, and from
|
||||
then on the framework stops reading your `.env` at all, on the entirely reasonable grounds that
|
||||
everything in it has already been baked in. That holds for settings read the normal way, through
|
||||
`config()`. ProjectSend reads one value earlier than that — `TRUSTED_PROXIES`, which has to be
|
||||
known before the middleware stack is assembled, so it is read straight from the environment. Cache
|
||||
the config and that read returns nothing.
|
||||
|
||||
Nothing breaks loudly. The site comes up, you log in, everything looks fine. But if there is a
|
||||
proxy or CDN in front of the server, ProjectSend goes back to believing every visitor is the proxy:
|
||||
the login rate limiter now counts all of your users as one attacker and locks the whole site out
|
||||
after five wrong passwords, and every row in the download log records the proxy's address instead
|
||||
of the person who actually downloaded the file. Both are the kind of thing you discover weeks
|
||||
later, from a complaint.
|
||||
|
||||
If you have already run it — or ran `php artisan optimize`, which includes it — `php artisan
|
||||
config:clear` puts things back immediately, and every update clears it too, saying why. The three commands above are safe and give you nearly
|
||||
all of the speed anyway; `config:cache` was always the smallest win of the four.
|
||||
Caching the configuration writes every resolved setting into one PHP file, and from then on the
|
||||
framework stops reading your `.env` at all — everything in it has already been baked in. So
|
||||
**re-run `php artisan config:cache` every time you edit `.env`**, or the edit does nothing and you
|
||||
are left staring at a setting that is plainly there and plainly ignored. `php artisan config:clear`
|
||||
goes back to reading `.env` directly, and every update clears it too, saying why.
|
||||
|
||||
---
|
||||
|
||||
@@ -502,13 +586,22 @@ applies to a non-standard port; behind a TLS proxy on 443 you do not need it.
|
||||
**A change I made in `.env` has no effect.**
|
||||
Run `php artisan optimize:clear`, then restart PHP-FPM and the worker. Both hold the old values
|
||||
until they are restarted. If it *still* has no effect, someone has run `php artisan config:cache`
|
||||
(or `optimize`) on this install — see [One command to skip](#one-command-to-skip-configcache).
|
||||
(or `optimize`) on this install — re-run it to pick the new value up, or `php artisan config:clear`
|
||||
to go back to reading `.env` directly.
|
||||
|
||||
**Everyone is locked out of the login form at once, or the download log shows the same IP for
|
||||
every download.**
|
||||
ProjectSend is seeing your proxy or CDN instead of your visitors. Set `TRUSTED_PROXIES` in `.env`
|
||||
(step 3) — and make sure `config:cache` has not been run, which stops that value from being read
|
||||
at all. Same section as above.
|
||||
(step 3) and restart PHP-FPM.
|
||||
|
||||
**Behind a reverse proxy: 419 "page expired" when you log in or save a form, or you land back on
|
||||
the login screen at random.**
|
||||
`TRUSTED_PROXIES` again (step 3). Without it ProjectSend never learns the proxy terminated TLS, so
|
||||
it builds its links and redirects with `http://` while the browser is on `https://`, and marks the
|
||||
session cookie as non-secure. The browser then declines to send that cookie back, the session
|
||||
arrives empty, and the write fails with a 419 that reads as an expired session. Make sure your
|
||||
proxy passes the original `Host` header through as well — `proxy_set_header Host $host;` in nginx,
|
||||
`passHostHeader=true` in Traefik (its default).
|
||||
|
||||
Still stuck? Ask in the [community forum](https://www.projectsend.org/) or open an issue on
|
||||
[GitHub](https://github.com/projectsend/projectsend/issues), and include the last few lines of
|
||||
|
||||
@@ -178,9 +178,11 @@ are listed at each step.
|
||||
| Legacy and ProjectSend are on the **same machine** | [**Direct**](#step-3a--direct-same-machine) |
|
||||
| Legacy is on **another server**, or on hosting you cannot reach from the new box | [**Bundle**](#step-3b--bundle-different-machines) |
|
||||
|
||||
Direct is faster and simpler, and on a single filesystem it does not copy your files at all — it
|
||||
hardlinks them, so 400 GB migrates in seconds and both installs point at the same bytes until you
|
||||
decide otherwise. Use it if you can.
|
||||
Direct is faster and simpler. It copies your files by default, and it can also *hardlink* them
|
||||
instead when you ask it to — on a single filesystem that writes no bytes at all, so 400 GB migrates
|
||||
in seconds and both installs point at the same bytes until you decide otherwise. Either way your
|
||||
Legacy install is left intact. Use Direct if you can; [Step 3a](#step-3a--direct-same-machine) has
|
||||
the strategies.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ per-seat pricing. It runs on your server, and the files stay there.
|
||||
- 16 languages
|
||||
- A REST API with scoped tokens and generated OpenAPI docs
|
||||
- Privacy controls, including GDPR-grade account erasure with a grace period
|
||||
- Local disk or S3-compatible storage
|
||||
- Local disk, S3-compatible storage, or Google Cloud Storage
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -78,7 +78,8 @@ docker compose -f compose.example.yaml up -d
|
||||
```
|
||||
|
||||
Open `APP_URL` and the first thing you see is a setup screen that creates your administrator
|
||||
account — or set `ADMIN_EMAIL` and `ADMIN_PASSWORD` in the file first and it is created for you.
|
||||
account — or uncomment `ADMIN_EMAIL` and `ADMIN_PASSWORD` in the file first, with a password of
|
||||
your own, and it is created for you.
|
||||
|
||||
Before you put real files in it, read **[DOCKER.md](DOCKER.md)** — where your database and uploads
|
||||
actually live, how to move them onto paths you chose, and how to back them up so an upgrade can't
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Updating ProjectSend
|
||||
|
||||
How to move an existing installation to a newer version, for both ways of running it. If you are
|
||||
installing for the first time, you want [INSTALL.md](INSTALL.md) (or [DOCKER.md](DOCKER.md))
|
||||
instead.
|
||||
installing for the first time, you want [Getting started](README.md#getting-started) for Docker or
|
||||
[INSTALL.md](INSTALL.md) for your own server instead.
|
||||
|
||||
Two rules hold everywhere in this document:
|
||||
|
||||
@@ -17,7 +17,7 @@ Which path you are on decides the rest:
|
||||
| How you installed | What updating means | Manual steps |
|
||||
|---|---|---|
|
||||
| The official Docker image (`projectsend/projectsend`) | Pull a new image, recreate the container | None — the container migrates itself |
|
||||
| Docker Compose built from source (DOCKER.md) | New code, rebuild the image | None — same entrypoint |
|
||||
| Docker Compose built from a clone (CONTRIBUTING.md) | New code, rebuild the image | None — same entrypoint |
|
||||
| A release zip on your own server (INSTALL.md) | Download the zip, run one script | `sudo ./update.sh`, and answer three questions |
|
||||
|
||||
ProjectSend also tells you which of these you are on: the **System** card on the dashboard prints
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ enum Action: string
|
||||
case ShareLinkRevoked = 'share_link.revoked';
|
||||
case ShareLinkDownloaded = 'share_link.downloaded';
|
||||
case PublicFileDownloaded = 'public_file.downloaded';
|
||||
case PublicFilePreviewed = 'public_file.previewed';
|
||||
case FolderCreated = 'folder.created';
|
||||
case FolderRenamed = 'folder.renamed';
|
||||
case FolderMoved = 'folder.moved';
|
||||
@@ -180,6 +181,7 @@ enum Action: string
|
||||
self::ShareLinkRevoked => 'Revoked a public link for the file ":subject"',
|
||||
self::ShareLinkDownloaded => 'Downloaded the file ":subject" via a public link',
|
||||
self::PublicFileDownloaded => 'Downloaded the file ":subject" via the public group listing',
|
||||
self::PublicFilePreviewed => 'Previewed the file ":subject" via the public group listing',
|
||||
self::FolderCreated => 'Created the folder ":subject"',
|
||||
self::FolderRenamed => 'Renamed the folder ":subject"',
|
||||
self::FolderMoved => 'Moved the folder ":subject"',
|
||||
@@ -278,6 +280,7 @@ enum Action: string
|
||||
self::ShareLinkRevoked => 'A public link was revoked',
|
||||
self::ShareLinkDownloaded => 'A file was downloaded via a public link',
|
||||
self::PublicFileDownloaded => 'A file was downloaded via the public group listing',
|
||||
self::PublicFilePreviewed => 'A file was previewed via the public group listing',
|
||||
self::FolderCreated => 'A folder was created',
|
||||
self::FolderRenamed => 'A folder was renamed',
|
||||
self::FolderMoved => 'A folder was moved',
|
||||
|
||||
@@ -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,23 +83,39 @@ 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
|
||||
{
|
||||
if (! in_array($action, [Action::FileDownloaded, Action::FilePreviewed, Action::ShareLinkDownloaded, Action::PublicFileDownloaded], true)) {
|
||||
if (! in_array($action, [Action::FileDownloaded, Action::FilePreviewed, Action::ShareLinkDownloaded, Action::PublicFileDownloaded, Action::PublicFilePreviewed], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,12 +5,17 @@ declare(strict_types=1);
|
||||
namespace App\Modules\Audit\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Audit\ActivityLogScope;
|
||||
use App\Modules\Audit\DownloadPresenter;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Platform\Localization\LocalDay;
|
||||
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||
use App\Support\Pagination;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
@@ -27,6 +32,7 @@ class DownloadsController extends Controller
|
||||
public function __construct(
|
||||
private readonly DownloadPresenter $presenter,
|
||||
private readonly ActivityLogScope $scope,
|
||||
private readonly TimezoneRegistry $timezones,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
@@ -34,15 +40,9 @@ class DownloadsController extends Controller
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
// A download row names the file and says who fetched it from which
|
||||
// IP, so it needs the viewer's library scope applied — not just
|
||||
// `view_actions_log`. See ActivityLogScope for the full reasoning.
|
||||
$entries = $this->scope
|
||||
->apply(ActivityLog::query(), $viewer)
|
||||
->where('subject_type', (new File)->getMorphClass())
|
||||
->whereIn('action', [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded])
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id')
|
||||
$filters = $this->validatedFilters($request);
|
||||
|
||||
$entries = $this->filteredQuery($filters, $viewer)
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
@@ -63,6 +63,65 @@ class DownloadsController extends Controller
|
||||
];
|
||||
})->all(),
|
||||
'pagination' => Pagination::meta($entries),
|
||||
'filters' => $filters,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{file: ?string, user: ?string, from: ?string, to: ?string}
|
||||
*/
|
||||
private function validatedFilters(Request $request): array
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'file' => ['nullable', 'string', 'max:255'],
|
||||
'user' => ['nullable', 'string', 'max:255'],
|
||||
'from' => ['nullable', 'date'],
|
||||
'to' => ['nullable', 'date', 'after_or_equal:from'],
|
||||
]);
|
||||
|
||||
return [
|
||||
'file' => $validated['file'] ?? null,
|
||||
'user' => $validated['user'] ?? null,
|
||||
'from' => $validated['from'] ?? null,
|
||||
'to' => $validated['to'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{file: ?string, user: ?string, from: ?string, to: ?string} $filters
|
||||
* @return Builder<ActivityLog>
|
||||
*/
|
||||
private function filteredQuery(array $filters, User $viewer): Builder
|
||||
{
|
||||
$timezone = $this->timezones->resolve($viewer);
|
||||
|
||||
// A download row names the file and says who fetched it from which
|
||||
// IP, so it needs the viewer's library scope applied — not just
|
||||
// `view_actions_log`. See ActivityLogScope for the full reasoning.
|
||||
return $this->scope
|
||||
->apply(ActivityLog::query(), $viewer)
|
||||
->where('subject_type', (new File)->getMorphClass())
|
||||
->whereIn('action', [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded])
|
||||
// Both names are matched on what the entry snapshotted, not on
|
||||
// a join: a file or an account deleted since is still findable
|
||||
// by the name it went out under, which is often exactly what
|
||||
// this page is being asked.
|
||||
->when($filters['file'], fn (Builder $query, string $file) => $query->where('subject_name', 'like', "%{$file}%"))
|
||||
// Only rows with a real account can match a name. The two
|
||||
// anonymous flavours ("Public link", "Public listing") are
|
||||
// labels this page prints, not stored values, so a search for
|
||||
// them finds nothing rather than something arbitrary.
|
||||
->when($filters['user'], fn (Builder $query, string $user) => $query->where('actor_name', 'like', "%{$user}%"))
|
||||
// The viewer's own calendar day, not the UTC one — see LocalDay.
|
||||
->when(
|
||||
$filters['from'] !== null ? LocalDay::start($filters['from'], $timezone) : null,
|
||||
fn (Builder $query, Carbon $from) => $query->where('created_at', '>=', $from),
|
||||
)
|
||||
->when(
|
||||
$filters['to'] !== null ? LocalDay::end($filters['to'], $timezone) : null,
|
||||
fn (Builder $query, Carbon $to) => $query->where('created_at', '<=', $to),
|
||||
)
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ class ClientSettingsController extends Controller
|
||||
'clients_can_select_group' => $this->settings->get(Setting::ClientsCanSelectGroup),
|
||||
'clients_membership_deny_cooldown_days' => $this->settings->get(Setting::ClientsMembershipDenyCooldownDays),
|
||||
'default_client_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
|
||||
'clients_can_preview_files' => $this->settings->get(Setting::ClientsCanPreviewFiles),
|
||||
'groups' => Group::query()->orderBy('name')->get()
|
||||
->map(fn (Group $group): array => ['id' => $group->id, 'name' => $group->name])
|
||||
->all(),
|
||||
@@ -47,6 +48,7 @@ class ClientSettingsController extends Controller
|
||||
'clients_can_select_group' => ['required', Rule::in(['none', 'public'])],
|
||||
'clients_membership_deny_cooldown_days' => ['required', 'integer', 'min:0', 'max:365'],
|
||||
'default_client_storage_quota_mb' => ['required', 'integer', 'min:0'],
|
||||
'clients_can_preview_files' => ['required', 'boolean'],
|
||||
]);
|
||||
|
||||
$this->settings->set(Setting::ClientsCanRegister, $validated['clients_can_register']);
|
||||
@@ -55,6 +57,7 @@ class ClientSettingsController extends Controller
|
||||
$this->settings->set(Setting::ClientsCanSelectGroup, $validated['clients_can_select_group']);
|
||||
$this->settings->set(Setting::ClientsMembershipDenyCooldownDays, (int) $validated['clients_membership_deny_cooldown_days']);
|
||||
$this->settings->set(Setting::DefaultClientStorageQuotaMb, (int) $validated['default_client_storage_quota_mb']);
|
||||
$this->settings->set(Setting::ClientsCanPreviewFiles, $validated['clients_can_preview_files']);
|
||||
|
||||
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'clients']);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Delivery;
|
||||
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Support\ContentDisposition;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* A stored file's own bytes, put on the wire for whichever disk it lives
|
||||
* on.
|
||||
*
|
||||
* Every route that hands over a file reaches this after authorizing in
|
||||
* its own way — a policy, a share token, a public-listing check. It
|
||||
* authorizes nothing itself, and deliberately knows nothing about who is
|
||||
* asking. The one thing it knows is the thing each caller kept getting
|
||||
* wrong on its own: that `$file->disk` decides how the bytes travel.
|
||||
*
|
||||
* Local disk: X-Accel-Redirect, so nginx streams the file and PHP never
|
||||
* touches the bytes. Anything else — S3, GCS and friends — gets a
|
||||
* short-lived presigned URL carrying the disposition, which an object
|
||||
* store ranges just as well.
|
||||
*
|
||||
* That distinction matters most for inline(): a <video> seeking through
|
||||
* an hour of footage issues a long tail of Range requests, and nginx's
|
||||
* static handler answers those with 206s on its own, dropping the
|
||||
* Content-Length below in favour of the range it actually served.
|
||||
*
|
||||
* Callers of inline() must have established that the mime type is
|
||||
* inline-safe first; PreviewKind is the allowlist, and the reason there
|
||||
* is one.
|
||||
*/
|
||||
class StoredFileResponse
|
||||
{
|
||||
/** Shown in place — a preview. */
|
||||
public function inline(File $file): Response|RedirectResponse
|
||||
{
|
||||
return $this->make($file, ContentDisposition::inline($file->original_name));
|
||||
}
|
||||
|
||||
/** Handed over — a download. */
|
||||
public function attachment(File $file): Response|RedirectResponse
|
||||
{
|
||||
return $this->make($file, ContentDisposition::attachment($file->original_name));
|
||||
}
|
||||
|
||||
private function make(File $file, string $disposition): Response|RedirectResponse
|
||||
{
|
||||
if ($file->disk !== 'files') {
|
||||
$url = Storage::disk($file->disk)->temporaryUrl(
|
||||
$file->path,
|
||||
now()->addHour(),
|
||||
['ResponseContentDisposition' => $disposition],
|
||||
);
|
||||
|
||||
return redirect()->away($url);
|
||||
}
|
||||
|
||||
return response('', 200, [
|
||||
'X-Accel-Redirect' => '/protected-files/'.$file->path,
|
||||
'Content-Type' => $file->mime_type,
|
||||
'Content-Disposition' => $disposition,
|
||||
'Content-Length' => (string) $file->size,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Modules\Files\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Audit\ActivityPresenter;
|
||||
@@ -18,10 +19,15 @@ use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Files\Models\ShareLink;
|
||||
use App\Modules\Files\Versions\FileVersionLinks;
|
||||
use App\Modules\Platform\Localization\LocalDay;
|
||||
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@@ -34,6 +40,41 @@ class FileDetailsController extends Controller
|
||||
/** Raw rows considered when grouping downloads() by actor — see that method's docblock. */
|
||||
private const DOWNLOADS_SUMMARY_LIMIT = 500;
|
||||
|
||||
/**
|
||||
* How a file leaves: three actions, because *how* it left matters —
|
||||
* a signed-in recipient, somebody following a public link, and a
|
||||
* visitor to a public group listing are all recorded separately.
|
||||
*
|
||||
* @var non-empty-list<Action>
|
||||
*/
|
||||
private const DOWNLOAD_ACTIONS = [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded];
|
||||
|
||||
/**
|
||||
* Looking at a file without taking it. One action today; if a second
|
||||
* way to preview is ever recorded separately, add it here and give
|
||||
* previews an ACTION_GROUPS entry the way downloads has one.
|
||||
*
|
||||
* @var non-empty-list<Action>
|
||||
*/
|
||||
private const PREVIEW_ACTIONS = [Action::FilePreviewed];
|
||||
|
||||
/**
|
||||
* Filters that stand for a question rather than for one logged action.
|
||||
*
|
||||
* Nobody reading a file's history wants to ask "who downloaded this?"
|
||||
* three times, so this offers it once — and only when the file's own
|
||||
* log holds more than one of the members, since otherwise it would
|
||||
* filter to exactly what its single member already offers.
|
||||
*
|
||||
* @var array<string, array{label: string, actions: non-empty-list<Action>}>
|
||||
*/
|
||||
private const ACTION_GROUPS = [
|
||||
'downloads' => [
|
||||
'label' => 'All downloads',
|
||||
'actions' => self::DOWNLOAD_ACTIONS,
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly ActivityPresenter $presenter,
|
||||
private readonly DownloadPresenter $downloadPresenter,
|
||||
@@ -41,6 +82,7 @@ class FileDetailsController extends Controller
|
||||
private readonly CommentingRules $commenting,
|
||||
private readonly FileVersionLinks $versionLinks,
|
||||
private readonly DownloadAllowance $allowance,
|
||||
private readonly TimezoneRegistry $timezones,
|
||||
) {}
|
||||
|
||||
public function show(Request $request, File $file): JsonResponse
|
||||
@@ -136,6 +178,65 @@ class FileDetailsController extends Controller
|
||||
return response()->json(['entries' => $entries, 'total' => $total]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Who has actually had this file: its downloads and previews, newest
|
||||
* first, with a count of each.
|
||||
*
|
||||
* A narrower question than activity() and a much more frequent one —
|
||||
* "did they ever actually get it?" — which the full log answers only
|
||||
* by being read past everything else that has happened to the file.
|
||||
*/
|
||||
public function access(Request $request, File $file): JsonResponse
|
||||
{
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
Gate::forUser($viewer)->authorize('view', $file);
|
||||
abort_unless($viewer->can('view_actions_log'), 403);
|
||||
|
||||
$base = fn (): Builder => ActivityLog::query()
|
||||
->where('subject_type', $file->getMorphClass())
|
||||
->where('subject_id', $file->id);
|
||||
|
||||
$entries = $base()
|
||||
->whereIn('action', [...self::DOWNLOAD_ACTIONS, ...self::PREVIEW_ACTIONS])
|
||||
->orderByDesc('created_at')->orderByDesc('id')
|
||||
->limit(20)->get()
|
||||
->map(fn (ActivityLog $entry): array => [
|
||||
// The sentence the presenter builds already says which of
|
||||
// the two this was ("Downloaded the file …"), so nothing
|
||||
// here has to label the row a second time.
|
||||
...$this->presenter->present($entry),
|
||||
// Subject to the privacy setting that decides whether an
|
||||
// address is recorded at all, so it is often null.
|
||||
'ip_address' => $entry->ip_address,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'entries' => $entries,
|
||||
'downloads_total' => $base()->whereIn('action', self::DOWNLOAD_ACTIONS)->count(),
|
||||
'previews_total' => $base()->whereIn('action', self::PREVIEW_ACTIONS)->count(),
|
||||
// Built here rather than in the page: which filter value stands
|
||||
// for "every download" is a fact about the log's vocabulary,
|
||||
// and a group key only exists while the group does.
|
||||
'downloads_url' => $this->historyUrl($file, 'downloads', self::DOWNLOAD_ACTIONS),
|
||||
'previews_url' => $this->historyUrl($file, 'previews', self::PREVIEW_ACTIONS),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The file's history, pre-filtered to one question: by the group when
|
||||
* one covers these actions, and by the action itself when the group
|
||||
* would have a single member and therefore does not exist.
|
||||
*
|
||||
* @param non-empty-list<Action> $actions
|
||||
*/
|
||||
private function historyUrl(File $file, string $groupKey, array $actions): string
|
||||
{
|
||||
$filter = isset(self::ACTION_GROUPS[$groupKey]) ? $groupKey : $actions[0]->value;
|
||||
|
||||
return route('files.activity.history', $file, false).'?action='.$filter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full, paginated activity history for a file — the "View full
|
||||
* history" destination linked from the details panel's Activity tab,
|
||||
@@ -148,7 +249,15 @@ class FileDetailsController extends Controller
|
||||
Gate::forUser($viewer)->authorize('view', $file);
|
||||
abort_unless($viewer->can('view_actions_log'), 403);
|
||||
|
||||
return $this->renderHistory($file->getMorphClass(), $file->id, $file->name, route('files.edit', $file, false));
|
||||
return $this->renderHistory(
|
||||
$request,
|
||||
$file->getMorphClass(),
|
||||
$file->id,
|
||||
$file->name,
|
||||
route('files.edit', $file, false).'?tab=activity',
|
||||
'files.activity.history',
|
||||
['file' => $file->id],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,7 +282,7 @@ class FileDetailsController extends Controller
|
||||
$query = ActivityLog::query()
|
||||
->where('subject_type', $file->getMorphClass())
|
||||
->where('subject_id', $file->id)
|
||||
->whereIn('action', [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded]);
|
||||
->whereIn('action', self::DOWNLOAD_ACTIONS);
|
||||
|
||||
$total = (clone $query)->count();
|
||||
|
||||
@@ -304,15 +413,35 @@ class FileDetailsController extends Controller
|
||||
Gate::forUser($viewer)->authorize('view', $folder);
|
||||
abort_unless($viewer->can('view_actions_log'), 403);
|
||||
|
||||
return $this->renderHistory($folder->getMorphClass(), $folder->id, $folder->name, route('files.index', ['folder' => $folder->id], false));
|
||||
return $this->renderHistory(
|
||||
$request,
|
||||
$folder->getMorphClass(),
|
||||
$folder->id,
|
||||
$folder->name,
|
||||
route('files.index', ['folder' => $folder->id], false),
|
||||
'folders.activity.history',
|
||||
['folder' => $folder->id],
|
||||
);
|
||||
}
|
||||
|
||||
private function renderHistory(string $morphClass, int $subjectId, string $subjectName, string $backUrl): Response
|
||||
{
|
||||
$entries = ActivityLog::query()
|
||||
->where('subject_type', $morphClass)
|
||||
->where('subject_id', $subjectId)
|
||||
->orderByDesc('created_at')->orderByDesc('id')
|
||||
/**
|
||||
* @param array<string, mixed> $routeParams
|
||||
*/
|
||||
private function renderHistory(
|
||||
Request $request,
|
||||
string $morphClass,
|
||||
int $subjectId,
|
||||
string $subjectName,
|
||||
string $backUrl,
|
||||
string $routeName,
|
||||
array $routeParams,
|
||||
): Response {
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
$filters = $this->validatedHistoryFilters($request);
|
||||
|
||||
$entries = $this->historyQuery($morphClass, $subjectId, $filters, $viewer)
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
@@ -327,8 +456,135 @@ class FileDetailsController extends Controller
|
||||
'next' => $entries->nextPageUrl(),
|
||||
'total' => $entries->total(),
|
||||
],
|
||||
'filters' => $filters,
|
||||
'action_options' => $this->actionOptions($morphClass, $subjectId, $filters['action']),
|
||||
'subject_name' => $subjectName,
|
||||
'back_url' => $backUrl,
|
||||
'route_name' => $routeName,
|
||||
'route_params' => $routeParams,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The actions this subject's history actually contains, with how many
|
||||
* times each happened.
|
||||
*
|
||||
* Built from the log rather than from `Action::cases()`: the enum has
|
||||
* over eighty members and all but a handful can never appear against a
|
||||
* file, so offering them all would be a dropdown you scroll past the
|
||||
* answer in. What is here is what happened.
|
||||
*
|
||||
* @return list<array{key: string, label: string, count: int}>
|
||||
*/
|
||||
private function actionOptions(string $morphClass, int $subjectId, ?string $active): array
|
||||
{
|
||||
/** @var array<string, int> $counts */
|
||||
$counts = ActivityLog::query()
|
||||
->where('subject_type', $morphClass)
|
||||
->where('subject_id', $subjectId)
|
||||
->selectRaw('action, count(*) as total')
|
||||
->groupBy('action')
|
||||
->pluck('total', 'action')
|
||||
->map(fn ($total): int => (int) $total)
|
||||
->all();
|
||||
|
||||
$options = [];
|
||||
|
||||
foreach (self::ACTION_GROUPS as $key => $group) {
|
||||
$present = array_filter($group['actions'], fn (Action $action): bool => isset($counts[$action->value]));
|
||||
|
||||
// One member present means the group would filter to exactly
|
||||
// what its member already offers, under a vaguer name — unless
|
||||
// this *is* what is currently being filtered on (the file
|
||||
// page's "View all downloads" button links straight to it), in
|
||||
// which case the dropdown has to be able to show its own value.
|
||||
if (count($present) < 2 && $active !== $key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$options[] = [
|
||||
'key' => $key,
|
||||
'label' => $group['label'],
|
||||
'count' => array_sum(array_map(fn (Action $action): int => $counts[$action->value], $present)),
|
||||
];
|
||||
}
|
||||
|
||||
// Enum order, not count order, so the list does not rearrange
|
||||
// itself under the reader every time the file is downloaded.
|
||||
foreach (Action::cases() as $action) {
|
||||
// Same reason as the group above: a filter arrived at from a
|
||||
// link stays visible in the dropdown even at a count of zero,
|
||||
// rather than leaving it blank over an empty table.
|
||||
if (! isset($counts[$action->value]) && $active !== $action->value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$options[] = [
|
||||
'key' => $action->value,
|
||||
'label' => $action->description(),
|
||||
'count' => $counts[$action->value] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{action: ?string, actor: ?string, from: ?string, to: ?string}
|
||||
*/
|
||||
private function validatedHistoryFilters(Request $request): array
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'action' => ['nullable', Rule::in([
|
||||
...array_keys(self::ACTION_GROUPS),
|
||||
...array_column(Action::cases(), 'value'),
|
||||
])],
|
||||
'actor' => ['nullable', 'string', 'max:255'],
|
||||
'from' => ['nullable', 'date'],
|
||||
'to' => ['nullable', 'date', 'after_or_equal:from'],
|
||||
]);
|
||||
|
||||
return [
|
||||
'action' => $validated['action'] ?? null,
|
||||
'actor' => $validated['actor'] ?? null,
|
||||
'from' => $validated['from'] ?? null,
|
||||
'to' => $validated['to'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{action: ?string, actor: ?string, from: ?string, to: ?string} $filters
|
||||
* @return Builder<ActivityLog>
|
||||
*/
|
||||
private function historyQuery(string $morphClass, int $subjectId, array $filters, User $viewer): Builder
|
||||
{
|
||||
$timezone = $this->timezones->resolve($viewer);
|
||||
|
||||
return ActivityLog::query()
|
||||
->where('subject_type', $morphClass)
|
||||
->where('subject_id', $subjectId)
|
||||
->when($filters['action'], function (Builder $query, string $action): void {
|
||||
$group = self::ACTION_GROUPS[$action] ?? null;
|
||||
|
||||
$group === null
|
||||
? $query->where('action', $action)
|
||||
: $query->whereIn('action', array_map(fn (Action $member): string => $member->value, $group['actions']));
|
||||
})
|
||||
// Matched on the name snapshotted onto the entry, the same as
|
||||
// the main log: an account deleted since is still findable by
|
||||
// the name it acted under, which is the whole point of the
|
||||
// snapshot.
|
||||
->when($filters['actor'], fn (Builder $query, string $actor) => $query->where('actor_name', 'like', "%{$actor}%"))
|
||||
// The reader's own calendar day, not the UTC one — see LocalDay.
|
||||
->when(
|
||||
$filters['from'] !== null ? LocalDay::start($filters['from'], $timezone) : null,
|
||||
fn (Builder $query, Carbon $from) => $query->where('created_at', '>=', $from),
|
||||
)
|
||||
->when(
|
||||
$filters['to'] !== null ? LocalDay::end($filters['to'], $timezone) : null,
|
||||
fn (Builder $query, Carbon $to) => $query->where('created_at', '<=', $to),
|
||||
)
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,28 +8,26 @@ use App\Http\Controllers\Controller;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Access\DownloadAllowance;
|
||||
use App\Modules\Files\Delivery\StoredFileResponse;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Support\ContentDisposition;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Authorized downloads without the bytes ever traversing PHP: for a file
|
||||
* on the local disk, the app checks the policy and answers with
|
||||
* X-Accel-Redirect; nginx streams the file from the protected location
|
||||
* (brief §3). The cloud edition swaps this for presigned URLs behind the
|
||||
* same route. A file on the community-only external storage disk already
|
||||
* gets exactly that — a presigned URL redirect — since nginx has no way
|
||||
* to serve bytes it doesn't have on disk.
|
||||
* Authorized downloads without the bytes ever traversing PHP: the app
|
||||
* checks the policy, and StoredFileResponse answers with either an
|
||||
* X-Accel-Redirect for nginx to stream from the protected location
|
||||
* (brief §3) or a presigned URL when the file lives on external storage,
|
||||
* since nginx has no way to serve bytes it doesn't have on disk.
|
||||
*/
|
||||
class FileDownloadController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly DownloadAllowance $allowance,
|
||||
private readonly StoredFileResponse $bytes,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request, File $file): Response|RedirectResponse
|
||||
@@ -44,21 +42,6 @@ class FileDownloadController extends Controller
|
||||
|
||||
$this->activity->log(Action::FileDownloaded, subject: $file);
|
||||
|
||||
if ($file->disk !== 'files') {
|
||||
$url = Storage::disk($file->disk)->temporaryUrl(
|
||||
$file->path,
|
||||
now()->addHour(),
|
||||
['ResponseContentDisposition' => ContentDisposition::attachment($file->original_name)],
|
||||
);
|
||||
|
||||
return redirect()->away($url);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,15 +8,21 @@ use App\Http\Controllers\Controller;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Access\DownloadAllowance;
|
||||
use App\Modules\Files\Delivery\StoredFileResponse;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Preview\PreviewKind;
|
||||
use App\Modules\Files\Thumbnails\Events\ResolvingImageRendering;
|
||||
use App\Modules\Files\Thumbnails\ImageAudience;
|
||||
use App\Modules\Files\Thumbnails\ImageRendition;
|
||||
use App\Modules\Files\Thumbnails\LocalSourceFile;
|
||||
use App\Modules\Files\Thumbnails\ThumbnailGenerator;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use App\Support\ContentDisposition;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -32,17 +38,25 @@ use Illuminate\Support\Facades\Storage;
|
||||
* file's contents — a real, audit-worthy action, just not a "download."
|
||||
*
|
||||
* SECURITY: both methods serve bytes inline, from this app's own origin,
|
||||
* with the File's stored mime type — so both are restricted to
|
||||
* with the File's stored mime type, so both are restricted to an
|
||||
* allowlist — but not the same one, because they are asking different
|
||||
* questions. `thumbnail()` is bounded by
|
||||
* ThumbnailGenerator::SUPPORTED_MIME_TYPES, the raster formats this app
|
||||
* renders itself. That list is the allowlist; nothing else is ever served
|
||||
* inline. Do NOT widen it to text/html, image/svg+xml, or anything else a
|
||||
* browser executes script from, and do not reach for the upload
|
||||
* allowed-extensions setting as a substitute: that setting matches on the
|
||||
* *extension*, while mime_type is detected from the *bytes*
|
||||
* (ChunkedUploadsController::complete), so a .txt holding HTML is stored
|
||||
* as text/html and would render as a page here. Serving a file inline as
|
||||
* a type the browser executes is same-origin script execution with the
|
||||
* viewer's session.
|
||||
* decodes and re-encodes itself, since a thumbnail *is* a rendition.
|
||||
* `preview()` is bounded by PreviewKind, which additionally admits the
|
||||
* video, audio and PDF types a browser plays natively and this app never
|
||||
* touches. PreviewKind's docblock carries the rule in full; the short
|
||||
* version is that neither list may ever grow a type a browser executes
|
||||
* script from, and neither may be derived from the upload
|
||||
* allowed-extensions setting, which matches on the *extension* while
|
||||
* mime_type is detected from the *bytes*
|
||||
* (ChunkedUploadsController::complete).
|
||||
*
|
||||
* Serving media inline is also why `preview()` logs at most one
|
||||
* Action::FilePreviewed per viewer per file per five minutes: a `<video>`
|
||||
* seeking through a recording issues a long tail of Range requests
|
||||
* against this same URL, and one row each would bury the log under a
|
||||
* single deliberate act.
|
||||
*
|
||||
* Renditions always cache on the local "files" disk regardless of where
|
||||
* the source file lives — they're a derived artifact, not the original,
|
||||
@@ -60,6 +74,9 @@ class FileThumbnailController extends Controller
|
||||
private readonly ThumbnailGenerator $thumbnails,
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly DownloadAllowance $allowance,
|
||||
private readonly StoredFileResponse $bytes,
|
||||
private readonly LocalSourceFile $source,
|
||||
private readonly Settings $settings,
|
||||
) {}
|
||||
|
||||
public function thumbnail(Request $request, File $file): Response
|
||||
@@ -82,66 +99,92 @@ class FileThumbnailController extends Controller
|
||||
/**
|
||||
* A file opened to be looked at.
|
||||
*
|
||||
* A preview is not the file — it is a rendered view of it, which is
|
||||
* why it may be decorated at all. But rendering one is expensive
|
||||
* (decoding and re-encoding a full-size photograph) where serving the
|
||||
* stored bytes is nearly free, so core only pays that cost when a
|
||||
* listener says this particular viewer must be served a rendering:
|
||||
* ResolvingImageRendering asks, and defaults to no. On an
|
||||
* For an image, a preview is not the file — it is a rendered view of
|
||||
* it, which is why it may be decorated at all. But rendering one is
|
||||
* expensive (decoding and re-encoding a full-size photograph) where
|
||||
* serving the stored bytes is nearly free, so core only pays that
|
||||
* cost when a listener says this particular viewer must be served a
|
||||
* rendering: ResolvingImageRendering asks, and defaults to no. On an
|
||||
* installation that watermarks, a client gets a bounded, watermarked
|
||||
* render and staff get the original; on one that does not, everyone
|
||||
* gets exactly what this endpoint has always returned.
|
||||
*
|
||||
* For video, audio and PDF there is no rendering to resolve — this
|
||||
* app cannot decode any of them, so it has no rendition to cache, no
|
||||
* watermark to stamp, and nothing to ask about. Those go straight to
|
||||
* the bytes.
|
||||
*/
|
||||
public function preview(Request $request, File $file): Response|RedirectResponse
|
||||
{
|
||||
Gate::authorize('view', $file);
|
||||
|
||||
// Only types this app renders itself may be served inline; anything
|
||||
// else is a download, not a preview. See the class docblock — the
|
||||
// stored mime type is sniffed from the bytes, so an allowed
|
||||
// The inline allowlist. See the class docblock and PreviewKind —
|
||||
// the stored mime type is sniffed from the bytes, so an allowed
|
||||
// extension is not evidence of a safe-to-render payload.
|
||||
abort_unless(ThumbnailGenerator::supports($file->mime_type), 404);
|
||||
$kind = PreviewKind::forMime($file->mime_type);
|
||||
|
||||
abort_if($kind === null, 404);
|
||||
|
||||
// Staff are never gated: this switch exists so an installation can
|
||||
// decide what its *clients* may do with a file short of taking it.
|
||||
// 404 rather than 403 because with the setting off the endpoint is
|
||||
// not a thing that exists for this viewer.
|
||||
abort_if(
|
||||
$request->user()?->isStaff() !== true && ! $this->settings->get(Setting::ClientsCanPreviewFiles),
|
||||
404,
|
||||
);
|
||||
|
||||
// A preview is not counted as a download, but it is refused once
|
||||
// the download limit is spent — because unless a listener asks
|
||||
// for a rendering (nothing does by default), the branches below
|
||||
// serve the *original bytes* at full size. Without this a cap
|
||||
// would be one URL away from meaningless for every image on the
|
||||
// install. thumbnail() needs no such guard: a 300px rendition is
|
||||
// not the file.
|
||||
// for a rendering (nothing does by default, and nothing ever does
|
||||
// for media), the branches below serve the *original bytes* at
|
||||
// full size. Without this a cap would be one URL away from
|
||||
// meaningless for every previewable file on the install.
|
||||
// thumbnail() needs no such guard: a 300px rendition is not the
|
||||
// file.
|
||||
abort_unless($this->allowance->allows($file, $request->user()), 403);
|
||||
|
||||
$this->activity->log(Action::FilePreviewed, subject: $file);
|
||||
$this->logPreview($file, $request);
|
||||
|
||||
$audience = ImageAudience::forViewer($request->user());
|
||||
if ($kind === PreviewKind::Image) {
|
||||
$audience = ImageAudience::forViewer($request->user());
|
||||
|
||||
$decision = new ResolvingImageRendering($audience, ImageRendition::Preview, $file->mime_type);
|
||||
Event::dispatch($decision);
|
||||
$decision = new ResolvingImageRendering($audience, ImageRendition::Preview, $file->mime_type);
|
||||
Event::dispatch($decision);
|
||||
|
||||
if ($decision->required) {
|
||||
$path = $this->render($file, $audience, ImageRendition::Preview);
|
||||
if ($decision->required) {
|
||||
$path = $this->render($file, $audience, ImageRendition::Preview);
|
||||
|
||||
abort_if($path === null, 404);
|
||||
abort_if($path === null, 404);
|
||||
|
||||
return $this->serve($file, $path);
|
||||
return $this->serve($file, $path);
|
||||
}
|
||||
}
|
||||
|
||||
if ($file->disk !== 'files') {
|
||||
$url = Storage::disk($file->disk)->temporaryUrl(
|
||||
$file->path,
|
||||
now()->addHour(),
|
||||
['ResponseContentDisposition' => ContentDisposition::inline($file->original_name)],
|
||||
);
|
||||
return $this->bytes->inline($file);
|
||||
}
|
||||
|
||||
return redirect()->away($url);
|
||||
/**
|
||||
* One log row per viewer per file per five minutes.
|
||||
*
|
||||
* Watching a video is a single deliberate act that the browser turns
|
||||
* into dozens of Range requests against this route, and each one
|
||||
* arrives here indistinguishable from someone clicking preview again.
|
||||
* Cache::add is the whole mechanism: it writes only if the key is
|
||||
* absent, so the first request through the window logs and the rest
|
||||
* are silent, without a read-then-write race between two of them.
|
||||
*
|
||||
* Keyed by viewer, so one client's playback never suppresses another
|
||||
* person's preview of the same file. Anonymous viewers do not reach
|
||||
* this route at all — see PublicGroupsController::preview.
|
||||
*/
|
||||
private function logPreview(File $file, Request $request): void
|
||||
{
|
||||
$key = 'file-preview-logged:'.$file->id.':'.($request->user()->id ?? 'guest');
|
||||
|
||||
if (Cache::add($key, true, now()->addMinutes(5))) {
|
||||
$this->activity->log(Action::FilePreviewed, subject: $file);
|
||||
}
|
||||
|
||||
return response('', 200, [
|
||||
'X-Accel-Redirect' => '/protected-files/'.$file->path,
|
||||
'Content-Type' => $file->mime_type,
|
||||
'Content-Disposition' => ContentDisposition::inline($file->original_name),
|
||||
'Content-Length' => (string) $file->size,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,15 +207,14 @@ class FileThumbnailController extends Controller
|
||||
}
|
||||
|
||||
$disk->makeDirectory(dirname($path));
|
||||
$sourcePath = $this->localSourcePathFor($file);
|
||||
|
||||
try {
|
||||
$this->thumbnails->generate($sourcePath, $disk->path($path), $file->mime_type, $audience, $rendition);
|
||||
} finally {
|
||||
if ($file->disk !== 'files') {
|
||||
@unlink($sourcePath);
|
||||
}
|
||||
}
|
||||
$this->source->use($file, fn (string $sourcePath) => $this->thumbnails->generate(
|
||||
$sourcePath,
|
||||
$disk->path($path),
|
||||
$file->mime_type,
|
||||
$audience,
|
||||
$rendition,
|
||||
));
|
||||
|
||||
return $path;
|
||||
}
|
||||
@@ -185,38 +227,4 @@ class FileThumbnailController extends Controller
|
||||
'Content-Disposition' => ContentDisposition::inline($file->original_name),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A local-disk file's real path (fast path). Anything else is
|
||||
* stream-copied to a temp file first — the caller unlinks it once
|
||||
* rendering is done.
|
||||
*/
|
||||
private function localSourcePathFor(File $file): string
|
||||
{
|
||||
if ($file->disk === 'files') {
|
||||
return Storage::disk('files')->path($file->path);
|
||||
}
|
||||
|
||||
$tempPath = tempnam(sys_get_temp_dir(), 'thumb-src-');
|
||||
|
||||
if ($tempPath === false) {
|
||||
throw new \RuntimeException('Could not create a temp file for '.$file->original_name);
|
||||
}
|
||||
|
||||
$stream = Storage::disk($file->disk)->readStream($file->path);
|
||||
$out = fopen($tempPath, 'wb');
|
||||
|
||||
if ($stream === null || $out === false) {
|
||||
throw new \RuntimeException('Could not read '.$file->original_name.' from its storage disk.');
|
||||
}
|
||||
|
||||
stream_copy_to_stream($stream, $out);
|
||||
fclose($out);
|
||||
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
|
||||
return $tempPath;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
@@ -206,6 +221,11 @@ class FilesController extends Controller
|
||||
'can_update' => Gate::forUser($viewer)->allows('update', $file),
|
||||
'can_delete' => Gate::forUser($viewer)->allows('delete', $file),
|
||||
'can_manage_public' => $viewer->can('upload_public'),
|
||||
// Whether this page offers its Activity tab. The file's own
|
||||
// page is where somebody lands from a link, a search or a
|
||||
// notification, so "what happened to this file" has to be
|
||||
// answerable here and not only from the library's list.
|
||||
'can_view_activity' => $viewer->can('view_actions_log'),
|
||||
// The per-file switch only does anything while the comment
|
||||
// scope is `selected`; under every other value the page hides
|
||||
// it rather than offer a control with no current effect.
|
||||
@@ -237,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
|
||||
@@ -250,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
|
||||
@@ -322,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;
|
||||
@@ -358,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
|
||||
@@ -260,6 +268,13 @@ class MyFilesController extends Controller
|
||||
'can_upload' => $client->can('upload'),
|
||||
'can_upload_here' => Folder::uploadableBy($client, $current),
|
||||
'can_create_folders' => $client->can('create_own_folders'),
|
||||
// Whether a row is clickable to look at rather than only to
|
||||
// take. Per page rather than per file: the mime type decides
|
||||
// which files can be previewed and every theme already knows
|
||||
// how to read one, so all this has to carry is whether the
|
||||
// installation offers it here at all. See
|
||||
// FileThumbnailController::preview, which re-checks it.
|
||||
'preview_enabled' => $this->settings->get(Setting::ClientsCanPreviewFiles),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -8,10 +8,10 @@ use App\Http\Controllers\Controller;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Access\DownloadAllowance;
|
||||
use App\Modules\Files\Delivery\StoredFileResponse;
|
||||
use App\Modules\Files\Models\Category;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\ShareLink;
|
||||
use App\Support\ContentDisposition;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Response;
|
||||
use Inertia\Inertia;
|
||||
@@ -28,6 +28,7 @@ class PublicShareController extends Controller
|
||||
public function __construct(
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly DownloadAllowance $allowance,
|
||||
private readonly StoredFileResponse $bytes,
|
||||
) {}
|
||||
|
||||
public function show(string $token): InertiaResponse
|
||||
@@ -101,11 +102,6 @@ class PublicShareController extends Controller
|
||||
|
||||
$this->activity->log(Action::ShareLinkDownloaded, 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Preview;
|
||||
|
||||
use App\Modules\Files\Thumbnails\ThumbnailGenerator;
|
||||
|
||||
/**
|
||||
* What kind of inline view, if any, a stored file gets — the single
|
||||
* answer to "may these bytes be served inline, and what element renders
|
||||
* them?", shared by FileThumbnailController::preview (signed in) and
|
||||
* PublicGroupsController::preview (anonymous).
|
||||
*
|
||||
* SECURITY: this is an allowlist, and it is the boundary. Preview serves
|
||||
* a file's own bytes inline, from this app's origin, labelled with the
|
||||
* mime type stored on the row — so anything a browser executes script
|
||||
* from would be same-origin script execution with the viewer's session.
|
||||
* Never add text/html, image/svg+xml, or any other document type, and
|
||||
* never derive this list from Setting::AllowedUploadExtensions: that
|
||||
* setting matches on the *extension* while mime_type is sniffed from the
|
||||
* *bytes* (ChunkedUploadsController::complete), so a .txt holding HTML is
|
||||
* stored as text/html and would arrive here looking allowed.
|
||||
*
|
||||
* Deliberately narrower than "files a browser might cope with": every
|
||||
* type below is one every current browser decodes natively. Formats like
|
||||
* video/quicktime, video/x-msvideo and video/x-matroska are left out
|
||||
* because an embedded player for them shows a black rectangle. They
|
||||
* upload and download exactly as before — only the inline view is
|
||||
* withheld.
|
||||
*
|
||||
* Distinct from ThumbnailGenerator::SUPPORTED_MIME_TYPES, which answers a
|
||||
* narrower question: which types this app can *decode and re-encode*
|
||||
* itself, and therefore has renditions, a cache and a watermark hook for.
|
||||
* Image delegates to it rather than restating it, so the two cannot drift.
|
||||
*/
|
||||
enum PreviewKind: string
|
||||
{
|
||||
/** Rendered with <img>; the only kind with thumbnails and renditions. */
|
||||
case Image = 'image';
|
||||
|
||||
/** Rendered with <video controls>. */
|
||||
case Video = 'video';
|
||||
|
||||
/** Rendered with <audio controls>. */
|
||||
case Audio = 'audio';
|
||||
|
||||
/** Rendered in a sandboxed <iframe>, by the browser's own viewer. */
|
||||
case Pdf = 'pdf';
|
||||
|
||||
/** @var list<string> */
|
||||
private const VIDEO_MIME_TYPES = [
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
'video/ogg',
|
||||
];
|
||||
|
||||
/**
|
||||
* More spellings than there are formats: the mime type is whatever
|
||||
* finfo made of the bytes, and it is not consistent across systems —
|
||||
* a .wav is audio/x-wav on one box and audio/vnd.wave on another, and
|
||||
* an .m4a can come back as audio/mp4 or audio/x-m4a.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const AUDIO_MIME_TYPES = [
|
||||
'audio/mpeg',
|
||||
'audio/wav',
|
||||
'audio/x-wav',
|
||||
'audio/vnd.wave',
|
||||
'audio/ogg',
|
||||
'audio/webm',
|
||||
'audio/mp4',
|
||||
'audio/x-m4a',
|
||||
'audio/aac',
|
||||
'audio/flac',
|
||||
'audio/x-flac',
|
||||
];
|
||||
|
||||
public static function forMime(string $mimeType): ?self
|
||||
{
|
||||
if (ThumbnailGenerator::supports($mimeType)) {
|
||||
return self::Image;
|
||||
}
|
||||
|
||||
if (in_array($mimeType, self::VIDEO_MIME_TYPES, true)) {
|
||||
return self::Video;
|
||||
}
|
||||
|
||||
if (in_array($mimeType, self::AUDIO_MIME_TYPES, true)) {
|
||||
return self::Audio;
|
||||
}
|
||||
|
||||
if ($mimeType === 'application/pdf') {
|
||||
return self::Pdf;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function supports(string $mimeType): bool
|
||||
{
|
||||
return self::forMime($mimeType) !== null;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Thumbnails;
|
||||
|
||||
use App\Modules\Files\Models\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* A real path on this machine for a stored file, so that something which
|
||||
* can only work on local bytes — image and video rendering, all of which
|
||||
* shells out or hands a path to a C library — can work on any file
|
||||
* whatever disk it lives on.
|
||||
*
|
||||
* A local file is used where it lies. Anything else is stream-copied to a
|
||||
* temp file and removed afterwards.
|
||||
*
|
||||
* The callback shape is the point. This started as a private method on
|
||||
* one controller that returned a path and left the caller to unlink it,
|
||||
* and the second place that needed it did not call it at all — it passed
|
||||
* the *local* disk's path() for a file on external storage, which is a
|
||||
* path that does not exist, so every public-listing thumbnail of an
|
||||
* externally stored file failed. Handing back a path is an invitation to
|
||||
* both of those mistakes; a closure that owns the lifetime is not.
|
||||
*/
|
||||
class LocalSourceFile
|
||||
{
|
||||
/**
|
||||
* @template TReturn
|
||||
*
|
||||
* @param callable(string): TReturn $work
|
||||
* @return TReturn
|
||||
*/
|
||||
public function use(File $file, callable $work): mixed
|
||||
{
|
||||
if ($file->disk === 'files') {
|
||||
return $work(Storage::disk('files')->path($file->path));
|
||||
}
|
||||
|
||||
$tempPath = tempnam(sys_get_temp_dir(), 'thumb-src-');
|
||||
|
||||
if ($tempPath === false) {
|
||||
throw new RuntimeException('Could not create a temp file for '.$file->original_name);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->copyDown($file, $tempPath);
|
||||
|
||||
return $work($tempPath);
|
||||
} finally {
|
||||
@unlink($tempPath);
|
||||
}
|
||||
}
|
||||
|
||||
private function copyDown(File $file, string $tempPath): void
|
||||
{
|
||||
$stream = Storage::disk($file->disk)->readStream($file->path);
|
||||
$out = fopen($tempPath, 'wb');
|
||||
|
||||
if ($stream === null || $out === false) {
|
||||
if (is_resource($out)) {
|
||||
fclose($out);
|
||||
}
|
||||
|
||||
throw new RuntimeException('Could not read '.$file->original_name.' from its storage disk.');
|
||||
}
|
||||
|
||||
try {
|
||||
stream_copy_to_stream($stream, $out);
|
||||
} finally {
|
||||
fclose($out);
|
||||
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Modules\Files\Uploads;
|
||||
use App\Modules\Files\Storage\ResolvingUploadDisk;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\File as FileSystem;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use RuntimeException;
|
||||
@@ -203,12 +204,40 @@ class LocalPartStore
|
||||
Event::dispatch($diskEvent);
|
||||
$disk = $diskEvent->disk;
|
||||
|
||||
Storage::disk($disk)->writeStream($targetPath, $readStream);
|
||||
$written = Storage::disk($disk)->writeStream($targetPath, $readStream);
|
||||
|
||||
if (is_resource($readStream)) {
|
||||
fclose($readStream);
|
||||
}
|
||||
|
||||
// The disks are configured with 'throw' => false, so a refused
|
||||
// write is a `false` return rather than an exception — and the
|
||||
// caller goes on to record a File row for bytes that were never
|
||||
// stored. Losing an upload silently is worse than failing it, and
|
||||
// this is the only place that can tell the difference: a real
|
||||
// instance of it was a GCS bucket rejecting the adapter's ACL,
|
||||
// which looked exactly like a successful upload.
|
||||
if ($written === false) {
|
||||
// The reason is lost by the time it gets here — 'throw' => false
|
||||
// means Flysystem swallowed the exception rather than passing it
|
||||
// on — so log what was attempted. Which bucket it was is the
|
||||
// difference between reading this as "my credentials expired"
|
||||
// and "I typed the wrong bucket name", and only the log can say
|
||||
// it: the message below is shown to whoever was uploading, which
|
||||
// includes clients, and a bucket name is not theirs to see.
|
||||
Log::error('Upload could not be written to storage.', [
|
||||
'disk' => $disk,
|
||||
'bucket' => config('filesystems.disks.'.$disk.'.bucket'),
|
||||
'driver' => config('filesystems.disks.'.$disk.'.driver'),
|
||||
'path' => $targetPath,
|
||||
]);
|
||||
|
||||
throw new RuntimeException(
|
||||
'Could not write the assembled upload to the "'.$disk.'" disk. '
|
||||
.'Check the storage backend is reachable and its credentials are still valid.'
|
||||
);
|
||||
}
|
||||
|
||||
$this->abort($session);
|
||||
|
||||
return [
|
||||
@@ -226,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,14 @@ use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Comments\CommentingRules;
|
||||
use App\Modules\Files\Access\DownloadAllowance;
|
||||
use App\Modules\Files\Delivery\StoredFileResponse;
|
||||
use App\Modules\Files\Models\Category;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Files\Preview\PreviewKind;
|
||||
use App\Modules\Files\Thumbnails\ImageAudience;
|
||||
use App\Modules\Files\Thumbnails\ImageRendition;
|
||||
use App\Modules\Files\Thumbnails\LocalSourceFile;
|
||||
use App\Modules\Files\Thumbnails\ThumbnailGenerator;
|
||||
use App\Modules\Files\Versions\FileVersionLinks;
|
||||
use App\Modules\Groups\Http\Controllers\Concerns\InteractsWithPublicListing;
|
||||
@@ -79,6 +82,8 @@ class PublicGroupsController extends Controller
|
||||
private readonly PublicThemeRegistry $themes,
|
||||
private readonly CapabilityRegistry $capabilities,
|
||||
private readonly CommentingRules $commenting,
|
||||
private readonly StoredFileResponse $bytes,
|
||||
private readonly LocalSourceFile $source,
|
||||
) {}
|
||||
|
||||
public function index(Request $request, string $publicSlug): InertiaResponse|RedirectResponse
|
||||
@@ -208,6 +213,13 @@ class PublicGroupsController extends Controller
|
||||
'thumbnail_url' => ThumbnailGenerator::supports($file->mime_type)
|
||||
? route('public.thumbnail', [$publicSlug, $file->slug])
|
||||
: null,
|
||||
// Null whenever preview is unavailable, for any of the three
|
||||
// reasons — switched off, wrong type, or the download limit
|
||||
// spent — so a theme has one thing to check and the setting
|
||||
// itself never ships to a visitor's browser. preview() below
|
||||
// re-checks all three: this decides what to offer, not what
|
||||
// is allowed.
|
||||
'preview_url' => $this->previewUrlFor($file, $publicSlug),
|
||||
'download_url' => route('public.download', [$publicSlug, $file->slug]),
|
||||
// Same decided shape the listings send, so a theme's single
|
||||
// file page disables its button for the same reason a row
|
||||
@@ -241,7 +253,18 @@ class PublicGroupsController extends Controller
|
||||
|
||||
if (! $disk->exists($thumbnailPath)) {
|
||||
$disk->makeDirectory(dirname($thumbnailPath));
|
||||
$this->thumbnails->generate($disk->path($file->path), $disk->path($thumbnailPath), $file->mime_type, ImageAudience::External, ImageRendition::Thumbnail);
|
||||
|
||||
// Never $disk->path($file->path): the rendition is cached on
|
||||
// the local disk, but the *source* lives on whichever disk the
|
||||
// file was uploaded to, and a local path for an externally
|
||||
// stored file is a path that does not exist.
|
||||
$this->source->use($file, fn (string $sourcePath) => $this->thumbnails->generate(
|
||||
$sourcePath,
|
||||
$disk->path($thumbnailPath),
|
||||
$file->mime_type,
|
||||
ImageAudience::External,
|
||||
ImageRendition::Thumbnail,
|
||||
));
|
||||
}
|
||||
|
||||
return response('', 200, [
|
||||
@@ -251,7 +274,59 @@ class PublicGroupsController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function download(string $publicSlug, File $file): Response
|
||||
/**
|
||||
* The anonymous twin of FileThumbnailController::preview: a public
|
||||
* file shown rather than handed over.
|
||||
*
|
||||
* Nothing is rendered or cached here — an anonymous viewer only ever
|
||||
* previews the stored bytes. The watermark hook that decorates a
|
||||
* client's image preview has no equivalent on this route, for the
|
||||
* same reason thumbnail() hardcodes ImageAudience::External: there is
|
||||
* no viewer to tell apart.
|
||||
*/
|
||||
public function preview(string $publicSlug, File $file): Response|RedirectResponse
|
||||
{
|
||||
$this->guardSlug($publicSlug);
|
||||
|
||||
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 404);
|
||||
abort_unless($this->settings->get(Setting::PublicListingPreviewEnabled) === true, 404);
|
||||
abort_if(PreviewKind::forMime($file->mime_type) === null, 404);
|
||||
|
||||
// 403 rather than 404 for the same reason download() does it, and
|
||||
// it is the same allowance being read: preview serves the whole
|
||||
// file, so a spent cap has to close this door too or it closes
|
||||
// nothing.
|
||||
abort_unless($this->allowance->allows($file, null), 403);
|
||||
|
||||
$this->activity->log(Action::PublicFilePreviewed, subject: $file);
|
||||
|
||||
return $this->bytes->inline($file);
|
||||
}
|
||||
|
||||
/**
|
||||
* What showFile() offers, which is not the same question as what
|
||||
* preview() permits — this one also declines to advertise a preview
|
||||
* whose download limit is already spent, so a visitor is not given a
|
||||
* button that can only answer 403.
|
||||
*/
|
||||
private function previewUrlFor(File $file, string $publicSlug): ?string
|
||||
{
|
||||
if ($this->settings->get(Setting::PublicListingPreviewEnabled) !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (PreviewKind::forMime($file->mime_type) === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->allowance->allows($file, null)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return route('public.preview', [$publicSlug, $file->slug]);
|
||||
}
|
||||
|
||||
public function download(string $publicSlug, File $file): Response|RedirectResponse
|
||||
{
|
||||
$this->guardSlug($publicSlug);
|
||||
|
||||
@@ -265,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ use App\Modules\Identity\Social\SocialProvider;
|
||||
use App\Modules\Identity\Social\SocialSettings;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse as SymfonyRedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
/**
|
||||
@@ -57,13 +58,13 @@ class SocialLoginController extends Controller
|
||||
}
|
||||
|
||||
/** Begin a sign-in. */
|
||||
public function redirect(Request $request, string $provider): SymfonyRedirectResponse|RedirectResponse
|
||||
public function redirect(Request $request, string $provider): Response
|
||||
{
|
||||
return $this->begin($request, $provider, 'login');
|
||||
}
|
||||
|
||||
/** Begin connecting a provider to the signed-in account. */
|
||||
public function connect(Request $request, string $provider): SymfonyRedirectResponse|RedirectResponse
|
||||
public function connect(Request $request, string $provider): Response
|
||||
{
|
||||
return $this->begin($request, $provider, 'link');
|
||||
}
|
||||
@@ -130,7 +131,7 @@ class SocialLoginController extends Controller
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
private function begin(Request $request, string $provider, string $intent): SymfonyRedirectResponse|RedirectResponse
|
||||
private function begin(Request $request, string $provider, string $intent): Response
|
||||
{
|
||||
$case = $this->provider($provider);
|
||||
$settings = SocialSettings::for($case);
|
||||
@@ -142,7 +143,13 @@ class SocialLoginController extends Controller
|
||||
|
||||
$request->session()->put([self::INTENT => $intent, self::PROVIDER => $case->value]);
|
||||
|
||||
return $this->gateway()->redirect($settings);
|
||||
// Inertia::location(), not the redirect itself. Connecting starts
|
||||
// as an Inertia XHR from the settings screen, and an XHR follows a
|
||||
// 302 to the provider cross-origin, where CORS kills it before the
|
||||
// person ever leaves the page. The 409 + X-Inertia-Location pair
|
||||
// makes the client navigate top-level instead; a plain browser
|
||||
// request — the login flow — passes through unchanged.
|
||||
return Inertia::location($this->gateway()->redirect($settings));
|
||||
}
|
||||
|
||||
private function completeLink(Request $request, SocialSettings $settings, SocialIdentity $identity): RedirectResponse
|
||||
|
||||
@@ -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';
|
||||
@@ -43,6 +50,15 @@ enum Capability: string
|
||||
// package (github.com/projectsend/cloud-modules), never in this repo.
|
||||
case Branding = 'branding.customize';
|
||||
|
||||
// Cloud-only — the storage backend is ours, supplied by the
|
||||
// environment when the instance is provisioned and not the customer's
|
||||
// to see or change. The counterpart of StorageConfigure above rather
|
||||
// than a contradiction of it: one edition configures its own bucket,
|
||||
// the other is given one. Behaviour lives in the private
|
||||
// projectsend/cloud-modules package; without it this capability is
|
||||
// simply inert and files stay on local disk.
|
||||
case StorageManaged = 'storage.managed';
|
||||
|
||||
// Cloud-only — managed installations supply CAPTCHA keys centrally, so
|
||||
// protection is on before anybody finds the settings screen. The
|
||||
// feature itself is in both editions and behind no capability: this
|
||||
@@ -50,21 +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::CaptchaManagedKeys => [Edition::Cloud],
|
||||
self::StorageManaged,
|
||||
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,
|
||||
|
||||
@@ -9,12 +9,17 @@ use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Platform\Settings\ExternalStorageConfigApplier;
|
||||
use App\Modules\Platform\Settings\ExternalStorageSettings;
|
||||
use App\Modules\Platform\Settings\StorageProvider;
|
||||
use Aws\S3\S3Client;
|
||||
use Closure;
|
||||
use Google\Cloud\Storage\StorageClient;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
@@ -37,6 +42,7 @@ class ExternalStorageSettingsController extends Controller
|
||||
|
||||
return Inertia::render('system/settings/storage', [
|
||||
'active' => $settings->active,
|
||||
'provider' => $settings->provider->value,
|
||||
// Never name a top-level Inertia prop "key" — Inertia's React
|
||||
// renderer spreads page props onto the component via
|
||||
// `{ key: <internal-remount-key>, ...props }`, and a prop
|
||||
@@ -45,6 +51,10 @@ class ExternalStorageSettingsController extends Controller
|
||||
// the component as an actual prop (React always strips `key`).
|
||||
'access_key' => $settings->key ?? '',
|
||||
'has_secret' => $settings->secret !== null && $settings->secret !== '',
|
||||
// Same treatment as the secret: never round-tripped, only
|
||||
// whether one is stored. A service account key file is more
|
||||
// sensitive than an access key, not less.
|
||||
'has_key_file' => $settings->key_file !== null && $settings->key_file !== '',
|
||||
'bucket' => $settings->bucket ?? '',
|
||||
'region' => $settings->region ?? '',
|
||||
'endpoint' => $settings->endpoint ?? '',
|
||||
@@ -56,35 +66,56 @@ class ExternalStorageSettingsController extends Controller
|
||||
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$request->merge(['provider' => $request->input('provider', StorageProvider::S3->value)]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'active' => ['required', 'boolean'],
|
||||
'access_key' => ['required', 'string', 'max:255'],
|
||||
'secret' => ['nullable', 'string', 'max:255'],
|
||||
// 'sometimes', not 'required': absent means S3, which is what
|
||||
// every payload written before this choice existed meant, and
|
||||
// stops a browser holding a stale bundle from failing to save
|
||||
// on a field it cannot see.
|
||||
'provider' => ['sometimes', Rule::enum(StorageProvider::class)],
|
||||
'bucket' => ['required', 'string', 'max:255'],
|
||||
'region' => ['required', 'string', 'max:255'],
|
||||
'root' => ['nullable', 'string', 'max:255'],
|
||||
|
||||
// Required only for the provider that uses them, so switching
|
||||
// to GCS does not demand an AWS region that means nothing.
|
||||
'access_key' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'],
|
||||
'secret' => ['nullable', 'string', 'max:255'],
|
||||
'region' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'],
|
||||
'endpoint' => ['nullable', 'string', 'max:255'],
|
||||
'use_path_style' => ['required', 'boolean'],
|
||||
'root' => ['nullable', 'string', 'max:255'],
|
||||
|
||||
// Checked for shape here rather than left to fail at the first
|
||||
// upload: a key file is pasted, and a paste that lost its last
|
||||
// line is the likeliest way this goes wrong.
|
||||
'key_file' => ['nullable', 'string', self::serviceAccountKeyRule()],
|
||||
]);
|
||||
|
||||
$settings = ExternalStorageSettings::current();
|
||||
|
||||
$settings->fill([
|
||||
'active' => $validated['active'],
|
||||
'key' => $validated['access_key'],
|
||||
'provider' => $validated['provider'],
|
||||
'key' => $validated['access_key'] ?? null,
|
||||
'bucket' => $validated['bucket'],
|
||||
'region' => $validated['region'],
|
||||
'region' => $validated['region'] ?? null,
|
||||
'endpoint' => $validated['endpoint'] ?? null,
|
||||
'use_path_style' => $validated['use_path_style'],
|
||||
'root' => $validated['root'] ?? null,
|
||||
]);
|
||||
|
||||
// A blank secret keeps whatever is already stored — the field is
|
||||
// never round-tripped to the browser (only `has_secret` is).
|
||||
// A blank credential keeps whatever is already stored — neither
|
||||
// field is ever round-tripped to the browser (only the has_*
|
||||
// flags are), so blank means "unchanged", not "cleared".
|
||||
if (is_string($validated['secret'] ?? null) && $validated['secret'] !== '') {
|
||||
$settings->secret = $validated['secret'];
|
||||
}
|
||||
|
||||
if (is_string($validated['key_file'] ?? null) && $validated['key_file'] !== '') {
|
||||
$settings->key_file = $validated['key_file'];
|
||||
}
|
||||
|
||||
$settings->save();
|
||||
|
||||
$this->configApplier->flush();
|
||||
@@ -101,43 +132,31 @@ class ExternalStorageSettingsController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the submitted (or, if the secret field was left blank, the
|
||||
* already-stored) credentials can actually reach the bucket, mirroring
|
||||
* v1's connection test — this exists specifically to catch a typo'd
|
||||
* key/bucket/region before switching uploads over to it.
|
||||
* Verifies the submitted (or, where a credential field was left
|
||||
* blank, the already-stored) details can actually reach the bucket,
|
||||
* mirroring v1's connection test — this exists specifically to catch
|
||||
* a typo'd key/bucket/region before switching uploads over to it.
|
||||
*/
|
||||
public function testConnection(Request $request): RedirectResponse
|
||||
{
|
||||
$request->merge(['provider' => $request->input('provider', StorageProvider::S3->value)]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'access_key' => ['required', 'string', 'max:255'],
|
||||
'secret' => ['nullable', 'string', 'max:255'],
|
||||
'provider' => ['sometimes', Rule::enum(StorageProvider::class)],
|
||||
'bucket' => ['required', 'string', 'max:255'],
|
||||
'region' => ['required', 'string', 'max:255'],
|
||||
'access_key' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'],
|
||||
'secret' => ['nullable', 'string', 'max:255'],
|
||||
'region' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'],
|
||||
'endpoint' => ['nullable', 'string', 'max:255'],
|
||||
'use_path_style' => ['nullable', 'boolean'],
|
||||
'key_file' => ['nullable', 'string', self::serviceAccountKeyRule()],
|
||||
]);
|
||||
|
||||
$settings = ExternalStorageSettings::current();
|
||||
$secret = (is_string($validated['secret'] ?? null) && $validated['secret'] !== '')
|
||||
? $validated['secret']
|
||||
: $settings->secret;
|
||||
|
||||
try {
|
||||
$config = [
|
||||
'version' => 'latest',
|
||||
'region' => $validated['region'],
|
||||
'credentials' => [
|
||||
'key' => $validated['access_key'],
|
||||
'secret' => (string) $secret,
|
||||
],
|
||||
'use_path_style_endpoint' => (bool) ($validated['use_path_style'] ?? false),
|
||||
];
|
||||
|
||||
if (is_string($validated['endpoint'] ?? null) && $validated['endpoint'] !== '') {
|
||||
$config['endpoint'] = $validated['endpoint'];
|
||||
}
|
||||
|
||||
(new S3Client($config))->headBucket(['Bucket' => $validated['bucket']]);
|
||||
match (StorageProvider::from($validated['provider'])) {
|
||||
StorageProvider::S3 => $this->probeS3($validated),
|
||||
StorageProvider::Gcs => $this->probeGcs($validated),
|
||||
};
|
||||
|
||||
$result = __('Success: connected to bucket ":bucket".', ['bucket' => $validated['bucket']]);
|
||||
} catch (Throwable $e) {
|
||||
@@ -146,4 +165,91 @@ class ExternalStorageSettingsController extends Controller
|
||||
|
||||
return back()->with('storage_test_result', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $validated
|
||||
*/
|
||||
private function probeS3(array $validated): void
|
||||
{
|
||||
$config = [
|
||||
'version' => 'latest',
|
||||
'region' => $validated['region'],
|
||||
'credentials' => [
|
||||
'key' => $validated['access_key'],
|
||||
'secret' => (string) $this->storedIfBlank($validated, 'secret'),
|
||||
],
|
||||
'use_path_style_endpoint' => (bool) ($validated['use_path_style'] ?? false),
|
||||
];
|
||||
|
||||
if (is_string($validated['endpoint'] ?? null) && $validated['endpoint'] !== '') {
|
||||
$config['endpoint'] = $validated['endpoint'];
|
||||
}
|
||||
|
||||
(new S3Client($config))->headBucket(['Bucket' => $validated['bucket']]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $validated
|
||||
*/
|
||||
private function probeGcs(array $validated): void
|
||||
{
|
||||
$keyFile = json_decode((string) $this->storedIfBlank($validated, 'key_file'), true);
|
||||
|
||||
if (! is_array($keyFile)) {
|
||||
throw new RuntimeException(__('No service account key has been saved yet.'));
|
||||
}
|
||||
|
||||
$bucket = (new StorageClient(['keyFile' => $keyFile]))->bucket($validated['bucket']);
|
||||
|
||||
// Listing one object rather than asking whether the bucket exists.
|
||||
// A least-privilege key — roles/storage.objectAdmin scoped to this
|
||||
// bucket, which is what the whole design rests on — can read and
|
||||
// write objects but cannot read the bucket's own metadata, so
|
||||
// $bucket->exists() reports failure for a key that works perfectly.
|
||||
// An empty bucket is a valid answer here, and returns no rows.
|
||||
iterator_to_array($bucket->objects(['maxResults' => 1]), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* A credential field left blank means "keep what is stored" on save,
|
||||
* so the connection test has to read it the same way — otherwise
|
||||
* testing an unchanged configuration would always fail.
|
||||
*
|
||||
* @param array<string, mixed> $validated
|
||||
*/
|
||||
private function storedIfBlank(array $validated, string $field): ?string
|
||||
{
|
||||
$submitted = $validated[$field] ?? null;
|
||||
|
||||
if (is_string($submitted) && $submitted !== '') {
|
||||
return $submitted;
|
||||
}
|
||||
|
||||
return ExternalStorageSettings::current()->{$field};
|
||||
}
|
||||
|
||||
/**
|
||||
* A pasted service account key, checked for the parts that have to be
|
||||
* there. Not a credential check — that is what Test connection is for.
|
||||
*/
|
||||
private static function serviceAccountKeyRule(): Closure
|
||||
{
|
||||
return function (string $attribute, mixed $value, Closure $fail): void {
|
||||
$decoded = json_decode((string) $value, true);
|
||||
|
||||
if (! is_array($decoded)) {
|
||||
$fail(__('That does not look like a service account key file: it is not valid JSON.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (['client_email', 'private_key'] as $required) {
|
||||
if (! isset($decoded[$required]) || ! is_string($decoded[$required]) || $decoded[$required] === '') {
|
||||
$fail(__('That service account key file is missing its :field.', ['field' => $required]));
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ class PublicListingSettingsController extends Controller
|
||||
return Inertia::render('system/settings/public-listing', [
|
||||
'public_listing_enabled' => $this->settings->get(Setting::PublicListingEnabled),
|
||||
'public_listing_slug' => $this->settings->get(Setting::PublicListingSlug),
|
||||
'public_listing_preview_enabled' => $this->settings->get(Setting::PublicListingPreviewEnabled),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -42,10 +43,12 @@ class PublicListingSettingsController extends Controller
|
||||
$validated = $request->validate([
|
||||
'public_listing_enabled' => ['required', 'boolean'],
|
||||
'public_listing_slug' => ['required', 'string', 'max:255', 'regex:/^[a-z0-9]+(-[a-z0-9]+)*$/'],
|
||||
'public_listing_preview_enabled' => ['required', 'boolean'],
|
||||
]);
|
||||
|
||||
$this->settings->set(Setting::PublicListingEnabled, $validated['public_listing_enabled']);
|
||||
$this->settings->set(Setting::PublicListingSlug, $validated['public_listing_slug']);
|
||||
$this->settings->set(Setting::PublicListingPreviewEnabled, $validated['public_listing_preview_enabled']);
|
||||
|
||||
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'public_listing']);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -5,35 +5,56 @@ declare(strict_types=1);
|
||||
namespace App\Modules\Platform\Installation;
|
||||
|
||||
/**
|
||||
* Whether this installation runs from a container image or from files on a
|
||||
* server somebody administers directly.
|
||||
* Whether this installation runs from a container image, from a container
|
||||
* the operator builds themselves, or from files on a server somebody
|
||||
* administers directly.
|
||||
*
|
||||
* It exists because the application tells administrators how to upgrade, and
|
||||
* the two answers have nothing in common. A container is replaced —
|
||||
* `docker compose pull && docker compose up -d`, with the entrypoint running
|
||||
* the migrations on the way up. A manual install is a sequence somebody
|
||||
* performs by hand: back up, take the site down, unpack the release over the
|
||||
* directory, migrate, refresh the caches, bring it back (INSTALL.md).
|
||||
* the three answers have nothing in common. A published container is
|
||||
* replaced — `docker compose pull && docker compose up -d`, with the
|
||||
* entrypoint running the migrations on the way up. A container built from a
|
||||
* checkout has to be given new code and rebuilt. A manual install is a
|
||||
* sequence somebody performs by hand: back up, take the site down, unpack
|
||||
* the release over the directory, migrate, refresh the caches, bring it back
|
||||
* (INSTALL.md).
|
||||
*
|
||||
* Printing the container command to someone who installed from a zip is
|
||||
* worse than printing nothing: it names a tool they do not have, for a stack
|
||||
* they are not running, at the exact moment they are trying to do the right
|
||||
* thing. That was the behaviour before this class existed — the command was
|
||||
* a hardcoded string in two React components, written when Docker was the
|
||||
* only supported path.
|
||||
* Printing the wrong one of those is worse than printing nothing. For a
|
||||
* manual install the container command names a tool they do not have, for a
|
||||
* stack they are not running, at the exact moment they are trying to do the
|
||||
* right thing — that was the behaviour before this class existed, when the
|
||||
* command was a hardcoded string in two React components. For a stack built
|
||||
* from a checkout it is worse still, because the command runs: `pull` skips
|
||||
* services that have no image to pull and `up -d` then finds every container
|
||||
* already current, so the update reports success and changes nothing, and
|
||||
* the dashboard goes on offering the same release forever (#1661).
|
||||
*
|
||||
* The detection is the presence of the file a container runtime leaves in
|
||||
* the root filesystem. It is a deliberately conservative signal: something
|
||||
* exotic enough to run neither Docker nor Podman is reported as a manual
|
||||
* install, which is the safer wrong answer of the two — the manual
|
||||
* instructions are steps a person follows and check for themselves, while
|
||||
* the container command is one they would paste.
|
||||
* Two signals, in order:
|
||||
*
|
||||
* 1. The published image sets PROJECTSEND_IMAGE. A positive marker set at
|
||||
* build time is the only one a bind mount can neither forge nor hide.
|
||||
* 2. Failing that — images published before that variable existed — a
|
||||
* working tree in the install directory. The image is built from an
|
||||
* unpacked release artifact and has none; the Compose stack in the
|
||||
* repository bind-mounts the repository itself.
|
||||
*
|
||||
* Being in a container at all is the presence of the file a container
|
||||
* runtime leaves in the root filesystem. It is a deliberately conservative
|
||||
* signal: something exotic enough to run neither Docker nor Podman is
|
||||
* reported as a manual install, which is the safer wrong answer of the
|
||||
* three — the manual instructions are steps a person follows and checks for
|
||||
* themselves, while the container commands are ones they would paste.
|
||||
*/
|
||||
class Installation
|
||||
{
|
||||
public function kind(): InstallationKind
|
||||
{
|
||||
return $this->inContainer() ? InstallationKind::Container : InstallationKind::Manual;
|
||||
if (! $this->inContainer()) {
|
||||
return InstallationKind::Manual;
|
||||
}
|
||||
|
||||
return $this->builtFromSource()
|
||||
? InstallationKind::ContainerSource
|
||||
: InstallationKind::Container;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,6 +64,33 @@ class Installation
|
||||
protected function inContainer(): bool
|
||||
{
|
||||
// Docker writes the first; Podman writes the second.
|
||||
return file_exists('/.dockerenv') || file_exists('/run/.containerenv');
|
||||
//
|
||||
// Suppressed, and it has to stay that way. Shared hosting sets
|
||||
// open_basedir to the webspace, and probing a path outside it is a
|
||||
// warning rather than a false — which the framework's error handler
|
||||
// turns into an exception, so the one call that asks which install
|
||||
// this is took the whole dashboard down with it (#1663). Under `@`
|
||||
// the warning is filtered and the probe answers false, which is the
|
||||
// right answer anyway: a host that restricts PHP to a vhost
|
||||
// directory is not the container image.
|
||||
return @file_exists('/.dockerenv') || @file_exists('/run/.containerenv');
|
||||
}
|
||||
|
||||
/**
|
||||
* Protected for the same reason as inContainer(), and answered the same
|
||||
* way in tests.
|
||||
*/
|
||||
protected function builtFromSource(): bool
|
||||
{
|
||||
// getenv() rather than env(): once the configuration is cached,
|
||||
// env() outside a config file returns null, and the answer would
|
||||
// silently flip on the installs most likely to have cached it.
|
||||
if (getenv('PROJECTSEND_IMAGE') === '1') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A worktree checkout writes .git as a file rather than a
|
||||
// directory, so ask whether it exists, not what it is.
|
||||
return file_exists(base_path('.git'));
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user