mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-04 05:25:51 +00:00
Compare commits
248 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 | |||
| e9dabc39e3 | |||
| 9dcfe1eb26 | |||
| c210187dab | |||
| 23aacc2b2a | |||
| 469ba8d8c8 | |||
| be44b4b6aa | |||
| 7c6c49e5f4 | |||
| 31f9b22b53 | |||
| dd779fafbe | |||
| 18917adaf2 | |||
| 8156664ccc | |||
| ae510e3e34 | |||
| 352b19a061 | |||
| d1acf18d67 | |||
| 2567d9f193 | |||
| 7093d1e24b | |||
| d32562e75c | |||
| 24e3019e21 | |||
| 98597d462d | |||
| 6a4e6df21a | |||
| bba0d78499 | |||
| b33f4c389a | |||
| cab9291d29 | |||
| b671d0d74a | |||
| 44f015c066 | |||
| e87ceb60ba | |||
| d888145b21 | |||
| 997debc6a3 | |||
| 9192779ee4 | |||
| a8bc2156cd | |||
| 928173e8be | |||
| c052175690 | |||
| de615cbd79 | |||
| 3f36630d46 | |||
| a8a7f3f340 | |||
| 29f1eaaa1b | |||
| 57c4b892ce | |||
| 090e975bb3 | |||
| b72d30d89e | |||
| 046567fdfc | |||
| 4ce6793da9 | |||
| abe97a1f4d | |||
| 55b02d510d | |||
| 120ba10972 | |||
| e3c58818e2 | |||
| 6ddfc1aa5d | |||
| 463f2aac30 | |||
| 982a682803 | |||
| f446398dfd | |||
| 745f24c7d9 | |||
| dad8d21dc8 | |||
| 0b994aebe2 | |||
| d30c2ddbb1 | |||
| 48bc0d4960 | |||
| ed0d36de25 | |||
| 72749a9070 | |||
| 5cc4c6254b | |||
| 14333d08f1 | |||
| 8e5e73a2cd | |||
| 4f10401c36 | |||
| 0e3f7d5c68 | |||
| d53bb9a2f7 | |||
| 9b265e3b87 | |||
| 377dff7603 |
+9
-1
@@ -61,6 +61,15 @@ SESSION_DOMAIN=null
|
|||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
BROADCAST_CONNECTION=log
|
||||||
FILESYSTEM_DISK=local
|
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
|
QUEUE_CONNECTION=redis
|
||||||
|
|
||||||
CACHE_STORE=redis
|
CACHE_STORE=redis
|
||||||
@@ -88,4 +97,3 @@ AWS_DEFAULT_REGION=us-east-1
|
|||||||
AWS_BUCKET=
|
AWS_BUCKET=
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 402 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 423 KiB After Width: | Height: | Size: 403 KiB |
@@ -27,10 +27,28 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
cla:
|
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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: CLA check
|
- 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
|
uses: contributor-assistant/github-action@v2.6.1
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
+45
-25
@@ -5,13 +5,33 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- develop
|
- develop
|
||||||
- main
|
- 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:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- develop
|
- develop
|
||||||
- main
|
- main
|
||||||
|
paths:
|
||||||
|
- 'resources/**'
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
- 'eslint.config.js'
|
||||||
|
- '.prettierrc*'
|
||||||
|
- 'tsconfig.json'
|
||||||
|
- '.github/workflows/lint.yml'
|
||||||
|
|
||||||
permissions:
|
# A second push supersedes the first.
|
||||||
contents: write
|
concurrency:
|
||||||
|
group: linter-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
quality:
|
quality:
|
||||||
@@ -19,32 +39,32 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup PHP
|
- uses: actions/setup-node@v4
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
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
|
- name: Install Dependencies
|
||||||
env:
|
run: npm ci
|
||||||
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
|
|
||||||
|
|
||||||
|
# `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
|
- name: Lint Frontend
|
||||||
run: npm run lint
|
run: npx eslint .
|
||||||
|
|
||||||
# - name: Commit Changes
|
# Two steps used to live here and were removed on 2026-08-23, because
|
||||||
# uses: stefanzweifel/git-auto-commit-action@v5
|
# neither could ever fail:
|
||||||
# with:
|
#
|
||||||
# commit_message: fix code style
|
# - `vendor/bin/pint`, without `--test` and with the auto-commit step
|
||||||
# commit_options: '--no-verify'
|
# 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:
|
branches:
|
||||||
- develop
|
- develop
|
||||||
- main
|
- 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:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- develop
|
- develop
|
||||||
- main
|
- 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:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
@@ -87,5 +138,17 @@ jobs:
|
|||||||
- name: Static Analysis
|
- name: Static Analysis
|
||||||
run: ./vendor/bin/phpstan analyse --no-progress
|
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
|
- 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
|
/database/seeders/DevDataSeeder.php
|
||||||
/docs/*.md
|
/docs/*.md
|
||||||
!/docs/api-guide.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/
|
||||||
|
|||||||
+209
@@ -13,6 +13,215 @@ 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
|
This section collects changes as they land; the release process turns it into a numbered entry when
|
||||||
a version is cut.
|
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
|
||||||
|
rather than just stopping, and — on a server you run yourself — reduces the whole procedure to one
|
||||||
|
command that asks before each step. This is also the first release published as an official Docker
|
||||||
|
image.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Ask for an update the moment you want to know.** ProjectSend checks once a day on its own,
|
||||||
|
which is no help to somebody who has just read that a release fixes the thing biting them. There
|
||||||
|
is now a **Check now** button, and it says what it found rather than only that it ran.
|
||||||
|
- **Updating ends somewhere.** The first time an administrator opens ProjectSend after an update,
|
||||||
|
they land on a page saying which version they are now on and what came with it — and pointing at
|
||||||
|
ProjectSend's Discord, which is where release news and help actually live.
|
||||||
|
- **A first run that shows you around.** A brand-new installation greets its administrator once,
|
||||||
|
with a short list of the things worth doing before real files go in. Steps tick themselves off as
|
||||||
|
you do them.
|
||||||
|
- **About says when this installation was last updated**, and to which version. It is the answer to
|
||||||
|
"when did this change?", asked after something looks different or by whoever inherited the server.
|
||||||
|
- **The database stops quietly filling up with things nobody reads.** Permanently failed emails and
|
||||||
|
already-read notifications both grew forever. Both now have a retention window you set under
|
||||||
|
**Housekeeping** on the Scheduler screen, with a nightly purge that honours it — thirty days and
|
||||||
|
ninety by default, and `0` to keep everything. Unread notifications are never deleted, whatever
|
||||||
|
their age.
|
||||||
|
- **The update is in the activity log**, filterable like everything else. Until now the biggest
|
||||||
|
change that can happen to an installation was the one thing its history did not record.
|
||||||
|
- **One command to update, and it asks first.** *(Self-hosted)* `sudo ./update.sh` is the whole
|
||||||
|
procedure: it offers to back up, verifies what it downloaded, and stops rather than guessing. Its
|
||||||
|
useful options — take the backup for me, just tell me what would happen — are one click away on
|
||||||
|
the update screen, and **[UPDATE.md](UPDATE.md)** documents the whole thing.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **The browser tab takes its name from your site**, not from the build, and from the moment the
|
||||||
|
page starts drawing rather than once it has loaded.
|
||||||
|
- **Each edition points at its own home**, and hosted customers are no longer asked to donate to
|
||||||
|
something they already pay for.
|
||||||
|
- **The Scheduler screen names its tasks in your language.** Ten of them were English on an
|
||||||
|
otherwise translated page.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **A deleted folder, file or group no longer takes its name with it.** Deleting any of the three
|
||||||
|
left the name reserved for good: creating another with that name failed with *"The slug has already been
|
||||||
|
taken"*, naming a conflict with a row the interface will not show you, and there was no way to
|
||||||
|
release it. Deleting now hands the name back, and names held by things you deleted earlier are
|
||||||
|
released when you update.
|
||||||
|
([#1645](https://github.com/projectsend/projectsend/issues/1645))
|
||||||
|
- **The Legacy migration tool installs with the command the guide gives you.** It is published now,
|
||||||
|
so `composer require projectsend/v1-migration-tool` works as written on any installation, with
|
||||||
|
nothing to add to your `composer.json`. The guide also says plainly what a zip or Docker install
|
||||||
|
can and cannot do — see the upgrade notes.
|
||||||
|
- **A non-standard port no longer disappears from links** in email and on public pages.
|
||||||
|
- **An update no longer stops over a symlink's ownership**, and the official image runs nginx as the
|
||||||
|
user php-fpm writes as.
|
||||||
|
- **Translations shipped by a package now reach the screen that wrote them.**
|
||||||
|
|
||||||
|
### Upgrade notes
|
||||||
|
|
||||||
|
- **Nothing is required beyond the usual update.** No new configuration, no new permissions to
|
||||||
|
grant. The one database change runs itself.
|
||||||
|
- **There is now an official Docker image**, `projectsend/projectsend`. If you have been building
|
||||||
|
from a clone, you can switch to it: it ships with its dependencies and frontend already compiled,
|
||||||
|
so it needs neither Composer nor Node. `compose.example.yaml` in `docker/production/` is a
|
||||||
|
working starting point.
|
||||||
|
- **Migrating from ProjectSend Legacy?** Read the first step of
|
||||||
|
**[MIGRATING-FROM-V1.md](MIGRATING-FROM-V1.md)** before you start. It now differs by how you
|
||||||
|
installed: a release zip and the official Docker image have no `/system/migrate` screen, because
|
||||||
|
the frontend they ship was built before the tool existed, and the `projectsend:migrate:*` commands
|
||||||
|
are the whole interface there. Nothing about the migration itself is missing.
|
||||||
|
- **Reporting a security issue** now has a front door: the **Report a vulnerability** button on the
|
||||||
|
repository's Security tab, or `contact@projectsend.org`. See
|
||||||
|
[SECURITY.md](SECURITY.md).
|
||||||
|
|
||||||
## 2.0.0 — 2026-08-14
|
## 2.0.0 — 2026-08-14
|
||||||
|
|
||||||
ProjectSend, rebuilt from the ground up. This is a new application rather than an update to the one
|
ProjectSend, rebuilt from the ground up. This is a new application rather than an update to the one
|
||||||
|
|||||||
+10
-2
@@ -22,8 +22,10 @@ arrive without prior discussion are hard to review and often need rework.
|
|||||||
|
|
||||||
## Setting up for development
|
## Setting up for development
|
||||||
|
|
||||||
The quickest way to a running copy is Docker — the steps that install ProjectSend are also the
|
A clone is a development copy, not an installation: `vendor/` and `public/build/` are deliberately
|
||||||
steps that set it up for development. From a fresh clone:
|
not in git, so nothing runs until Composer and npm have filled them. That is what these steps do,
|
||||||
|
and it is why the published Docker image — which ships both, already built — is what the README
|
||||||
|
sends users to instead. From a fresh clone:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -49,6 +51,12 @@ A few things worth knowing:
|
|||||||
`docker compose --profile dev up -d adminer`.
|
`docker compose --profile dev up -d adminer`.
|
||||||
- On later boots the container migrates automatically. The manual `migrate` above is only needed on
|
- On later boots the container migrates automatically. The manual `migrate` above is only needed on
|
||||||
the first install, before `vendor/` exists.
|
the first install, before `vendor/` exists.
|
||||||
|
- Until `composer install` has run, the `worker` and `scheduler` containers have no application to
|
||||||
|
run and exit with a message saying so; they pick themselves up once it has. If a page answers
|
||||||
|
"ProjectSend is not installed yet" or "not built yet", it is naming the step that is missing.
|
||||||
|
- Two checkouts of this repository share one Compose project name, so `docker compose up` in the
|
||||||
|
second one takes over the first one's containers. Pass `-p some-other-name` when you want them
|
||||||
|
side by side.
|
||||||
|
|
||||||
**Staff and clients are different things.** Staff — "system users" — administer the installation and
|
**Staff and clients are different things.** Staff — "system users" — administer the installation and
|
||||||
upload files. Clients are the people files are shared with. There is no staff registration page:
|
upload files. Clients are the people files are shared with. There is no staff registration page:
|
||||||
|
|||||||
@@ -8,20 +8,37 @@ data with it.
|
|||||||
|
|
||||||
Read this before you put real files in ProjectSend, not after.
|
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#development).
|
> **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).
|
> 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 |
|
| 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 |
|
| **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** | `storage/app/files/` in the project directory | The files your clients downloaded — gone |
|
| **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 |
|
||||||
| **`.env`** | The project directory | `APP_KEY`, without which saved SMTP and LDAP passwords cannot be decrypted |
|
|
||||||
|
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
|
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
|
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.
|
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
|
- **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
|
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
|
somebody looks at a file). They sit inside the volume you are backing up anyway, so the simplest
|
||||||
thing is to back up all of it and not think about which is which.
|
thing is to take all of it and not think about which is which.
|
||||||
|
|
||||||
## The good news, and the one command to fear
|
## The good news, and the one command to fear
|
||||||
|
|
||||||
Named volumes are already outside the container lifecycle. `docker compose down`,
|
Named volumes are already outside the container lifecycle. `docker compose pull`,
|
||||||
`docker compose up --build`, deleting and recreating every container — none of those touch
|
`docker compose down`, deleting and recreating every container — none of those touch
|
||||||
`projectsend_db-data`. Upgrading does not lose your database, and never did.
|
`projectsend_db-data` or `projectsend_storage`. Upgrading does not lose your data, and never did.
|
||||||
|
|
||||||
The command that *does* destroy it is:
|
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
|
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
|
database and every uploaded file in about a second, with no confirmation. The same goes for
|
||||||
`docker system prune --volumes` when the stack happens to be down.
|
`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
|
So the actual problem with the default setup is not fragility, it is **invisibility**: your data is
|
||||||
database is somewhere under `/var/lib/docker/volumes/`, which means most people never back it up
|
somewhere under `/var/lib/docker/volumes/`, which means most people never back it up and would not
|
||||||
and would not know where to look. The rest of this page fixes that.
|
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
|
## Putting the data where you chose
|
||||||
|
|
||||||
Bind-mount both to real paths on the host, so your data sits somewhere you picked, somewhere you
|
Bind-mount both volumes to real paths on the host, so your data sits somewhere you picked, somewhere
|
||||||
can see in `ls`, and somewhere your existing backup tool already knows about.
|
you can see in `ls`, and somewhere your existing backup tool already knows about.
|
||||||
|
|
||||||
### 1. Make the directories
|
### 1. Make the directories
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo mkdir -p /srv/projectsend/files /srv/projectsend/mysql
|
sudo mkdir -p /srv/projectsend/storage /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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Leave `/srv/projectsend/mysql` owned by root — the MySQL image sets its own ownership the first
|
No `chown` needed for either. The ProjectSend container recreates the directory tree it needs on
|
||||||
time it starts.
|
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
|
`compose.example.yaml` is yours — you downloaded and edited it — so change the volumes in place
|
||||||
never edit the tracked `compose.yaml` and nothing you write here is lost on the next update.
|
rather than layering an override on top:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
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:
|
app:
|
||||||
volumes:
|
volumes:
|
||||||
- /srv/projectsend/files:/var/www/html/storage/app/files
|
# Was: storage:/var/www/html/storage
|
||||||
web:
|
- /srv/projectsend/storage:/var/www/html/storage
|
||||||
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
|
|
||||||
|
|
||||||
db:
|
db:
|
||||||
volumes:
|
volumes:
|
||||||
|
# Was: db-data:/var/lib/mysql
|
||||||
- /srv/projectsend/mysql:/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
|
```sh
|
||||||
docker compose config
|
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
|
Stop everything first. Copying a database out from under a running MySQL is how you get a backup
|
||||||
that restores into a corrupt table.
|
that restores into a corrupt table.
|
||||||
@@ -127,25 +256,22 @@ that restores into a corrupt table.
|
|||||||
docker compose down # no -v
|
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
|
```sh
|
||||||
sudo rsync -a storage/app/files/ /srv/projectsend/files/
|
docker run --rm \
|
||||||
sudo chown -R 1000:1000 /srv/projectsend/files
|
-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 \
|
docker run --rm \
|
||||||
-v projectsend_db-data:/from \
|
-v projectsend_db-data:/from \
|
||||||
-v /srv/projectsend/mysql:/to \
|
-v /srv/projectsend/mysql:/to \
|
||||||
alpine sh -c 'cd /from && cp -a . /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
|
(Those are the volumes' real names — the `storage` and `db-data` from your compose file, prefixed
|
||||||
the project name. `docker volume ls` will confirm it.)
|
with the project name. `docker volume ls` will confirm them.)
|
||||||
|
|
||||||
### 4. Start, and check
|
### 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
|
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
|
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
|
open a file, **download it**, and upload a new one; confirm the new upload appears under
|
||||||
`/srv/projectsend/files/` on the host. A download that returns nothing means one of the four
|
`/srv/projectsend/storage/app/files/` on the host.
|
||||||
containers is missing the mount from step 2.
|
|
||||||
|
|
||||||
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
|
```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:
|
different moments, and it may restore into something subtly broken. Use a dump:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose exec -T db \
|
docker compose exec -T db sh -c \
|
||||||
mysqldump -u root -p"${DB_ROOT_PASSWORD:-root}" \
|
'mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" \
|
||||||
--single-transaction --routines --triggers \
|
--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
|
`--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
|
```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
|
Ordinary files, no special handling — and taking the whole directory is what picks up `.env` with
|
||||||
(`chown -R 1000:1000`).
|
`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
|
```sh
|
||||||
holds `APP_KEY` — lose that and the SMTP and LDAP passwords stored in your database become
|
docker run --rm -v projectsend_storage:/from -v "$PWD":/to \
|
||||||
undecryptable, even though the rest of the backup is perfect.
|
alpine tar czf /to/projectsend-storage-$(date +%F).tar.gz -C /from .
|
||||||
|
```
|
||||||
|
|
||||||
### Restoring
|
### Restoring
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose up -d db
|
docker compose up -d db
|
||||||
docker compose exec -T db mysql -u root -p"${DB_ROOT_PASSWORD:-root}" projectsend < projectsend-2026-08-08.sql
|
docker compose exec -T db sh -c \
|
||||||
sudo rsync -a /your/backup/location/files/ /srv/projectsend/files/
|
'mysql -u root -p"$MYSQL_ROOT_PASSWORD" projectsend' < projectsend-2026-08-08.sql
|
||||||
sudo chown -R 1000:1000 /srv/projectsend/files
|
sudo rsync -a /your/backup/location/storage/ /srv/projectsend/storage/
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -222,14 +360,17 @@ restored is a hypothesis, not a backup.
|
|||||||
With the data outside the containers, an upgrade touches only the containers:
|
With the data outside the containers, an upgrade touches only the containers:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose down # again: no -v
|
docker compose pull
|
||||||
git pull # or unpack the new release over the directory
|
docker compose up -d
|
||||||
docker compose up -d --build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The app container migrates the database itself on boot and verifies its reference data, so there is
|
That is the whole procedure. The container runs `php artisan projectsend:update` itself on boot —
|
||||||
no separate migration step. Take a database dump first anyway — migrations move forwards, not
|
the same command a manual install runs — so it migrates the database and verifies its reference data
|
||||||
backwards, and the one time you skip it will be the time you want it.
|
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.
|
||||||
|
|
||||||
|
**[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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -238,10 +379,14 @@ backwards, and the one time you skip it will be the time you want it.
|
|||||||
This is the payoff for everything above, and it is worth doing once deliberately so you know it
|
This is the payoff for everything above, and it is worth doing once deliberately so you know it
|
||||||
works:
|
works:
|
||||||
|
|
||||||
1. Dump the database and copy `/srv/projectsend/`, `.env` and the dump to the new machine.
|
1. Dump the database, and copy `/srv/projectsend/` (or the storage tarball) and the dump to the new
|
||||||
2. Install Docker, put the project directory in place, restore both as described under
|
machine.
|
||||||
|
2. Install Docker, put your `compose.yaml` in place, restore both as described under
|
||||||
[Restoring](#restoring).
|
[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.
|
Bring `APP_KEY` across with the storage directory — a fresh key on the new machine leaves the site
|
||||||
That is the property worth protecting, and the reason this page exists.
|
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.
|
||||||
|
|||||||
+161
-46
@@ -5,7 +5,7 @@ published with each release.
|
|||||||
|
|
||||||
If you can run Docker, use Docker instead — it is one command, and everything on this page
|
If you can run Docker, use Docker instead — it is one command, and everything on this page
|
||||||
(PHP extensions, the web server, the background worker, the scheduled tasks) is already wired up
|
(PHP extensions, the web server, the background worker, the scheduled tasks) is already wired up
|
||||||
for you. See [the Docker instructions](README.md#development), and
|
for you. See [the Docker instructions](README.md#getting-started), and
|
||||||
[DOCKER.md](DOCKER.md) for keeping your database and uploads outside the containers. Come back here
|
[DOCKER.md](DOCKER.md) for keeping your database and uploads outside the containers. Come back here
|
||||||
if Docker is not an option on your hosting.
|
if Docker is not an option on your hosting.
|
||||||
|
|
||||||
@@ -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:
|
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
|
- 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.
|
but is more moving parts than just using nginx. Give the proxy some header headroom while you are
|
||||||
- Store your files in S3-compatible object storage instead (see
|
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)).
|
[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
|
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
|
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
|
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
|
## Step 5 — Prepare the application
|
||||||
|
|
||||||
Three commands. Run them from the install directory, as the web server's user, so that everything
|
Three commands. Run them from the install directory, as the web server's user, so that everything
|
||||||
@@ -275,7 +350,10 @@ edits the nginx config for you.
|
|||||||
|
|
||||||
Open your site in a browser. Because no account exists yet, every address takes you to the setup
|
Open your site in a browser. Because no account exists yet, every address takes you to the setup
|
||||||
screen, which asks for a site name and the name, email and password of the first administrator.
|
screen, which asks for a site name and the name, email and password of the first administrator.
|
||||||
Fill it in, and you are done — sign in and start adding clients.
|
Fill it in, and you are done. The first time you sign in, ProjectSend opens on a short list of the
|
||||||
|
things worth doing first — adding a client, uploading a file, choosing how your file lists and your
|
||||||
|
email look — each one linking straight to the screen that does it. It appears once; afterwards it
|
||||||
|
lives at **About → Getting started**.
|
||||||
|
|
||||||
If you would rather not do it in the browser (or you are scripting the install), the same thing
|
If you would rather not do it in the browser (or you are scripting the install), the same thing
|
||||||
from the command line:
|
from the command line:
|
||||||
@@ -306,7 +384,7 @@ User=www-data
|
|||||||
Group=www-data
|
Group=www-data
|
||||||
Restart=always
|
Restart=always
|
||||||
WorkingDirectory=/var/www/projectsend
|
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]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
@@ -318,6 +396,15 @@ Then:
|
|||||||
sudo systemctl enable --now projectsend-worker
|
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
|
**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
|
too: saving your email settings restarts the worker so it picks up the new values, and it needs to
|
||||||
come back on its own.
|
come back on its own.
|
||||||
@@ -369,8 +456,20 @@ the worker afterwards.
|
|||||||
### Storing files somewhere other than this server
|
### 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
|
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
|
object storage instead from **System → Settings → Storage** — useful when the files outgrow the
|
||||||
outgrow the server's disk.
|
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
|
### Making it faster
|
||||||
|
|
||||||
@@ -382,53 +481,45 @@ sudo -u www-data php artisan view:cache
|
|||||||
sudo -u www-data php artisan event:cache
|
sudo -u www-data php artisan event:cache
|
||||||
```
|
```
|
||||||
|
|
||||||
Re-run them after every update. If you change your mind, `php artisan optimize:clear` undoes all
|
You only run these once: `projectsend:update` notices they are in place and rebuilds them for you
|
||||||
three.
|
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
|
Every Laravel deployment guide also lists `php artisan config:cache`, and `php artisan optimize`
|
||||||
three, and `php artisan optimize` runs it for you. **Don't** — not on this application.
|
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
|
Caching the configuration writes every resolved setting into one PHP file, and from then on the
|
||||||
then on the framework stops reading your `.env` at all, on the entirely reasonable grounds that
|
framework stops reading your `.env` at all — everything in it has already been baked in. So
|
||||||
everything in it has already been baked in. That holds for settings read the normal way, through
|
**re-run `php artisan config:cache` every time you edit `.env`**, or the edit does nothing and you
|
||||||
`config()`. ProjectSend reads one value earlier than that — `TRUSTED_PROXIES`, which has to be
|
are left staring at a setting that is plainly there and plainly ignored. `php artisan config:clear`
|
||||||
known before the middleware stack is assembled, so it is read straight from the environment. Cache
|
goes back to reading `.env` directly, and every update clears it too, saying why.
|
||||||
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. 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Updating to a new version
|
## Updating to a new version
|
||||||
|
|
||||||
1. **Back up first** — the database, the `.env` file, and `storage/app/files/`. Every time.
|
```sh
|
||||||
2. Put the site in maintenance mode: `sudo -u www-data php artisan down`
|
cd /var/www/projectsend
|
||||||
3. Unpack the new zip over the install directory. Keep your `.env` and your `storage/` folder —
|
sudo ./update.sh
|
||||||
the zip does not contain either, but check your unzip tool is not helpfully deleting things.
|
```
|
||||||
4. Run the updates:
|
|
||||||
|
|
||||||
```sh
|
The script ships with every release, in this directory. It asks whether to check GitHub for a newer
|
||||||
sudo -u www-data php artisan migrate --force
|
version, whether to download it (checking the published checksum), and whether you have a backup —
|
||||||
sudo -u www-data php artisan projectsend:ensure-roles
|
then takes the site down, unpacks the release, migrates the database, rebuilds whichever caches you
|
||||||
sudo -u www-data php artisan optimize:clear
|
were using, reloads PHP-FPM, restarts the worker and brings the site back up. Your `.env`, your
|
||||||
sudo -u www-data php artisan queue:restart
|
uploads and your `public/storage` link are never touched.
|
||||||
```
|
|
||||||
|
|
||||||
5. Bring it back: `sudo -u www-data php artisan up`
|
`sudo ./update.sh --zip ~/projectsend-2.1.0.zip` applies a zip you downloaded yourself, and
|
||||||
|
`./update.sh --check` just reports what is available.
|
||||||
|
|
||||||
`projectsend:ensure-roles` teaches the built-in roles about any permissions the new version added.
|
**[UPDATE.md](UPDATE.md)** is the full reference: what it does in order, every option, the same
|
||||||
It never touches permissions you have customised yourself.
|
steps done by hand, how to check it worked, and how to go back. Read it before your first update —
|
||||||
|
particularly the part about reloading PHP-FPM, which is the step that decides whether an update
|
||||||
|
takes effect at all.
|
||||||
|
|
||||||
|
Back up first, every time. The script can dump the database for you (`--backup`), but your uploaded
|
||||||
|
files in `storage/app/files/` are yours to look after.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -474,19 +565,43 @@ report the problem on the settings screen instead. If you are stuck anyway, add
|
|||||||
`php artisan projectsend:captcha-off`. Your keys are kept either way. To check a key without
|
`php artisan projectsend:captcha-off`. Your keys are kept either way. To check a key without
|
||||||
locking anything, `php artisan projectsend:captcha-test` asks the provider directly.
|
locking anything, `php artisan projectsend:captcha-test` asks the provider directly.
|
||||||
|
|
||||||
|
**Links and redirects drop the port number** (you are served on `:8080`, and the site sends you to
|
||||||
|
port 80).
|
||||||
|
Recent Debian and Ubuntu nginx packages set `HTTP_HOST` to `$host` in `/etc/nginx/fastcgi_params`,
|
||||||
|
deliberately — it stops a client-supplied `Host` header reaching the application — and `$host`
|
||||||
|
carries no port. On 80 or 443 that changes nothing. On any other port, every absolute URL the
|
||||||
|
application builds loses it. Add this to the `location ~ \.php$` block, **after** `include
|
||||||
|
fastcgi_params;`:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
fastcgi_param HTTP_HOST $http_host;
|
||||||
|
```
|
||||||
|
|
||||||
|
On nginx 1.30 and later the safer form is `$host$is_request_port$request_port`. Either way this only
|
||||||
|
applies to a non-standard port; behind a TLS proxy on 443 you do not need it.
|
||||||
|
|
||||||
**Emails and links point at `localhost` or the wrong domain.**
|
**Emails and links point at `localhost` or the wrong domain.**
|
||||||
`APP_URL` in `.env`. Fix it and run `php artisan optimize:clear`.
|
`APP_URL` in `.env`. Fix it and run `php artisan optimize:clear`.
|
||||||
|
|
||||||
**A change I made in `.env` has no effect.**
|
**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
|
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`
|
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
|
**Everyone is locked out of the login form at once, or the download log shows the same IP for
|
||||||
every download.**
|
every download.**
|
||||||
ProjectSend is seeing your proxy or CDN instead of your visitors. Set `TRUSTED_PROXIES` in `.env`
|
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
|
(step 3) and restart PHP-FPM.
|
||||||
at all. Same section as above.
|
|
||||||
|
**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
|
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
|
[GitHub](https://github.com/projectsend/projectsend/issues), and include the last few lines of
|
||||||
|
|||||||
+83
-25
@@ -100,32 +100,74 @@ The tool reports each of these before it starts and names every affected row. It
|
|||||||
|
|
||||||
## Step 1 — Install the tool
|
## Step 1 — Install the tool
|
||||||
|
|
||||||
On the **new** install:
|
Everything here happens on the **new** install. How the tool gets there — and whether you get its
|
||||||
|
screen — depends on how ProjectSend itself got onto the machine, so find yours below and use that
|
||||||
|
section on its own.
|
||||||
|
|
||||||
|
### If you installed from a release zip
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
composer require projectsend/v1-migration-tool
|
composer require projectsend/v1-migration-tool
|
||||||
php artisan migrate # creates the tool's two tables
|
php artisan migrate # creates the tool's two tables
|
||||||
npm run build # so its screen enters the frontend bundle
|
|
||||||
```
|
```
|
||||||
|
|
||||||
In Docker, prefix each with `docker compose exec app` (except `npm run build`, which runs on the
|
That is the whole installation — there is no `npm run build` to run. The zip ships its assets
|
||||||
host).
|
already compiled and deliberately without the toolchain that compiled them, so there is no
|
||||||
|
`package.json` to build from.
|
||||||
|
|
||||||
> While the repository is private, `composer require` needs to be told where to find it — add a
|
One consequence is worth knowing before you go looking for it: **`/system/migrate` is not available
|
||||||
> `vcs` entry to your `composer.json` `repositories` and give Composer credentials for the repo:
|
on a zip install.** A package's screens enter the frontend bundle when that bundle is built, which
|
||||||
>
|
for a zip is when the release was built — necessarily before this package was on your install.
|
||||||
> ```json
|
|
||||||
> "repositories": [
|
|
||||||
> { "type": "vcs", "url": "https://github.com/projectsend/v1-migration-tool" }
|
|
||||||
> ]
|
|
||||||
> ```
|
|
||||||
|
|
||||||
Then open **`/system/migrate`** on your new install, signed in as a staff user with the *Edit
|
Nothing is missing from the migration itself. Every step below lists the command that does it, the
|
||||||
settings* permission. There is no sidebar link — a one-time tool does not earn a permanent slot in
|
commands do everything the screen does, and on a zip install they are the interface rather than a
|
||||||
the navigation of an install that will use it once.
|
fallback. Start with [Step 2](#step-2--pick-your-route) and follow the commands.
|
||||||
|
|
||||||
Everything below can also be done entirely from the command line; the equivalent commands are
|
### If you are running the official Docker image
|
||||||
listed at each step.
|
|
||||||
|
The image carries no Composer. It ships the application already built and has no use for one, so
|
||||||
|
fetch it for the length of the migration and use it in place:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose exec -u www-data app \
|
||||||
|
php -r 'copy("https://getcomposer.org/composer.phar", "/tmp/composer.phar");'
|
||||||
|
docker compose exec -u www-data app \
|
||||||
|
php /tmp/composer.phar require projectsend/v1-migration-tool --update-no-dev
|
||||||
|
docker compose exec -u www-data app php artisan migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
`-u www-data` is not decoration: `exec` lands as root, and a root-owned `vendor/` is a problem the
|
||||||
|
application runs into later rather than now. `--update-no-dev` keeps the test tooling that the lock
|
||||||
|
file knows about out of a production install.
|
||||||
|
|
||||||
|
That install lives in the container's writable layer, so it lasts until the container is replaced —
|
||||||
|
a `docker compose pull`, or any change to your compose file, takes it with it. For a migration you
|
||||||
|
finish in one sitting that is fine, and if it does go, install it again: the run's progress and its
|
||||||
|
id map are in the database, not in the package.
|
||||||
|
|
||||||
|
There is no `/system/migrate` screen here either, for the same reason as a zip install. The commands
|
||||||
|
are the interface — start with [Step 2](#step-2--pick-your-route).
|
||||||
|
|
||||||
|
### If you are running from a git checkout
|
||||||
|
|
||||||
|
Including the Docker stack in this repository, which builds the application from source rather than
|
||||||
|
pulling the published image.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
composer require projectsend/v1-migration-tool
|
||||||
|
php artisan migrate # creates the tool's two tables
|
||||||
|
npm run build # so the tool's screen enters the frontend bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
In Docker, prefix the first two with `docker compose exec app`; `npm run build` runs on the host,
|
||||||
|
where the toolchain is.
|
||||||
|
|
||||||
|
Then open **`/system/migrate`**, signed in as a staff user with the *Edit settings* permission.
|
||||||
|
There is no sidebar link — a one-time tool does not earn a permanent slot in the navigation of an
|
||||||
|
install that will use it once.
|
||||||
|
|
||||||
|
Everything the screen does can also be done entirely from the command line; the equivalent commands
|
||||||
|
are listed at each step.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -136,9 +178,11 @@ listed at each step.
|
|||||||
| Legacy and ProjectSend are on the **same machine** | [**Direct**](#step-3a--direct-same-machine) |
|
| 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) |
|
| 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
|
Direct is faster and simpler. It copies your files by default, and it can also *hardlink* them
|
||||||
hardlinks them, so 400 GB migrates in seconds and both installs point at the same bytes until you
|
instead when you ask it to — on a single filesystem that writes no bytes at all, so 400 GB migrates
|
||||||
decide otherwise. Use it if you can.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -151,7 +195,8 @@ Point the tool at your Legacy install directory. It reads the database credentia
|
|||||||
php artisan projectsend:migrate:preflight --v1-path=/var/www/projectsend-legacy
|
php artisan projectsend:migrate:preflight --v1-path=/var/www/projectsend-legacy
|
||||||
```
|
```
|
||||||
|
|
||||||
On the screen, choose **Direct**, enter the same path, and pick how to move file bytes:
|
If you have the screen, choose **Direct** and enter the same path. Either way, pick how to move
|
||||||
|
file bytes:
|
||||||
|
|
||||||
| `--files=` | What it does |
|
| `--files=` | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -168,8 +213,20 @@ If ProjectSend runs in Docker, the Legacy directory has to be visible **inside t
|
|||||||
## Step 3b — Bundle (different machines)
|
## Step 3b — Bundle (different machines)
|
||||||
|
|
||||||
Run one dependency-free PHP file on the Legacy box; it produces a portable directory you bring
|
Run one dependency-free PHP file on the Legacy box; it produces a portable directory you bring
|
||||||
over. Download it from `/system/migrate` (there is a link on the screen) or take it from the
|
over. Take it from the package, which is where it lives on every install:
|
||||||
package at `bin/projectsend-v1-export.php`.
|
|
||||||
|
```sh
|
||||||
|
vendor/projectsend/v1-migration-tool/bin/projectsend-v1-export.php
|
||||||
|
```
|
||||||
|
|
||||||
|
In Docker, copy it out of the container first:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose cp \
|
||||||
|
app:/var/www/html/vendor/projectsend/v1-migration-tool/bin/projectsend-v1-export.php .
|
||||||
|
```
|
||||||
|
|
||||||
|
If you have the screen, `/system/migrate` offers it as a download instead.
|
||||||
|
|
||||||
On the **Legacy** server:
|
On the **Legacy** server:
|
||||||
|
|
||||||
@@ -207,8 +264,9 @@ rsync -a legacy-server:/var/www/projectsend/upload/files/ /tmp/ps-export/files/
|
|||||||
The import finds them there. If you would rather have everything in one object, export with
|
The import finds them there. If you would rather have everything in one object, export with
|
||||||
`--files=copy` instead — convenient, and it doubles the disk you need on the Legacy box.
|
`--files=copy` instead — convenient, and it doubles the disk you need on the Legacy box.
|
||||||
|
|
||||||
Then copy the bundle to the new server, choose **Bundle** on the screen and give it the path (again,
|
Then copy the bundle to the new server and give it the path — on the screen, choose **Bundle**;
|
||||||
the path *inside* the app container if you are using Docker):
|
otherwise pass it directly. Either way it is the path *inside* the app container if you are using
|
||||||
|
Docker:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
php artisan projectsend:migrate:preflight --bundle=/srv/ps-export
|
php artisan projectsend:migrate:preflight --bundle=/srv/ps-export
|
||||||
|
|||||||
@@ -47,12 +47,12 @@ per-seat pricing. It runs on your server, and the files stay there.
|
|||||||
- 16 languages
|
- 16 languages
|
||||||
- A REST API with scoped tokens and generated OpenAPI docs
|
- A REST API with scoped tokens and generated OpenAPI docs
|
||||||
- Privacy controls, including GDPR-grade account erasure with a grace period
|
- 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
|
## Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src=".github/screenshots/dashboard.png" alt="The dashboard, showing counters for files, clients and groups alongside largest files, recent activity and system information" width="900">
|
<img src=".github/screenshots/dashboard.png" alt="The dashboard: counters for files, clients and groups, the clients using the most storage against their quotas, a month of uploads and downloads as a line chart, and recent activity" width="900">
|
||||||
</p>
|
</p>
|
||||||
<p align="center"><em>The dashboard — what is in the installation, and what has been happening in it.</em></p>
|
<p align="center"><em>The dashboard — what is in the installation, and what has been happening in it.</em></p>
|
||||||
|
|
||||||
@@ -68,17 +68,18 @@ per-seat pricing. It runs on your server, and the files stay there.
|
|||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
**With Docker** — the quickest path, and the one we recommend.
|
**With Docker** — the quickest path, and the one we recommend. Nothing to build: the published
|
||||||
|
image ships with its dependencies and its frontend already compiled.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/projectsend/projectsend.git
|
curl -O https://raw.githubusercontent.com/projectsend/projectsend/main/docker/production/compose.example.yaml
|
||||||
cd projectsend
|
# edit the passwords and APP_URL in it, then:
|
||||||
cp .env.example .env # set PROJECTSEND_EDITION=community
|
docker compose -f compose.example.yaml up -d
|
||||||
docker compose up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The app is at `http://localhost:8090`, and the first thing it shows you is a setup screen that
|
Open `APP_URL` and the first thing you see is a setup screen that creates your administrator
|
||||||
creates your administrator account.
|
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
|
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
|
actually live, how to move them onto paths you chose, and how to back them up so an upgrade can't
|
||||||
@@ -89,6 +90,14 @@ take them with it.
|
|||||||
updating and troubleshooting. You do not need Composer or npm on the server; the zip ships ready to
|
updating and troubleshooting. You do not need Composer or npm on the server; the zip ships ready to
|
||||||
run.
|
run.
|
||||||
|
|
||||||
|
**Already running it?** **[UPDATE.md](UPDATE.md)** is how you move to a new version — one command
|
||||||
|
on Docker, one script on your own server, and what to check afterwards either way.
|
||||||
|
|
||||||
|
**Want to work on ProjectSend itself?** Cloning the repository gets you a development copy, not an
|
||||||
|
installation: the dependencies and the compiled frontend are deliberately not in git, so a clone
|
||||||
|
needs Composer and npm before it runs. **[CONTRIBUTING.md](CONTRIBUTING.md)** has the sequence, and
|
||||||
|
it is short.
|
||||||
|
|
||||||
## Coming from ProjectSend Legacy?
|
## Coming from ProjectSend Legacy?
|
||||||
|
|
||||||
The previous generation of ProjectSend lives on at
|
The previous generation of ProjectSend lives on at
|
||||||
|
|||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
**Use [GitHub's private vulnerability reporting](https://github.com/projectsend/projectsend/security/advisories/new).**
|
||||||
|
It is the "Report a vulnerability" button on this repository's Security tab. The report stays
|
||||||
|
private between you and the maintainers, the whole exchange lives in one place, and it is the route
|
||||||
|
that can end in a published advisory with a CVE and your name on it.
|
||||||
|
|
||||||
|
If you would rather not use GitHub, or the report does not fit that form, email
|
||||||
|
<contact@projectsend.org> instead. Either is fine. What matters is that it does not start in
|
||||||
|
public.
|
||||||
|
|
||||||
|
**Please do not open a public issue for a security report.** An issue is world-readable the moment
|
||||||
|
it is filed, including by people running the version you just described how to break.
|
||||||
|
|
||||||
|
### What helps
|
||||||
|
|
||||||
|
Enough to reproduce it, and nothing you would not want to write down:
|
||||||
|
|
||||||
|
- The version, from **System → About** (self-hosted) or `config/projectsend.php`.
|
||||||
|
- How the installation is deployed — the Docker image, a manual install behind nginx, something
|
||||||
|
else — and anything unusual in front of it.
|
||||||
|
- The steps, and what you saw. A short recording or a `curl` command beats a description.
|
||||||
|
- What an attacker gets out of it, if it is not obvious.
|
||||||
|
|
||||||
|
You do not need a proof-of-concept exploit, and you should not run one against an installation that
|
||||||
|
is not yours.
|
||||||
|
|
||||||
|
### What to expect
|
||||||
|
|
||||||
|
An acknowledgement within a few days, and a real answer — a fix, a plan, or a reason it is not
|
||||||
|
what it looked like — once it has been reproduced. If a fix ships, you are credited by name unless
|
||||||
|
you would rather not be.
|
||||||
|
|
||||||
|
This is a small project. If a week goes by in silence, assume the message went astray rather than
|
||||||
|
that it was ignored, and send it again.
|
||||||
|
|
||||||
|
## What is in scope
|
||||||
|
|
||||||
|
Anything that lets somebody reach a file, an account, or an installation they should not: the
|
||||||
|
sharing and permission rules, authentication and two-factor, the public pages and share links, the
|
||||||
|
API, the upload and download paths, and the setup and update flows.
|
||||||
|
|
||||||
|
Some things are worth a report but are not vulnerabilities in ProjectSend:
|
||||||
|
|
||||||
|
- **An installation that has not done what the install guide says.** Serving the storage directory
|
||||||
|
straight from the web server, or running without the protected-file rules, is a deployment
|
||||||
|
problem — see [INSTALL.md](INSTALL.md) and [DOCKER.md](DOCKER.md). Tell us anyway if the
|
||||||
|
documentation is what led somebody there.
|
||||||
|
- **Findings from a scanner, unread.** A header a tool wanted and an exploit are different
|
||||||
|
claims. Say which one you have.
|
||||||
|
- **Anything in a dependency**, unless ProjectSend's use of it is what makes it reachable. Those
|
||||||
|
belong upstream, and Dependabot already watches for them here.
|
||||||
|
|
||||||
|
## Supported versions
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **ProjectSend 2.x** | Supported. Fixes land on the current release line; upgrade before reporting that an older 2.x behaves differently. |
|
||||||
|
| **The companion packages** — [`community-modules`](https://github.com/projectsend/community-modules), [`v1-migration-tool`](https://github.com/projectsend/v1-migration-tool) | Supported, on their own version lines. Report them here or on their own repository; both reach the same people. |
|
||||||
|
| **ProjectSend Legacy (v1)** | A separate application in a separate repository — see [projectsend/legacy](https://github.com/projectsend/legacy) for how it handles reports. Nothing here applies to it. |
|
||||||
|
|
||||||
|
## Hardening your own installation
|
||||||
|
|
||||||
|
If you are trying to configure an installation rather than report a bug, the deployment
|
||||||
|
documentation is what you want: [INSTALL.md](INSTALL.md) for a manual install, including the web
|
||||||
|
server rules that keep uploaded files private, and [DOCKER.md](DOCKER.md) for the image, where
|
||||||
|
those rules are already in place.
|
||||||
|
|
||||||
|
Two things are worth knowing whichever way you installed:
|
||||||
|
|
||||||
|
- **Put TLS in front of it**, and set `TRUSTED_PROXIES` when you do. Without it every visitor
|
||||||
|
appears to come from the proxy, which turns the login rate limiter into one bucket for all of
|
||||||
|
them and records the wrong address in the download log.
|
||||||
|
- **`APP_KEY` decrypts what is already stored.** Back it up with the database, and do not rotate it
|
||||||
|
on a running installation without knowing what you are doing.
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
# 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 [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:
|
||||||
|
|
||||||
|
- **Back up first.** Every time, including the update you are sure about. Migrations move forwards,
|
||||||
|
not backwards: there is no command that undoes them.
|
||||||
|
- **Read [CHANGELOG.md](CHANGELOG.md) for the version you are moving to.** Anything a release needs
|
||||||
|
beyond the steps below is written there. If a release raises the PHP requirement, that is where it
|
||||||
|
says so.
|
||||||
|
|
||||||
|
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 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
|
||||||
|
the update instructions for *this* server, and the notice that appears when a new version is
|
||||||
|
released links to the same thing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Before you start, whichever path you are on
|
||||||
|
|
||||||
|
1. **Back up the database.**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Docker — --single-transaction is what makes this safe on a running database
|
||||||
|
docker compose exec -T db \
|
||||||
|
mysqldump -u root -p"${DB_ROOT_PASSWORD:-root}" \
|
||||||
|
--single-transaction --routines --triggers \
|
||||||
|
projectsend > projectsend-before-update.sql
|
||||||
|
|
||||||
|
# Your own server
|
||||||
|
mysqldump -u projectsend -p --single-transaction --routines --triggers \
|
||||||
|
projectsend > projectsend-before-update.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Back up the uploads and `.env`.** On Docker these live on the `storage` volume (or the host
|
||||||
|
directory you pointed it at — see [DOCKER.md](DOCKER.md)); on a manual install they are
|
||||||
|
`storage/app/files/` and `.env` in the install directory.
|
||||||
|
|
||||||
|
3. **Note the version you are on**, from the dashboard's System card. If you have to go back, that
|
||||||
|
is the image tag or zip you go back to.
|
||||||
|
|
||||||
|
A backup that has never been restored is a hope, not a backup. If you have never tried, this is a
|
||||||
|
good moment: restoring into a scratch database takes five minutes and tells you something real.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Updating a Docker installation
|
||||||
|
|
||||||
|
### The official image
|
||||||
|
|
||||||
|
If you are using the published image — the `compose.example.yaml` shipped with it pins
|
||||||
|
`projectsend/projectsend:2`, which follows every 2.x release — the whole update is:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the complete procedure. There is no migration step to run, no cache to clear, and nothing
|
||||||
|
to do afterwards.
|
||||||
|
|
||||||
|
If you pinned an exact version instead (`projectsend/projectsend:2.0.0`), edit the tag in your
|
||||||
|
compose file first — `pull` on a pinned tag fetches the same image you already have.
|
||||||
|
|
||||||
|
### Compose built from source
|
||||||
|
|
||||||
|
Same idea, one extra step because the image is yours to build:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose down # no -v, ever: -v deletes your data volumes
|
||||||
|
git pull # or unpack the new release over the directory
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose exec app composer install # if composer.lock moved
|
||||||
|
npm ci && npm run build # if package-lock.json or the frontend moved
|
||||||
|
```
|
||||||
|
|
||||||
|
The last two lines are what a source checkout has that an image does not: its dependencies and its
|
||||||
|
compiled frontend live outside git, so a release that changed either leaves them stale. If a page
|
||||||
|
comes back saying ProjectSend is "not installed yet" or "not built yet", it is naming which of the
|
||||||
|
two you skipped.
|
||||||
|
|
||||||
|
### What the container does on the way up
|
||||||
|
|
||||||
|
The entrypoint runs before the web server accepts a single request, in this order:
|
||||||
|
|
||||||
|
1. Recreates any missing `storage/` directories, and generates `APP_KEY` **only if one does not
|
||||||
|
already exist** — yours is on the storage volume and is left alone. A changed key would make
|
||||||
|
every existing session invalid and every encrypted column unreadable.
|
||||||
|
2. Waits up to 60 seconds for the database to accept connections, so a slow-starting database is a
|
||||||
|
pause rather than a crash loop.
|
||||||
|
3. Runs `php artisan projectsend:update` — the same command a manual install runs, and the only
|
||||||
|
definition of what an update does. It migrates the database, restores any built-in role a new
|
||||||
|
version added a permission to (without touching roles you customised), relinks storage, clears
|
||||||
|
the compiled caches and records the version it applied.
|
||||||
|
4. Starts php-fpm, nginx, the queue worker and the scheduler.
|
||||||
|
|
||||||
|
The queue worker and scheduler run inside that same container, so they are replaced with it and
|
||||||
|
never keep running old code.
|
||||||
|
|
||||||
|
### Checking it worked
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose logs app | grep -A5 "Running migrations"
|
||||||
|
docker compose ps # the app container should reach "healthy"
|
||||||
|
```
|
||||||
|
|
||||||
|
The first time the administrator opens ProjectSend after an update, they land on a page naming the
|
||||||
|
version now running and what the release brought, read from `CHANGELOG.md` inside the release
|
||||||
|
itself. It appears once, for the account that administers the installation; afterwards it stays
|
||||||
|
reachable from **About**, under the version line.
|
||||||
|
|
||||||
|
Then open the dashboard: the **System** card's *Version* line is the version now running, and the
|
||||||
|
"a new version is available" notice disappears on its own once the running version has caught up —
|
||||||
|
it compares against what is installed on every page load, so there is nothing to clear.
|
||||||
|
|
||||||
|
### If the container will not come up
|
||||||
|
|
||||||
|
The entrypoint stops on the first failure, so a container that restarts in a loop has told you why
|
||||||
|
in its own log:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose logs app | tail -40
|
||||||
|
```
|
||||||
|
|
||||||
|
The two common ones are a database that never became reachable (`DB_HOST`, credentials, or a
|
||||||
|
database container that failed its healthcheck) and a migration that could not run. Neither leaves
|
||||||
|
a half-updated site serving traffic: nginx is not started until the entrypoint finishes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Updating a manual installation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /var/www/projectsend
|
||||||
|
sudo ./update.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the whole procedure. It asks three questions — whether to check GitHub for a newer release,
|
||||||
|
whether to download it (verifying the checksum published beside it), and whether you have a backup —
|
||||||
|
then does the rest. If you would rather fetch the zip yourself, hand it over instead:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo ./update.sh --zip ~/projectsend-2.1.0.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
`./update.sh --check` reports what is installed and what is available, changes nothing, and does not
|
||||||
|
need root.
|
||||||
|
|
||||||
|
### What it does, in order
|
||||||
|
|
||||||
|
1. Refuses to run inside a container, where updating means pulling a new image.
|
||||||
|
2. Works out who your web server runs as, from the owner of `public/index.php`.
|
||||||
|
3. Reads the version out of the zip and refuses to go backwards — migrations only move forwards —
|
||||||
|
unless you pass `--force`. The *same* version is accepted, and is how you restore files that were
|
||||||
|
modified or lost.
|
||||||
|
4. Takes a database dump, if you asked for one (`--backup`).
|
||||||
|
5. Puts the site into maintenance mode, and guarantees it comes back out: if anything fails, or you
|
||||||
|
interrupt it, the site is brought back up before the script exits.
|
||||||
|
6. Unpacks the release over your installation, leaving `.env`, `storage/` and `public/storage`
|
||||||
|
alone, and replacing `vendor/` and `public/build/` wholesale rather than merging them.
|
||||||
|
7. Runs `php artisan projectsend:update`, which migrates the database, restores the built-in roles,
|
||||||
|
relinks storage, clears the compiled caches, rebuilds the optional ones **if you were using
|
||||||
|
them**, and records the version it applied.
|
||||||
|
8. Reloads PHP-FPM and restarts the queue worker.
|
||||||
|
9. Brings the site back up and tells you what is running.
|
||||||
|
|
||||||
|
### Useful options
|
||||||
|
|
||||||
|
| Option | What for |
|
||||||
|
|---|---|
|
||||||
|
| `--zip <path>` | Apply a zip you downloaded yourself. |
|
||||||
|
| `--backup` | Dump the database first, to `/var/backups/projectsend` (`--backup-dir` to change). |
|
||||||
|
| `--check` | Report versions and stop. No root needed. |
|
||||||
|
| `--user`, `--php-fpm`, `--worker` | Override what it detected. |
|
||||||
|
| `--no-restart` | Leave systemd alone. You must then reload PHP-FPM yourself. |
|
||||||
|
| `-y`, `--yes` | Unattended. Requires `--backup` or `--i-have-a-backup`. |
|
||||||
|
| `--force` | Apply an older release. Read the sentence about migrations again first. |
|
||||||
|
|
||||||
|
### Doing it by hand
|
||||||
|
|
||||||
|
The script is not magic, and there is no harm in running the steps yourself:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /var/www/projectsend
|
||||||
|
sudo -u www-data php artisan down
|
||||||
|
# unpack the release over this directory, keeping .env and storage/
|
||||||
|
sudo chown -R www-data:www-data .
|
||||||
|
sudo -u www-data php artisan projectsend:update
|
||||||
|
sudo systemctl reload php8.4-fpm # not optional — see below
|
||||||
|
sudo systemctl restart projectsend-worker
|
||||||
|
sudo -u www-data php artisan up
|
||||||
|
```
|
||||||
|
|
||||||
|
**The reload is the step that matters.** With OPcache configured the way production guides recommend
|
||||||
|
(`opcache.validate_timestamps=0`, which our own Docker image uses), PHP does not re-read a file it
|
||||||
|
has already compiled. Replacing the files changes nothing for the running site: the database ends up
|
||||||
|
on the new version and the web server keeps serving the old code, while `php artisan` reports the
|
||||||
|
new version the whole time you are trying to work out why.
|
||||||
|
|
||||||
|
If it happens anyway, ProjectSend now says so: staff see a banner naming the version being served,
|
||||||
|
the version that was installed, and the command that fixes it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When it goes wrong
|
||||||
|
|
||||||
|
**The site shows the old version after updating.**
|
||||||
|
Stale OPcache — reload PHP-FPM. `php artisan` reporting the new version while the browser reads the
|
||||||
|
old one is this exact symptom, and staff now see a banner saying so, naming both versions and the
|
||||||
|
command that fixes it. `sudo ./update.sh` does the reload for you unless you passed `--no-restart`.
|
||||||
|
|
||||||
|
**500 errors on every page, right after an update.**
|
||||||
|
Usually compiled caches from the previous version. `php artisan optimize:clear`, then reload
|
||||||
|
PHP-FPM. Check `storage/logs/` for the real error before assuming.
|
||||||
|
|
||||||
|
**"Base table or view not found" or a missing-column error.**
|
||||||
|
The migrations did not run, or did not finish. Run `php artisan migrate --force` and read its output.
|
||||||
|
On Docker, `docker compose logs app`.
|
||||||
|
|
||||||
|
**A setting looks stale — an old value the settings screen does not agree with.**
|
||||||
|
`php artisan cache:clear` is safe at any time and fixes it.
|
||||||
|
|
||||||
|
**The background worker is still running old code.**
|
||||||
|
It exits on `queue:restart` only once it finishes its current job, and something has to start it
|
||||||
|
again. Check the service INSTALL.md sets up: `sudo systemctl status projectsend-worker`.
|
||||||
|
|
||||||
|
**Permission errors after unpacking.**
|
||||||
|
Step 2's `chown`. `storage/` and `bootstrap/cache/` must be writable by the web server user — the
|
||||||
|
same requirement as [INSTALL.md](INSTALL.md) step 4.
|
||||||
|
|
||||||
|
**You need to go back.**
|
||||||
|
|
||||||
|
1. Restore the code: the previous image tag (Docker), or the previous directory or zip (manual).
|
||||||
|
2. Restore the database dump you took before starting.
|
||||||
|
3. On a manual install, run `sudo -u www-data php artisan projectsend:update` afterwards, so the
|
||||||
|
installation agrees with the code you restored. (A container does this itself on boot.) Until it
|
||||||
|
runs, staff see the banner described above — which is correct: the database is ahead of the code.
|
||||||
|
|
||||||
|
Restore both, not one. A newer database against older code is the one combination nothing in this
|
||||||
|
application expects, because migrations only ever move forwards.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version-specific notes
|
||||||
|
|
||||||
|
Anything a particular release needs beyond this document is in [CHANGELOG.md](CHANGELOG.md), under
|
||||||
|
that version. It is worth reading before you start rather than after, particularly for a release
|
||||||
|
that changes a requirement — the PHP floor, or a new extension.
|
||||||
@@ -8,6 +8,8 @@ use App\Modules\Audit\Action;
|
|||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
use App\Modules\Clients\ClientFieldContext;
|
use App\Modules\Clients\ClientFieldContext;
|
||||||
use App\Modules\Clients\ClientPortalCustomFields;
|
use App\Modules\Clients\ClientPortalCustomFields;
|
||||||
|
use App\Modules\Identity\Erasure\ErasureSchedule;
|
||||||
|
use App\Modules\Identity\StaffAccounts;
|
||||||
use App\Modules\Platform\Localization\TimezoneRegistry;
|
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||||
use App\Modules\Platform\Settings\Setting;
|
use App\Modules\Platform\Settings\Setting;
|
||||||
use App\Modules\Platform\Settings\Settings;
|
use App\Modules\Platform\Settings\Settings;
|
||||||
@@ -23,6 +25,7 @@ class ProfileController extends Controller
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ClientPortalCustomFields $customFields,
|
private readonly ClientPortalCustomFields $customFields,
|
||||||
private readonly TimezoneRegistry $timezones,
|
private readonly TimezoneRegistry $timezones,
|
||||||
|
private readonly StaffAccounts $accounts,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,12 +106,24 @@ class ProfileController extends Controller
|
|||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
assert($user !== null);
|
assert($user !== null);
|
||||||
|
|
||||||
|
// The rule every other door into this already asks: Staff update(),
|
||||||
|
// guardDeletable(), and both role-conversion directions. This one
|
||||||
|
// did not, and self-deletion is the one door where the account
|
||||||
|
// being removed is certainly signed in — so the last active
|
||||||
|
// administrator could take themselves out, leaving no live staff
|
||||||
|
// row at all. EnsureSetupIsComplete then reopens first-run setup to
|
||||||
|
// anybody who asks, which is the other half of this and is closed
|
||||||
|
// below.
|
||||||
|
$this->accounts->guardLastAdministrator(
|
||||||
|
$user,
|
||||||
|
removesAdmin: $this->accounts->isAdministratorRole($user->role_id),
|
||||||
|
);
|
||||||
|
|
||||||
Auth::logout();
|
Auth::logout();
|
||||||
|
|
||||||
// Self-deletion: soft delete now, permanent GDPR erasure after
|
// Self-deletion: soft delete now, permanent GDPR erasure after
|
||||||
// the disclosed grace period (Setting::AccountErasureGraceDays).
|
// the disclosed grace period (Setting::AccountErasureGraceDays).
|
||||||
$graceDays = (int) app(Settings::class)->get(Setting::AccountErasureGraceDays);
|
app(ErasureSchedule::class)->apply($user);
|
||||||
$user->forceFill(['erase_after' => now()->addDays($graceDays)])->save();
|
|
||||||
$user->delete();
|
$user->delete();
|
||||||
|
|
||||||
app(ActivityLogger::class)->log(Action::UserDeleted, $user, context: ['name' => $user->name]);
|
app(ActivityLogger::class)->log(Action::UserDeleted, $user, context: ['name' => $user->name]);
|
||||||
|
|||||||
@@ -15,11 +15,14 @@ use App\Modules\Platform\Attribution\Attribution;
|
|||||||
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||||
use App\Modules\Platform\Captcha\Captcha;
|
use App\Modules\Platform\Captcha\Captcha;
|
||||||
use App\Modules\Platform\Installation\Installation;
|
use App\Modules\Platform\Installation\Installation;
|
||||||
|
use App\Modules\Files\Queue\StalledZipBuilds;
|
||||||
use App\Modules\Platform\Localization\LocaleRegistry;
|
use App\Modules\Platform\Localization\LocaleRegistry;
|
||||||
use App\Modules\Platform\Localization\TimezoneRegistry;
|
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||||
|
use App\Modules\Platform\OfficialLinks;
|
||||||
use App\Modules\Platform\Settings\Setting;
|
use App\Modules\Platform\Settings\Setting;
|
||||||
use App\Modules\Platform\Settings\Settings;
|
use App\Modules\Platform\Settings\Settings;
|
||||||
use App\Modules\Platform\Updates\LatestReleaseInfo;
|
use App\Modules\Platform\Updates\LatestReleaseInfo;
|
||||||
|
use App\Modules\Platform\Updates\RunningCodeState;
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Middleware;
|
use Inertia\Middleware;
|
||||||
@@ -71,7 +74,10 @@ class HandleInertiaRequests extends Middleware
|
|||||||
'edition' => $capabilities->edition()->value,
|
'edition' => $capabilities->edition()->value,
|
||||||
'noindex' => app(Settings::class)->get(Setting::DiscourageSearchIndexing),
|
'noindex' => app(Settings::class)->get(Setting::DiscourageSearchIndexing),
|
||||||
'version' => config('projectsend.version'),
|
'version' => config('projectsend.version'),
|
||||||
'links' => config('projectsend.links'),
|
// Resolved rather than handed over raw: each edition has its
|
||||||
|
// own front door, and a managed installation offers no
|
||||||
|
// donation link at all. See OfficialLinks.
|
||||||
|
'links' => app(OfficialLinks::class)->toArray(),
|
||||||
// Whether the client- and visitor-facing surfaces name
|
// Whether the client- and visitor-facing surfaces name
|
||||||
// ProjectSend. True everywhere unless a package answers
|
// ProjectSend. True everywhere unless a package answers
|
||||||
// otherwise — see ResolvingAttribution. Staff surfaces
|
// otherwise — see ResolvingAttribution. Staff surfaces
|
||||||
@@ -96,6 +102,8 @@ class HandleInertiaRequests extends Middleware
|
|||||||
'password_policy' => app(PasswordPolicy::class)->descriptor(),
|
'password_policy' => app(PasswordPolicy::class)->descriptor(),
|
||||||
'pending' => $this->pendingCounts($request),
|
'pending' => $this->pendingCounts($request),
|
||||||
'update_notice' => $this->updateNotice($request),
|
'update_notice' => $this->updateNotice($request),
|
||||||
|
'code_notice' => $this->codeNotice($request),
|
||||||
|
'worker_notice' => $this->workerNotice($request),
|
||||||
'locale' => app()->getLocale(),
|
'locale' => app()->getLocale(),
|
||||||
// The clock this viewer reads dates by, and whether it is a
|
// The clock this viewer reads dates by, and whether it is a
|
||||||
// choice or a fallback. The frontend needs both: the first to
|
// choice or a fallback. The frontend needs both: the first to
|
||||||
@@ -139,10 +147,14 @@ class HandleInertiaRequests extends Middleware
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($checker->allows($user, Permission::ApproveGroupsMembershipsRequests)) {
|
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()
|
$counts['membership_requests'] = MembershipRequest::query()
|
||||||
->pending()
|
->pending()
|
||||||
->whereHas('user')
|
->whereHas('user')
|
||||||
->whereHas('group')
|
->whereHas('group')
|
||||||
|
->approvableBy($user)
|
||||||
->count();
|
->count();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,10 +229,67 @@ class HandleInertiaRequests extends Middleware
|
|||||||
: [...$release, 'install_kind' => app(Installation::class)->kind()->value];
|
: [...$release, 'install_kind' => app(Installation::class)->kind()->value];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this process is running the code the installation was last
|
||||||
|
* updated to — see RunningCodeState for the failure it catches.
|
||||||
|
*
|
||||||
|
* Gated on view_system_info rather than manage_updates: the latter is
|
||||||
|
* edition-gated (Capability::SystemUpdates), and a server executing
|
||||||
|
* code that does not match its own database is not a feature anyone
|
||||||
|
* buys, it is a fact about the machine.
|
||||||
|
*
|
||||||
|
* @return array{reason: string, applied: string, running: string, applied_at: string, install_kind: string}|null
|
||||||
|
*/
|
||||||
|
protected function codeNotice(Request $request): ?array
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if ($user === null || ! app(PermissionChecker::class)->allows($user, Permission::ViewSystemInfo)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
* App strings use English text as the translation key, so "en" ships no
|
||||||
* messages — the key itself is the fallback.
|
* messages — the key itself is the fallback.
|
||||||
*
|
*
|
||||||
|
* Asked of the framework's own loader rather than read out of
|
||||||
|
* lang/{locale}.json directly, so that a package which registers its
|
||||||
|
* catalogue with loadJsonTranslationsFrom() reaches the frontend too.
|
||||||
|
* Reading the file worked for as long as every translatable string
|
||||||
|
* belonged to this repository; the companion packages own screens of
|
||||||
|
* their own, and theirs were rendering in English in every language
|
||||||
|
* because their catalogue never got this far.
|
||||||
|
*
|
||||||
|
* Precedence comes from the loader and is the useful way round: an
|
||||||
|
* installation's own lang/{locale}.json is merged last and therefore
|
||||||
|
* wins, so a package string can be overridden locally.
|
||||||
|
*
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
protected function translations(string $locale): array
|
protected function translations(string $locale): array
|
||||||
@@ -229,13 +298,7 @@ class HandleInertiaRequests extends Middleware
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$path = lang_path("{$locale}.json");
|
|
||||||
|
|
||||||
if (! is_file($path)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var array<string, string> */
|
/** @var array<string, string> */
|
||||||
return json_decode((string) file_get_contents($path), true, flags: JSON_THROW_ON_ERROR);
|
return app('translator')->getLoader()->load($locale, '*', '*');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Modules\Platform\Onboarding\InstallationWelcome;
|
||||||
|
use App\Modules\Platform\Updates\UpdateWelcome;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes the administrator to whichever page is waiting for them: the
|
||||||
|
* getting-started list on a new installation, or the what's-new page the
|
||||||
|
* first time they arrive after an update.
|
||||||
|
*
|
||||||
|
* Attached to the dashboard alone rather than to the whole web group.
|
||||||
|
* The dashboard is where a login lands and where the sidebar's logo
|
||||||
|
* points, so it catches the arrival either way — including the common
|
||||||
|
* case on a self-hosted server, where the person who ran update.sh was
|
||||||
|
* already signed in and never logs in at all. Applying it to every
|
||||||
|
* request instead would mean intercepting somebody mid-download or
|
||||||
|
* mid-upload to congratulate them, which is a worse trade than missing
|
||||||
|
* an administrator who happens to bookmark /files.
|
||||||
|
*
|
||||||
|
* One middleware for both because they are the same interruption, and a
|
||||||
|
* second one on the same route would have to know about the first to
|
||||||
|
* avoid arguing with it. Installation wins: the two cannot both be
|
||||||
|
* waiting in practice — an update marker is only raised for an
|
||||||
|
* installation that already existed — but if they ever were, somebody
|
||||||
|
* who has just installed this does not need release notes for a version
|
||||||
|
* they never ran.
|
||||||
|
*/
|
||||||
|
class RedirectToGreeting
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly InstallationWelcome $installation,
|
||||||
|
private readonly UpdateWelcome $update,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
// GET only: a redirect swallows a POST body, and nothing that
|
||||||
|
// writes should ever be answered with a greeting.
|
||||||
|
if ($request->isMethod('GET')) {
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if ($user !== null) {
|
||||||
|
if ($this->installation->isWaitingFor($user)) {
|
||||||
|
return redirect()->route('system.getting-started');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->update->isWaitingFor($user)) {
|
||||||
|
return redirect()->route('system.whats-new');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,12 +16,12 @@ use League\CommonMark\MarkdownConverter;
|
|||||||
/**
|
/**
|
||||||
* The API reference, inside the admin UI.
|
* The API reference, inside the admin UI.
|
||||||
*
|
*
|
||||||
* Rendered from the two files that are already the source of truth — the
|
* Rendered from the files that are already the source of truth — the
|
||||||
* committed OpenAPI document and docs/api-guide.md — rather than embedding
|
* committed OpenAPI document, docs/api-guide.md and docs/api-zapier.md —
|
||||||
* a third-party documentation UI. An iframe or a CDN-hosted renderer would
|
* rather than embedding a third-party documentation UI. An iframe or a
|
||||||
* mean a page that ignores the app's theme, breaks its links, and goes
|
* CDN-hosted renderer would mean a page that ignores the app's theme,
|
||||||
* blank on an install with no outbound internet access, which self-hosted
|
* breaks its links, and goes blank on an install with no outbound internet
|
||||||
* installations regularly are.
|
* access, which self-hosted installations regularly are.
|
||||||
*
|
*
|
||||||
* The markdown is converted server-side with league/commonmark, already a
|
* The markdown is converted server-side with league/commonmark, already a
|
||||||
* framework dependency, so no JavaScript renderer joins the bundle.
|
* framework dependency, so no JavaScript renderer joins the bundle.
|
||||||
@@ -31,7 +31,11 @@ class ApiDocsController extends Controller
|
|||||||
public function __invoke(Request $request): Response
|
public function __invoke(Request $request): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('api/docs', [
|
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(),
|
'endpoints' => $this->endpoints(),
|
||||||
'spec_url' => route('api.openapi'),
|
'spec_url' => route('api.openapi'),
|
||||||
'version' => $this->spec()['info']['version'] ?? null,
|
'version' => $this->spec()['info']['version'] ?? null,
|
||||||
@@ -96,16 +100,20 @@ class ApiDocsController extends Controller
|
|||||||
return $position === false ? '' : substr($description, $position);
|
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)) {
|
if (! is_file($path)) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// A deliberately small extension set. The guide is a file shipped
|
// A deliberately small extension set. These are files shipped with
|
||||||
// with the application, not user input — but rendering it with the
|
// the application, not user input — but rendering them with the
|
||||||
// narrowest converter that does the job keeps it that way even if
|
// narrowest converter that does the job keeps it that way even if
|
||||||
// someone later points this at something less trustworthy.
|
// someone later points this at something less trustworthy.
|
||||||
$environment = new Environment([
|
$environment = new Environment([
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ use Illuminate\Support\Carbon;
|
|||||||
* one forever. The cost is re-seeing the boundary row, which a client
|
* one forever. The cost is re-seeing the boundary row, which a client
|
||||||
* de-duplicates by id — the safe direction of the trade.
|
* 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
|
* Known limitation, documented rather than papered over: polling cannot
|
||||||
* observe deletions. A soft-deleted row simply stops appearing. Webhooks
|
* observe deletions. A soft-deleted row simply stops appearing. Webhooks
|
||||||
* are the fix, and are deliberately a later phase.
|
* are the fix, and are deliberately a later phase.
|
||||||
@@ -39,9 +46,11 @@ class PollingQuery
|
|||||||
* @template TModel of Model
|
* @template TModel of Model
|
||||||
*
|
*
|
||||||
* @param Builder<TModel> $query
|
* @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>
|
* @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');
|
$since = $request->query('updated_since');
|
||||||
|
|
||||||
@@ -53,11 +62,11 @@ class PollingQuery
|
|||||||
// a polling client would see an empty result forever instead of
|
// a polling client would see an empty result forever instead of
|
||||||
// an error. Carbon also normalises the offset into the app's
|
// an error. Carbon also normalises the offset into the app's
|
||||||
// timezone, so a caller in any timezone gets the same rows.
|
// timezone, so a caller in any timezone gets the same rows.
|
||||||
$query->where("{$table}.updated_at", '>=', Carbon::parse($since)->timezone(config('app.timezone')))
|
$query->where("{$table}.{$column}", '>=', Carbon::parse($since)->timezone(config('app.timezone')))
|
||||||
->orderBy("{$table}.updated_at")
|
->orderBy("{$table}.{$column}")
|
||||||
->orderBy("{$table}.id");
|
->orderBy("{$table}.id");
|
||||||
} else {
|
} else {
|
||||||
$query->orderByDesc("{$table}.updated_at")
|
$query->orderByDesc("{$table}.{$column}")
|
||||||
->orderByDesc("{$table}.id");
|
->orderByDesc("{$table}.id");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ enum Action: string
|
|||||||
// Platform / lifecycle (v1 action 0: "ProjectSend has been installed")
|
// Platform / lifecycle (v1 action 0: "ProjectSend has been installed")
|
||||||
case SetupCompleted = 'setup.completed';
|
case SetupCompleted = 'setup.completed';
|
||||||
case SettingsUpdated = 'settings.updated';
|
case SettingsUpdated = 'settings.updated';
|
||||||
|
case ApplicationUpdated = 'application.updated';
|
||||||
|
|
||||||
// Identity
|
// Identity
|
||||||
case Login = 'auth.login';
|
case Login = 'auth.login';
|
||||||
@@ -53,6 +54,7 @@ enum Action: string
|
|||||||
case ShareLinkRevoked = 'share_link.revoked';
|
case ShareLinkRevoked = 'share_link.revoked';
|
||||||
case ShareLinkDownloaded = 'share_link.downloaded';
|
case ShareLinkDownloaded = 'share_link.downloaded';
|
||||||
case PublicFileDownloaded = 'public_file.downloaded';
|
case PublicFileDownloaded = 'public_file.downloaded';
|
||||||
|
case PublicFilePreviewed = 'public_file.previewed';
|
||||||
case FolderCreated = 'folder.created';
|
case FolderCreated = 'folder.created';
|
||||||
case FolderRenamed = 'folder.renamed';
|
case FolderRenamed = 'folder.renamed';
|
||||||
case FolderMoved = 'folder.moved';
|
case FolderMoved = 'folder.moved';
|
||||||
@@ -142,6 +144,7 @@ enum Action: string
|
|||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::SetupCompleted => 'Installed ProjectSend',
|
self::SetupCompleted => 'Installed ProjectSend',
|
||||||
|
self::ApplicationUpdated => 'Updated ProjectSend to :to, from :from',
|
||||||
self::SettingsUpdated => 'Updated the system settings (:section)',
|
self::SettingsUpdated => 'Updated the system settings (:section)',
|
||||||
self::Login => 'Logged in',
|
self::Login => 'Logged in',
|
||||||
self::Logout => 'Logged out',
|
self::Logout => 'Logged out',
|
||||||
@@ -178,6 +181,7 @@ enum Action: string
|
|||||||
self::ShareLinkRevoked => 'Revoked a public link for the file ":subject"',
|
self::ShareLinkRevoked => 'Revoked a public link for the file ":subject"',
|
||||||
self::ShareLinkDownloaded => 'Downloaded the file ":subject" via a public link',
|
self::ShareLinkDownloaded => 'Downloaded the file ":subject" via a public link',
|
||||||
self::PublicFileDownloaded => 'Downloaded the file ":subject" via the public group listing',
|
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::FolderCreated => 'Created the folder ":subject"',
|
||||||
self::FolderRenamed => 'Renamed the folder ":subject"',
|
self::FolderRenamed => 'Renamed the folder ":subject"',
|
||||||
self::FolderMoved => 'Moved the folder ":subject"',
|
self::FolderMoved => 'Moved the folder ":subject"',
|
||||||
@@ -242,6 +246,7 @@ enum Action: string
|
|||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::SetupCompleted => 'ProjectSend was installed',
|
self::SetupCompleted => 'ProjectSend was installed',
|
||||||
|
self::ApplicationUpdated => 'ProjectSend was updated to a new version',
|
||||||
self::SettingsUpdated => 'System settings were updated',
|
self::SettingsUpdated => 'System settings were updated',
|
||||||
self::Login => 'Logged in',
|
self::Login => 'Logged in',
|
||||||
self::Logout => 'Logged out',
|
self::Logout => 'Logged out',
|
||||||
@@ -275,6 +280,7 @@ enum Action: string
|
|||||||
self::ShareLinkRevoked => 'A public link was revoked',
|
self::ShareLinkRevoked => 'A public link was revoked',
|
||||||
self::ShareLinkDownloaded => 'A file was downloaded via a public link',
|
self::ShareLinkDownloaded => 'A file was downloaded via a public link',
|
||||||
self::PublicFileDownloaded => 'A file was downloaded via the public group listing',
|
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::FolderCreated => 'A folder was created',
|
||||||
self::FolderRenamed => 'A folder was renamed',
|
self::FolderRenamed => 'A folder was renamed',
|
||||||
self::FolderMoved => 'A folder was moved',
|
self::FolderMoved => 'A folder was moved',
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ declare(strict_types=1);
|
|||||||
namespace App\Modules\Audit;
|
namespace App\Modules\Audit;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Modules\Audit\Events\ResolvingActivityOrigin;
|
||||||
use App\Modules\Platform\Settings\Setting;
|
use App\Modules\Platform\Settings\Setting;
|
||||||
use App\Modules\Platform\Settings\Settings;
|
use App\Modules\Platform\Settings\Settings;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
|
||||||
class ActivityLogger
|
class ActivityLogger
|
||||||
{
|
{
|
||||||
@@ -35,16 +37,20 @@ class ActivityLogger
|
|||||||
// gaps. Reading the current request's credential is the same kind of
|
// gaps. Reading the current request's credential is the same kind of
|
||||||
// implicit lookup this class already does for the actor and the IP.
|
// implicit lookup this class already does for the actor and the IP.
|
||||||
$token = $user?->currentAccessToken();
|
$token = $user?->currentAccessToken();
|
||||||
|
[$origin, $credentialName] = $this->originFor($user, $token);
|
||||||
|
|
||||||
ActivityLog::query()->create([
|
ActivityLog::query()->create([
|
||||||
'actor_id' => $user?->getKey(),
|
'actor_id' => $user?->getKey(),
|
||||||
'actor_name' => $user?->name,
|
'actor_name' => $user?->name,
|
||||||
'actor_type' => $user?->type->value,
|
'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(),
|
'api_token_id' => $token?->getKey(),
|
||||||
// Snapshotted beside the id for the same reason actor_name is:
|
// Snapshotted beside the id for the same reason actor_name is:
|
||||||
// a revoked token must not leave its entries pointing at nothing.
|
// a revoked token must not leave its entries pointing at nothing.
|
||||||
'api_token_name' => $token?->getAttribute('name'),
|
'api_token_name' => $credentialName,
|
||||||
'action' => $action,
|
'action' => $action,
|
||||||
'subject_type' => $subject?->getMorphClass(),
|
'subject_type' => $subject?->getMorphClass(),
|
||||||
'subject_id' => $subject?->getKey(),
|
'subject_id' => $subject?->getKey(),
|
||||||
@@ -77,23 +83,39 @@ class ActivityLogger
|
|||||||
* untestable — the failure mode being that it looks right in
|
* untestable — the failure mode being that it looks right in
|
||||||
* production and nothing proves it. A console command and a queued job
|
* production and nothing proves it. A console command and a queued job
|
||||||
* have no route; a request does.
|
* 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) {
|
if ($token !== null) {
|
||||||
return ActivityOrigin::Api;
|
$name = $token->getAttribute('name');
|
||||||
|
|
||||||
|
return [ActivityOrigin::Api, is_string($name) ? $name : null];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($actor !== null) {
|
if ($actor === null) {
|
||||||
return ActivityOrigin::Ui;
|
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
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,18 @@ enum ActivityOrigin: string
|
|||||||
/** Scheduled tasks and console commands. */
|
/** Scheduled tasks and console commands. */
|
||||||
case System = 'system';
|
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.
|
* English label — also the translation key.
|
||||||
*/
|
*/
|
||||||
@@ -48,6 +60,7 @@ enum ActivityOrigin: string
|
|||||||
self::Api => 'API',
|
self::Api => 'API',
|
||||||
self::Public => 'Not signed in',
|
self::Public => 'Not signed in',
|
||||||
self::System => 'System',
|
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,
|
'key' => $action->value,
|
||||||
'description' => $action->description(),
|
'description' => $action->description(),
|
||||||
], Action::cases()),
|
], Action::cases()),
|
||||||
'origins' => array_map(fn (ActivityOrigin $origin): array => [
|
// Every origin this installation could actually produce.
|
||||||
'key' => $origin->value,
|
// Offering a filter that can only ever return nothing would
|
||||||
'label' => $origin->label(),
|
// be dangling a feature this edition does not have, which is
|
||||||
], ActivityOrigin::cases()),
|
// 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\Api\ApiUsage;
|
||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLog;
|
use App\Modules\Audit\ActivityLog;
|
||||||
|
use App\Modules\Audit\ActivityLogScope;
|
||||||
|
use App\Modules\Audit\ActivityPresenter;
|
||||||
use App\Modules\Audit\DashboardWidgetPreferences;
|
use App\Modules\Audit\DashboardWidgetPreferences;
|
||||||
use App\Modules\Clients\ClientStorageUsage;
|
use App\Modules\Clients\ClientStorageUsage;
|
||||||
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
use App\Modules\Groups\Models\Group;
|
use App\Modules\Groups\Models\Group;
|
||||||
use App\Modules\Identity\UserType;
|
use App\Modules\Identity\UserType;
|
||||||
@@ -24,6 +27,7 @@ use App\Modules\Platform\Settings\Settings;
|
|||||||
use App\Modules\Platform\Storage\StorageDurability;
|
use App\Modules\Platform\Storage\StorageDurability;
|
||||||
use App\Modules\Platform\System\SystemEnvironment;
|
use App\Modules\Platform\System\SystemEnvironment;
|
||||||
use App\Modules\Platform\Updates\LatestReleaseInfo;
|
use App\Modules\Platform\Updates\LatestReleaseInfo;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -50,6 +54,9 @@ class DashboardController extends Controller
|
|||||||
private readonly Installation $installation,
|
private readonly Installation $installation,
|
||||||
private readonly TimezoneRegistry $timezones,
|
private readonly TimezoneRegistry $timezones,
|
||||||
private readonly SystemEnvironment $environment,
|
private readonly SystemEnvironment $environment,
|
||||||
|
private readonly ActivityPresenter $presenter,
|
||||||
|
private readonly ActivityLogScope $scope,
|
||||||
|
private readonly StaffLibraryScope $library,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function __invoke(Request $request): Response
|
public function __invoke(Request $request): Response
|
||||||
@@ -81,10 +88,10 @@ class DashboardController extends Controller
|
|||||||
? ['preset' => $preset, 'from' => $from->toDateString(), 'to' => $to->toDateString()]
|
? ['preset' => $preset, 'from' => $from->toDateString(), 'to' => $to->toDateString()]
|
||||||
: null,
|
: null,
|
||||||
'top_clients_by_storage' => $canStatistics && $prefs->isEnabled($user, 'top_clients_by_storage')
|
'top_clients_by_storage' => $canStatistics && $prefs->isEnabled($user, 'top_clients_by_storage')
|
||||||
? $this->topClientsByStorage()
|
? $this->topClientsByStorage($user)
|
||||||
: null,
|
: null,
|
||||||
'largest_files' => $canStatistics && $prefs->isEnabled($user, 'largest_files') ? $this->largestFiles($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,
|
'system' => $canSystem && $prefs->isEnabled($user, 'system') ? $this->systemInfo() : null,
|
||||||
// Both editions — informational content, not an update action,
|
// Both editions — informational content, not an update action,
|
||||||
// so no Capability check alongside the permission (unlike
|
// so no Capability check alongside the permission (unlike
|
||||||
@@ -206,6 +213,12 @@ class DashboardController extends Controller
|
|||||||
*/
|
*/
|
||||||
private function counters(): array
|
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 [
|
return [
|
||||||
'files' => File::query()->count(),
|
'files' => File::query()->count(),
|
||||||
'files_bytes' => (int) File::query()->sum('size'),
|
'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}>
|
* @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()
|
$rows = File::query()
|
||||||
->select('uploaded_by', DB::raw('SUM(size) as total_bytes'))
|
->select('uploaded_by', DB::raw('SUM(size) as total_bytes'))
|
||||||
->whereHas('uploader', fn ($query) => $query->where('type', UserType::Client))
|
->whereHas('uploader', fn ($query) => $query->where('type', UserType::Client))
|
||||||
|
->when($clientIds !== null, fn (Builder $query) => $query->whereIn('uploaded_by', $clientIds))
|
||||||
->groupBy('uploaded_by')
|
->groupBy('uploaded_by')
|
||||||
->orderByDesc('total_bytes')
|
->orderByDesc('total_bytes')
|
||||||
->limit(5)
|
->limit(5)
|
||||||
@@ -338,7 +361,14 @@ class DashboardController extends Controller
|
|||||||
$staffModule = $this->capabilities->has(Capability::UsersManage) && $viewer->can('manage_users');
|
$staffModule = $this->capabilities->has(Capability::UsersManage) && $viewer->can('manage_users');
|
||||||
$canStaffUsers = $staffModule && $viewer->can('edit_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')
|
->with('uploader:id,name,type')
|
||||||
->orderByDesc('size')
|
->orderByDesc('size')
|
||||||
->limit(10)
|
->limit(10)
|
||||||
@@ -377,8 +407,21 @@ class DashboardController extends Controller
|
|||||||
$canFiles = $viewer->can('upload') || $viewer->can('edit_files') || $viewer->can('edit_others_files');
|
$canFiles = $viewer->can('upload') || $viewer->can('edit_files') || $viewer->can('edit_others_files');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'count' => File::query()->expired()->count(),
|
// Both the count and the list read the viewer's library, so
|
||||||
'files' => array_values(File::query()->expired()->orderBy('expires_at')->limit(10)
|
// 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'])
|
->get(['id', 'name', 'expires_at'])
|
||||||
->map(fn (File $file): array => [
|
->map(fn (File $file): array => [
|
||||||
'id' => $file->id,
|
'id' => $file->id,
|
||||||
@@ -386,6 +429,10 @@ class DashboardController extends Controller
|
|||||||
'expires_at' => $file->expires_at?->toIso8601String(),
|
'expires_at' => $file->expires_at?->toIso8601String(),
|
||||||
'edit_url' => $canFiles ? route('files.edit', $file->id, false) : null,
|
'edit_url' => $canFiles ? route('files.edit', $file->id, false) : null,
|
||||||
])->all()),
|
])->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),
|
'auto_delete_enabled' => (bool) $this->settings->get(Setting::ExpiredFilesAutoDeleteEnabled),
|
||||||
// Schedule::command('projectsend:purge-expired-files')->daily()
|
// Schedule::command('projectsend:purge-expired-files')->daily()
|
||||||
// runs at 00:00 — always "tonight" from whenever this loads.
|
// runs at 00:00 — always "tonight" from whenever this loads.
|
||||||
@@ -396,28 +443,27 @@ class DashboardController extends Controller
|
|||||||
/**
|
/**
|
||||||
* @return array<int, array<string, mixed>>
|
* @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('created_at')
|
||||||
->orderByDesc('id')
|
->orderByDesc('id')
|
||||||
->limit(8)
|
->limit(8)
|
||||||
->get()
|
->get()
|
||||||
->map(fn (ActivityLog $entry): array => [
|
->map(fn (ActivityLog $entry): array => $this->presenter->present($entry))
|
||||||
'id' => $entry->id,
|
->all();
|
||||||
'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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,12 +5,17 @@ declare(strict_types=1);
|
|||||||
namespace App\Modules\Audit\Http\Controllers;
|
namespace App\Modules\Audit\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLog;
|
use App\Modules\Audit\ActivityLog;
|
||||||
use App\Modules\Audit\ActivityLogScope;
|
use App\Modules\Audit\ActivityLogScope;
|
||||||
use App\Modules\Audit\DownloadPresenter;
|
use App\Modules\Audit\DownloadPresenter;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
|
use App\Modules\Platform\Localization\LocalDay;
|
||||||
|
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||||
use App\Support\Pagination;
|
use App\Support\Pagination;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
@@ -27,6 +32,7 @@ class DownloadsController extends Controller
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly DownloadPresenter $presenter,
|
private readonly DownloadPresenter $presenter,
|
||||||
private readonly ActivityLogScope $scope,
|
private readonly ActivityLogScope $scope,
|
||||||
|
private readonly TimezoneRegistry $timezones,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): Response
|
public function index(Request $request): Response
|
||||||
@@ -34,15 +40,9 @@ class DownloadsController extends Controller
|
|||||||
$viewer = $request->user();
|
$viewer = $request->user();
|
||||||
assert($viewer !== null);
|
assert($viewer !== null);
|
||||||
|
|
||||||
// A download row names the file and says who fetched it from which
|
$filters = $this->validatedFilters($request);
|
||||||
// IP, so it needs the viewer's library scope applied — not just
|
|
||||||
// `view_actions_log`. See ActivityLogScope for the full reasoning.
|
$entries = $this->filteredQuery($filters, $viewer)
|
||||||
$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')
|
|
||||||
->paginate(25)
|
->paginate(25)
|
||||||
->withQueryString();
|
->withQueryString();
|
||||||
|
|
||||||
@@ -63,6 +63,65 @@ class DownloadsController extends Controller
|
|||||||
];
|
];
|
||||||
})->all(),
|
})->all(),
|
||||||
'pagination' => Pagination::meta($entries),
|
'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\Permissions\SystemRole;
|
||||||
use App\Modules\Identity\UserType;
|
use App\Modules\Identity\UserType;
|
||||||
use App\Modules\Platform\Settings\Setting;
|
use App\Modules\Platform\Settings\Setting;
|
||||||
|
use App\Modules\Platform\Seats\SeatAllowance;
|
||||||
use App\Modules\Platform\Settings\Settings;
|
use App\Modules\Platform\Settings\Settings;
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ class ClientProvisioning
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Settings $settings,
|
private readonly Settings $settings,
|
||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
|
private readonly SeatAllowance $seats,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,6 +72,15 @@ class ClientProvisioning
|
|||||||
): User {
|
): User {
|
||||||
$autoApprove ??= $this->autoApproves();
|
$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([
|
$client = User::create([
|
||||||
'type' => UserType::Client,
|
'type' => UserType::Client,
|
||||||
'active' => $autoApprove,
|
'active' => $autoApprove,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Modules\Clients\Http\Controllers;
|
namespace App\Modules\Clients\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Modules\Platform\Seats\SeatAllowance;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
@@ -28,6 +29,7 @@ use Inertia\Response;
|
|||||||
class AccountRequestsController extends Controller
|
class AccountRequestsController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
private readonly SeatAllowance $seats,
|
||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
private readonly Settings $settings,
|
private readonly Settings $settings,
|
||||||
) {}
|
) {}
|
||||||
@@ -67,6 +69,12 @@ class AccountRequestsController extends Controller
|
|||||||
{
|
{
|
||||||
abort_unless($client->isClient() && $client->account_requested, 404);
|
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([
|
$client->forceFill([
|
||||||
'active' => true,
|
'active' => true,
|
||||||
'account_requested' => false,
|
'account_requested' => false,
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ use App\Modules\Audit\Action;
|
|||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
use App\Modules\Clients\ClientCustomFieldType;
|
use App\Modules\Clients\ClientCustomFieldType;
|
||||||
use App\Modules\Clients\ClientStorageUsage;
|
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\Http\Resources\Api\ClientResource;
|
||||||
use App\Modules\Clients\Models\ClientCustomField;
|
use App\Modules\Clients\Models\ClientCustomField;
|
||||||
use App\Modules\Clients\Models\ClientCustomFieldValue;
|
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\Clients\Notifications\ClientWelcomeNotification;
|
||||||
use App\Modules\Files\DeletedAccountContent;
|
use App\Modules\Files\DeletedAccountContent;
|
||||||
use App\Modules\Identity\AccountContentDeletion;
|
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\Models\Role;
|
||||||
use App\Modules\Identity\Permissions\SystemRole;
|
use App\Modules\Identity\Permissions\SystemRole;
|
||||||
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
|
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
|
||||||
@@ -28,6 +32,7 @@ use Illuminate\Database\Eloquent\Builder;
|
|||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
@@ -54,6 +59,9 @@ class ClientsController extends Controller
|
|||||||
private readonly ClientStorageUsage $storageUsage,
|
private readonly ClientStorageUsage $storageUsage,
|
||||||
private readonly DeletedAccountContent $accountContent,
|
private readonly DeletedAccountContent $accountContent,
|
||||||
private readonly AccountContentDeletion $accountDeletion,
|
private readonly AccountContentDeletion $accountDeletion,
|
||||||
|
private readonly StaffLibraryScope $scope,
|
||||||
|
private readonly SeatAllowance $seats,
|
||||||
|
private readonly ErasureSchedule $erasure,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): AnonymousResourceCollection
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
@@ -63,7 +71,12 @@ class ClientsController extends Controller
|
|||||||
'status' => ['nullable', Rule::in(['active', 'inactive'])],
|
'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) {
|
if (($filters['search'] ?? null) !== null) {
|
||||||
$search = $filters['search'];
|
$search = $filters['search'];
|
||||||
@@ -79,18 +92,36 @@ class ClientsController extends Controller
|
|||||||
return ClientResource::collection($this->polling->paginate($request, $query, 'users'));
|
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);
|
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);
|
return $this->resourceFor($client);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->seats->guardClient();
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'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
|
// No `confirmed`: repeating a password is a defence against a
|
||||||
// human mistyping into a form, and an API caller has no second
|
// human mistyping into a form, and an API caller has no second
|
||||||
// field to mistype. This installation's password policy still
|
// 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
|
public function update(Request $request, User $client): ClientResource
|
||||||
{
|
{
|
||||||
abort_unless($client->isClient(), 404);
|
$this->guardTarget($request, $client);
|
||||||
|
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => ['sometimes', 'string', 'max:255'],
|
'name' => ['sometimes', 'string', 'max:255'],
|
||||||
@@ -159,7 +191,11 @@ class ClientsController extends Controller
|
|||||||
$client->storage_quota_mb = $validated['storage_quota_mb'] ?? 0;
|
$client->storage_quota_mb = $validated['storage_quota_mb'] ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Approval, and so the moment the seat is spent — same rule the
|
||||||
|
// web edit screen and approve() answer to. Inside the branch, so a
|
||||||
|
// capped installation can still edit a client it already holds.
|
||||||
if (($validated['active'] ?? false) && $client->account_requested) {
|
if (($validated['active'] ?? false) && $client->account_requested) {
|
||||||
|
$this->seats->guardClient('active');
|
||||||
$client->account_requested = false;
|
$client->account_requested = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,9 +239,10 @@ class ClientsController extends Controller
|
|||||||
* in the activity log against the caller. Answers 204 whether or not a
|
* in the activity log against the caller. Answers 204 whether or not a
|
||||||
* second factor was actually in force.
|
* 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);
|
$twoFactor->reset($client);
|
||||||
|
|
||||||
@@ -230,16 +267,29 @@ class ClientsController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function destroy(Request $request, User $client): JsonResponse
|
public function destroy(Request $request, User $client): JsonResponse
|
||||||
{
|
{
|
||||||
abort_unless($client->isClient(), 404);
|
$this->guardTarget($request, $client);
|
||||||
|
|
||||||
|
|
||||||
$validated = $this->accountDeletion->validate($request, $client);
|
$validated = $this->accountDeletion->validate($request, $client);
|
||||||
|
|
||||||
$name = $client->name;
|
// Soft-deleting the account and disposing of its files are two
|
||||||
$client->delete();
|
// 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);
|
return response()->json(status: 204);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class ClientSettingsController extends Controller
|
|||||||
'clients_can_select_group' => $this->settings->get(Setting::ClientsCanSelectGroup),
|
'clients_can_select_group' => $this->settings->get(Setting::ClientsCanSelectGroup),
|
||||||
'clients_membership_deny_cooldown_days' => $this->settings->get(Setting::ClientsMembershipDenyCooldownDays),
|
'clients_membership_deny_cooldown_days' => $this->settings->get(Setting::ClientsMembershipDenyCooldownDays),
|
||||||
'default_client_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
|
'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()
|
'groups' => Group::query()->orderBy('name')->get()
|
||||||
->map(fn (Group $group): array => ['id' => $group->id, 'name' => $group->name])
|
->map(fn (Group $group): array => ['id' => $group->id, 'name' => $group->name])
|
||||||
->all(),
|
->all(),
|
||||||
@@ -47,6 +48,7 @@ class ClientSettingsController extends Controller
|
|||||||
'clients_can_select_group' => ['required', Rule::in(['none', 'public'])],
|
'clients_can_select_group' => ['required', Rule::in(['none', 'public'])],
|
||||||
'clients_membership_deny_cooldown_days' => ['required', 'integer', 'min:0', 'max:365'],
|
'clients_membership_deny_cooldown_days' => ['required', 'integer', 'min:0', 'max:365'],
|
||||||
'default_client_storage_quota_mb' => ['required', 'integer', 'min:0'],
|
'default_client_storage_quota_mb' => ['required', 'integer', 'min:0'],
|
||||||
|
'clients_can_preview_files' => ['required', 'boolean'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->settings->set(Setting::ClientsCanRegister, $validated['clients_can_register']);
|
$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::ClientsCanSelectGroup, $validated['clients_can_select_group']);
|
||||||
$this->settings->set(Setting::ClientsMembershipDenyCooldownDays, (int) $validated['clients_membership_deny_cooldown_days']);
|
$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::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']);
|
$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\Audit\ActivityLogger;
|
||||||
use App\Modules\Clients\ClientCustomFieldType;
|
use App\Modules\Clients\ClientCustomFieldType;
|
||||||
use App\Modules\Clients\ClientStorageUsage;
|
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\ClientCustomField;
|
||||||
use App\Modules\Clients\Models\ClientCustomFieldValue;
|
use App\Modules\Clients\Models\ClientCustomFieldValue;
|
||||||
use App\Modules\Clients\Notifications\ClientAccountEditedNotification;
|
use App\Modules\Clients\Notifications\ClientAccountEditedNotification;
|
||||||
use App\Modules\Clients\Notifications\ClientWelcomeNotification;
|
use App\Modules\Clients\Notifications\ClientWelcomeNotification;
|
||||||
use App\Modules\Files\DeletedAccountContent;
|
use App\Modules\Files\DeletedAccountContent;
|
||||||
use App\Modules\Identity\AccountContentDeletion;
|
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\Models\Role;
|
||||||
use App\Modules\Identity\Permissions\SystemRole;
|
use App\Modules\Identity\Permissions\SystemRole;
|
||||||
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
|
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
|
||||||
@@ -26,6 +30,7 @@ use App\Support\Pagination;
|
|||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
@@ -44,6 +49,9 @@ class ClientsController extends Controller
|
|||||||
private readonly ClientStorageUsage $storageUsage,
|
private readonly ClientStorageUsage $storageUsage,
|
||||||
private readonly DeletedAccountContent $accountContent,
|
private readonly DeletedAccountContent $accountContent,
|
||||||
private readonly AccountContentDeletion $accountDeletion,
|
private readonly AccountContentDeletion $accountDeletion,
|
||||||
|
private readonly StaffLibraryScope $scope,
|
||||||
|
private readonly SeatAllowance $seats,
|
||||||
|
private readonly ErasureSchedule $erasure,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): Response
|
public function index(Request $request): Response
|
||||||
@@ -58,8 +66,14 @@ class ClientsController extends Controller
|
|||||||
'status' => $validated['status'] ?? null,
|
'status' => $validated['status'] ?? null,
|
||||||
];
|
];
|
||||||
|
|
||||||
$clients = User::query()
|
// Narrowed by the same rule the buttons on each row are guarded
|
||||||
->where('type', UserType::Client)
|
// 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
|
->when($filters['search'], fn (Builder $query, string $search) => $query->where(fn (Builder $q) => $q
|
||||||
->where('name', 'like', "%{$search}%")
|
->where('name', 'like', "%{$search}%")
|
||||||
->orWhere('email', 'like', "%{$search}%")))
|
->orWhere('email', 'like', "%{$search}%")))
|
||||||
@@ -85,11 +99,23 @@ class ClientsController extends Controller
|
|||||||
'pagination' => Pagination::meta($clients),
|
'pagination' => Pagination::meta($clients),
|
||||||
'filters' => $filters,
|
'filters' => $filters,
|
||||||
'reassign_candidates' => $this->accountDeletion->candidates(),
|
'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', [
|
return Inertia::render('clients/create', [
|
||||||
'custom_fields' => $this->customFieldDefinitions(),
|
'custom_fields' => $this->customFieldDefinitions(),
|
||||||
'default_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
|
'default_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
|
||||||
@@ -98,9 +124,13 @@ class ClientsController extends Controller
|
|||||||
|
|
||||||
public function store(Request $request): RedirectResponse
|
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([
|
$validated = $request->validate(array_merge([
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'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()],
|
'password' => ['required', 'confirmed', Password::defaults()],
|
||||||
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
|
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
|
||||||
], $this->customFieldRules()));
|
], $this->customFieldRules()));
|
||||||
@@ -130,13 +160,43 @@ class ClientsController extends Controller
|
|||||||
$client->notify(new ClientWelcomeNotification);
|
$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);
|
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', [
|
return Inertia::render('clients/edit', [
|
||||||
'client' => [
|
'client' => [
|
||||||
'id' => $client->id,
|
'id' => $client->id,
|
||||||
@@ -160,7 +220,8 @@ class ClientsController extends Controller
|
|||||||
|
|
||||||
public function update(Request $request, User $client): RedirectResponse
|
public function update(Request $request, User $client): RedirectResponse
|
||||||
{
|
{
|
||||||
abort_unless($client->isClient(), 404);
|
$this->guardTarget($request, $client);
|
||||||
|
|
||||||
|
|
||||||
$validated = $request->validate(array_merge([
|
$validated = $request->validate(array_merge([
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
@@ -185,8 +246,13 @@ class ClientsController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Activating a pending account through the edit screen counts as
|
// Activating a pending account through the edit screen counts as
|
||||||
// approval and clears the request flag.
|
// approval and clears the request flag — which is the moment a
|
||||||
|
// seat is spent, so the cap is asked here for the same reason
|
||||||
|
// AccountRequestsController::approve() asks it one screen over.
|
||||||
|
// Inside the branch, not above it: an installation at its cap must
|
||||||
|
// still be able to rename a client it already has.
|
||||||
if ($client->account_requested && $validated['active']) {
|
if ($client->account_requested && $validated['active']) {
|
||||||
|
$this->seats->guardClient('active');
|
||||||
$client->account_requested = false;
|
$client->account_requested = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,9 +285,10 @@ class ClientsController extends Controller
|
|||||||
* Remove this account's second factor, for the client who has lost
|
* Remove this account's second factor, for the client who has lost
|
||||||
* their authenticator and their recovery codes.
|
* 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);
|
$twoFactor->reset($client);
|
||||||
|
|
||||||
@@ -230,16 +297,29 @@ class ClientsController extends Controller
|
|||||||
|
|
||||||
public function destroy(Request $request, User $client): RedirectResponse
|
public function destroy(Request $request, User $client): RedirectResponse
|
||||||
{
|
{
|
||||||
abort_unless($client->isClient(), 404);
|
$this->guardTarget($request, $client);
|
||||||
|
|
||||||
|
|
||||||
$validated = $this->accountDeletion->validate($request, $client);
|
$validated = $this->accountDeletion->validate($request, $client);
|
||||||
|
|
||||||
$name = $client->name;
|
// Soft-deleting the account and disposing of its files are two
|
||||||
$client->delete();
|
// 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.'));
|
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
|
* Every comment this staff member may read, across their whole library
|
||||||
* — the management screen's query, rather than one file's thread.
|
* — the management screen's query, rather than one file's thread.
|
||||||
@@ -171,25 +215,40 @@ class VisibleCommentScope
|
|||||||
return $counts;
|
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
|
* @param Builder<FileComment> $query
|
||||||
* @return Builder<FileComment>
|
* @return Builder<FileComment>
|
||||||
*/
|
*/
|
||||||
private function applyVisibility(Builder $query, ?User $viewer, bool $isPublic): Builder
|
private function applyVisibility(Builder $query, ?User $viewer, bool $isPublic): Builder
|
||||||
{
|
{
|
||||||
// A comment awaiting moderation exists only for those who can act
|
$this->hideUnapproved($query, $viewer);
|
||||||
// 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)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($viewer === null) {
|
if ($viewer === null) {
|
||||||
// Publicness is re-derived here on every read rather than
|
// 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).
|
* Who may write a comment (Setting::CommentsAuthors).
|
||||||
*
|
*
|
||||||
* This is a setting rather than a permission on purpose. Roles are only
|
* This is a setting rather than a permission on purpose, and one of the
|
||||||
* editable in the community edition — the cloud edition gates the whole
|
* two reasons has since expired. It used to be that roles were editable
|
||||||
* roles screen behind Capability::UsersManage — so a permission key would
|
* only in the community edition — the cloud edition gated the whole roles
|
||||||
* be unconfigurable for half our installs. It also expresses something a
|
* screen behind Capability::UsersManage — so a permission key would have
|
||||||
* permission structurally cannot: `Everyone` includes anonymous visitors,
|
* been unconfigurable for half our installs. That stopped being true in
|
||||||
* who have no account and therefore no role to hold a key.
|
* 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
|
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}
|
* @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;
|
$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'])
|
->with(['author', 'clientContext'])
|
||||||
->orderBy('created_at')
|
->orderBy('created_at')
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Modules\Comments;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Modules\Comments\Access\VisibleCommentScope;
|
use App\Modules\Comments\Access\VisibleCommentScope;
|
||||||
use App\Modules\Comments\Models\FileComment;
|
use App\Modules\Comments\Models\FileComment;
|
||||||
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,6 +21,7 @@ class FileCommentPolicy
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly VisibleCommentScope $scope,
|
private readonly VisibleCommentScope $scope,
|
||||||
private readonly CommentingRules $rules,
|
private readonly CommentingRules $rules,
|
||||||
|
private readonly StaffLibraryScope $library,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function view(User $user, FileComment $comment): bool
|
public function view(User $user, FileComment $comment): bool
|
||||||
@@ -44,16 +46,38 @@ class FileCommentPolicy
|
|||||||
|
|
||||||
public function delete(User $user, FileComment $comment): bool
|
public function delete(User $user, FileComment $comment): bool
|
||||||
{
|
{
|
||||||
if ($this->moderate($user)) {
|
if ($this->moderate($user, $comment)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $comment->author_id === $user->id && $this->withinEditWindow($comment);
|
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
|
private function withinEditWindow(FileComment $comment): bool
|
||||||
|
|||||||
@@ -220,10 +220,27 @@ class FileComments
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Asked of the column, not of the relation. client_context_id is
|
||||||
|
// cascadeOnDelete, but a user is soft-deleted, so the cascade
|
||||||
|
// never fires: the column goes on pointing at a row that is still
|
||||||
|
// there while the relation resolves to null. Branching on the
|
||||||
|
// relation therefore read "this is Alice's conversation" as "this
|
||||||
|
// has no conversation" — and a null context on a Clients comment
|
||||||
|
// is the branch every client on the file reads (see
|
||||||
|
// VisibleCommentScope's opening rule). A private reply became a
|
||||||
|
// circular, and canAssignClient below was skipped on the way.
|
||||||
|
if ($replyTo->client_context_id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$client = $replyTo->clientContext;
|
$client = $replyTo->clientContext;
|
||||||
|
|
||||||
if ($client === null) {
|
if ($client === null) {
|
||||||
return null;
|
// The column points at somebody, and that somebody is gone.
|
||||||
|
// There is nobody to answer, and the one outcome that must
|
||||||
|
// not follow from a filled column is the broadcast above, so
|
||||||
|
// this refuses rather than falling through to it.
|
||||||
|
throw new AuthorizationException('You cannot reply in this conversation.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $this->library->canAssignClient($author, $client)) {
|
if (! $this->library->canAssignClient($author, $client)) {
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ class CommentModerationController extends Controller
|
|||||||
{
|
{
|
||||||
$viewer = $request->user();
|
$viewer = $request->user();
|
||||||
assert($viewer !== null);
|
assert($viewer !== null);
|
||||||
Gate::forUser($viewer)->authorize('moderate', FileComment::class);
|
// Moderation rights are not a way around the library boundary; the
|
||||||
// Moderation rights are not a way around the library boundary.
|
// policy weighs the comment's file, so name the comment.
|
||||||
abort_unless($this->library->allowsFile($viewer, $comment->file), 403);
|
Gate::authorize('moderate', $comment);
|
||||||
|
|
||||||
$this->comments->approve($comment, $viewer);
|
$this->comments->approve($comment, $viewer);
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use App\Modules\Comments\CommentPresenter;
|
|||||||
use App\Modules\Comments\CommentVisibility;
|
use App\Modules\Comments\CommentVisibility;
|
||||||
use App\Modules\Comments\FileComments;
|
use App\Modules\Comments\FileComments;
|
||||||
use App\Modules\Comments\Models\FileComment;
|
use App\Modules\Comments\Models\FileComment;
|
||||||
use App\Modules\Files\Access\StaffLibraryScope;
|
|
||||||
use App\Modules\Platform\Localization\LocalDay;
|
use App\Modules\Platform\Localization\LocalDay;
|
||||||
use App\Modules\Platform\Localization\TimezoneRegistry;
|
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
@@ -44,7 +43,6 @@ class CommentsController extends Controller
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly FileComments $comments,
|
private readonly FileComments $comments,
|
||||||
private readonly StaffLibraryScope $library,
|
|
||||||
private readonly CommentPresenter $presenter,
|
private readonly CommentPresenter $presenter,
|
||||||
private readonly VisibleCommentScope $scope,
|
private readonly VisibleCommentScope $scope,
|
||||||
private readonly TimezoneRegistry $timezones,
|
private readonly TimezoneRegistry $timezones,
|
||||||
@@ -95,9 +93,9 @@ class CommentsController extends Controller
|
|||||||
{
|
{
|
||||||
$viewer = $request->user();
|
$viewer = $request->user();
|
||||||
assert($viewer !== null);
|
assert($viewer !== null);
|
||||||
Gate::forUser($viewer)->authorize('moderate', FileComment::class);
|
// Moderation rights are not a way around the library boundary; the
|
||||||
// Moderation rights are not a way around the library boundary.
|
// policy weighs the comment's file, so name the comment.
|
||||||
abort_unless($this->library->allowsFile($viewer, $comment->file), 403);
|
Gate::forUser($viewer)->authorize('moderate', $comment);
|
||||||
|
|
||||||
$this->comments->approve($comment, $viewer);
|
$this->comments->approve($comment, $viewer);
|
||||||
|
|
||||||
@@ -113,7 +111,6 @@ class CommentsController extends Controller
|
|||||||
$viewer = $request->user();
|
$viewer = $request->user();
|
||||||
assert($viewer !== null);
|
assert($viewer !== null);
|
||||||
Gate::forUser($viewer)->authorize('delete', $comment);
|
Gate::forUser($viewer)->authorize('delete', $comment);
|
||||||
abort_unless($this->library->allowsFile($viewer, $comment->file), 403);
|
|
||||||
|
|
||||||
$this->comments->remove($comment);
|
$this->comments->remove($comment);
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class FileCommentsController extends Controller
|
|||||||
|
|
||||||
$this->comments->edit($comment, $validated['body']);
|
$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
|
public function destroy(Request $request, FileComment $comment): JsonResponse
|
||||||
@@ -90,10 +90,13 @@ class FileCommentsController extends Controller
|
|||||||
|
|
||||||
$this->comments->remove($comment);
|
$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>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
private function payload(User $viewer, File $file): array
|
private function payload(User $viewer, File $file): array
|
||||||
@@ -101,6 +104,28 @@ class FileCommentsController extends Controller
|
|||||||
return $this->presenter->thread($viewer, $file);
|
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
|
* The comment being answered, resolved through the same scope that
|
||||||
* decided what this viewer may read. A reply can therefore only ever
|
* 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;
|
namespace App\Modules\Comments\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
use App\Modules\Comments\CommentingRules;
|
use App\Modules\Comments\CommentingRules;
|
||||||
use App\Modules\Comments\CommentPresenter;
|
use App\Modules\Comments\CommentPresenter;
|
||||||
use App\Modules\Comments\CommentVisibility;
|
use App\Modules\Comments\CommentVisibility;
|
||||||
@@ -17,6 +18,7 @@ use App\Modules\Platform\Settings\Settings;
|
|||||||
use App\Support\Rules;
|
use App\Support\Rules;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Comments on a publicly-listed file, for visitors who are not logged in.
|
* 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);
|
$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
|
public function store(Request $request, string $publicSlug, File $file): JsonResponse
|
||||||
@@ -86,7 +88,31 @@ class PublicFileCommentsController extends Controller
|
|||||||
$this->guests->remember($comment->id);
|
$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
|
* The name to show. Snapshotted for guests at write time; read live
|
||||||
* for accounts so a rename is reflected everywhere at once.
|
* for accounts so a rename is reflected everywhere at once.
|
||||||
*
|
*
|
||||||
* author_id cascades on delete, so a row that has one always has the
|
* A deleted account is still read. author_id cascades on delete, but
|
||||||
* account behind it — there is no deleted-author case to snapshot
|
* a user is soft-deleted and the cascade never fires, so the row
|
||||||
* against, unlike the activity log's actor_name.
|
* behind a deleted commenter is still there — and reading it through
|
||||||
|
* the plain relation returned null, which sent a named client's
|
||||||
|
* comment out as "Anonymous". That is what a guest comment looks
|
||||||
|
* like, and a guest comment is governed by different rules; the two
|
||||||
|
* must not be able to look the same. Whether the author is a guest is
|
||||||
|
* decided by author_id alone, which is also what isFromGuest() asks.
|
||||||
*/
|
*/
|
||||||
public function authorName(): string
|
public function authorName(): string
|
||||||
{
|
{
|
||||||
|
if ($this->author_id === null) {
|
||||||
|
return $this->guest_name ?? (string) __('Anonymous');
|
||||||
|
}
|
||||||
|
|
||||||
$author = $this->author;
|
$author = $this->author;
|
||||||
|
|
||||||
if ($author !== null) {
|
if ($author !== null) {
|
||||||
return $author->name;
|
return $author->name;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->guest_name ?? (string) __('Anonymous');
|
// Trashed: the row is still there, the relation simply will not
|
||||||
|
// hand it over. Nothing comes back only once the grace-period
|
||||||
|
// erasure has removed the row for real.
|
||||||
|
$name = $this->author()->withTrashed()->value('name');
|
||||||
|
|
||||||
|
return is_string($name) ? $name : (string) __('Anonymous');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ namespace App\Modules\Files\Access;
|
|||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
|
use App\Modules\Files\Models\FileAssignment;
|
||||||
use App\Modules\Files\Models\Folder;
|
use App\Modules\Files\Models\Folder;
|
||||||
|
use App\Modules\Files\Models\FolderAssignment;
|
||||||
use App\Modules\Groups\Models\Group;
|
use App\Modules\Groups\Models\Group;
|
||||||
|
use App\Modules\Identity\UserType;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,10 +29,37 @@ use Illuminate\Database\Eloquent\Builder;
|
|||||||
*/
|
*/
|
||||||
class StaffLibraryScope
|
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>
|
* @return Builder<File>
|
||||||
*/
|
*/
|
||||||
public function files(User $user): Builder
|
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();
|
$query = File::query();
|
||||||
|
|
||||||
@@ -53,6 +83,14 @@ class StaffLibraryScope
|
|||||||
* @return Builder<Folder>
|
* @return Builder<Folder>
|
||||||
*/
|
*/
|
||||||
public function folders(User $user): Builder
|
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();
|
$query = Folder::query();
|
||||||
|
|
||||||
@@ -139,10 +177,124 @@ class StaffLibraryScope
|
|||||||
return $ids === null || in_array($client->id, $ids, true);
|
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
|
public function canAssignGroup(User $user, Group $group): bool
|
||||||
{
|
{
|
||||||
$ids = $this->assignableGroupIds($user);
|
$ids = $this->assignableGroupIds($user);
|
||||||
|
|
||||||
return $ids === null || in_array($group->id, $ids, true);
|
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 App\Modules\Files\Models\ZipDownload;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use League\Flysystem\UnableToRetrieveMetadata;
|
||||||
|
|
||||||
class PurgeZipDownloadsCommand extends Command
|
class PurgeZipDownloadsCommand extends Command
|
||||||
{
|
{
|
||||||
@@ -18,16 +19,80 @@ class PurgeZipDownloadsCommand extends Command
|
|||||||
{
|
{
|
||||||
$stale = ZipDownload::query()->where('created_at', '<', now()->subDay())->get();
|
$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) {
|
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) {
|
if ($zipDownload->path !== null) {
|
||||||
Storage::disk('files')->delete($zipDownload->path);
|
$artifacts[] = $zipDownload->path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Storage::disk('files')->delete(array_values(array_unique($artifacts)));
|
||||||
|
|
||||||
$zipDownload->delete();
|
$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;
|
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;
|
namespace App\Modules\Files;
|
||||||
|
|
||||||
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
use App\Modules\Files\Models\Folder;
|
use App\Modules\Files\Models\Folder;
|
||||||
use App\Modules\Files\Notifications\FileShareDigestNotification;
|
use App\Modules\Files\Notifications\FileShareDigestNotification;
|
||||||
@@ -20,6 +21,17 @@ use Illuminate\Support\ServiceProvider;
|
|||||||
|
|
||||||
class FilesServiceProvider extends 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
|
public function boot(): void
|
||||||
{
|
{
|
||||||
Gate::policy(File::class, FilePolicy::class);
|
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\Clients\ClientStorageUsage;
|
||||||
use App\Modules\Comments\CommentingRules;
|
use App\Modules\Comments\CommentingRules;
|
||||||
use App\Modules\Comments\CommentScope;
|
use App\Modules\Comments\CommentScope;
|
||||||
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
use App\Modules\Files\Access\ViewableFileScope;
|
use App\Modules\Files\Access\ViewableFileScope;
|
||||||
use App\Modules\Files\DownloadLimitScope;
|
use App\Modules\Files\DownloadLimitScope;
|
||||||
use App\Modules\Files\Http\Resources\Api\FileResource;
|
use App\Modules\Files\Http\Resources\Api\FileResource;
|
||||||
@@ -57,6 +58,7 @@ class FilesController extends Controller
|
|||||||
private readonly ClientStorageUsage $storageUsage,
|
private readonly ClientStorageUsage $storageUsage,
|
||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
private readonly CommentingRules $commenting,
|
private readonly CommentingRules $commenting,
|
||||||
|
private readonly StaffLibraryScope $scope,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -174,7 +176,7 @@ class FilesController extends Controller
|
|||||||
'file' => ['required', 'file'],
|
'file' => ['required', 'file'],
|
||||||
'name' => ['nullable', 'string', 'max:255'],
|
'name' => ['nullable', 'string', 'max:255'],
|
||||||
'description' => ['nullable', 'string', 'max:2000'],
|
'description' => ['nullable', 'string', 'max:2000'],
|
||||||
'folder_id' => ['nullable', 'integer', 'exists:folders,id'],
|
'folder_id' => Rules::folderId(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** @var UploadedFile $upload */
|
/** @var UploadedFile $upload */
|
||||||
@@ -275,7 +277,7 @@ class FilesController extends Controller
|
|||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => ['sometimes', 'string', 'max:255'],
|
'name' => ['sometimes', 'string', 'max:255'],
|
||||||
'description' => ['sometimes', 'nullable', 'string', 'max:2000'],
|
'description' => ['sometimes', 'nullable', 'string', 'max:2000'],
|
||||||
'folder_id' => ['sometimes', 'nullable', 'integer', 'exists:folders,id'],
|
'folder_id' => ['sometimes', ...Rules::folderId()],
|
||||||
'public' => ['sometimes', 'boolean'],
|
'public' => ['sometimes', 'boolean'],
|
||||||
'commentable' => ['sometimes', 'boolean'],
|
'commentable' => ['sometimes', 'boolean'],
|
||||||
'slug' => Rules::slug('files', $file->id),
|
'slug' => Rules::slug('files', $file->id),
|
||||||
@@ -286,6 +288,21 @@ class FilesController extends Controller
|
|||||||
'download_limit_scope' => ['sometimes', Rule::enum(DownloadLimitScope::class)],
|
'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']));
|
$attributes = array_intersect_key($validated, array_flip(['name', 'description', 'folder_id']));
|
||||||
|
|
||||||
if (array_key_exists('expires_at', $validated) && $user->can('set_file_expiration_date')) {
|
if (array_key_exists('expires_at', $validated) && $user->can('set_file_expiration_date')) {
|
||||||
|
|||||||
@@ -81,7 +81,14 @@ class CategoriesController extends Controller
|
|||||||
|
|
||||||
$this->activity->log(Action::CategoryCreated, subject: $category);
|
$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
|
public function edit(Category $category): Response
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ use App\Modules\Identity\UserType;
|
|||||||
use App\Modules\Notifications\Notifier;
|
use App\Modules\Notifications\Notifier;
|
||||||
use App\Modules\Platform\Settings\Setting;
|
use App\Modules\Platform\Settings\Setting;
|
||||||
use App\Modules\Platform\Settings\Settings;
|
use App\Modules\Platform\Settings\Settings;
|
||||||
|
use App\Support\Rules;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
@@ -71,7 +73,7 @@ class ChunkedUploadsController extends Controller
|
|||||||
'size' => ['required', 'integer', 'min:1'],
|
'size' => ['required', 'integer', 'min:1'],
|
||||||
'type' => ['nullable', 'string', 'max:255'],
|
'type' => ['nullable', 'string', 'max:255'],
|
||||||
'description' => ['nullable', 'string', 'max:2000'],
|
'description' => ['nullable', 'string', 'max:2000'],
|
||||||
'folder_id' => ['nullable', 'integer', 'exists:folders,id'],
|
'folder_id' => Rules::folderId(),
|
||||||
'previous_file_id' => ['nullable', 'integer'],
|
'previous_file_id' => ['nullable', 'integer'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -208,7 +210,18 @@ class ChunkedUploadsController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response('', 200, ['ETag' => '"'.$etag.'"']);
|
return response('', 200, [
|
||||||
|
'ETag' => '"'.$etag.'"',
|
||||||
|
// Stated rather than left to Symfony, whose default for an
|
||||||
|
// empty body is text/html. A CDN that rewrites HTML — as
|
||||||
|
// Cloudflare's Email Obfuscation and Automatic HTTPS Rewrites
|
||||||
|
// do — drops the origin's ETag from an HTML response, since a
|
||||||
|
// rewritten body would no longer match it. Nothing on this side
|
||||||
|
// would notice (LocalPartStore keeps its own record of every
|
||||||
|
// part), but the client never learns the part landed, so the
|
||||||
|
// upload stalls at 100% with no error anywhere.
|
||||||
|
'Content-Type' => 'application/octet-stream',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -229,6 +242,33 @@ class ChunkedUploadsController extends Controller
|
|||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
assert($user !== null);
|
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));
|
$extension = strtolower(pathinfo($session->original_name, PATHINFO_EXTENSION));
|
||||||
$targetPath = now()->format('Y/m').'/'.Str::uuid()->toString().($extension !== '' ? '.'.$extension : '');
|
$targetPath = now()->format('Y/m').'/'.Str::uuid()->toString().($extension !== '' ? '.'.$extension : '');
|
||||||
|
|
||||||
@@ -238,6 +278,22 @@ class ChunkedUploadsController extends Controller
|
|||||||
throw ValidationException::withMessages(['parts' => $exception->getMessage()]);
|
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 —
|
// store()'s quota check used a client-declared, unverified size —
|
||||||
// re-check against the real assembled byte count before this
|
// re-check against the real assembled byte count before this
|
||||||
// becomes a File row. No File row exists yet at this point, so
|
// becomes a File row. No File row exists yet at this point, so
|
||||||
@@ -261,6 +317,23 @@ class ChunkedUploadsController extends Controller
|
|||||||
// the previewer's browser. Detect the real mime type from the assembled bytes.
|
// the previewer's browser. Detect the real mime type from the assembled bytes.
|
||||||
$mimeType = Storage::disk($assembled['disk'])->mimeType($assembled['path']) ?: 'application/octet-stream';
|
$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(
|
$file = $this->storeFile->create(
|
||||||
uploader: $user,
|
uploader: $user,
|
||||||
originalName: $session->original_name,
|
originalName: $session->original_name,
|
||||||
@@ -269,7 +342,7 @@ class ChunkedUploadsController extends Controller
|
|||||||
size: $assembled['size'],
|
size: $assembled['size'],
|
||||||
checksum: $assembled['checksum'],
|
checksum: $assembled['checksum'],
|
||||||
description: $session->description,
|
description: $session->description,
|
||||||
folderId: $session->folder_id,
|
folderId: $folderId,
|
||||||
disk: $assembled['disk'],
|
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;
|
namespace App\Modules\Files\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLog;
|
use App\Modules\Audit\ActivityLog;
|
||||||
use App\Modules\Audit\ActivityPresenter;
|
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\Folder;
|
||||||
use App\Modules\Files\Models\ShareLink;
|
use App\Modules\Files\Models\ShareLink;
|
||||||
use App\Modules\Files\Versions\FileVersionLinks;
|
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\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
@@ -34,6 +40,41 @@ class FileDetailsController extends Controller
|
|||||||
/** Raw rows considered when grouping downloads() by actor — see that method's docblock. */
|
/** Raw rows considered when grouping downloads() by actor — see that method's docblock. */
|
||||||
private const DOWNLOADS_SUMMARY_LIMIT = 500;
|
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(
|
public function __construct(
|
||||||
private readonly ActivityPresenter $presenter,
|
private readonly ActivityPresenter $presenter,
|
||||||
private readonly DownloadPresenter $downloadPresenter,
|
private readonly DownloadPresenter $downloadPresenter,
|
||||||
@@ -41,6 +82,7 @@ class FileDetailsController extends Controller
|
|||||||
private readonly CommentingRules $commenting,
|
private readonly CommentingRules $commenting,
|
||||||
private readonly FileVersionLinks $versionLinks,
|
private readonly FileVersionLinks $versionLinks,
|
||||||
private readonly DownloadAllowance $allowance,
|
private readonly DownloadAllowance $allowance,
|
||||||
|
private readonly TimezoneRegistry $timezones,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function show(Request $request, File $file): JsonResponse
|
public function show(Request $request, File $file): JsonResponse
|
||||||
@@ -136,6 +178,65 @@ class FileDetailsController extends Controller
|
|||||||
return response()->json(['entries' => $entries, 'total' => $total]);
|
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
|
* Full, paginated activity history for a file — the "View full
|
||||||
* history" destination linked from the details panel's Activity tab,
|
* history" destination linked from the details panel's Activity tab,
|
||||||
@@ -148,7 +249,15 @@ class FileDetailsController extends Controller
|
|||||||
Gate::forUser($viewer)->authorize('view', $file);
|
Gate::forUser($viewer)->authorize('view', $file);
|
||||||
abort_unless($viewer->can('view_actions_log'), 403);
|
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()
|
$query = ActivityLog::query()
|
||||||
->where('subject_type', $file->getMorphClass())
|
->where('subject_type', $file->getMorphClass())
|
||||||
->where('subject_id', $file->id)
|
->where('subject_id', $file->id)
|
||||||
->whereIn('action', [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded]);
|
->whereIn('action', self::DOWNLOAD_ACTIONS);
|
||||||
|
|
||||||
$total = (clone $query)->count();
|
$total = (clone $query)->count();
|
||||||
|
|
||||||
@@ -304,15 +413,35 @@ class FileDetailsController extends Controller
|
|||||||
Gate::forUser($viewer)->authorize('view', $folder);
|
Gate::forUser($viewer)->authorize('view', $folder);
|
||||||
abort_unless($viewer->can('view_actions_log'), 403);
|
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
|
/**
|
||||||
{
|
* @param array<string, mixed> $routeParams
|
||||||
$entries = ActivityLog::query()
|
*/
|
||||||
->where('subject_type', $morphClass)
|
private function renderHistory(
|
||||||
->where('subject_id', $subjectId)
|
Request $request,
|
||||||
->orderByDesc('created_at')->orderByDesc('id')
|
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)
|
->paginate(25)
|
||||||
->withQueryString();
|
->withQueryString();
|
||||||
|
|
||||||
@@ -327,8 +456,135 @@ class FileDetailsController extends Controller
|
|||||||
'next' => $entries->nextPageUrl(),
|
'next' => $entries->nextPageUrl(),
|
||||||
'total' => $entries->total(),
|
'total' => $entries->total(),
|
||||||
],
|
],
|
||||||
|
'filters' => $filters,
|
||||||
|
'action_options' => $this->actionOptions($morphClass, $subjectId, $filters['action']),
|
||||||
'subject_name' => $subjectName,
|
'subject_name' => $subjectName,
|
||||||
'back_url' => $backUrl,
|
'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\Action;
|
||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
use App\Modules\Files\Access\DownloadAllowance;
|
use App\Modules\Files\Access\DownloadAllowance;
|
||||||
|
use App\Modules\Files\Delivery\StoredFileResponse;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
use App\Support\ContentDisposition;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authorized downloads without the bytes ever traversing PHP: for a file
|
* Authorized downloads without the bytes ever traversing PHP: the app
|
||||||
* on the local disk, the app checks the policy and answers with
|
* checks the policy, and StoredFileResponse answers with either an
|
||||||
* X-Accel-Redirect; nginx streams the file from the protected location
|
* X-Accel-Redirect for nginx to stream from the protected location
|
||||||
* (brief §3). The cloud edition swaps this for presigned URLs behind the
|
* (brief §3) or a presigned URL when the file lives on external storage,
|
||||||
* same route. A file on the community-only external storage disk already
|
* since nginx has no way to serve bytes it doesn't have on disk.
|
||||||
* gets exactly that — a presigned URL redirect — since nginx has no way
|
|
||||||
* to serve bytes it doesn't have on disk.
|
|
||||||
*/
|
*/
|
||||||
class FileDownloadController extends Controller
|
class FileDownloadController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
private readonly DownloadAllowance $allowance,
|
private readonly DownloadAllowance $allowance,
|
||||||
|
private readonly StoredFileResponse $bytes,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function __invoke(Request $request, File $file): Response|RedirectResponse
|
public function __invoke(Request $request, File $file): Response|RedirectResponse
|
||||||
@@ -44,21 +42,6 @@ class FileDownloadController extends Controller
|
|||||||
|
|
||||||
$this->activity->log(Action::FileDownloaded, subject: $file);
|
$this->activity->log(Action::FileDownloaded, subject: $file);
|
||||||
|
|
||||||
if ($file->disk !== 'files') {
|
return $this->bytes->attachment($file);
|
||||||
$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,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,21 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
use App\Modules\Files\Access\DownloadAllowance;
|
use App\Modules\Files\Access\DownloadAllowance;
|
||||||
|
use App\Modules\Files\Delivery\StoredFileResponse;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
|
use App\Modules\Files\Preview\PreviewKind;
|
||||||
use App\Modules\Files\Thumbnails\Events\ResolvingImageRendering;
|
use App\Modules\Files\Thumbnails\Events\ResolvingImageRendering;
|
||||||
use App\Modules\Files\Thumbnails\ImageAudience;
|
use App\Modules\Files\Thumbnails\ImageAudience;
|
||||||
use App\Modules\Files\Thumbnails\ImageRendition;
|
use App\Modules\Files\Thumbnails\ImageRendition;
|
||||||
|
use App\Modules\Files\Thumbnails\LocalSourceFile;
|
||||||
use App\Modules\Files\Thumbnails\ThumbnailGenerator;
|
use App\Modules\Files\Thumbnails\ThumbnailGenerator;
|
||||||
|
use App\Modules\Platform\Settings\Setting;
|
||||||
|
use App\Modules\Platform\Settings\Settings;
|
||||||
use App\Support\ContentDisposition;
|
use App\Support\ContentDisposition;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
@@ -32,17 +38,25 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
* file's contents — a real, audit-worthy action, just not a "download."
|
* file's contents — a real, audit-worthy action, just not a "download."
|
||||||
*
|
*
|
||||||
* SECURITY: both methods serve bytes inline, from this app's own origin,
|
* 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
|
* ThumbnailGenerator::SUPPORTED_MIME_TYPES, the raster formats this app
|
||||||
* renders itself. That list is the allowlist; nothing else is ever served
|
* decodes and re-encodes itself, since a thumbnail *is* a rendition.
|
||||||
* inline. Do NOT widen it to text/html, image/svg+xml, or anything else a
|
* `preview()` is bounded by PreviewKind, which additionally admits the
|
||||||
* browser executes script from, and do not reach for the upload
|
* video, audio and PDF types a browser plays natively and this app never
|
||||||
* allowed-extensions setting as a substitute: that setting matches on the
|
* touches. PreviewKind's docblock carries the rule in full; the short
|
||||||
* *extension*, while mime_type is detected from the *bytes*
|
* version is that neither list may ever grow a type a browser executes
|
||||||
* (ChunkedUploadsController::complete), so a .txt holding HTML is stored
|
* script from, and neither may be derived from the upload
|
||||||
* as text/html and would render as a page here. Serving a file inline as
|
* allowed-extensions setting, which matches on the *extension* while
|
||||||
* a type the browser executes is same-origin script execution with the
|
* mime_type is detected from the *bytes*
|
||||||
* viewer's session.
|
* (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
|
* Renditions always cache on the local "files" disk regardless of where
|
||||||
* the source file lives — they're a derived artifact, not the original,
|
* 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 ThumbnailGenerator $thumbnails,
|
||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
private readonly DownloadAllowance $allowance,
|
private readonly DownloadAllowance $allowance,
|
||||||
|
private readonly StoredFileResponse $bytes,
|
||||||
|
private readonly LocalSourceFile $source,
|
||||||
|
private readonly Settings $settings,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function thumbnail(Request $request, File $file): Response
|
public function thumbnail(Request $request, File $file): Response
|
||||||
@@ -82,66 +99,92 @@ class FileThumbnailController extends Controller
|
|||||||
/**
|
/**
|
||||||
* A file opened to be looked at.
|
* A file opened to be looked at.
|
||||||
*
|
*
|
||||||
* A preview is not the file — it is a rendered view of it, which is
|
* For an image, a preview is not the file — it is a rendered view of
|
||||||
* why it may be decorated at all. But rendering one is expensive
|
* it, which is why it may be decorated at all. But rendering one is
|
||||||
* (decoding and re-encoding a full-size photograph) where serving the
|
* expensive (decoding and re-encoding a full-size photograph) where
|
||||||
* stored bytes is nearly free, so core only pays that cost when a
|
* serving the stored bytes is nearly free, so core only pays that
|
||||||
* listener says this particular viewer must be served a rendering:
|
* cost when a listener says this particular viewer must be served a
|
||||||
* ResolvingImageRendering asks, and defaults to no. On an
|
* rendering: ResolvingImageRendering asks, and defaults to no. On an
|
||||||
* installation that watermarks, a client gets a bounded, watermarked
|
* installation that watermarks, a client gets a bounded, watermarked
|
||||||
* render and staff get the original; on one that does not, everyone
|
* render and staff get the original; on one that does not, everyone
|
||||||
* gets exactly what this endpoint has always returned.
|
* 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
|
public function preview(Request $request, File $file): Response|RedirectResponse
|
||||||
{
|
{
|
||||||
Gate::authorize('view', $file);
|
Gate::authorize('view', $file);
|
||||||
|
|
||||||
// Only types this app renders itself may be served inline; anything
|
// The inline allowlist. See the class docblock and PreviewKind —
|
||||||
// else is a download, not a preview. See the class docblock — the
|
// the stored mime type is sniffed from the bytes, so an allowed
|
||||||
// stored mime type is sniffed from the bytes, so an allowed
|
|
||||||
// extension is not evidence of a safe-to-render payload.
|
// 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
|
// A preview is not counted as a download, but it is refused once
|
||||||
// the download limit is spent — because unless a listener asks
|
// the download limit is spent — because unless a listener asks
|
||||||
// for a rendering (nothing does by default), the branches below
|
// for a rendering (nothing does by default, and nothing ever does
|
||||||
// serve the *original bytes* at full size. Without this a cap
|
// for media), the branches below serve the *original bytes* at
|
||||||
// would be one URL away from meaningless for every image on the
|
// full size. Without this a cap would be one URL away from
|
||||||
// install. thumbnail() needs no such guard: a 300px rendition is
|
// meaningless for every previewable file on the install.
|
||||||
// not the file.
|
// thumbnail() needs no such guard: a 300px rendition is not the
|
||||||
|
// file.
|
||||||
abort_unless($this->allowance->allows($file, $request->user()), 403);
|
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);
|
$decision = new ResolvingImageRendering($audience, ImageRendition::Preview, $file->mime_type);
|
||||||
Event::dispatch($decision);
|
Event::dispatch($decision);
|
||||||
|
|
||||||
if ($decision->required) {
|
if ($decision->required) {
|
||||||
$path = $this->render($file, $audience, ImageRendition::Preview);
|
$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') {
|
return $this->bytes->inline($file);
|
||||||
$url = Storage::disk($file->disk)->temporaryUrl(
|
}
|
||||||
$file->path,
|
|
||||||
now()->addHour(),
|
|
||||||
['ResponseContentDisposition' => ContentDisposition::inline($file->original_name)],
|
|
||||||
);
|
|
||||||
|
|
||||||
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));
|
$disk->makeDirectory(dirname($path));
|
||||||
$sourcePath = $this->localSourcePathFor($file);
|
|
||||||
|
|
||||||
try {
|
$this->source->use($file, fn (string $sourcePath) => $this->thumbnails->generate(
|
||||||
$this->thumbnails->generate($sourcePath, $disk->path($path), $file->mime_type, $audience, $rendition);
|
$sourcePath,
|
||||||
} finally {
|
$disk->path($path),
|
||||||
if ($file->disk !== 'files') {
|
$file->mime_type,
|
||||||
@unlink($sourcePath);
|
$audience,
|
||||||
}
|
$rendition,
|
||||||
}
|
));
|
||||||
|
|
||||||
return $path;
|
return $path;
|
||||||
}
|
}
|
||||||
@@ -185,38 +227,4 @@ class FileThumbnailController extends Controller
|
|||||||
'Content-Disposition' => ContentDisposition::inline($file->original_name),
|
'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'],
|
'file' => ['required', 'file', 'max:102400'],
|
||||||
'name' => ['nullable', 'string', 'max:255'],
|
'name' => ['nullable', 'string', 'max:255'],
|
||||||
'description' => ['nullable', 'string', 'max:2000'],
|
'description' => ['nullable', 'string', 'max:2000'],
|
||||||
'folder_id' => ['nullable', 'integer', 'exists:folders,id'],
|
'folder_id' => Rules::folderId(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** @var UploadedFile $upload */
|
/** @var UploadedFile $upload */
|
||||||
@@ -93,6 +93,17 @@ class FilesController extends Controller
|
|||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
assert($user !== null);
|
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())) {
|
if (! app(UploadExtensionPolicy::class)->isAllowed($user, $upload->getClientOriginalName())) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'file' => __('This file type is not allowed for upload.'),
|
'file' => __('This file type is not allowed for upload.'),
|
||||||
@@ -197,7 +208,11 @@ class FilesController extends Controller
|
|||||||
'url' => route('files.edit', $member, false),
|
'url' => route('files.edit', $member, false),
|
||||||
'is_current' => $member->id === $file->id,
|
'is_current' => $member->id === $file->id,
|
||||||
])->values(),
|
])->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(),
|
->map(fn (Folder $folder): array => ['id' => $folder->id, 'name' => $folder->name])->all(),
|
||||||
'categories' => Category::query()->orderBy('name')->get(['id', 'name', 'color'])
|
'categories' => Category::query()->orderBy('name')->get(['id', 'name', 'color'])
|
||||||
->map(fn (Category $category): array => ['id' => $category->id, 'name' => $category->name, 'color' => $category->color])->all(),
|
->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_update' => Gate::forUser($viewer)->allows('update', $file),
|
||||||
'can_delete' => Gate::forUser($viewer)->allows('delete', $file),
|
'can_delete' => Gate::forUser($viewer)->allows('delete', $file),
|
||||||
'can_manage_public' => $viewer->can('upload_public'),
|
'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
|
// The per-file switch only does anything while the comment
|
||||||
// scope is `selected`; under every other value the page hides
|
// scope is `selected`; under every other value the page hides
|
||||||
// it rather than offer a control with no current effect.
|
// it rather than offer a control with no current effect.
|
||||||
@@ -237,7 +257,7 @@ class FilesController extends Controller
|
|||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'description' => ['nullable', 'string', 'max:2000'],
|
'description' => ['nullable', 'string', 'max:2000'],
|
||||||
'folder_id' => ['nullable', 'integer', 'exists:folders,id'],
|
'folder_id' => Rules::folderId(),
|
||||||
'public' => ['sometimes', 'boolean'],
|
'public' => ['sometimes', 'boolean'],
|
||||||
'commentable' => ['sometimes', 'boolean'],
|
'commentable' => ['sometimes', 'boolean'],
|
||||||
// The slug only matters (and is only shown) once a file is
|
// 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)],
|
'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 = [
|
$attributes = [
|
||||||
'name' => $validated['name'],
|
'name' => $validated['name'],
|
||||||
'description' => $validated['description'] ?? null,
|
'description' => $validated['description'] ?? null,
|
||||||
'folder_id' => $validated['folder_id'] ?? null,
|
'folder_id' => $folderId,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Only meaningful while the comment scope is `selected`, and only
|
// Only meaningful while the comment scope is `selected`, and only
|
||||||
@@ -322,7 +357,7 @@ class FilesController extends Controller
|
|||||||
Gate::authorize('update', $file);
|
Gate::authorize('update', $file);
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'folder_id' => ['nullable', 'integer', 'exists:folders,id'],
|
'folder_id' => Rules::folderId(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$folderId = $validated['folder_id'] ?? null;
|
$folderId = $validated['folder_id'] ?? null;
|
||||||
@@ -358,7 +393,7 @@ class FilesController extends Controller
|
|||||||
'file_ids.*' => ['integer', 'distinct'],
|
'file_ids.*' => ['integer', 'distinct'],
|
||||||
|
|
||||||
'folder_action' => ['required', Rule::in(['no_change', 'move'])],
|
'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_action' => ['required', Rule::in(['no_change', 'set'])],
|
||||||
'description' => ['nullable', 'string', 'max:2000'],
|
'description' => ['nullable', 'string', 'max:2000'],
|
||||||
|
|||||||
@@ -186,7 +186,11 @@ class FoldersController extends Controller
|
|||||||
'expired' => $expired,
|
'expired' => $expired,
|
||||||
'categories' => Category::query()->orderBy('name')->get(['id', 'name', 'color'])
|
'categories' => Category::query()->orderBy('name')->get(['id', 'name', 'color'])
|
||||||
->map(fn (Category $category): array => ['id' => $category->id, 'name' => $category->name, 'color' => $category->color])->all(),
|
->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(),
|
->map(fn (Folder $folder): array => ['id' => $folder->id, 'name' => $folder->name])->all(),
|
||||||
'can_create_folders' => $user->can('create_own_folders'),
|
'can_create_folders' => $user->can('create_own_folders'),
|
||||||
'can_upload' => $user->can('upload'),
|
'can_upload' => $user->can('upload'),
|
||||||
@@ -305,7 +309,7 @@ class FoldersController extends Controller
|
|||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'parent_id' => ['nullable', 'integer', 'exists:folders,id'],
|
'parent_id' => Rules::folderId(),
|
||||||
'public' => ['sometimes', 'boolean'],
|
'public' => ['sometimes', 'boolean'],
|
||||||
'slug' => Rules::slug('folders'),
|
'slug' => Rules::slug('folders'),
|
||||||
'allow_client_uploads' => ['sometimes', 'boolean'],
|
'allow_client_uploads' => ['sometimes', 'boolean'],
|
||||||
@@ -389,7 +393,7 @@ class FoldersController extends Controller
|
|||||||
Gate::authorize('update', $folder);
|
Gate::authorize('update', $folder);
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'parent_id' => ['nullable', 'integer', 'exists:folders,id'],
|
'parent_id' => Rules::folderId(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$newParent = $this->resolveParent($request->user(), $validated['parent_id'] ?? null);
|
$newParent = $this->resolveParent($request->user(), $validated['parent_id'] ?? null);
|
||||||
@@ -401,10 +405,32 @@ class FoldersController extends Controller
|
|||||||
return back();
|
return back();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Folder $folder): RedirectResponse
|
public function destroy(Request $request, Folder $folder): RedirectResponse
|
||||||
{
|
{
|
||||||
Gate::authorize('delete', $folder);
|
Gate::authorize('delete', $folder);
|
||||||
|
|
||||||
|
$viewer = $request->user();
|
||||||
|
assert($viewer !== null);
|
||||||
|
|
||||||
|
// Deleting a folder cascades to every file in its subtree, and a
|
||||||
|
// File's `deleted` hook removes the bytes from disk — there is no
|
||||||
|
// restore. Authorizing the folder is not authorizing its contents:
|
||||||
|
// FilePolicy::delete asks for `delete_others_files` on somebody
|
||||||
|
// else's upload, and for the library boundary on top of that, and
|
||||||
|
// neither question is asked anywhere on this path.
|
||||||
|
//
|
||||||
|
// MyFoldersController::destroy already refuses for the client half
|
||||||
|
// of the same cascade, in the same words. This is the staff half.
|
||||||
|
$blocked = $this->undeletableFileCount($viewer, $folder);
|
||||||
|
|
||||||
|
if ($blocked > 0) {
|
||||||
|
return back()->with('error', trans_choice(
|
||||||
|
'This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.',
|
||||||
|
$blocked,
|
||||||
|
['count' => (string) $blocked],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
$name = $folder->name;
|
$name = $folder->name;
|
||||||
$parentId = $folder->parent_id;
|
$parentId = $folder->parent_id;
|
||||||
|
|
||||||
@@ -415,6 +441,50 @@ class FoldersController extends Controller
|
|||||||
return redirect()->route('files.index', $parentId !== null ? ['folder' => $parentId] : [])->with('success', __('Folder deleted.'));
|
return redirect()->route('files.index', $parentId !== null ? ['folder' => $parentId] : [])->with('success', __('Folder deleted.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many files in this folder's subtree the viewer may not delete.
|
||||||
|
*
|
||||||
|
* Asked as one count rather than FilePolicy::delete per file: a folder
|
||||||
|
* can hold thousands, Gate resolves a fresh policy for every check, and
|
||||||
|
* a per-row policy check on a listing is the cost 0a8b609e went to
|
||||||
|
* some trouble to remove. The two halves of FilePolicy::delete are
|
||||||
|
* expressible in SQL — the permission half is constant for this
|
||||||
|
* viewer, and the library half is the query StaffLibraryScope already
|
||||||
|
* memoises per request.
|
||||||
|
*
|
||||||
|
* Somebody holding both delete permissions and no library scope can
|
||||||
|
* delete anything in the subtree by construction, so they never pay for
|
||||||
|
* the query at all.
|
||||||
|
*/
|
||||||
|
private function undeletableFileCount(User $viewer, Folder $folder): int
|
||||||
|
{
|
||||||
|
$mayDeleteOwn = $viewer->can('delete_files');
|
||||||
|
$mayDeleteOthers = $viewer->can('delete_others_files');
|
||||||
|
$scoped = $viewer->isClientScoped();
|
||||||
|
|
||||||
|
if ($mayDeleteOwn && $mayDeleteOthers && ! $scoped) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return File::query()
|
||||||
|
->whereIn('folder_id', $folder->subtreeFolderIds())
|
||||||
|
->where(function (Builder $outer) use ($viewer, $mayDeleteOwn, $mayDeleteOthers, $scoped): void {
|
||||||
|
if (! $mayDeleteOwn) {
|
||||||
|
$outer->orWhere('uploaded_by', $viewer->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $mayDeleteOthers) {
|
||||||
|
$outer->orWhere(fn (Builder $others): Builder => $others
|
||||||
|
->whereNull('uploaded_by')->orWhere('uploaded_by', '!=', $viewer->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($scoped) {
|
||||||
|
$outer->orWhereNotIn('id', $this->scope->files($viewer)->select('id'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveParent(?User $user, ?int $parentId): ?Folder
|
private function resolveParent(?User $user, ?int $parentId): ?Folder
|
||||||
{
|
{
|
||||||
if ($user === null || $parentId === null) {
|
if ($user === null || $parentId === null) {
|
||||||
|
|||||||
@@ -122,15 +122,23 @@ class MyFilesController extends Controller
|
|||||||
|
|
||||||
// Subfolders: at root, every visible folder whose parent isn't
|
// Subfolders: at root, every visible folder whose parent isn't
|
||||||
// itself visible (top of each shared subtree, or a client-owned
|
// itself visible (top of each shared subtree, or a client-owned
|
||||||
// folder with no visible parent); inside a folder, its direct
|
// folder with no visible parent); inside a folder, the visible
|
||||||
// children.
|
// 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) {
|
if ($current === null) {
|
||||||
$folders = Folder::query()
|
$folders = Folder::query()
|
||||||
->whereIn('id', $visibleIds)
|
->whereIn('id', $visibleIds)
|
||||||
->where(fn ($q) => $q->whereNull('parent_id')->orWhereNotIn('parent_id', $visibleIds))
|
->where(fn ($q) => $q->whereNull('parent_id')->orWhereNotIn('parent_id', $visibleIds))
|
||||||
->orderBy('name');
|
->orderBy('name');
|
||||||
} else {
|
} 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
|
// 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' => $client->can('upload'),
|
||||||
'can_upload_here' => Folder::uploadableBy($client, $current),
|
'can_upload_here' => Folder::uploadableBy($client, $current),
|
||||||
'can_create_folders' => $client->can('create_own_folders'),
|
'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\Folders\FolderService;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
use App\Modules\Files\Models\Folder;
|
use App\Modules\Files\Models\Folder;
|
||||||
|
use App\Support\Rules;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
@@ -43,7 +44,7 @@ class MyFoldersController extends Controller
|
|||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'parent_id' => ['nullable', 'integer', 'exists:folders,id'],
|
'parent_id' => Rules::folderId(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$parent = null;
|
$parent = null;
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
use App\Modules\Files\Access\DownloadAllowance;
|
use App\Modules\Files\Access\DownloadAllowance;
|
||||||
|
use App\Modules\Files\Delivery\StoredFileResponse;
|
||||||
use App\Modules\Files\Models\Category;
|
use App\Modules\Files\Models\Category;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
use App\Modules\Files\Models\ShareLink;
|
use App\Modules\Files\Models\ShareLink;
|
||||||
use App\Support\ContentDisposition;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
@@ -28,6 +28,7 @@ class PublicShareController extends Controller
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
private readonly DownloadAllowance $allowance,
|
private readonly DownloadAllowance $allowance,
|
||||||
|
private readonly StoredFileResponse $bytes,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function show(string $token): InertiaResponse
|
public function show(string $token): InertiaResponse
|
||||||
@@ -101,11 +102,6 @@ class PublicShareController extends Controller
|
|||||||
|
|
||||||
$this->activity->log(Action::ShareLinkDownloaded, subject: $file);
|
$this->activity->log(Action::ShareLinkDownloaded, subject: $file);
|
||||||
|
|
||||||
return response('', 200, [
|
return $this->bytes->attachment($file);
|
||||||
'X-Accel-Redirect' => '/protected-files/'.$file->path,
|
|
||||||
'Content-Type' => $file->mime_type,
|
|
||||||
'Content-Disposition' => ContentDisposition::attachment($file->original_name),
|
|
||||||
'Content-Length' => (string) $file->size,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,16 @@ use App\Modules\Files\Models\File;
|
|||||||
use App\Modules\Files\Models\Folder;
|
use App\Modules\Files\Models\Folder;
|
||||||
use App\Modules\Files\Models\ZipDownload;
|
use App\Modules\Files\Models\ZipDownload;
|
||||||
use App\Modules\Files\Uploads\StoreUploadedFile;
|
use App\Modules\Files\Uploads\StoreUploadedFile;
|
||||||
|
use App\Modules\Platform\Settings\Setting;
|
||||||
|
use App\Modules\Platform\Settings\Settings;
|
||||||
use App\Support\ContentDisposition;
|
use App\Support\ContentDisposition;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A folder's "Download as zip" button and the file listing's multi-select
|
* A folder's "Download as zip" button and the file listing's multi-select
|
||||||
@@ -40,6 +44,7 @@ class ZipDownloadsController extends Controller
|
|||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
private readonly ViewableFileScope $viewable,
|
private readonly ViewableFileScope $viewable,
|
||||||
private readonly DownloadAllowance $allowance,
|
private readonly DownloadAllowance $allowance,
|
||||||
|
private readonly Settings $settings,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
@@ -47,6 +52,22 @@ class ZipDownloadsController extends Controller
|
|||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
assert($user !== null);
|
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([
|
$validated = $request->validate([
|
||||||
'file_ids' => ['array'],
|
'file_ids' => ['array'],
|
||||||
'file_ids.*' => ['integer'],
|
'file_ids.*' => ['integer'],
|
||||||
@@ -98,9 +119,32 @@ class ZipDownloadsController extends Controller
|
|||||||
fn (Folder $folder): int => (clone $visible)->whereIn('folder_id', $folder->subtreeFolderIds())->count(),
|
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 === 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.'));
|
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([
|
$zipDownload = ZipDownload::query()->create([
|
||||||
'requested_by' => $user->id,
|
'requested_by' => $user->id,
|
||||||
'status' => ZipDownload::STATUS_PENDING,
|
'status' => ZipDownload::STATUS_PENDING,
|
||||||
@@ -137,8 +181,7 @@ class ZipDownloadsController extends Controller
|
|||||||
// Only the first time. Re-fetching one prepared archive is the
|
// Only the first time. Re-fetching one prepared archive is the
|
||||||
// same delivery, not a fresh download of everything inside it.
|
// same delivery, not a fresh download of everything inside it.
|
||||||
if ($zipDownload->delivered_at === null) {
|
if ($zipDownload->delivered_at === null) {
|
||||||
$this->logContainedDownloads($zipDownload, $user);
|
$this->deliverOnce($zipDownload, $user);
|
||||||
$zipDownload->forceFill(['delivered_at' => now()])->save();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$size = Storage::disk('files')->size($path);
|
$size = Storage::disk('files')->size($path);
|
||||||
@@ -152,14 +195,81 @@ class ZipDownloadsController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every file actually bundled gets a FileDownloaded entry — otherwise
|
* Hand the archive over, once: refuse it if anything inside is out of
|
||||||
* a file's download history/count would silently miss zip downloads.
|
* 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);
|
$visible = $this->viewable->for($requester);
|
||||||
$fileIds = collect($zipDownload->file_ids);
|
$fileIds = collect($zipDownload->file_ids);
|
||||||
|
|
||||||
@@ -177,9 +287,7 @@ class ZipDownloadsController extends Controller
|
|||||||
// further past it.
|
// further past it.
|
||||||
$skipped = collect($zipDownload->skipped_files ?? [])->pluck('id')->all();
|
$skipped = collect($zipDownload->skipped_files ?? [])->pluck('id')->all();
|
||||||
|
|
||||||
foreach ((clone $visible)->whereIn('id', $fileIds->unique())->whereNotIn('id', $skipped)->get() as $file) {
|
return (clone $visible)->whereIn('id', $fileIds->unique())->whereNotIn('id', $skipped)->get();
|
||||||
$this->activity->log(Action::FileDownloaded, subject: $file);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function filenameFor(ZipDownload $zipDownload): string
|
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\File;
|
||||||
use App\Modules\Files\Models\Folder;
|
use App\Modules\Files\Models\Folder;
|
||||||
use App\Modules\Files\Models\ZipDownload;
|
use App\Modules\Files\Models\ZipDownload;
|
||||||
|
use App\Modules\Platform\Settings\Setting;
|
||||||
|
use App\Modules\Platform\Settings\Settings;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@@ -17,6 +19,7 @@ use Illuminate\Database\Eloquent\Collection;
|
|||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
use ZipArchive;
|
use ZipArchive;
|
||||||
@@ -40,9 +43,40 @@ class BuildZipDownloadJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
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(
|
public function __construct(
|
||||||
private readonly int $zipDownloadId,
|
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
|
public function handle(): void
|
||||||
{
|
{
|
||||||
@@ -52,6 +86,14 @@ class BuildZipDownloadJob implements ShouldQueue
|
|||||||
return;
|
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
|
// Authorization is re-derived here, against the requester, rather
|
||||||
// than trusted from what the controller stored: a folder id only
|
// than trusted from what the controller stored: a folder id only
|
||||||
// says "this user may open this folder", never "this user may read
|
// says "this user may open this folder", never "this user may read
|
||||||
@@ -88,9 +130,19 @@ class BuildZipDownloadJob implements ShouldQueue
|
|||||||
$tempFiles = [];
|
$tempFiles = [];
|
||||||
$skipped = [];
|
$skipped = [];
|
||||||
|
|
||||||
// Counted rather than derived from $usedNames, which also
|
// Collected rather than derived from $usedNames, which also
|
||||||
// holds the folder entry names.
|
// holds the folder entry names. Recording the ids, not just a
|
||||||
$added = 0;
|
// 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) {
|
foreach ((clone $visible)->whereIn('id', $zipDownload->file_ids)->get() as $file) {
|
||||||
// Re-checked here for the same reason visibility is: the
|
// Re-checked here for the same reason visibility is: the
|
||||||
@@ -105,24 +157,87 @@ class BuildZipDownloadJob implements ShouldQueue
|
|||||||
$entryName = $this->dedupeName($usedNames, $this->entrySegment($file->original_name));
|
$entryName = $this->dedupeName($usedNames, $this->entrySegment($file->original_name));
|
||||||
$zip->addFile($this->localPathFor($file, $tempFiles), $entryName);
|
$zip->addFile($this->localPathFor($file, $tempFiles), $entryName);
|
||||||
$totalSize += $file->size;
|
$totalSize += $file->size;
|
||||||
$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);
|
$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) {
|
foreach ($tempFiles as $tempFile) {
|
||||||
@unlink($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([
|
$zipDownload->update([
|
||||||
'status' => ZipDownload::STATUS_READY,
|
'status' => ZipDownload::STATUS_READY,
|
||||||
'path' => $relativePath,
|
'path' => $relativePath,
|
||||||
'total_size' => $totalSize,
|
'total_size' => $totalSize,
|
||||||
'file_count' => $added,
|
'file_count' => count($added),
|
||||||
|
'contained_file_ids' => array_keys($added),
|
||||||
'skipped_files' => $skipped === [] ? null : $skipped,
|
'skipped_files' => $skipped === [] ? null : $skipped,
|
||||||
]);
|
]);
|
||||||
} catch (Throwable $e) {
|
} 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
|
* A local-disk file is added by its real path (fast path). Anything
|
||||||
* else gets stream-copied to a temp file first — ZipArchive::addFile()
|
* 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 array<int, string> $tempFiles
|
||||||
* @param Builder<File> $visible every file the requester may read
|
* @param Builder<File> $visible every file the requester may read
|
||||||
* @param list<array{id: int, name: string}> $skipped
|
* @param list<array{id: int, name: string}> $skipped
|
||||||
|
* @param 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);
|
$allowance = app(DownloadAllowance::class);
|
||||||
|
|
||||||
@@ -194,6 +349,15 @@ class BuildZipDownloadJob implements ShouldQueue
|
|||||||
$totalSize = 0;
|
$totalSize = 0;
|
||||||
|
|
||||||
foreach ((clone $visible)->whereIn('folder_id', $subtreeIds)->get() as $file) {
|
foreach ((clone $visible)->whereIn('folder_id', $subtreeIds)->get() as $file) {
|
||||||
|
// Already in the archive under another part of the selection —
|
||||||
|
// named loosely, or inside a folder selected before this one.
|
||||||
|
// Skipped rather than added again: a second entry is a second
|
||||||
|
// copy of the same bytes, and delivery charges one download
|
||||||
|
// however many copies went out.
|
||||||
|
if (isset($added[$file->id])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Holding the folder does not entitle the requester to a file
|
// Holding the folder does not entitle the requester to a file
|
||||||
// inside it whose own allowance is spent — same reason the
|
// inside it whose own allowance is spent — same reason the
|
||||||
// per-file visibility filter is re-derived rather than
|
// per-file visibility filter is re-derived rather than
|
||||||
@@ -209,12 +373,38 @@ class BuildZipDownloadJob implements ShouldQueue
|
|||||||
$entryPath = $this->dedupeName($usedNames, $entryPath);
|
$entryPath = $this->dedupeName($usedNames, $entryPath);
|
||||||
$zip->addFile($this->localPathFor($file, $tempFiles), $entryPath);
|
$zip->addFile($this->localPathFor($file, $tempFiles), $entryPath);
|
||||||
$totalSize += $file->size;
|
$totalSize += $file->size;
|
||||||
$added++;
|
$added[$file->id] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $totalSize;
|
return $totalSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The selected folders with the redundant ones dropped: one that sits
|
||||||
|
* inside another selected folder is already covered by it.
|
||||||
|
*
|
||||||
|
* Zipping both would reach the same file twice, and which of the two
|
||||||
|
* paths the surviving entry ended up under would be decided by
|
||||||
|
* whatever order the database returned the rows in. Keeping the outer
|
||||||
|
* folder keeps the fuller path — Reports/Q1/report.pdf rather than
|
||||||
|
* Q1/report.pdf — and gives the same archive on every run.
|
||||||
|
*
|
||||||
|
* @param list<int> $folderIds
|
||||||
|
* @return Collection<int, Folder>
|
||||||
|
*/
|
||||||
|
private function outermostFolders(array $folderIds): Collection
|
||||||
|
{
|
||||||
|
/** @var Collection<int, Folder> $folders */
|
||||||
|
$folders = Folder::query()->whereIn('id', $folderIds)->orderBy('id')->get();
|
||||||
|
|
||||||
|
return $folders
|
||||||
|
->reject(fn (Folder $folder): bool => $folders->contains(
|
||||||
|
fn (Folder $other): bool => $other->id !== $folder->id
|
||||||
|
&& str_starts_with($folder->path, $other->subtreePathPrefix()),
|
||||||
|
))
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Collection<int, Folder> $foldersById Every folder in the root's subtree, keyed by id.
|
* @param Collection<int, Folder> $foldersById Every folder in the root's subtree, keyed by id.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -105,7 +105,20 @@ class File extends Model
|
|||||||
// because it needs the row's own pointers intact.
|
// because it needs the row's own pointers intact.
|
||||||
app(FileVersions::class)->detachOnDelete($file);
|
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;
|
namespace App\Modules\Files\Models;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
use App\Modules\Groups\Models\Group;
|
use App\Modules\Groups\Models\Group;
|
||||||
use App\Support\Concerns\HasUniqueSlug;
|
use App\Support\Concerns\HasUniqueSlug;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@@ -152,11 +153,19 @@ class Folder extends Model
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether $user may upload a new file directly into $folder (null =
|
* Whether $user may upload a new file directly into $folder (null =
|
||||||
* loose at the root, always allowed). Staff already validate folder_id
|
* loose at the root, always allowed).
|
||||||
* through FilesController's own flow — this is the client-facing
|
*
|
||||||
* check, used by ChunkedUploadsController: the client owns the
|
* Staff are held to the library boundary they are held to everywhere
|
||||||
* folder, or it's a public folder that opts into client uploads and
|
* else: an unscoped staff member may use any folder, a client-scoped
|
||||||
* the client's role permits uploading into public folders at all.
|
* 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
|
public static function uploadableBy(User $user, ?self $folder): bool
|
||||||
{
|
{
|
||||||
@@ -165,7 +174,7 @@ class Folder extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($user->isStaff()) {
|
if ($user->isStaff()) {
|
||||||
return true;
|
return app(StaffLibraryScope::class)->allowsFolder($user, $folder);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $folder->isOwnedBy($user)
|
return $folder->isOwnedBy($user)
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ use Illuminate\Support\Carbon;
|
|||||||
* @property string|null $error
|
* @property string|null $error
|
||||||
* @property list<int> $file_ids
|
* @property list<int> $file_ids
|
||||||
* @property list<int> $folder_ids
|
* @property list<int> $folder_ids
|
||||||
|
* @property list<int>|null $contained_file_ids
|
||||||
* @property list<array{id: int, name: string}>|null $skipped_files
|
* @property list<array{id: int, name: string}>|null $skipped_files
|
||||||
* @property Carbon|null $delivered_at
|
* @property Carbon|null $delivered_at
|
||||||
|
* @property Carbon|null $started_at
|
||||||
*/
|
*/
|
||||||
class ZipDownload extends Model
|
class ZipDownload extends Model
|
||||||
{
|
{
|
||||||
@@ -40,8 +42,10 @@ class ZipDownload extends Model
|
|||||||
return [
|
return [
|
||||||
'file_ids' => 'array',
|
'file_ids' => 'array',
|
||||||
'folder_ids' => 'array',
|
'folder_ids' => 'array',
|
||||||
|
'contained_file_ids' => 'array',
|
||||||
'skipped_files' => 'array',
|
'skipped_files' => 'array',
|
||||||
'delivered_at' => 'datetime',
|
'delivered_at' => 'datetime',
|
||||||
|
'started_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Modules\Files;
|
|||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
|
use App\Modules\Files\Thumbnails\ImageRendition;
|
||||||
use App\Modules\Files\Uploads\UploadExtensionPolicy;
|
use App\Modules\Files\Uploads\UploadExtensionPolicy;
|
||||||
use App\Modules\Platform\Settings\ExternalStorageConfigApplier;
|
use App\Modules\Platform\Settings\ExternalStorageConfigApplier;
|
||||||
use App\Modules\Platform\Settings\ExternalStorageSettings;
|
use App\Modules\Platform\Settings\ExternalStorageSettings;
|
||||||
@@ -20,13 +21,6 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
*/
|
*/
|
||||||
class OrphanFileScanner
|
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(
|
public function __construct(
|
||||||
private readonly UploadExtensionPolicy $extensionPolicy,
|
private readonly UploadExtensionPolicy $extensionPolicy,
|
||||||
private readonly ExternalStorageConfigApplier $externalStorage,
|
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
|
private function isExcluded(string $path): bool
|
||||||
{
|
{
|
||||||
foreach (self::EXCLUDED_PREFIXES as $prefix) {
|
foreach ($this->excludedPrefixes() as $prefix) {
|
||||||
if (str_starts_with($path, $prefix)) {
|
if (str_starts_with($path, $prefix)) {
|
||||||
return true;
|
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 App\Modules\Files\Storage\ResolvingUploadDisk;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
use Illuminate\Support\Facades\File as FileSystem;
|
use Illuminate\Support\Facades\File as FileSystem;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
@@ -203,12 +204,40 @@ class LocalPartStore
|
|||||||
Event::dispatch($diskEvent);
|
Event::dispatch($diskEvent);
|
||||||
$disk = $diskEvent->disk;
|
$disk = $diskEvent->disk;
|
||||||
|
|
||||||
Storage::disk($disk)->writeStream($targetPath, $readStream);
|
$written = Storage::disk($disk)->writeStream($targetPath, $readStream);
|
||||||
|
|
||||||
if (is_resource($readStream)) {
|
if (is_resource($readStream)) {
|
||||||
fclose($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);
|
$this->abort($session);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -226,7 +255,26 @@ class LocalPartStore
|
|||||||
|
|
||||||
private function directory(UploadSession $session): string
|
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
|
private function partPath(UploadSession $session, int $partNumber): string
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
use App\Modules\Groups\Http\Resources\Api\GroupResource;
|
use App\Modules\Groups\Http\Resources\Api\GroupResource;
|
||||||
use App\Modules\Groups\Models\Group;
|
use App\Modules\Groups\Models\Group;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -17,6 +18,7 @@ class GroupMembersController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
|
private readonly StaffLibraryScope $scope,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function store(Request $request, Group $group): GroupResource
|
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
|
// syncWithoutDetaching, so adding an existing member is a no-op and
|
||||||
// a retried request is safe.
|
// a retried request is safe.
|
||||||
$group->members()->syncWithoutDetaching([$client->id]);
|
$group->members()->syncWithoutDetaching([$client->id]);
|
||||||
@@ -46,8 +59,15 @@ class GroupMembersController extends Controller
|
|||||||
return new GroupResource($group->loadCount('members')->load('members'));
|
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);
|
$group->members()->detach($member->id);
|
||||||
|
|
||||||
$this->activity->log(Action::GroupMemberRemoved, subject: $group, context: ['member' => $member->name]);
|
$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\Action;
|
||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
use App\Modules\Groups\Http\Resources\Api\GroupResource;
|
use App\Modules\Groups\Http\Resources\Api\GroupResource;
|
||||||
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
use App\Modules\Groups\Models\Group;
|
use App\Modules\Groups\Models\Group;
|
||||||
use App\Support\Rules;
|
use App\Support\Rules;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
@@ -29,6 +31,7 @@ class GroupsController extends Controller
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PollingQuery $polling,
|
private readonly PollingQuery $polling,
|
||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
|
private readonly StaffLibraryScope $scope,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): AnonymousResourceCollection
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
@@ -54,9 +57,20 @@ class GroupsController extends Controller
|
|||||||
return GroupResource::collection($this->polling->paginate($request, $query, 'groups'));
|
return GroupResource::collection($this->polling->paginate($request, $query, 'groups'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Group $group): GroupResource
|
public function show(Request $request, Group $group): GroupResource
|
||||||
{
|
{
|
||||||
return new GroupResource($group->loadCount('members')->load('members'));
|
$viewer = $request->user();
|
||||||
|
assert($viewer !== null);
|
||||||
|
|
||||||
|
// The web edit screen's boundary, on its API twin: this is the read
|
||||||
|
// half of the group that update() and destroy() below already refuse
|
||||||
|
// to touch, and it hands back the membership with addresses.
|
||||||
|
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
|
||||||
|
|
||||||
|
return new GroupResource($group->loadCount('members')->load([
|
||||||
|
'members' => fn (BelongsToMany $members) => $members
|
||||||
|
->whereIn('users.id', $this->scope->clients($viewer)->select('id')),
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
@@ -83,6 +97,14 @@ class GroupsController extends Controller
|
|||||||
|
|
||||||
public function update(Request $request, Group $group): GroupResource
|
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([
|
$validated = $request->validate([
|
||||||
'name' => ['sometimes', 'string', 'max:255'],
|
'name' => ['sometimes', 'string', 'max:255'],
|
||||||
'slug' => Rules::slug('groups', $group->id),
|
'slug' => Rules::slug('groups', $group->id),
|
||||||
@@ -108,8 +130,16 @@ class GroupsController extends Controller
|
|||||||
return new GroupResource($group->refresh()->loadCount('members'));
|
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;
|
$name = $group->name;
|
||||||
$group->delete();
|
$group->delete();
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
use App\Modules\Groups\Models\Group;
|
use App\Modules\Groups\Models\Group;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -17,6 +18,7 @@ class GroupMembersController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
|
private readonly StaffLibraryScope $scope,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function store(Request $request, Group $group): RedirectResponse
|
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]);
|
$group->members()->syncWithoutDetaching([$client->id]);
|
||||||
|
|
||||||
$this->activity->log(Action::GroupMemberAdded, subject: $group, context: ['member' => $client->name]);
|
$this->activity->log(Action::GroupMemberAdded, subject: $group, context: ['member' => $client->name]);
|
||||||
@@ -42,8 +55,15 @@ class GroupMembersController extends Controller
|
|||||||
return back();
|
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);
|
$group->members()->detach($member->id);
|
||||||
|
|
||||||
$this->activity->log(Action::GroupMemberRemoved, subject: $group, context: ['member' => $member->name]);
|
$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\Models\User;
|
||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
use App\Modules\Groups\Models\Group;
|
use App\Modules\Groups\Models\Group;
|
||||||
use App\Modules\Identity\UserType;
|
|
||||||
use App\Support\Pagination;
|
use App\Support\Pagination;
|
||||||
use App\Support\PublicUrl;
|
use App\Support\PublicUrl;
|
||||||
use App\Support\Rules;
|
use App\Support\Rules;
|
||||||
@@ -25,6 +25,7 @@ class GroupsController extends Controller
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
private readonly PublicUrl $publicUrl,
|
private readonly PublicUrl $publicUrl,
|
||||||
|
private readonly StaffLibraryScope $scope,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): Response
|
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]);
|
$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', [
|
return Inertia::render('groups/edit', [
|
||||||
'group' => [
|
'group' => [
|
||||||
'id' => $group->id,
|
'id' => $group->id,
|
||||||
@@ -105,14 +121,24 @@ class GroupsController extends Controller
|
|||||||
'description' => $group->description,
|
'description' => $group->description,
|
||||||
'public' => $group->public,
|
'public' => $group->public,
|
||||||
],
|
],
|
||||||
'members' => $group->members()->orderBy('name')->get()
|
// Both lists narrow through StaffLibraryScope::clients(), which
|
||||||
|
// is the listing half of the rule this screen's buttons are
|
||||||
|
// already guarded with: a member outside the roster cannot be
|
||||||
|
// removed here (allowsGroupMembership refuses it), and a client
|
||||||
|
// outside it cannot be added. Naming them anyway, with their
|
||||||
|
// address, was the same mistake the client list made before
|
||||||
|
// that method existed. An unscoped viewer sees everything,
|
||||||
|
// unchanged.
|
||||||
|
'members' => $group->members()
|
||||||
|
->whereIn('users.id', $this->scope->clients($viewer)->select('id'))
|
||||||
|
->orderBy('name')
|
||||||
|
->get()
|
||||||
->map(fn (User $member): array => [
|
->map(fn (User $member): array => [
|
||||||
'id' => $member->id,
|
'id' => $member->id,
|
||||||
'name' => $member->name,
|
'name' => $member->name,
|
||||||
'email' => $member->email,
|
'email' => $member->email,
|
||||||
])->all(),
|
])->all(),
|
||||||
'available_clients' => User::query()
|
'available_clients' => $this->scope->clients($viewer)
|
||||||
->where('type', UserType::Client)
|
|
||||||
->whereNotIn('id', $group->members()->pluck('users.id'))
|
->whereNotIn('id', $group->members()->pluck('users.id'))
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get()
|
->get()
|
||||||
@@ -126,6 +152,19 @@ class GroupsController extends Controller
|
|||||||
|
|
||||||
public function update(Request $request, Group $group): RedirectResponse
|
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([
|
$validated = $request->validate([
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
// The slug only matters (and is only shown) once a group is
|
// 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.'));
|
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;
|
$name = $group->name;
|
||||||
$group->delete();
|
$group->delete();
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace App\Modules\Groups\Http\Controllers;
|
namespace App\Modules\Groups\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
|
use App\Modules\Groups\Models\Group;
|
||||||
use App\Modules\Groups\Models\MembershipRequest;
|
use App\Modules\Groups\Models\MembershipRequest;
|
||||||
use App\Modules\Groups\Notifications\GroupMembershipDeniedNotification;
|
use App\Modules\Groups\Notifications\GroupMembershipDeniedNotification;
|
||||||
use App\Modules\Notifications\Notifier;
|
use App\Modules\Notifications\Notifier;
|
||||||
@@ -30,6 +33,7 @@ class MembershipRequestsController extends Controller
|
|||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
private readonly Settings $settings,
|
private readonly Settings $settings,
|
||||||
private readonly Notifier $notifier,
|
private readonly Notifier $notifier,
|
||||||
|
private readonly StaffLibraryScope $scope,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): Response
|
public function index(Request $request): Response
|
||||||
@@ -40,12 +44,19 @@ class MembershipRequestsController extends Controller
|
|||||||
|
|
||||||
$filters = ['search' => $validated['search'] ?? null];
|
$filters = ['search' => $validated['search'] ?? null];
|
||||||
|
|
||||||
|
$viewer = $request->user();
|
||||||
|
assert($viewer !== null);
|
||||||
|
|
||||||
$requests = MembershipRequest::query()
|
$requests = MembershipRequest::query()
|
||||||
->pending()
|
->pending()
|
||||||
// A request whose client or group vanished is dead weight; excluding
|
// A request whose client or group vanished is dead weight; excluding
|
||||||
// it in SQL (not after fetching) keeps pagination counts honest.
|
// it in SQL (not after fetching) keeps pagination counts honest.
|
||||||
->whereHas('user')
|
->whereHas('user')
|
||||||
->whereHas('group')
|
->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'])
|
->with(['group', 'user'])
|
||||||
->when($filters['search'], fn (Builder $query, string $search) => $query->where(fn (Builder $q) => $q
|
->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}%"))
|
->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;
|
$group = $membershipRequest->group;
|
||||||
$client = $membershipRequest->user;
|
$client = $membershipRequest->user;
|
||||||
|
|
||||||
abort_unless($group !== null && $client !== null && $membershipRequest->status === MembershipRequest::STATUS_PENDING, 404);
|
abort_unless($group !== null && $client !== null && $membershipRequest->status === MembershipRequest::STATUS_PENDING, 404);
|
||||||
|
|
||||||
|
$this->guardRequest($request, $group, $client);
|
||||||
|
|
||||||
$group->members()->syncWithoutDetaching([$client->id]);
|
$group->members()->syncWithoutDetaching([$client->id]);
|
||||||
$membershipRequest->delete();
|
$membershipRequest->delete();
|
||||||
|
|
||||||
@@ -85,11 +98,26 @@ class MembershipRequestsController extends Controller
|
|||||||
return back()->with('success', __('Membership request approved.'));
|
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;
|
$group = $membershipRequest->group;
|
||||||
$client = $membershipRequest->user;
|
$client = $membershipRequest->user;
|
||||||
|
|
||||||
|
if ($group !== null && $client !== null) {
|
||||||
|
$this->guardRequest($request, $group, $client);
|
||||||
|
}
|
||||||
|
|
||||||
// The denied row persists: the client sees the outcome, and it
|
// The denied row persists: the client sees the outcome, and it
|
||||||
// enforces the re-request cooldown.
|
// enforces the re-request cooldown.
|
||||||
$membershipRequest->forceFill([
|
$membershipRequest->forceFill([
|
||||||
@@ -107,4 +135,25 @@ class MembershipRequestsController extends Controller
|
|||||||
|
|
||||||
return back()->with('success', __('Membership request denied.'));
|
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\Audit\ActivityLogger;
|
||||||
use App\Modules\Comments\CommentingRules;
|
use App\Modules\Comments\CommentingRules;
|
||||||
use App\Modules\Files\Access\DownloadAllowance;
|
use App\Modules\Files\Access\DownloadAllowance;
|
||||||
|
use App\Modules\Files\Delivery\StoredFileResponse;
|
||||||
use App\Modules\Files\Models\Category;
|
use App\Modules\Files\Models\Category;
|
||||||
use App\Modules\Files\Models\File;
|
use App\Modules\Files\Models\File;
|
||||||
use App\Modules\Files\Models\Folder;
|
use App\Modules\Files\Models\Folder;
|
||||||
|
use App\Modules\Files\Preview\PreviewKind;
|
||||||
use App\Modules\Files\Thumbnails\ImageAudience;
|
use App\Modules\Files\Thumbnails\ImageAudience;
|
||||||
use App\Modules\Files\Thumbnails\ImageRendition;
|
use App\Modules\Files\Thumbnails\ImageRendition;
|
||||||
|
use App\Modules\Files\Thumbnails\LocalSourceFile;
|
||||||
use App\Modules\Files\Thumbnails\ThumbnailGenerator;
|
use App\Modules\Files\Thumbnails\ThumbnailGenerator;
|
||||||
use App\Modules\Files\Versions\FileVersionLinks;
|
use App\Modules\Files\Versions\FileVersionLinks;
|
||||||
use App\Modules\Groups\Http\Controllers\Concerns\InteractsWithPublicListing;
|
use App\Modules\Groups\Http\Controllers\Concerns\InteractsWithPublicListing;
|
||||||
@@ -79,6 +82,8 @@ class PublicGroupsController extends Controller
|
|||||||
private readonly PublicThemeRegistry $themes,
|
private readonly PublicThemeRegistry $themes,
|
||||||
private readonly CapabilityRegistry $capabilities,
|
private readonly CapabilityRegistry $capabilities,
|
||||||
private readonly CommentingRules $commenting,
|
private readonly CommentingRules $commenting,
|
||||||
|
private readonly StoredFileResponse $bytes,
|
||||||
|
private readonly LocalSourceFile $source,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request, string $publicSlug): InertiaResponse|RedirectResponse
|
public function index(Request $request, string $publicSlug): InertiaResponse|RedirectResponse
|
||||||
@@ -208,6 +213,13 @@ class PublicGroupsController extends Controller
|
|||||||
'thumbnail_url' => ThumbnailGenerator::supports($file->mime_type)
|
'thumbnail_url' => ThumbnailGenerator::supports($file->mime_type)
|
||||||
? route('public.thumbnail', [$publicSlug, $file->slug])
|
? route('public.thumbnail', [$publicSlug, $file->slug])
|
||||||
: null,
|
: 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]),
|
'download_url' => route('public.download', [$publicSlug, $file->slug]),
|
||||||
// Same decided shape the listings send, so a theme's single
|
// Same decided shape the listings send, so a theme's single
|
||||||
// file page disables its button for the same reason a row
|
// file page disables its button for the same reason a row
|
||||||
@@ -241,7 +253,18 @@ class PublicGroupsController extends Controller
|
|||||||
|
|
||||||
if (! $disk->exists($thumbnailPath)) {
|
if (! $disk->exists($thumbnailPath)) {
|
||||||
$disk->makeDirectory(dirname($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, [
|
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);
|
$this->guardSlug($publicSlug);
|
||||||
|
|
||||||
@@ -265,11 +340,6 @@ class PublicGroupsController extends Controller
|
|||||||
|
|
||||||
$this->activity->log(Action::PublicFileDownloaded, subject: $file);
|
$this->activity->log(Action::PublicFileDownloaded, subject: $file);
|
||||||
|
|
||||||
return response('', 200, [
|
return $this->bytes->attachment($file);
|
||||||
'X-Accel-Redirect' => '/protected-files/'.$file->path,
|
|
||||||
'Content-Type' => $file->mime_type,
|
|
||||||
'Content-Disposition' => ContentDisposition::attachment($file->original_name),
|
|
||||||
'Content-Length' => (string) $file->size,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ use Illuminate\Http\Resources\Json\JsonResource;
|
|||||||
* @mixin Group
|
* @mixin Group
|
||||||
*
|
*
|
||||||
* Members carry a name and an email, which is what the group edit screen
|
* Members carry a name and an email, which is what the group edit screen
|
||||||
* already shows to anyone holding `edit_groups`. They are attached only
|
* shows the same viewer. That is a claim about the screen, so it holds
|
||||||
* when explicitly loaded, so a listing of groups does not become a bulk
|
* only for as long as the screen does: both narrow the list to the
|
||||||
* export of every client's address.
|
* clients the viewer may act on, and the controller loading this relation
|
||||||
|
* is where that narrowing is applied. They are attached only when
|
||||||
|
* explicitly loaded, so a listing of groups does not become a bulk export
|
||||||
|
* of every client's address.
|
||||||
*/
|
*/
|
||||||
class GroupResource extends JsonResource
|
class GroupResource extends JsonResource
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Modules\Groups\Models;
|
namespace App\Modules\Groups\Models;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
@@ -44,6 +45,37 @@ class MembershipRequest extends Model
|
|||||||
return $query->where('status', self::STATUS_PENDING);
|
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>
|
* @return BelongsTo<Group, $this>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ namespace App\Modules\Identity;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
use App\Modules\Identity\Models\Role;
|
use App\Modules\Identity\Models\Role;
|
||||||
|
use App\Modules\Platform\Seats\SeatAllowance;
|
||||||
use App\Modules\Identity\Permissions\SystemRole;
|
use App\Modules\Identity\Permissions\SystemRole;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@@ -36,6 +38,8 @@ class AccountConversion
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly StaffAccounts $accounts,
|
private readonly StaffAccounts $accounts,
|
||||||
private readonly ActivityLogger $activity,
|
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
|
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);
|
$this->guardSelf($actor, $target);
|
||||||
|
|
||||||
// Only on this direction. "Could the actor have granted the
|
// Only on this direction. "Could the actor have granted the
|
||||||
@@ -84,11 +92,31 @@ class AccountConversion
|
|||||||
{
|
{
|
||||||
$this->guardSelf($actor, $target);
|
$this->guardSelf($actor, $target);
|
||||||
|
|
||||||
// No guardTarget here — see guardToClient(). What actually limits
|
// A promotion takes a staff seat. It frees a client one at the same
|
||||||
// a promotion is the role being granted, and that is enforced by
|
// moment, so the two caps move in opposite directions and only the
|
||||||
// the caller validating role_id against
|
// one being filled can refuse. Asked in the guard rather than in
|
||||||
// StaffAccounts::assignableRoleIds(): nobody hands out authority
|
// toStaff() so a refusal happens before the transaction opens.
|
||||||
// they do not hold.
|
$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
|
// An account request is not an account yet. Approving one is a
|
||||||
// deliberate decision with its own screen and its own audit entry;
|
// deliberate decision with its own screen and its own audit entry;
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ namespace App\Modules\Identity\Console;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
|
use App\Modules\Identity\Erasure\AvailableEmailRule;
|
||||||
use App\Modules\Identity\Models\Role;
|
use App\Modules\Identity\Models\Role;
|
||||||
use App\Modules\Identity\Permissions\SystemRole;
|
use App\Modules\Identity\Permissions\SystemRole;
|
||||||
use App\Modules\Identity\UserType;
|
use App\Modules\Identity\UserType;
|
||||||
|
use App\Modules\Platform\Onboarding\InstallationWelcome;
|
||||||
use App\Modules\Platform\Settings\Setting;
|
use App\Modules\Platform\Settings\Setting;
|
||||||
use App\Modules\Platform\Settings\Settings;
|
use App\Modules\Platform\Settings\Settings;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
@@ -42,7 +44,7 @@ class CreateAdminCommand extends Command
|
|||||||
['name' => $name, 'email' => $email, 'password' => $password],
|
['name' => $name, 'email' => $email, 'password' => $password],
|
||||||
[
|
[
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'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()],
|
'password' => ['required', Password::defaults()],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -72,6 +74,12 @@ class CreateAdminCommand extends Command
|
|||||||
$settings->set(Setting::AdminNotificationEmails, [$user->email]);
|
$settings->set(Setting::AdminNotificationEmails, [$user->email]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unattended provisioning skips the setup screen entirely, so this
|
||||||
|
// is the only place that can record "somebody just installed this"
|
||||||
|
// for a container that came up from environment variables. They
|
||||||
|
// still deserve showing around on their first visit.
|
||||||
|
app(InstallationWelcome::class)->raise();
|
||||||
|
|
||||||
$this->info("Administrator {$user->email} created.");
|
$this->info("Administrator {$user->email} created.");
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class PurgeErasuresCommand extends Command
|
|||||||
{
|
{
|
||||||
protected $signature = 'projectsend:purge-erasures';
|
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
|
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.
|
* Moving an account between staff and clients.
|
||||||
*
|
*
|
||||||
* Community edition only, by the same route group as every other
|
* Both editions since 2.2.0, by the same route group as every other
|
||||||
* staff-account screen — managed installations create staff accounts
|
* staff-account screen: whoever may create a staff account may promote
|
||||||
* outside the application, so a converter there would be a second,
|
* one, and a managed installation limits that by seats rather than by
|
||||||
* unmanaged way to create one.
|
* closing the screen — AccountConversion asks SeatAllowance on both
|
||||||
|
* directions, because a promotion spends a staff seat and a demotion
|
||||||
|
* spends a client one.
|
||||||
*
|
*
|
||||||
* The rules live in AccountConversion, which calls StaffAccounts for the
|
* The rules live in AccountConversion, which calls StaffAccounts for the
|
||||||
* authority questions. This controller is the request shape and the
|
* authority questions. This controller is the request shape and the
|
||||||
@@ -120,7 +122,9 @@ class AccountConversionController extends Controller
|
|||||||
'is_system' => $role->is_system,
|
'is_system' => $role->is_system,
|
||||||
'client_scoped' => $role->client_scoped,
|
'client_scoped' => $role->client_scoped,
|
||||||
])->all(),
|
])->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])
|
->map(fn (User $client): array => ['id' => $client->id, 'name' => $client->name])
|
||||||
->values()->all(),
|
->values()->all(),
|
||||||
]);
|
]);
|
||||||
@@ -152,7 +156,8 @@ class AccountConversionController extends Controller
|
|||||||
'assigned_clients' => ['array'],
|
'assigned_clients' => ['array'],
|
||||||
'assigned_clients.*' => [
|
'assigned_clients.*' => [
|
||||||
'integer',
|
'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]),
|
Rule::notIn([$user->id]),
|
||||||
],
|
],
|
||||||
// Required only for an account whose credential lives in the
|
// 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\Api\Support\PollingQuery;
|
||||||
use App\Modules\Files\DeletedAccountContent;
|
use App\Modules\Files\DeletedAccountContent;
|
||||||
use App\Modules\Identity\AccountContentDeletion;
|
use App\Modules\Identity\AccountContentDeletion;
|
||||||
|
use App\Modules\Identity\Erasure\AvailableEmailRule;
|
||||||
use App\Modules\Identity\Http\Resources\Api\StaffUserResource;
|
use App\Modules\Identity\Http\Resources\Api\StaffUserResource;
|
||||||
use App\Modules\Identity\StaffAccounts;
|
use App\Modules\Identity\StaffAccounts;
|
||||||
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
|
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
|
||||||
@@ -17,6 +18,7 @@ use Illuminate\Database\Eloquent\Builder;
|
|||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@@ -24,12 +26,18 @@ use Illuminate\Validation\ValidationException;
|
|||||||
/**
|
/**
|
||||||
* Staff accounts over the API — the API twin of the /users screens.
|
* Staff accounts over the API — the API twin of the /users screens.
|
||||||
*
|
*
|
||||||
* **Community only.** Every route is behind `capability:users.manage`, so
|
* Both editions since 2.2.0. Every route is behind
|
||||||
* a cloud install answers 403 `capability_unavailable`: managed
|
* `capability:users.manage`, which cloud installations now hold as well:
|
||||||
* installations create staff accounts outside the application, and an API
|
* a platform sells staff seats and the tenant fills them, so an API that
|
||||||
* that could mint them there would be a second, unmanaged door into the
|
* creates one is the same door the screen is, not a second unmanaged one
|
||||||
* same thing.
|
* (see Capability::UsersManage). How many it may create is
|
||||||
* The routes are still registered in every edition so the committed
|
* SeatAllowance's question, asked here through StaffAccounts, and an
|
||||||
|
* installation at its limit answers 422 rather than 403.
|
||||||
|
*
|
||||||
|
* The capability stays in front of the routes rather than being dropped:
|
||||||
|
* it is the seam an edition difference would have to travel through, and
|
||||||
|
* an installation without it answers 403 `capability_unavailable`. The
|
||||||
|
* routes are registered in every edition either way, so the committed
|
||||||
* OpenAPI document is identical everywhere — the middleware refuses, the
|
* OpenAPI document is identical everywhere — the middleware refuses, the
|
||||||
* route table does not lie.
|
* route table does not lie.
|
||||||
*
|
*
|
||||||
@@ -118,14 +126,18 @@ class UsersController extends Controller
|
|||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'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))],
|
'role_id' => ['required', 'integer', Rule::in($this->accounts->assignableRoleIds($actor))],
|
||||||
// No `confirmed`: repeating a password defends against a human
|
// No `confirmed`: repeating a password defends against a human
|
||||||
// mistyping into a form, and an API caller has no second field
|
// mistyping into a form, and an API caller has no second field
|
||||||
// to mistype. Password::defaults() still applies.
|
// to mistype. Password::defaults() still applies.
|
||||||
'password' => ['required', Password::defaults()],
|
'password' => ['required', Password::defaults()],
|
||||||
'assigned_clients' => ['array'],
|
'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([
|
$user = $this->accounts->create([
|
||||||
@@ -163,18 +175,34 @@ class UsersController extends Controller
|
|||||||
'active' => ['sometimes', 'boolean'],
|
'active' => ['sometimes', 'boolean'],
|
||||||
'password' => ['sometimes', 'nullable', Password::defaults()],
|
'password' => ['sometimes', 'nullable', Password::defaults()],
|
||||||
'assigned_clients' => ['sometimes', 'array'],
|
'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:
|
// The same refusal the web screen makes, and for the same reason:
|
||||||
// locking yourself out is never what was meant.
|
// locking yourself out is never what was meant.
|
||||||
if ($user->is($actor) && ($validated['active'] ?? true) === false) {
|
if ($user->is($actor) && $deactivating) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'active' => __('You cannot deactivate your own account.'),
|
'active' => __('You cannot deactivate your own account.'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$attributes = array_intersect_key($validated, array_flip(['name', 'email', 'active', 'password']));
|
$attributes = array_intersect_key($validated, array_flip(['name', 'email', 'password']));
|
||||||
|
|
||||||
|
if (array_key_exists('active', $validated)) {
|
||||||
|
$attributes['active'] = $request->boolean('active');
|
||||||
|
}
|
||||||
|
|
||||||
if (array_key_exists('role_id', $validated)) {
|
if (array_key_exists('role_id', $validated)) {
|
||||||
$attributes['role_id'] = (int) $validated['role_id'];
|
$attributes['role_id'] = (int) $validated['role_id'];
|
||||||
@@ -215,9 +243,15 @@ class UsersController extends Controller
|
|||||||
|
|
||||||
$validated = $this->accountDeletion->validate($request, $user);
|
$validated = $this->accountDeletion->validate($request, $user);
|
||||||
|
|
||||||
$name = $this->accounts->delete($user);
|
// Soft-deleting the account and disposing of its files are two
|
||||||
|
// separate writes; keep them in one transaction so a failure in the
|
||||||
$this->accountDeletion->apply($validated, $user, $name);
|
// 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);
|
return response()->json(status: 204);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,9 +89,12 @@ class RolesController extends Controller
|
|||||||
|
|
||||||
$this->guardGrantablePermissions($request, $validated['permissions'] ?? []);
|
$this->guardGrantablePermissions($request, $validated['permissions'] ?? []);
|
||||||
|
|
||||||
|
$clientScoped = $request->boolean('client_scoped');
|
||||||
|
$this->guardScopeRemoval($request, removesScope: ! $clientScoped);
|
||||||
|
|
||||||
$role = Role::query()->create([
|
$role = Role::query()->create([
|
||||||
'name' => $validated['name'],
|
'name' => $validated['name'],
|
||||||
'client_scoped' => $validated['client_scoped'] ?? false,
|
'client_scoped' => $clientScoped,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->syncPermissions($role, $validated['permissions'] ?? []);
|
$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
|
// Built-in roles have fixed names and a fixed scope flag; only their
|
||||||
// permission set is editable. Custom roles can change name + scope.
|
// permission set is editable. Custom roles can change name + scope.
|
||||||
if (! $role->is_system) {
|
if (! $role->is_system) {
|
||||||
|
$clientScoped = $request->boolean('client_scoped');
|
||||||
|
$this->guardScopeRemoval($request, removesScope: $role->client_scoped && ! $clientScoped);
|
||||||
|
|
||||||
$role->update([
|
$role->update([
|
||||||
'name' => $validated['name'],
|
'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
|
* @param list<string> $permissions
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Modules\Audit\ActivityLogger;
|
|||||||
use App\Modules\Identity\Models\Role;
|
use App\Modules\Identity\Models\Role;
|
||||||
use App\Modules\Identity\Permissions\SystemRole;
|
use App\Modules\Identity\Permissions\SystemRole;
|
||||||
use App\Modules\Identity\UserType;
|
use App\Modules\Identity\UserType;
|
||||||
|
use App\Modules\Platform\Onboarding\InstallationWelcome;
|
||||||
use App\Modules\Platform\Settings\Setting;
|
use App\Modules\Platform\Settings\Setting;
|
||||||
use App\Modules\Platform\Settings\Settings;
|
use App\Modules\Platform\Settings\Settings;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
@@ -28,6 +29,7 @@ class SetupController extends Controller
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Settings $settings,
|
private readonly Settings $settings,
|
||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
|
private readonly InstallationWelcome $welcome,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function show(): Response|RedirectResponse
|
public function show(): Response|RedirectResponse
|
||||||
@@ -72,6 +74,11 @@ class SetupController extends Controller
|
|||||||
$this->settings->set(Setting::AdminNotificationEmails, [$admin->email]);
|
$this->settings->set(Setting::AdminNotificationEmails, [$admin->email]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// They will be shown around the first time they sign in — which is
|
||||||
|
// the next thing that happens, since setup deliberately does not
|
||||||
|
// log anybody in.
|
||||||
|
$this->welcome->raise();
|
||||||
|
|
||||||
// Deliberately no auto-login: the new administrator proves their
|
// Deliberately no auto-login: the new administrator proves their
|
||||||
// credentials at the login form, which also confirms they work.
|
// credentials at the login form, which also confirms they work.
|
||||||
return redirect()->route('setup.success')->with('setup_completed', true);
|
return redirect()->route('setup.success')->with('setup_completed', true);
|
||||||
@@ -90,8 +97,16 @@ class SetupController extends Controller
|
|||||||
return Inertia::render('setup-success');
|
return Inertia::render('setup-success');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trashed staff count, for the reason EnsureSetupIsComplete gives:
|
||||||
|
* this asks whether the installation was ever set up, and store()
|
||||||
|
* below is the door a stranger walks through if the answer is wrong.
|
||||||
|
* The middleware and this must agree — one of them saying "not set
|
||||||
|
* up" while the other says "set up" is either a redirect loop or an
|
||||||
|
* open form.
|
||||||
|
*/
|
||||||
private function setupIsComplete(): bool
|
private function setupIsComplete(): bool
|
||||||
{
|
{
|
||||||
return User::query()->where('type', UserType::Staff)->exists();
|
return User::query()->withTrashed()->where('type', UserType::Staff)->exists();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ use App\Modules\Identity\Social\SocialProvider;
|
|||||||
use App\Modules\Identity\Social\SocialSettings;
|
use App\Modules\Identity\Social\SocialSettings;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
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;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,13 +58,13 @@ class SocialLoginController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Begin a sign-in. */
|
/** 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');
|
return $this->begin($request, $provider, 'login');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Begin connecting a provider to the signed-in account. */
|
/** 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');
|
return $this->begin($request, $provider, 'link');
|
||||||
}
|
}
|
||||||
@@ -130,7 +131,7 @@ class SocialLoginController extends Controller
|
|||||||
return redirect()->intended(route('dashboard', absolute: false));
|
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);
|
$case = $this->provider($provider);
|
||||||
$settings = SocialSettings::for($case);
|
$settings = SocialSettings::for($case);
|
||||||
@@ -142,7 +143,13 @@ class SocialLoginController extends Controller
|
|||||||
|
|
||||||
$request->session()->put([self::INTENT => $intent, self::PROVIDER => $case->value]);
|
$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
|
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\Api\Auth\ApiTokens;
|
||||||
use App\Modules\Files\DeletedAccountContent;
|
use App\Modules\Files\DeletedAccountContent;
|
||||||
use App\Modules\Identity\AccountContentDeletion;
|
use App\Modules\Identity\AccountContentDeletion;
|
||||||
|
use App\Modules\Identity\Erasure\AvailableEmailRule;
|
||||||
use App\Modules\Identity\Models\Role;
|
use App\Modules\Identity\Models\Role;
|
||||||
use App\Modules\Identity\StaffAccounts;
|
use App\Modules\Identity\StaffAccounts;
|
||||||
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
|
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
|
||||||
use App\Modules\Identity\UserType;
|
use App\Modules\Identity\UserType;
|
||||||
|
use App\Modules\Platform\Seats\SeatAllowance;
|
||||||
use App\Support\Pagination;
|
use App\Support\Pagination;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@@ -24,9 +27,17 @@ use Inertia\Inertia;
|
|||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Staff ("system users") management — community edition only; managed
|
* Staff ("system users") management. Clients are a different population
|
||||||
* installations create them outside the application. Clients are a different
|
* managed by the Clients module: they never appear here.
|
||||||
* population managed by the Clients module: they never appear here.
|
*
|
||||||
|
* Available on both editions since 2.2.0. A managed installation is sold a
|
||||||
|
* number of seats and fills them itself — see Capability::UsersManage for
|
||||||
|
* why capacity is the platform's and who fills it is the tenant's.
|
||||||
|
*
|
||||||
|
* That makes a full installation an ordinary state rather than an error,
|
||||||
|
* so `index()` reports the seat position and `create()` refuses to open a
|
||||||
|
* form nothing can be submitted through. SeatAllowance::guardStaff() still
|
||||||
|
* runs in `store()`: this is the courtesy, that is the rule.
|
||||||
*/
|
*/
|
||||||
class UsersController extends Controller
|
class UsersController extends Controller
|
||||||
{
|
{
|
||||||
@@ -35,6 +46,7 @@ class UsersController extends Controller
|
|||||||
private readonly AccountContentDeletion $accountDeletion,
|
private readonly AccountContentDeletion $accountDeletion,
|
||||||
private readonly ApiTokens $apiTokens,
|
private readonly ApiTokens $apiTokens,
|
||||||
private readonly StaffAccounts $accounts,
|
private readonly StaffAccounts $accounts,
|
||||||
|
private readonly SeatAllowance $seats,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): Response
|
public function index(Request $request): Response
|
||||||
@@ -97,11 +109,24 @@ class UsersController extends Controller
|
|||||||
'roles' => Role::query()->orderBy('name')->get(['id', 'name'])
|
'roles' => Role::query()->orderBy('name')->get(['id', 'name'])
|
||||||
->map(fn (Role $role): array => ['id' => $role->id, 'name' => $role->name])->all(),
|
->map(fn (Role $role): array => ['id' => $role->id, 'name' => $role->name])->all(),
|
||||||
'reassign_candidates' => $this->accountDeletion->candidates(),
|
'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', [
|
return Inertia::render('users/create', [
|
||||||
'roles' => $this->roleOptions(),
|
'roles' => $this->roleOptions(),
|
||||||
'clients' => $this->clientOptions(),
|
'clients' => $this->clientOptions(),
|
||||||
@@ -112,11 +137,14 @@ class UsersController extends Controller
|
|||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'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()))],
|
'role_id' => ['required', 'integer', Rule::in($this->accounts->assignableRoleIds($this->actor()))],
|
||||||
'password' => ['required', 'confirmed', Password::defaults()],
|
'password' => ['required', 'confirmed', Password::defaults()],
|
||||||
'assigned_clients' => ['array'],
|
'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([
|
$user = $this->accounts->create([
|
||||||
@@ -126,7 +154,12 @@ class UsersController extends Controller
|
|||||||
'password' => $validated['password'],
|
'password' => $validated['password'],
|
||||||
], $validated['assigned_clients'] ?? []);
|
], $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
|
public function edit(User $user): Response
|
||||||
@@ -171,7 +204,10 @@ class UsersController extends Controller
|
|||||||
'active' => ['required', 'boolean'],
|
'active' => ['required', 'boolean'],
|
||||||
'password' => ['nullable', 'confirmed', Password::defaults()],
|
'password' => ['nullable', 'confirmed', Password::defaults()],
|
||||||
'assigned_clients' => ['array'],
|
'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
|
// Deactivating yourself is refused here rather than in StaffAccounts
|
||||||
@@ -216,9 +252,15 @@ class UsersController extends Controller
|
|||||||
|
|
||||||
$validated = $this->accountDeletion->validate($request, $user);
|
$validated = $this->accountDeletion->validate($request, $user);
|
||||||
|
|
||||||
$name = $this->accounts->delete($user);
|
// Soft-deleting the account and disposing of its files are two
|
||||||
|
// separate writes; keep them in one transaction so a failure in the
|
||||||
$this->accountDeletion->apply($validated, $user, $name);
|
// 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.'));
|
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}>
|
* @return array<int, array{id: int, name: string}>
|
||||||
*/
|
*/
|
||||||
private function clientOptions(): array
|
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])
|
->map(fn (User $client): array => ['id' => $client->id, 'name' => $client->name])
|
||||||
->values()->all();
|
->values()->all();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Modules\Identity\Http\Middleware;
|
|||||||
use App\Modules\Identity\TwoFactor\TwoFactorEnforcement;
|
use App\Modules\Identity\TwoFactor\TwoFactorEnforcement;
|
||||||
use App\Modules\Platform\Settings\Setting;
|
use App\Modules\Platform\Settings\Setting;
|
||||||
use App\Modules\Platform\Settings\Settings;
|
use App\Modules\Platform\Settings\Settings;
|
||||||
|
use App\Support\WriteSafeRedirect;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
@@ -39,15 +40,20 @@ class EnforceTwoFactor
|
|||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
// password.confirm is on this list because the two-factor mutation
|
// password.confirm* is on this list because the two-factor mutation
|
||||||
// routes now require it: without the exemption, enrolling would
|
// routes now require it: without the exemption, enrolling would
|
||||||
// redirect to the confirm-password screen, which this middleware
|
// redirect to the confirm-password screen, which this middleware
|
||||||
// would redirect straight back to two-factor.show — a loop that
|
// would redirect straight back to two-factor.show — a loop that
|
||||||
// locks the user out of the only exit.
|
// locks the user out of the only exit.
|
||||||
if ($request->routeIs('two-factor.*', 'password.confirm', 'logout', 'locale.update')) {
|
//
|
||||||
|
// The pattern covers both halves of that screen. Naming only the
|
||||||
|
// GET left the form rendering and its submission redirected away,
|
||||||
|
// so the password was never confirmed and the loop stayed shut
|
||||||
|
// one step further along than before.
|
||||||
|
if ($request->routeIs('two-factor.*', 'password.confirm*', 'logout', 'locale.update')) {
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
namespace App\Modules\Identity\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Support\WriteSafeRedirect;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -25,9 +26,9 @@ class EnsureAccountIsActive
|
|||||||
$request->session()->invalidate();
|
$request->session()->invalidate();
|
||||||
$request->session()->regenerateToken();
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
return redirect()->route('login')->withErrors([
|
return WriteSafeRedirect::apply($request, redirect()->route('login')->withErrors([
|
||||||
'email' => __('Your account has been deactivated.'),
|
'email' => __('Your account has been deactivated.'),
|
||||||
]);
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Modules\Identity\Http\Middleware;
|
|||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Modules\Identity\UserType;
|
use App\Modules\Identity\UserType;
|
||||||
|
use App\Support\WriteSafeRedirect;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
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
|
* sent to the first-run setup screen. The database is the only source of
|
||||||
* truth — no install flags. Client accounts do not count: setup is about
|
* truth — no install flags. Client accounts do not count: setup is about
|
||||||
* having an administrator.
|
* having an administrator.
|
||||||
|
*
|
||||||
|
* Trashed staff count. "Has this installation been set up" is not the
|
||||||
|
* same question as "does it have a working administrator right now", and
|
||||||
|
* only the first one belongs here: a soft-deleted staff row is still
|
||||||
|
* evidence that setup happened, and an installation that has lost its
|
||||||
|
* last administrator needs a recovery path, not a stranger filling in
|
||||||
|
* the first-run form. Deleting a staff account is guarded against
|
||||||
|
* reaching zero (StaffAccounts::guardLastAdministrator), so this is the
|
||||||
|
* second lock rather than the first — but the first one is asked at five
|
||||||
|
* separate doors, and this one is asked once.
|
||||||
*/
|
*/
|
||||||
class EnsureSetupIsComplete
|
class EnsureSetupIsComplete
|
||||||
{
|
{
|
||||||
@@ -24,10 +35,10 @@ class EnsureSetupIsComplete
|
|||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (User::query()->where('type', UserType::Staff)->exists()) {
|
if (User::query()->withTrashed()->where('type', UserType::Staff)->exists()) {
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
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\Models\User;
|
||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLogger;
|
use App\Modules\Audit\ActivityLogger;
|
||||||
|
use App\Modules\Files\Access\StaffLibraryScope;
|
||||||
|
use App\Modules\Identity\Erasure\ErasureSchedule;
|
||||||
use App\Modules\Identity\Models\Role;
|
use App\Modules\Identity\Models\Role;
|
||||||
|
use App\Modules\Platform\Seats\SeatAllowance;
|
||||||
use App\Modules\Identity\Permissions\PermissionChecker;
|
use App\Modules\Identity\Permissions\PermissionChecker;
|
||||||
use App\Modules\Identity\Permissions\SystemRole;
|
use App\Modules\Identity\Permissions\SystemRole;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@@ -34,6 +37,9 @@ class StaffAccounts
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ActivityLogger $activity,
|
private readonly ActivityLogger $activity,
|
||||||
private readonly PermissionChecker $permissions,
|
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
|
* permission into every permission and makes the rest of the matrix
|
||||||
* decorative.
|
* decorative.
|
||||||
*
|
*
|
||||||
* An administrator holds every permission by construction, so this is
|
* A role's `client_scoped` flag is part of that authority, and the
|
||||||
* always true for them and the admin experience is unchanged.
|
* 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
|
public function mayGrant(User $actor, Role $role): bool
|
||||||
{
|
{
|
||||||
@@ -58,6 +73,10 @@ class StaffAccounts
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($actor->isClientScoped() && ! $role->client_scoped) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$held = $this->permissions->grantedKeys($actor);
|
$held = $this->permissions->grantedKeys($actor);
|
||||||
$granting = $role->permissions()->pluck('permission')->all();
|
$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());
|
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
|
* 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
|
* grant the target's role, they have no business editing or deleting
|
||||||
@@ -138,6 +190,31 @@ class StaffAccounts
|
|||||||
&& Role::query()->whereKey($roleId)->where('is_administrator', true)->exists();
|
&& Role::query()->whereKey($roleId)->where('is_administrator', true)->exists();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The installation's principal administrator: the oldest active one.
|
||||||
|
*
|
||||||
|
* "Oldest" is the account created first, which on any installation
|
||||||
|
* that went through setup is the person who set it up — and on one
|
||||||
|
* imported from v1, the first administrator that import created.
|
||||||
|
* There is no *flag* for this because the application deliberately has
|
||||||
|
* no owner concept: administrators are equal in authority, and
|
||||||
|
* inventing a superior one to hold this would be a real change to the
|
||||||
|
* permission model in exchange for a greeting.
|
||||||
|
*
|
||||||
|
* Active, so that a founder who has since left the company does not
|
||||||
|
* silently swallow anything addressed here; it moves to the next
|
||||||
|
* administrator instead.
|
||||||
|
*/
|
||||||
|
public function mainAdministrator(): ?User
|
||||||
|
{
|
||||||
|
return User::query()
|
||||||
|
->where('type', UserType::Staff)
|
||||||
|
->where('active', true)
|
||||||
|
->whereHas('role', fn ($query) => $query->where('is_administrator', true))
|
||||||
|
->orderBy('id')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How many staff are active administrators right now — used to flag
|
* How many staff are active administrators right now — used to flag
|
||||||
* the sole one in the UI so its delete/demote/deactivate controls
|
* the sole one in the UI so its delete/demote/deactivate controls
|
||||||
@@ -158,6 +235,12 @@ class StaffAccounts
|
|||||||
*/
|
*/
|
||||||
public function create(array $attributes, array $assignedClients = []): User
|
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([
|
$user = User::create([
|
||||||
'type' => UserType::Staff,
|
'type' => UserType::Staff,
|
||||||
'active' => true,
|
'active' => true,
|
||||||
@@ -269,14 +352,29 @@ class StaffAccounts
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Soft-delete the account and record it. Returns the name, which the
|
* Soft-delete the account, schedule its permanent erasure and record
|
||||||
* caller needs afterwards for the content-reassignment step — by then
|
* it. Returns the name, which the caller needs afterwards for the
|
||||||
* the model is trashed and reading it back is needless ceremony.
|
* 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
|
public function delete(User $user): string
|
||||||
{
|
{
|
||||||
$name = $user->name;
|
$name = $user->name;
|
||||||
|
|
||||||
|
$this->erasure->apply($user);
|
||||||
$user->delete();
|
$user->delete();
|
||||||
|
|
||||||
$this->activity->log(Action::UserDeleted, context: ['name' => $name]);
|
$this->activity->log(Action::UserDeleted, context: ['name' => $name]);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use BaconQrCode\Renderer\RendererStyle\Fill;
|
|||||||
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
|
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
|
||||||
use BaconQrCode\Writer;
|
use BaconQrCode\Writer;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use PragmaRX\Google2FA\Google2FA;
|
use PragmaRX\Google2FA\Google2FA;
|
||||||
|
|
||||||
@@ -112,20 +113,45 @@ class TwoFactorService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Consume a recovery code; each code works exactly once.
|
* 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
|
public function consumeRecoveryCode(User $user, string $code): bool
|
||||||
{
|
{
|
||||||
/** @var list<string>|null $codes */
|
return DB::transaction(function () use ($user, $code): bool {
|
||||||
$codes = $user->two_factor_recovery_codes;
|
$locked = User::query()->whereKey($user->getKey())->lockForUpdate()->first();
|
||||||
|
|
||||||
if ($codes === null || ! in_array($code, $codes, true)) {
|
/** @var list<string>|null $codes */
|
||||||
return false;
|
$codes = $locked?->two_factor_recovery_codes;
|
||||||
}
|
|
||||||
|
|
||||||
$user->forceFill([
|
if ($codes === null || ! in_array($code, $codes, true)) {
|
||||||
'two_factor_recovery_codes' => array_values(array_diff($codes, [$code])),
|
return false;
|
||||||
])->save();
|
}
|
||||||
|
|
||||||
return true;
|
$user->forceFill([
|
||||||
|
'two_factor_recovery_codes' => array_values(array_diff($codes, [$code])),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Notifications\Console;
|
||||||
|
|
||||||
|
use App\Modules\Notifications\InAppNotification;
|
||||||
|
use App\Modules\Platform\Settings\Setting;
|
||||||
|
use App\Modules\Platform\Settings\Settings;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete read notifications past the retention window.
|
||||||
|
*
|
||||||
|
* One row per recipient per event, and until now nothing ever removed
|
||||||
|
* one. On an installation where every share notifies a handful of people
|
||||||
|
* this is the fastest-growing table in the database, and the growth buys
|
||||||
|
* nothing: nobody scrolls a year back through a notification list.
|
||||||
|
*
|
||||||
|
* **Unread notifications are never deleted, at any age.** A notification
|
||||||
|
* nobody has looked at is the one thing in this table still doing its
|
||||||
|
* job, and an installation whose owner was away for four months should
|
||||||
|
* come back to their news rather than to a clean slate. The history is
|
||||||
|
* not an audit trail either way — the activity log is, and it is never
|
||||||
|
* pruned.
|
||||||
|
*/
|
||||||
|
class PurgeNotificationsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'projectsend:purge-notifications';
|
||||||
|
|
||||||
|
protected $description = 'Delete read notifications older than the configured retention window (runs daily)';
|
||||||
|
|
||||||
|
public function handle(Settings $settings): int
|
||||||
|
{
|
||||||
|
$days = (int) $settings->get(Setting::NotificationRetentionDays);
|
||||||
|
|
||||||
|
if ($days <= 0) {
|
||||||
|
$this->info('Notification retention is disabled; nothing pruned.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunked for the same reason the API request log is: this is a
|
||||||
|
// high-volume table, and a single unbounded DELETE on a busy
|
||||||
|
// installation holds locks for as long as it takes.
|
||||||
|
$cutoff = now()->subDays($days);
|
||||||
|
$deleted = 0;
|
||||||
|
|
||||||
|
do {
|
||||||
|
$batch = InAppNotification::query()
|
||||||
|
->whereNotNull('read_at')
|
||||||
|
->where('created_at', '<', $cutoff)
|
||||||
|
->limit(5000)
|
||||||
|
->delete();
|
||||||
|
$deleted += $batch;
|
||||||
|
} while ($batch > 0);
|
||||||
|
|
||||||
|
$this->info("Pruned {$deleted} read notifications older than {$days} days.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,11 @@ namespace App\Modules\Notifications\Http\Controllers;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Modules\Notifications\NotificationPreference;
|
use App\Modules\Notifications\NotificationPreference;
|
||||||
use App\Modules\Notifications\NotificationPreferences;
|
use App\Modules\Notifications\NotificationPreferences;
|
||||||
|
use App\Modules\Notifications\NotificationTypeDefinition;
|
||||||
use App\Modules\Notifications\NotificationTypeRegistry;
|
use App\Modules\Notifications\NotificationTypeRegistry;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
@@ -30,21 +32,12 @@ class NotificationPreferencesController extends Controller
|
|||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
assert($user !== null);
|
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', [
|
return Inertia::render('settings/notifications', [
|
||||||
'types' => array_map(fn ($type) => [
|
'types' => array_map(fn (NotificationTypeDefinition $type): array => [
|
||||||
'key' => $type->key,
|
'key' => $type->key,
|
||||||
'label' => $type->label,
|
'label' => $type->label,
|
||||||
'email_enabled' => $this->preferences->emailEnabledFor($user, $type),
|
'email_enabled' => $this->preferences->emailEnabledFor($user, $type),
|
||||||
], $emailable),
|
], $this->emailable()),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +48,11 @@ class NotificationPreferencesController extends Controller
|
|||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'preferences' => ['required', 'array'],
|
'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'],
|
'preferences.*.email_enabled' => ['required', 'boolean'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -68,4 +65,31 @@ class NotificationPreferencesController extends Controller
|
|||||||
|
|
||||||
return back();
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ class NotificationsServiceProvider extends ServiceProvider
|
|||||||
$this->app->singleton(NotificationTypeRegistry::class);
|
$this->app->singleton(NotificationTypeRegistry::class);
|
||||||
$this->app->singleton(Notifier::class);
|
$this->app->singleton(Notifier::class);
|
||||||
$this->app->singleton(NotificationPreferences::class);
|
$this->app->singleton(NotificationPreferences::class);
|
||||||
|
|
||||||
|
if ($this->app->runningInConsole()) {
|
||||||
|
$this->commands([
|
||||||
|
Console\PurgeNotificationsCommand::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
|
|||||||
@@ -19,7 +19,14 @@ namespace App\Modules\Platform\Capabilities;
|
|||||||
*/
|
*/
|
||||||
enum Capability: string
|
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 UsersManage = 'users.manage';
|
||||||
case StorageConfigure = 'storage.configure';
|
case StorageConfigure = 'storage.configure';
|
||||||
case EmailTransportConfigure = 'email.transport.configure';
|
case EmailTransportConfigure = 'email.transport.configure';
|
||||||
@@ -43,6 +50,15 @@ enum Capability: string
|
|||||||
// package (github.com/projectsend/cloud-modules), never in this repo.
|
// package (github.com/projectsend/cloud-modules), never in this repo.
|
||||||
case Branding = 'branding.customize';
|
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
|
// Cloud-only — managed installations supply CAPTCHA keys centrally, so
|
||||||
// protection is on before anybody finds the settings screen. The
|
// protection is on before anybody finds the settings screen. The
|
||||||
// feature itself is in both editions and behind no capability: this
|
// feature itself is in both editions and behind no capability: this
|
||||||
@@ -50,21 +66,55 @@ enum Capability: string
|
|||||||
// inside a self-hosted package.
|
// inside a self-hosted package.
|
||||||
case CaptchaManagedKeys = 'captcha.managed_keys';
|
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>
|
* @return list<Edition>
|
||||||
*/
|
*/
|
||||||
public function editions(): array
|
public function editions(): array
|
||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::UsersManage,
|
|
||||||
self::StorageConfigure,
|
self::StorageConfigure,
|
||||||
self::EmailTransportConfigure,
|
self::EmailTransportConfigure,
|
||||||
self::SystemUpdates,
|
self::SystemUpdates,
|
||||||
self::SchedulerMonitoring,
|
self::SchedulerMonitoring,
|
||||||
self::CustomAssets => [Edition::Community],
|
self::CustomAssets => [Edition::Community],
|
||||||
|
|
||||||
|
self::UsersManage => [Edition::Community, Edition::Cloud],
|
||||||
|
|
||||||
self::Branding,
|
self::Branding,
|
||||||
self::CaptchaManagedKeys => [Edition::Cloud],
|
self::StorageManaged,
|
||||||
|
self::CaptchaManagedKeys,
|
||||||
|
self::PlatformManaged,
|
||||||
|
self::AiConnector => [Edition::Cloud],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ namespace App\Modules\Platform\Http\Controllers;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Modules\Platform\Capabilities\Capability;
|
use App\Modules\Platform\Capabilities\Capability;
|
||||||
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||||
|
use App\Modules\Platform\Settings\Setting;
|
||||||
|
use App\Modules\Platform\Settings\Settings;
|
||||||
use App\Modules\Platform\System\SystemEnvironment;
|
use App\Modules\Platform\System\SystemEnvironment;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
@@ -32,6 +34,7 @@ class AboutController extends Controller
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CapabilityRegistry $capabilities,
|
private readonly CapabilityRegistry $capabilities,
|
||||||
private readonly SystemEnvironment $environment,
|
private readonly SystemEnvironment $environment,
|
||||||
|
private readonly Settings $settings,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function __invoke(Request $request): Response
|
public function __invoke(Request $request): Response
|
||||||
@@ -45,6 +48,35 @@ class AboutController extends Controller
|
|||||||
return Inertia::render('system/about', [
|
return Inertia::render('system/about', [
|
||||||
'license' => 'GNU General Public License v2',
|
'license' => 'GNU General Public License v2',
|
||||||
'environment' => $canSeeEnvironment ? $this->environment->toArray() : null,
|
'environment' => $canSeeEnvironment ? $this->environment->toArray() : null,
|
||||||
|
'updated' => $canSeeEnvironment ? $this->lastUpdate() : null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When `projectsend:update` last brought this installation to a new
|
||||||
|
* version.
|
||||||
|
*
|
||||||
|
* Both values are written by that command alone, and RunningCodeState
|
||||||
|
* has until now been their only reader — which means the fact was
|
||||||
|
* recorded and then only ever mentioned when something was *wrong*.
|
||||||
|
* On a healthy installation nothing said when it was last updated,
|
||||||
|
* which is the ordinary question of the two.
|
||||||
|
*
|
||||||
|
* Null on anything that has never been updated through the command: a
|
||||||
|
* fresh install, or one older than the command itself. There is no
|
||||||
|
* honest date to show there, and "unknown" is noise.
|
||||||
|
*
|
||||||
|
* @return array{version: string, at: string}|null
|
||||||
|
*/
|
||||||
|
private function lastUpdate(): ?array
|
||||||
|
{
|
||||||
|
$version = $this->settings->get(Setting::AppliedVersion);
|
||||||
|
$at = $this->settings->get(Setting::AppliedVersionAt);
|
||||||
|
|
||||||
|
if (! is_string($version) || $version === '' || ! is_string($at) || $at === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['version' => $version, 'at' => $at];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user