ProjectSend 2.0.0

Client file sharing, rebuilt from the ground up: a private area per
client, resumable uploads, folders, groups and categories, sharing with
expiry dates and download limits, comments, file versions, an activity
log, a REST API, and sixteen languages.

This repository begins here. ProjectSend 2 was developed privately, and
that development history is not published — the previous generation
remains available, with its own history, at projectsend/legacy.

Free software under the GNU General Public License v2, or (at your
option) any later version.
This commit is contained in:
ignacionelson
2026-08-14 01:38:12 -03:00
commit 0a46acb478
1065 changed files with 164519 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[docker-compose.yml]
indent_size = 4
+91
View File
@@ -0,0 +1,91 @@
APP_NAME=ProjectSend
PROJECTSEND_EDITION=community
# Emergency off switch for the CAPTCHA on public forms, for an operator who
# has a shell but no working login. Everything else about the feature is
# configured at /system/settings/captcha.
# PROJECTSEND_CAPTCHA_DISABLED=true
# Optional: uid/gid the app/web containers' internal user runs as, so the
# bind-mounted repo needs no permission fixes. Defaults to 1000; override
# if your host user's `id -u`/`id -g` differ.
# WWWUSER=1000
# WWWGROUP=1000
# Optional: create the first administrator unattended on container start.
# Leave unset to use the first-run setup screen instead.
# ADMIN_NAME="Administrator"
# ADMIN_EMAIL=admin@example.com
# ADMIN_PASSWORD=
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_TIMEZONE=UTC
APP_URL=http://localhost
# Required whenever a proxy/load balancer sits in front of this app (an
# ALB, Cloudflare, a hosted ingress) — otherwise every request looks like
# it comes from the proxy, collapsing per-IP rate limits and the download
# IP log. Comma-separated addresses/CIDRs, or "*" to trust any proxy
# (only safe when nothing but the proxy can reach the app).
# TRUSTED_PROXIES=
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=projectsend
DB_USERNAME=projectsend
DB_PASSWORD=secret
SESSION_DRIVER=redis
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis
CACHE_STORE=redis
CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
+10
View File
@@ -0,0 +1,10 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
CHANGELOG.md export-ignore
README.md export-ignore
Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

+61
View File
@@ -0,0 +1,61 @@
# Gates pull requests on CLA signature using contributor-assistant/github-action.
# Signatures are stored as a JSON file in a separate private repo — do NOT store
# them in this public repo, they contain contributor emails.
#
# Setup before enabling:
# 1. Create a private repo, e.g. projectsend/cla-signatures
# 2. Create a PAT with 'repo' scope that can write to it
# 3. Add it as a secret named PERSONAL_ACCESS_TOKEN in this repository
#
# The two github.com/projectsend/projectsend URLs below are shown to
# contributors when the bot asks them to sign. They must point at a repo an
# outside contributor can actually read.
name: CLA Assistant
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, closed, synchronize]
permissions:
actions: write
contents: read
pull-requests: write
statuses: write
jobs:
cla:
runs-on: ubuntu-latest
steps:
- name: CLA check
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
uses: contributor-assistant/github-action@v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
with:
path-to-signatures: 'signatures/version1/cla.json'
path-to-document: 'https://github.com/projectsend/projectsend/blob/main/CLA-INDIVIDUAL.md'
branch: 'main'
remote-organization-name: 'projectsend'
remote-repository-name: 'cla-signatures'
allowlist: dependabot[bot],renovate[bot],*[bot]
custom-notsigned-prcomment: |
Thanks for the pull request!
Before we can merge it, we need you to sign the Contributor License Agreement.
It's a one-time thing and takes about a minute — you keep the copyright in your
contribution, and it lets the project offer commercial licenses that fund
development of the free version. The reasoning is written out in
[CONTRIBUTING.md](https://github.com/projectsend/projectsend/blob/main/CONTRIBUTING.md#licensing-and-the-contributor-license-agreement).
Please read the **[CLA]($pathToCLADocument)**, then post exactly this as a comment
on this pull request:
custom-pr-sign-comment: 'I have read the CLA Document and I hereby sign the CLA'
custom-allsigned-prcomment: 'CLA signed — thanks. A maintainer will review this shortly.'
lock-pullrequest-aftermerge: false
+50
View File
@@ -0,0 +1,50 @@
name: linter
on:
push:
branches:
- develop
- main
pull_request:
branches:
- develop
- main
permissions:
contents: write
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
# community-modules resolves from its public GitHub repository (the vcs
# entry in composer.json); cloud-modules is not required. COMPOSER_AUTH
# just lifts the anonymous GitHub API rate limit for the fetch.
- name: Install Dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GITHUB_TOKEN }}"}}'
run: |
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
npm install
- name: Run Pint
run: vendor/bin/pint
- name: Format Frontend
run: npm run format
- name: Lint Frontend
run: npm run lint
# - name: Commit Changes
# uses: stefanzweifel/git-auto-commit-action@v5
# with:
# commit_message: fix code style
# commit_options: '--no-verify'
+91
View File
@@ -0,0 +1,91 @@
name: tests
on:
push:
branches:
- develop
- main
pull_request:
branches:
- develop
- main
jobs:
ci:
runs-on: ubuntu-latest
# This job has no MySQL/db service — it's SQLite-only (see
# phpunit.xml) — but .env.example's DB_CONNECTION=mysql/DB_HOST=db is
# for the local docker-compose stack. Bare artisan calls that run
# outside phpunit.xml's env (composer's package:discover,
# key:generate) would otherwise try to reach a "db" host that
# doesn't exist here. Same story for CACHE_STORE: every process
# boot reads a mail-config cache entry (PlatformServiceProvider::
# boot() -> MailConfigApplier::apply()), which needs a store that
# works without a migrated schema this early.
env:
CACHE_STORE: array
DB_CONNECTION: sqlite
# config/database.php's sqlite connection reuses DB_DATABASE for
# the file path — .env.example's DB_DATABASE=projectsend (the
# MySQL database name) would otherwise make sqlite look for a
# file literally named "projectsend".
DB_DATABASE: database/database.sqlite
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.4
tools: composer:v2
coverage: xdebug
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
# community-modules is the community edition's companion package,
# resolved from its public GitHub repository (the vcs entry in
# composer.json). A plain clone installs it with no local packages/
# checkout — cloud-modules is not required at all. COMPOSER_AUTH just
# hands composer the runner's token so the GitHub API calls it makes
# to fetch the package are not subject to the anonymous rate limit.
# composer install's post-autoload-dump script boots the app
# (package:discover), which needs the sqlite file to already exist
# even before .env is copied — config/database.php defaults to it.
- name: Create SQLite Database
run: touch database/database.sqlite
- name: Install PHP Dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GITHUB_TOKEN }}"}}'
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Install Node Dependencies
run: npm ci
# tsconfig.json maps the "ziggy-js" import to vendor/tightenco/ziggy,
# so this needs PHP deps installed first.
- name: Typecheck Frontend
run: npm run types
- name: Build Assets
run: npm run build
- name: Copy Environment File
run: cp .env.example .env
- name: Generate Application Key
run: php artisan key:generate
- name: Static Analysis
run: ./vendor/bin/phpstan analyse --no-progress
- name: Tests
run: ./vendor/bin/pest
+41
View File
@@ -0,0 +1,41 @@
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.fleet
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/bootstrap/ssr
/node_modules
/packages
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
auth.json
Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log
/.release-build
/compose.override.yaml
/compose.override.yml
# ── Maintainer-only working files ──
# Planning notes, local tooling and development fixtures are kept outside
# this repository, so these paths are ignored here rather than published.
# If docs/ looks sparser than you expect, that is why. The two entries
# below the ignore are application data the app reads at runtime and are
# part of the repository.
/.claude
/scripts/refresh-sim.sh
/database/seeders/DevDataSeeder.php
/docs/*.md
!/docs/api-guide.md
+43
View File
@@ -0,0 +1,43 @@
# Secret scanning config.
#
# docker run --rm -v "$PWD:/repo" -w /repo zricethezav/gitleaks:latest \
# detect --source /repo --log-opts="--all" --redact --no-banner
#
# `--log-opts="--all"` is the part that matters: it scans every commit on
# every ref, not just the working tree. Publishing a repository makes its
# entire history world-readable, so a credential that was committed once and
# removed the next day is still exposed — and still needs rotating, not just
# deleting.
#
# Run this before publishing anything, and treat any new finding as real
# until proven otherwise.
[extend]
useDefault = true
[[rules]]
id = "curl-auth-header"
[rules.allowlist]
description = """
The API guide documents authentication, so every example carries an
Authorization header. All of them are placeholders — YOUR_TOKEN, or the
psend_xxxx shape used to show what a real token looks like. Scanned and
confirmed clean 2026-08-09 across 347 commits.
This allowlist is deliberately narrow: it excuses the two placeholder
spellings in one file, not the rule, so a real token pasted into that same
guide would still be caught.
matchCondition = "AND" is what makes that true and must not be removed.
Gitleaks ORs allowlist conditions by default, so without it the `paths`
entry alone excuses every Authorization header in the file — including a
real one. Verified both ways: the placeholders pass, and an injected
token-shaped string is still reported.
"""
matchCondition = "AND"
paths = ['''docs/api-guide\.md''']
regexes = [
'''Bearer YOUR_TOKEN''',
'''Bearer \d+\|psend_x+''',
]
+1
View File
@@ -0,0 +1 @@
resources/js/components/ui/*
+18
View File
@@ -0,0 +1,18 @@
{
"semi": true,
"singleQuote": true,
"singleAttributePerLine": false,
"htmlWhitespaceSensitivity": "css",
"printWidth": 150,
"plugins": ["prettier-plugin-organize-imports", "prettier-plugin-tailwindcss"],
"tailwindFunctions": ["clsx", "cn"],
"tabWidth": 4,
"overrides": [
{
"files": "**/*.yml",
"options": {
"tabWidth": 2
}
}
]
}
+84
View File
@@ -0,0 +1,84 @@
# Changelog
What changed in each release of ProjectSend, written for the people running it.
Versions follow [SemVer](https://semver.org/): the middle number moves when there are new features,
the last one when there are only fixes, and the first one when an upgrade needs more from you than
dropping in the new files and running the migrations.
Anything under **Upgrade notes** is something you have to do, not something we did.
## Unreleased
This section collects changes as they land; the release process turns it into a numbered entry when
a version is cut.
## 2.0.0 — 2026-08-14
ProjectSend, rebuilt from the ground up. This is a new application rather than an update to the one
before it: it installs fresh, and an optional tool imports your existing site into it. The previous
generation continues to be available as ProjectSend Legacy.
Everything you already rely on came across — roles and granular permissions, folders, two-factor
authentication, LDAP, single sign-on, per-file download limits, S3-compatible storage, custom
fields, per-client quotas, zip downloads, resumable uploads, thumbnails, editable email templates,
client portal themes and the activity log. What follows is what is genuinely new. **If you are
running the previous version, read the upgrade notes first.**
### Added
- **A REST API.** Files, clients, groups and comments — and, on self-hosted installations, staff
accounts — with scoped tokens, documentation the application generates itself, and a usage
dashboard. There was previously no programmable surface at all.
- **Comments on files.** The conversation about a file lives on the file, with a per-comment choice
of who can see it and strict separation between clients.
- **File versions.** Mark an upload as replacing an earlier one and the two stay linked: the older
file is marked *Outdated* everywhere, sharing is inherited, and recipients are notified.
- **A notification centre.** A bell with an unread count on every screen for staff and clients
alike, a full history, and per-person control over what also arrives by email.
- **Folder sharing.** Share a folder with a client or group and everything inside follows, including
files added later.
- **Per-person timezones.** Every date reads in the timezone of whoever is looking at it, detected
once from their browser, instead of one setting for the whole installation.
- **Recoverability, and real erasure.** Deleted files and accounts can be restored. Self-service
account deletion runs a disclosed grace period and then a genuine erasure, activity log included.
- **Scoped staff access.** "Limit to assigned clients" narrows a staff member's entire view, not
just who they may upload to.
- **Sixteen languages, and a menu you curate.** Choose which languages your clients are offered and
which one everybody starts in.
- **Themed email.** Four independent email themes, with a live preview of a real message.
- **Guided setup.** A two-minute first run or fully unattended provisioning, a database that
migrates itself on boot, and upgrade instructions matching how you actually installed.
### Improved
- **Uploads survive the connection.** Pause and resume, retrying the missing pieces rather than the
whole file — and clients and API callers get the identical pipeline staff use.
- **Zip downloads no longer tie up the server.** Built in the background, with the folder structure
preserved inside, and still recorded against every file bundled.
- **The interface.** Dark mode, drag-and-drop moves, a details panel with sharing and history in
tabs, and the same search-and-filter toolbar on every list — with filters in the address bar, so a
view can be bookmarked or sent to a colleague.
- **A storage floor, not just ceilings.** A site-wide default quota that individual clients
override, so an installation with self-registration is no longer unbounded.
- **Categories your clients can read.** Flat, cross-cutting and colour-coded, shown everywhere the
file appears — including the client portal and public pages.
### Upgrade notes
- **This is not an in-place upgrade.** Install this release fresh, then bring your old site into it
with the migration tool. Nothing is ever written to your old installation, it keeps running
throughout, and any import can be undone with a single command.
- **Your clients will sign in with their email address**, not their username. Their existing
passwords keep working, so nobody has to reset anything.
- **Three things the previous version could do are not here yet:** IP allow and deny lists, email as
a second factor, and encryption at rest. If you depend on any of them, stay where you are for now.
- **Two accounts cannot share an email address.** The previous version allowed it, because it signed
people in by username. The import refuses to start and names the accounts to fix, rather than
silently merging two people into one.
- **Nested categories become flat.** Each keeps its full path as its name — "Clients / Acme /
Invoices" — so nothing is lost and no two categories collapse into one. Rename them afterwards if
you would rather.
- **Files encrypted at rest, or held on external cloud storage, are not imported.** They are listed
for you individually before the run starts, and skipped rather than guessed at.
+144
View File
@@ -0,0 +1,144 @@
# ProjectSend Entity Contributor License Agreement
**Version 1.0**
This agreement is for organizations whose employees or contractors contribute to ProjectSend.
If you are contributing as an individual on your own behalf, sign the Individual CLA instead.
This Agreement is between Ignacio Nelson, the original author and maintainer of ProjectSend
("the Licensor"), and the entity identified below
("You" or "Your"). It clarifies the intellectual property rights granted with Contributions made
by Your employees, contractors, and designated contributors.
**Your organization keeps the copyright in its Contributions.** This is a license, not a transfer
of ownership.
---
## 1. Definitions
**"You"** (or **"Your"**) means the corporation or other legal entity signing this Agreement,
together with all other entities that control, are controlled by, or are under common control
with that entity. For this definition, "control" means (i) the power, direct or indirect, to
cause the direction or management of such entity, whether by contract or otherwise, (ii)
ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership
of such entity.
**"Contribution"** means any original work of authorship, including any modifications or additions
to an existing work, that is or has been intentionally submitted by You to the Licensor for
inclusion in, or documentation of, any of the products owned or managed by the Licensor (the
"Work"). "Submitted" means any form of electronic, verbal, or written communication sent to the
Licensor or its representatives, including but not limited to communication on electronic mailing
lists, source code control systems, and issue tracking systems that are managed by, or on behalf
of, the Licensor for the purpose of discussing and improving the Work — but excluding
communication that is conspicuously marked or otherwise designated in writing by You as
"Not a Contribution."
**For the avoidance of doubt, this Agreement applies to all Contributions made on Your behalf —
those submitted after signing it and those submitted before**, including any contributions made
to ProjectSend prior to the existence of this Agreement.
## 2. Grant of Copyright License
Subject to the terms and conditions of this Agreement, You hereby grant to the Licensor and to
recipients of software distributed by the Licensor a perpetual, worldwide, non-exclusive,
no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of,
publicly display, publicly perform, sublicense, and distribute Your Contributions and such
derivative works.
You further grant to the Licensor the right to license Your Contributions, and derivative works
of Your Contributions, under any license terms of the Licensor's choosing. This includes, without
limitation, open source licenses other than the one under which the Work is currently
distributed, and commercial or proprietary licenses.
You retain all right, title, and interest in and to Your Contributions.
## 3. Grant of Patent License
Subject to the terms and conditions of this Agreement, You hereby grant to the Licensor and to
recipients of software distributed by the Licensor a perpetual, worldwide, non-exclusive,
no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make,
have made, use, offer to sell, sell, import, and otherwise transfer the Work. This license
applies only to those patent claims licensable by You that are necessarily infringed by Your
Contributions alone or by combination of Your Contributions with the Work to which such
Contributions were submitted.
If any entity institutes patent litigation against You or any other entity (including a
cross-claim or counterclaim in a lawsuit) alleging that a Contribution, or the Work to which You
have contributed, constitutes direct or contributory patent infringement, then any patent
licenses granted to that entity under this Agreement for that Contribution or Work shall
terminate as of the date such litigation is filed.
## 4. Your Representations
You represent that:
a. You are legally entitled to grant the above licenses.
b. Each employee or contractor of Yours designated on Schedule A below is authorized to submit
Contributions on Your behalf.
c. Each Contribution is an original creation of the designated contributor, or You have obtained
the necessary rights to submit it under the terms of this Agreement.
d. To the best of Your knowledge, no Contribution violates any third party's copyrights,
trademarks, patents, or other intellectual property rights.
## 5. Third-Party Materials
Should You wish to submit work that is not an original creation, You may submit it separately
from any Contribution, identifying the complete details of its source and of any license or other
restriction of which You are aware, and conspicuously marking the work as
"Submitted on behalf of a third party: [named here]".
## 6. Disclaimer
Unless required by applicable law or agreed to in writing, You provide Your Contributions on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
## 7. No Obligation
The decision to include any Contribution in any product or source repository is entirely at the
discretion of the Licensor.
## 8. Notification
You agree to notify the Licensor of any facts or circumstances of which You become aware that
would make the representations in this Agreement inaccurate in any respect, and to keep
Schedule A current.
## 9. Successors and Assigns
The Licensor may assign this Agreement, and the rights granted under it, to any successor in
interest to the ProjectSend project.
## 10. Governing Law
This Agreement is governed by the laws of the Argentine Republic, without regard to its conflict
of law provisions. Any dispute arising from or relating to this Agreement shall be submitted to
the ordinary courts of the Autonomous City of Buenos Aires, Argentina.
---
## Signature
| Field | Value |
|---|---|
| Entity name | |
| Registered address | |
| Point of contact | |
| Email | |
| Signed by (name and title) | |
| Signature | |
| Date | |
## Schedule A — Designated Contributors
List each employee or contractor authorized to submit Contributions on Your behalf. Send updates
to <contact@projectsend.org>.
| Full name | Email | GitHub username |
|---|---|---|
| | | |
| | | |
| | | |
+133
View File
@@ -0,0 +1,133 @@
# ProjectSend Individual Contributor License Agreement
**Version 1.0**
Thank you for contributing to ProjectSend. This agreement clarifies the intellectual property
rights granted with Contributions from any person or entity. It protects You as a Contributor,
protects the Project, and protects everyone who relies on ProjectSend.
**You keep the copyright in Your Contribution.** This is a license, not a transfer of ownership.
You grant Ignacio Nelson, the original author and maintainer of ProjectSend ("the Licensor"),
broad permission to use and license Your Contribution,
including the right to distribute it under licenses other than the project's current open source
license — which is what makes it possible to offer ProjectSend to organizations that cannot
accept copyleft terms, and to fund continued development of the free version.
By submitting a Contribution to the Project, You accept and agree to the following terms.
---
## 1. Definitions
**"You"** (or **"Your"**) means the individual who submits a Contribution to the Licensor.
**"Contribution"** means any original work of authorship, including any modifications or
additions to an existing work, that is or has been intentionally submitted by You to the Licensor
for inclusion in, or documentation of, any of the products owned or managed by the Licensor
(the "Work"). "Submitted" means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to communication on electronic
mailing lists, source code control systems, and issue tracking systems that are managed by, or
on behalf of, the Licensor for the purpose of discussing and improving the Work — but excluding
communication that is conspicuously marked or otherwise designated in writing by You as
"Not a Contribution."
**For the avoidance of doubt, this Agreement applies to all of Your Contributions — those You
submit after signing it and those You submitted before**, including any contributions made to
ProjectSend prior to the existence of this Agreement.
## 2. Grant of Copyright License
Subject to the terms and conditions of this Agreement, You hereby grant to the Licensor and to
recipients of software distributed by the Licensor a perpetual, worldwide, non-exclusive,
no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of,
publicly display, publicly perform, sublicense, and distribute Your Contribution and such
derivative works.
You further grant to the Licensor the right to license Your Contribution, and derivative works of
Your Contribution, under any license terms of the Licensor's choosing. This includes, without
limitation, open source licenses other than the one under which the Work is currently
distributed, and commercial or proprietary licenses.
You retain all right, title, and interest in and to Your Contribution. Nothing in this Agreement
restricts Your ability to use, license, or otherwise exploit Your Contribution for any purpose.
## 3. Grant of Patent License
Subject to the terms and conditions of this Agreement, You hereby grant to the Licensor and to
recipients of software distributed by the Licensor a perpetual, worldwide, non-exclusive,
no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make,
have made, use, offer to sell, sell, import, and otherwise transfer the Work. This license
applies only to those patent claims licensable by You that are necessarily infringed by Your
Contribution alone or by combination of Your Contribution with the Work to which such
Contribution was submitted.
If any entity institutes patent litigation against You or any other entity (including a
cross-claim or counterclaim in a lawsuit) alleging that Your Contribution, or the Work to which
You have contributed, constitutes direct or contributory patent infringement, then any patent
licenses granted to that entity under this Agreement for that Contribution or Work shall
terminate as of the date such litigation is filed.
## 4. Your Representations
You represent that:
a. You are legally entitled to grant the above licenses.
b. Each of Your Contributions is Your original creation, or You have the necessary rights to
submit it under the terms of this Agreement.
c. If Your employer has rights to intellectual property that You create — including anything You
create as part of Your employment or using Your employer's resources — You have received
permission to make the Contribution on behalf of that employer, that Your employer has waived
such rights, or that Your employer has executed the ProjectSend Entity Contributor License
Agreement.
d. Your Contribution does not, to the best of Your knowledge, violate any third party's
copyrights, trademarks, patents, or other intellectual property rights.
## 5. Third-Party Materials
Should You wish to submit work that is not Your original creation, You may submit it separately
from any Contribution, identifying the complete details of its source and of any license or other
restriction of which You are personally aware, and conspicuously marking the work as
"Submitted on behalf of a third party: [named here]".
## 6. Disclaimer
Unless required by applicable law or agreed to in writing, You provide Your Contributions on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including,
without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or
FITNESS FOR A PARTICULAR PURPOSE.
## 7. No Obligation
You understand that the decision to include Your Contribution in any product or source repository
is entirely at the discretion of the Licensor, and this Agreement does not obligate the Licensor
to include Your Contribution in any product.
## 8. Notification
You agree to notify the Licensor of any facts or circumstances of which You become aware that
would make the representations in this Agreement inaccurate in any respect.
## 9. Successors and Assigns
The Licensor may assign this Agreement, and the rights granted under it, to any successor in
interest to the ProjectSend project, including in connection with a reorganization, transfer of
stewardship, or sale of assets.
## 10. Governing Law
This Agreement is governed by the laws of the Argentine Republic, without regard to its conflict
of law provisions. Any dispute arising from or relating to this Agreement shall be submitted to
the ordinary courts of the Autonomous City of Buenos Aires, Argentina.
---
## How to sign
Contributions to ProjectSend are gated by an automated CLA check. When You open Your first pull
request, a bot will comment with a link. Signing takes one click and is recorded against Your
GitHub account.
Questions before signing: <contact@projectsend.org>
+184
View File
@@ -0,0 +1,184 @@
# Contributing to ProjectSend
ProjectSend has been maintained since 2011 and is used by freelancers, agencies, NGOs, schools
and government offices around the world. Thanks for helping keep it good.
## Ways to contribute
- **Report a bug** — open an [issue](https://github.com/projectsend/projectsend/issues) with how you
run ProjectSend (Docker or manual install), the version, and steps to reproduce.
- **Report a security vulnerability** — do **not** open a public issue. Report it privately via
[GitHub security advisories](https://github.com/projectsend/projectsend/security/advisories/new).
- **Suggest a feature** — start a [discussion](https://github.com/projectsend/projectsend/discussions)
first. It saves everyone time to agree on the shape of a thing before code is written.
- **Translate** — translations live in this repo as JSON files under [lang/](lang/). Fixing or
completing a locale is a pull request like any other.
- **Write code** — read the rest of this document first.
## Before you write code
For anything beyond a small fix, open an issue or discussion first. Large pull requests that
arrive without prior discussion are hard to review and often need rework.
## Setting up for development
The quickest way to a running copy is Docker — the steps that install ProjectSend are also the
steps that set it up for development. From a fresh clone:
```sh
cp .env.example .env
docker compose up -d
docker compose exec app composer install
docker compose exec app php artisan key:generate
docker compose exec app php artisan migrate
npm install && npm run build # or `npm run dev` for hot reload
```
The app is then at `http://localhost:8090`. Until a staff user exists every request redirects to the
first-run setup screen, which creates the initial administrator. For unattended provisioning you can
instead set `ADMIN_NAME` / `ADMIN_EMAIL` / `ADMIN_PASSWORD` in `.env` before starting (the container
creates the account on boot, idempotently), or run
`docker compose exec app php artisan projectsend:admin` at any time.
A few things worth knowing:
- Self-hosted installs run the community edition — `PROJECTSEND_EDITION=community` in `.env`.
- `APP_PORT` / `ADMINER_PORT` / `DB_PORT_FORWARD` override the ports if they clash with something
you already run.
- Adminer, a database GUI, is available for development with
`docker compose --profile dev up -d adminer`.
- On later boots the container migrates automatically. The manual `migrate` above is only needed on
the first install, before `vendor/` exists.
**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:
staff are created by an administrator. Client self-registration is optional and can require
approval.
You don't need anything private to work on ProjectSend. The `community-modules` companion package
is public and Composer fetches it for you; the paid Cloud package is not required by the core.
### Before you open a pull request
Run the same checks CI runs, and make sure they pass:
```sh
docker compose exec app ./vendor/bin/pest # tests
docker compose exec app ./vendor/bin/phpstan # static analysis (level 8)
docker compose exec app ./vendor/bin/pint # code style
npm run types # TypeScript
npm run lint # ESLint
```
If you touched the frontend, `npm run build` must also succeed.
## Pull requests
1. Fork the repo and branch off `main`.
2. One logical change per pull request.
3. Run the checks above and keep the existing code style.
4. Write a clear description: what changes, why, and how you tested it.
5. If your change affects the database schema, include the migration.
6. If your change affects user-facing strings, use the existing translation helpers
(`t()` / `__()`) — never hardcode English.
## Licensing and the Contributor License Agreement
ProjectSend is free software licensed under the **GNU General Public License v2, or (at your
option) any later version**. See [LICENSE](LICENSE). That isn't changing.
Before your first pull request can be merged, you'll need to sign a Contributor License Agreement.
A bot will comment on your pull request with a link; signing takes one click and is recorded
against your GitHub account. You only do this once, and it covers everything you've contributed
to ProjectSend — past and future.
- Contributing on your own behalf → [Individual CLA](CLA-INDIVIDUAL.md)
- Contributing as part of your job, or on behalf of a company → [Entity CLA](CLA-ENTITY.md)
### Why a CLA?
CLAs are contentious in open source and the concern behind that is legitimate, so we'd rather
explain this properly than bury it.
**You keep the copyright in your contribution.** The CLA is a license, not a transfer of
ownership. You can continue to use, relicense, or sell your own code however you want. Nothing
you grant here takes anything away from you.
**It lets the project fund itself.** The CLA gives the maintainers the right to also distribute
the code under other terms — specifically, a commercial license for organizations whose legal
departments can't accept the GPL, and who want to embed ProjectSend in a product of their own.
That revenue, along with managed hosting at [projectsend.cloud](https://projectsend.cloud), is
what pays for continued development of the free, self-hosted version everyone uses. See
[LICENSING.md](LICENSING.md).
**It keeps our options open.** Without a CLA, changing ProjectSend's license in the future —
even to a newer version of the GPL — would require tracking down every contributor from the past
fifteen years and getting individual permission. We're not planning a license change. But we'd
rather not be permanently unable to make one.
### What we commit to in return
- **ProjectSend's core stays under an OSI-approved open source license.** Not source-available,
not BSL, not SSPL.
- **Nothing that is free today will ever move behind a paid tier.**
- **The self-hosted version will never be crippleware.** No artificial caps on users, clients,
files or storage designed to push you toward the hosted plan.
- **Every release ever published stays available under the license it was published under.**
**Being straight with you about what we do sell:** we operate ProjectSend Cloud, and it has
features the self-hosted core doesn't. [LICENSING.md](LICENSING.md) sets out exactly where that
line is and what stays in the core permanently. If you'd rather your work not go into a project
that funds itself this way, that's a fair call, and we'd rather you know before you contribute
than after.
If you're not comfortable signing, that's a reasonable position. Open an issue describing the bug
or the design instead — that's a real contribution too, and it doesn't require any agreement.
### A note on AI-assisted code
If you used an AI coding assistant, that's fine, but you're still making the representations in
the CLA: that the contribution is your original work and that you have the right to submit it.
Review what you submit and understand it well enough to maintain it.
## Third-party code
If your pull request includes code you didn't write, say so explicitly in the description and
name the source and its license. Don't quietly paste in a snippet from Stack Overflow or another
project — it creates real problems for everyone downstream.
## How the code is organised
A modular monolith under `app/Modules/` — Identity, Clients, Groups, Files, Sharing, Storage,
Notifications, Comments, Audit, Platform and friends. Modules talk to each other through public
service classes and events rather than reaching into each other's internals.
Two conventions are worth knowing before your first pull request:
- **File bytes never travel through PHP.** Downloads are served by the web server itself
(`X-Accel-Redirect`) or straight from object storage, so a large download does not occupy a PHP
worker.
- **Behaviour that differs between installations flows through one registry**
(`app/Modules/Platform/Capabilities/`) rather than scattered conditionals. Enforcement derives
from it in three places: route middleware, the UI (via the `useCapability()` hook) and the API,
which answers `403 capability_unavailable`.
## Translations
No user-facing string is hardcoded. **English text is the translation key** — `__('Save changes')`
in PHP, `t('Save changes')` in React — so English needs no catalogue and a missing translation
falls back to English rather than breaking. Every other language is one file: `lang/{locale}.json`.
Framework strings live in `lang/{locale}/*.php` and come from [laravel-lang](https://laravel-lang.com).
**Write your feature in English, and leave the translating to a separate pass.** Shipping should
never wait on a language nobody in the room speaks, and the cost of a lagging translation is low
while the cost of a rushed one is not. Translations are brought up to date on a cadence, and always
before a release.
Adding a language is adding a file: a locale is installed exactly when `lang/{locale}.json` exists.
Corrections from native speakers are especially welcome — most of the current catalogues have not
yet been read by one.
## Questions
- [Discussions](https://github.com/projectsend/projectsend/discussions)
- <contact@projectsend.org>
+247
View File
@@ -0,0 +1,247 @@
# Running ProjectSend with Docker: where your data lives
Docker is the recommended way to run ProjectSend, and this page is about the one part of it that
bites people: **your files and your database do not belong to the containers, and you should be
able to prove it.** Containers are meant to be thrown away and rebuilt — that is the whole point of
them — so an upgrade, a crash, or a bad `docker compose` command must never be able to take your
data with it.
Read this before you put real files in ProjectSend, not after.
> Getting started with Docker in the first place is covered in [README](README.md#development).
> Installing without Docker, on a plain PHP server, is [INSTALL.md](INSTALL.md).
---
## The three things that matter
Everything ProjectSend cannot regenerate lives in exactly three places:
| What | Where it is by default | Losing it means |
|---|---|---|
| **The database** | A Docker *named volume*, `projectsend_db-data` | Everything except the files themselves: accounts, groups, permissions, share links, comments, the activity log |
| **Uploaded files** | `storage/app/files/` in the project directory | The files your clients downloaded — gone |
| **`.env`** | The project directory | `APP_KEY`, without which saved SMTP and LDAP passwords cannot be decrypted |
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
the container's own filesystem — and warns you about the last two. The database is the one thing it
cannot check: it runs in its own container, and the only way for PHP to see inside that one would be
to hand it the Docker socket, which would turn any vulnerability in the application into root on
your server. That half is on you, and it is what the backup section below is for.
Two things you may be surprised to find you do **not** need to protect:
- **Redis** (`projectsend_redis-data`) holds sessions, the cache and the job queue. Losing it signs
everyone out and drops any not-yet-sent emails or half-built zips. Annoying; not data loss.
- **Parts of `storage/app/files/`** are derived, not precious: `zips/` (built downloads, deleted
automatically after a day), `thumbnails/` and `previews/` (rebuilt on demand the next time
somebody looks at a file). They sit in the same directory as the real uploads, so the simplest
thing is to back up all of it and not think about which is which.
## The good news, and the one command to fear
Named volumes are already outside the container lifecycle. `docker compose down`,
`docker compose up --build`, deleting and recreating every container — none of those touch
`projectsend_db-data`. Upgrading does not lose your database, and never did.
The command that *does* destroy it is:
```sh
docker compose down -v # ← the -v deletes the named volumes
```
That flag exists to clean up a development machine. On a real installation it deletes your entire
database in about a second, with no confirmation. The same goes for `docker volume prune` and
`docker system prune --volumes` when the stack happens to be down.
So the actual problem with the default setup is not fragility, it is **invisibility**: your
database is somewhere under `/var/lib/docker/volumes/`, which means most people never back it up
and would not know where to look. The rest of this page fixes that.
---
## Putting the data where you chose
Bind-mount both to real paths on the host, so your data sits somewhere you picked, somewhere you
can see in `ls`, and somewhere your existing backup tool already knows about.
### 1. Make the directories
```sh
sudo mkdir -p /srv/projectsend/files /srv/projectsend/mysql
# The app containers run as uid 1000 by default (the WWWUSER build argument).
# If you set WWWUSER to something else in .env, use that instead.
sudo chown -R 1000:1000 /srv/projectsend/files
```
Leave `/srv/projectsend/mysql` owned by root — the MySQL image sets its own ownership the first
time it starts.
### 2. Create `compose.override.yaml`
Next to `compose.yaml`. Docker Compose reads this file automatically and merges it on top, so you
never edit the tracked `compose.yaml` and nothing you write here is lost on the next update.
```yaml
services:
# All four app containers must see the same files directory. Missing one of
# them is the classic mistake: uploads land in one place and downloads are
# served from another, so every download 404s. `web` is the one people
# forget — nginx serves the bytes itself, from
# /var/www/html/storage/app/files/, so it needs the mount just as much as
# the container that wrote them.
app:
volumes:
- /srv/projectsend/files:/var/www/html/storage/app/files
web:
volumes:
- /srv/projectsend/files:/var/www/html/storage/app/files
worker:
volumes:
- /srv/projectsend/files:/var/www/html/storage/app/files
scheduler:
volumes:
- /srv/projectsend/files:/var/www/html/storage/app/files
db:
volumes:
- /srv/projectsend/mysql:/var/lib/mysql
```
Check the result before applying it — this prints the fully merged configuration:
```sh
docker compose config
```
### 3. Move the data you already have
**Skip this on a brand-new installation.** There is nothing to move; go straight to step 4.
Stop everything first. Copying a database out from under a running MySQL is how you get a backup
that restores into a corrupt table.
```sh
docker compose down # no -v
```
Files, which are already on the host inside the project directory:
```sh
sudo rsync -a storage/app/files/ /srv/projectsend/files/
sudo chown -R 1000:1000 /srv/projectsend/files
```
The database, which is in the named volume. A throwaway container is the tidy way to reach inside
one:
```sh
docker run --rm \
-v projectsend_db-data:/from \
-v /srv/projectsend/mysql:/to \
alpine sh -c 'cd /from && cp -a . /to'
```
(`projectsend_db-data` is the volume's real name — the `db-data` from `compose.yaml` prefixed with
the project name. `docker volume ls` will confirm it.)
### 4. Start, and check
```sh
docker compose up -d
```
Then prove it worked rather than assuming: log in and check the dashboard's System panel — **Files
stored on** should now read *Host directory*, and the Docker-volume warning should be gone. Then
open a file, **download it**, and upload a new one; confirm the new upload appears in
`/srv/projectsend/files/` on the host. A download that returns nothing means one of the four
containers is missing the mount from step 2.
Once you are satisfied, and not before, you can reclaim the old volume:
```sh
docker volume rm projectsend_db-data
```
---
## Backing up
Bind mounts make your data visible. They do not make it backed up.
### The database
**Do not back up the MySQL directory by copying it while the database is running.** A file-level
copy of a live data directory is not a snapshot — it is a set of files captured at slightly
different moments, and it may restore into something subtly broken. Use a dump:
```sh
docker compose exec -T db \
mysqldump -u root -p"${DB_ROOT_PASSWORD:-root}" \
--single-transaction --routines --triggers \
projectsend > projectsend-$(date +%F).sql
```
`--single-transaction` is what makes this safe on a running database: the dump sees one consistent
moment in time without locking anybody out.
### The files
```sh
rsync -a /srv/projectsend/files/ /your/backup/location/files/
```
Ordinary files, no special handling. Restoring means copying them back and fixing ownership
(`chown -R 1000:1000`).
### `.env`
Copy it somewhere safe, once, and again whenever you change it. It is a few hundred bytes and it
holds `APP_KEY` — lose that and the SMTP and LDAP passwords stored in your database become
undecryptable, even though the rest of the backup is perfect.
### Restoring
```sh
docker compose up -d db
docker compose exec -T db mysql -u root -p"${DB_ROOT_PASSWORD:-root}" projectsend < projectsend-2026-08-08.sql
sudo rsync -a /your/backup/location/files/ /srv/projectsend/files/
sudo chown -R 1000:1000 /srv/projectsend/files
docker compose up -d
```
**Test this at least once, on a machine that is not your live one.** A backup nobody has ever
restored is a hypothesis, not a backup.
---
## Upgrading
With the data outside the containers, an upgrade touches only the containers:
```sh
docker compose down # again: no -v
git pull # or unpack the new release over the directory
docker compose up -d --build
```
The app container migrates the database itself on boot and verifies its reference data, so there is
no separate migration 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.
---
## Moving to another server
This is the payoff for everything above, and it is worth doing once deliberately so you know it
works:
1. Dump the database and copy `/srv/projectsend/`, `.env` and the dump to the new machine.
2. Install Docker, put the project directory in place, restore both as described under
[Restoring](#restoring).
3. Point DNS at the new machine, and update `APP_URL` in `.env` if the address changed.
No export tool, no vendor involvement, nothing that only works while the old machine is alive.
That is the property worth protecting, and the reason this page exists.
+493
View File
@@ -0,0 +1,493 @@
# Installing ProjectSend
This guide is for installing ProjectSend **manually on your own server**, from the `.zip` file
published with each release.
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
for you. See [the Docker instructions](README.md#development), and
[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.
The whole thing takes about ten minutes. You will need shell access to the server and the ability
to create a database — this is not an install you can do over FTP alone.
---
## What you need
| | |
|---|---|
| **PHP** | 8.4 or newer, both the command-line PHP and PHP-FPM |
| **PHP extensions** | `bcmath` `ctype` `curl` `dom` `fileinfo` `filter` `gd` `iconv` `intl` `json` `ldap` `mbstring` `openssl` `pcntl` `pdo_mysql` `session` `simplexml` `tokenizer` `zip` |
| **Database** | MySQL 8.0 or newer (we test on 8.4 LTS) |
| **Web server** | **nginx**, with PHP-FPM — see the note below |
| **Disk space** | The app itself is small; plan for whatever your users will upload |
A few notes on that list:
- **`ldap` is required even if you never use LDAP.** One of the libraries ProjectSend depends on
declares it, so PHP will refuse to start the app without it. On Debian/Ubuntu it is
`php8.4-ldap`; on RHEL-family systems, `php-ldap`.
- **nginx is not a preference, it is a requirement.** See [Why nginx](#why-nginx) — it is worth
two minutes of reading before you commit to a server, because Apache cannot be made to work by
configuring it differently.
- **Redis is optional.** The Docker setup uses it, but a manual install works fine with the
database for sessions, cache and queues. If you already have Redis, see
[Optional extras](#optional-extras) below.
### Why nginx
Your uploaded files do not live under `public/`. They sit in `storage/app/files/`, outside the web
root, where no URL can reach them — which is the whole point: a file is only yours to download if
ProjectSend says so, and a file sitting in a guessable public folder has already lost that
argument.
So every download has to pass through a permission check. The obvious way to do that is to let PHP
read the file and echo it back to the browser, and that is what most PHP applications do. It works,
and it is a bad idea at any real size: a single 5 GB download occupies a PHP process for its entire
duration, so a handful of people downloading at once can exhaust every worker your server has while
the CPU sits idle. Resumable downloads, byte ranges and progress bars all have to be reimplemented
by hand, usually incorrectly.
ProjectSend does the other thing. PHP checks permissions, logs the download, and then answers with
an empty response carrying a header that says *"nginx, please send this file."* nginx streams the
bytes with the same code it uses for any static file — sendfile, byte ranges, resume support, no
PHP process held open — and the visitor never sees the real path. The header is
`X-Accel-Redirect`, and the matching `location /protected-files/` block in
[step 6](#step-6--point-your-web-server-at-it) is marked `internal`, which is what stops anyone
from requesting that path directly.
**Apache has no equivalent that ProjectSend can use.** Apache's closest feature, `mod_xsendfile`,
reads a differently-named header (`X-Sendfile`) that ProjectSend does not send, and it is not
installed by default anyway. LiteSpeed has its own third spelling. On any of them the application
installs fine and every page works — you can log in, upload, manage clients, browse the library —
but **every download returns an empty response or a 404**, because nothing is listening for the
instruction PHP just gave. There is no setting to change; the header names simply do not match.
Two ways out, if nginx really is impossible on your hosting:
- Put nginx in front of Apache as a reverse proxy, serving `/protected-files/` itself. This works
but is more moving parts than just using nginx.
- Store your files in S3-compatible object storage instead (see
[Storing files somewhere other than this server](#storing-files-somewhere-other-than-this-server)).
Files kept there are never on your server's disk, so downloads become a signed, expiring redirect
to the storage provider and the web server is not involved at all. This is a genuine, supported
path — just decide it before people start uploading, not after.
---
## Step 1 — Put the files on the server
Download `projectsend-x.y.z.zip` from the
[releases page](https://github.com/projectsend/projectsend/releases), upload it to your server, and
unpack it where you want the site to live:
```sh
cd /var/www
unzip projectsend-2.0.0.zip -d projectsend
cd projectsend
```
The release zip is ready to run — you do **not** need Composer, Node, or npm. Everything the app
needs is already inside it.
> **Important:** point your web server at the `public/` folder inside this directory, never at the
> directory itself. Everything above `public/` — your configuration, your database credentials,
> your users' uploaded files — is meant to be unreachable from the web. Step 6 covers this.
## Step 2 — Create the database
Create an empty database and a user that owns it. From the MySQL shell:
```sql
CREATE DATABASE projectsend CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'projectsend'@'localhost' IDENTIFIED BY 'a-long-random-password';
GRANT ALL PRIVILEGES ON projectsend.* TO 'projectsend'@'localhost';
FLUSH PRIVILEGES;
```
Leave the database empty. ProjectSend creates its own tables in step 5.
## Step 3 — Tell ProjectSend about your server
Copy the example configuration and open it in an editor:
```sh
cp .env.example .env
```
`.env` is a plain list of `NAME=value` lines. The example file is written for the Docker setup, so
there is a fair amount to change. Here is a complete, working configuration for a manual install —
paste it over the top of the file, then change the addresses and passwords to yours:
```ini
APP_NAME="ProjectSend"
APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=https://files.example.com
APP_TIMEZONE=UTC
PROJECTSEND_EDITION=community
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=projectsend
DB_USERNAME=projectsend
DB_PASSWORD=a-long-random-password
SESSION_DRIVER=database
CACHE_STORE=database
QUEUE_CONNECTION=database
FILESYSTEM_DISK=local
```
The four that actually matter:
- **`APP_URL`** — the address people will type to reach your site, including `https://`. Links in
emails are built from this, so getting it wrong means broken links in every notification.
- **`APP_DEBUG=false`** — leave it off. With debug on, an error page will show visitors parts of
your configuration.
- **`DB_*`** — what you created in step 2.
- **`APP_KEY`** — leave it empty for now; step 5 fills it in.
The three `database` lines mean sessions, cache and background jobs all live in MySQL, so there is
nothing else to install. If you have Redis, see [Redis](#redis) — but do the install first.
If your site is served over HTTPS, also add:
```ini
SESSION_SECURE_COOKIE=true
```
And if there is a proxy, load balancer or CDN (Cloudflare, an nginx in front of another nginx)
between your visitors and this server, add `TRUSTED_PROXIES` too — the `.env.example` file explains
the format. Without it every visitor appears to come from the proxy, which breaks per-visitor rate
limiting and makes the download log useless.
You can ignore the mail settings for now — email is configured from inside the app once you are
logged in. See [Sending email](#sending-email).
## Step 4 — Set the file permissions
ProjectSend writes to two folders: `storage/` (uploaded files, logs, sessions, cache) and
`bootstrap/cache/`. Both need to be writable by the user your web server runs as — usually
`www-data`, sometimes `nginx`.
```sh
sudo chown -R www-data:www-data /var/www/projectsend
sudo chmod -R 775 /var/www/projectsend/storage /var/www/projectsend/bootstrap/cache
```
## Step 5 — Prepare the application
Three commands. Run them from the install directory, as the web server's user, so that everything
they create ends up with the right owner:
```sh
sudo -u www-data php artisan key:generate
sudo -u www-data php artisan migrate --force
sudo -u www-data php artisan storage:link
```
What they do: the first generates the secret key used to encrypt sessions and cookies (it writes
itself into your `.env`); the second creates all the database tables; the third makes public assets
like your logo reachable from the web.
> Keep a copy of `APP_KEY` with your backups. Anything encrypted with it — including saved
> credentials for your mail server — cannot be read back without it.
## Step 6 — Point your web server at it
A complete nginx server block. Change `server_name`, and change `/var/www/projectsend` to wherever
you unpacked the files (there are **three** places, including one inside `/protected-files/`):
```nginx
server {
listen 80;
server_name files.example.com;
root /var/www/projectsend/public;
index index.php;
client_max_body_size 100m;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# How downloads are served: ProjectSend checks permissions, then asks
# nginx to send the file. This block must NOT be reachable directly —
# "internal" is what guarantees that, so do not remove it.
location /protected-files/ {
internal;
alias /var/www/projectsend/storage/app/files/;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "sandbox; default-src 'none'" always;
}
location ~ \.php$ {
try_files $uri =404;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_buffer_size 32k;
fastcgi_buffers 8 32k;
}
location ~ /\.(?!well-known) {
deny all;
}
}
```
Then check your PHP settings. Large uploads are sent in 20 MB pieces, so PHP never has to handle a
whole 5 GB file at once — but the pieces still need room. In your `php.ini`:
```ini
upload_max_filesize = 100M
post_max_size = 100M
memory_limit = 256M
```
Reload both services:
```sh
sudo nginx -t && sudo systemctl reload nginx
sudo systemctl restart php8.4-fpm
```
Set up HTTPS while you are here — [certbot](https://certbot.eff.org/) issues a free certificate and
edits the nginx config for you.
## Step 7 — Create your administrator
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.
Fill it in, and you are done — sign in and start adding clients.
If you would rather not do it in the browser (or you are scripting the install), the same thing
from the command line:
```sh
sudo -u www-data php artisan projectsend:admin
```
---
## Two things to finish
The app runs without these, but parts of it will quietly not work. Both take a minute.
### The background worker
Sending email and building zip archives of multiple files happen in the background, so nobody sits
watching a spinner. Something has to actually run that work. Create
`/etc/systemd/system/projectsend-worker.service`:
```ini
[Unit]
Description=ProjectSend queue worker
After=network.target
[Service]
User=www-data
Group=www-data
Restart=always
WorkingDirectory=/var/www/projectsend
ExecStart=/usr/bin/php artisan queue:work --tries=3 --backoff=3
[Install]
WantedBy=multi-user.target
```
Then:
```sh
sudo systemctl enable --now projectsend-worker
```
**Without this, no email is ever sent** and zip downloads never finish. `Restart=always` matters
too: saving your email settings restarts the worker so it picks up the new values, and it needs to
come back on its own.
### The scheduled tasks
A handful of daily housekeeping jobs — deleting expired files, cleaning up abandoned uploads,
checking for new ProjectSend versions. Add one line to the web user's crontab
(`sudo crontab -u www-data -e`):
```cron
* * * * * cd /var/www/projectsend && php artisan schedule:run >> /dev/null 2>&1
```
Yes, every minute. ProjectSend decides internally what is actually due; the cron entry just gives
it a heartbeat.
To check that both of these are working, log in and open **System → Settings → Scheduler**.
It lists every task, when it last ran and whether it succeeded — along with any background job that
failed. If the page says nothing has ever run, your cron line is not firing.
---
## Optional extras
### Sending email
Log in, go to **System → Settings → Email**, and enter your SMTP server's details there. That is
the place to configure it — the settings screen also has a "send test email" button, which will
save you a lot of guessing. The `MAIL_*` values in `.env` are only used until you fill that screen
in.
### Redis
If you have Redis available, it is faster than the database for sessions, cache and queues. Install
the `redis` PHP extension and change three lines in `.env`:
```ini
SESSION_DRIVER=redis
CACHE_STORE=redis
QUEUE_CONNECTION=redis
```
Restart PHP-FPM and the background worker afterwards.
Add `REDIS_HOST`, `REDIS_PORT` and `REDIS_PASSWORD` if it is not a default local install. Restart
the worker afterwards.
### Storing files somewhere other than this server
Out of the box, uploads live in `storage/app/files/` on this machine. You can point ProjectSend at
S3-compatible object storage instead from **System → Settings → Storage** — useful when the files
outgrow the server's disk.
### Making it faster
For a busy install, let PHP pre-compile the app's routes and views:
```sh
sudo -u www-data php artisan route:cache
sudo -u www-data php artisan view: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
three.
#### One command to skip: `config:cache`
Every Laravel deployment guide on the internet lists `php artisan config:cache` alongside those
three, and `php artisan optimize` runs it for you. **Don't** — not on this application.
Here is why. Caching the configuration writes every resolved setting into one PHP file, and from
then on the framework stops reading your `.env` at all, on the entirely reasonable grounds that
everything in it has already been baked in. That holds for settings read the normal way, through
`config()`. ProjectSend reads one value earlier than that — `TRUSTED_PROXIES`, which has to be
known before the middleware stack is assembled, so it is read straight from the environment. Cache
the config and that read returns nothing.
Nothing breaks loudly. The site comes up, you log in, everything looks fine. But if there is a
proxy or CDN in front of the server, ProjectSend goes back to believing every visitor is the proxy:
the login rate limiter now counts all of your users as one attacker and locks the whole site out
after five wrong passwords, and every row in the download log records the proxy's address instead
of the person who actually downloaded the file. Both are the kind of thing you discover weeks
later, from a complaint.
If you have already run it — or ran `php artisan optimize`, which includes it — `php artisan
config:clear` puts things back immediately. 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
1. **Back up first** — the database, the `.env` file, and `storage/app/files/`. Every time.
2. Put the site in maintenance mode: `sudo -u www-data php artisan down`
3. Unpack the new zip over the install directory. Keep your `.env` and your `storage/` folder —
the zip does not contain either, but check your unzip tool is not helpfully deleting things.
4. Run the updates:
```sh
sudo -u www-data php artisan migrate --force
sudo -u www-data php artisan projectsend:ensure-roles
sudo -u www-data php artisan optimize:clear
sudo -u www-data php artisan queue:restart
```
5. Bring it back: `sudo -u www-data php artisan up`
`projectsend:ensure-roles` teaches the built-in roles about any permissions the new version added.
It never touches permissions you have customised yourself.
---
## When something goes wrong
**A page that says "ProjectSend is not configured yet."**
This is not an error — it is ProjectSend telling you which setup step is still missing. You reached
the site before creating your `.env` (step 3) or before generating `APP_KEY` (step 5). The page
names the exact command to run; do that, then reload.
**Every page is blank, or shows a 500 error.**
Look in `storage/logs/` — open the newest file, the real error is at the bottom. Nine times out of ten it is
folder permissions (step 4) or a wrong database password (step 3).
**"Please provide a valid cache path" or "failed to open stream".**
`storage/` or `bootstrap/cache/` is not writable by the web server user. Step 4.
**`php artisan` says "Table 'sessions' doesn't exist".**
Something reached the database before `migrate` created its tables. Finish step 5 in order —
`key:generate`, then `migrate`, then `storage:link`.
**Every address redirects me to the setup screen.**
That is correct behaviour until the first administrator exists. Finish step 7. If you have already
created one and it still happens, ProjectSend cannot reach your database — check `storage/logs/`.
**Pages load but downloads give a 404, or download a 0-byte file.**
The `/protected-files/` block is missing from your nginx config, or its `alias` path does not match
where you installed ProjectSend. It must point at `storage/app/files/` and end with a slash. If you
are on Apache or LiteSpeed, no configuration will fix this — see [Why nginx](#why-nginx).
**Uploads fail partway through.**
`client_max_body_size` in nginx, or `upload_max_filesize` / `post_max_size` in `php.ini`, is
smaller than a 20 MB upload piece. Step 6.
**No email arrives, and the test email button says it worked.**
"It worked" means it was queued, not delivered. The background worker is not running — see
[The background worker](#the-background-worker).
**The CAPTCHA is stopping people signing in, and I cannot get in to switch it off.**
It should not be able to: a wrong secret key or an unreachable provider both let people through and
report the problem on the settings screen instead. If you are stuck anyway, add
`PROJECTSEND_CAPTCHA_DISABLED=true` to `.env` and run `php artisan optimize:clear`, or run
`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.
**Emails and links point at `localhost` or the wrong domain.**
`APP_URL` in `.env`. Fix it and run `php artisan optimize:clear`.
**A change I made in `.env` has no effect.**
Run `php artisan optimize:clear`, then restart PHP-FPM and the worker. Both hold the old values
until they are restarted. If it *still* has no effect, someone has run `php artisan config:cache`
(or `optimize`) on this install — see [One command to skip](#one-command-to-skip-configcache).
**Everyone is locked out of the login form at once, or the download log shows the same IP for
every download.**
ProjectSend is seeing your proxy or CDN instead of your visitors. Set `TRUSTED_PROXIES` in `.env`
(step 3) — and make sure `config:cache` has not been run, which stops that value from being read
at all. Same section as above.
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
the newest file in `storage/logs/` — it is almost always the fastest way to an answer.
+338
View File
@@ -0,0 +1,338 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Moe Ghoul>, 1 April 1989
Moe Ghoul, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
+106
View File
@@ -0,0 +1,106 @@
# ProjectSend Licensing
ProjectSend is available under two licenses. Pick whichever fits.
## 1. GNU GPL v2 — free, for everyone
The default, and the license ProjectSend has used since 2011. Free of charge, forever, with the
four freedoms intact: run it, study it, modify it, share it. See [LICENSE](LICENSE) for the full
text. Formally the grant is "GPLv2, or at your option, any later version" — the "or later"
option is there because some bundled dependencies (such as the AWS SDK) are Apache-2.0 licensed,
which combines cleanly with GPLv3 terms but not with GPLv2-only.
**If you're self-hosting ProjectSend to share files with your own clients, this is you, and there
is nothing to think about.** Using the software — even commercially, even inside a large company,
even with paying clients — triggers no obligations. You don't have to publish anything.
The GPL asks for reciprocity in one situation: **if you distribute a modified version**, you ship
your changes' source alongside it, under the GPL. Running it on your own server isn't
distribution.
## 2. Commercial license — for when the GPL doesn't fit
Some organizations can't work under copyleft terms. Common cases:
- You want to **embed** ProjectSend inside a closed-source product you sell.
- You want to **white-label** it as part of a commercial offering and distribute it to customers
without publishing your modifications.
- Your legal or procurement department has a blanket policy against GPL dependencies in shipped
products.
- You need **warranties or indemnification**, which no open source license provides.
A commercial license removes the GPL's source-sharing obligations. It's the same software.
Contact <contact@projectsend.org> with a short description of what you're building and how you
plan to distribute it, and we'll come back with terms.
---
## What's in the core, and what's in ProjectSend Cloud
We run [ProjectSend Cloud](https://projectsend.cloud), a hosted version. It has features the
self-hosted core doesn't. Rather than let you find that out one feature announcement at a time,
here's the line we draw and the commitments that go with it.
**The principle:** if a capability makes ProjectSend work for one organization on its own server,
it belongs in the core. If it only exists because we run installations on other people's behalf,
it belongs in Cloud.
| | |
|---|---|
| **Always in the free core** | Client accounts and groups · file assignment and expiration · client uploads · download tracking and activity logs · categories and folders · themes and custom branding · two-factor authentication · role-based permissions · S3-compatible storage · public links · translations · the extension API |
| **Cloud only** | Billing and subscriptions · automated provisioning · managed backups and restore · infrastructure monitoring and uptime SLA · managed email deliverability · cross-organization administration |
**Our commitments:**
1. Nothing that is free today will ever move behind a paid tier.
2. No artificial limits in the self-hosted version — no caps on users, clients, files or storage
designed to push you toward the hosted plan. Self-hosted ProjectSend is a complete product,
not a demo.
3. Cloud features are built on a public extension API. The same API is available to you, so
anything we can build as a first-party extension, you can build too.
4. When we add a paid feature, we'll say so plainly in the release notes rather than letting you
discover it.
**Where it gets genuinely gray:** enterprise-oriented capabilities like SAML/SSO, advanced audit
exports, and long-horizon retention policies would work fine self-hosted, and we may build some
of them for paying customers. We're not going to pretend otherwise. What we won't do is take
something out of the core to put it there.
---
## Frequently asked questions
**I run ProjectSend on my own server for my clients. Do I owe anything?**
No. Self-hosting for your own use — including commercial use, including with paying clients —
carries no obligation. You don't have to publish anything and you don't need a commercial license.
**I made changes to my installation. Now what?**
If you're not distributing that modified version to anyone else, nothing. Keep your changes to
yourself if you want. If you do hand out copies, they come with the source under the GPL. Either
way, we'd rather you upstream them.
**Can I resell ProjectSend hosting?**
Yes. The GPL doesn't stop anyone from offering ProjectSend as a hosted service, including in
competition with us.
**Why is there a CLA if the license isn't changing?**
Two reasons. It's what lets us offer the commercial license described above — without rights to
every contributor's code, we can't license the whole thing to anyone. And it means we're not
permanently locked out of ever updating the project's license, which today would require
tracking down fifteen years of contributors. We have no license change planned. See
[CONTRIBUTING.md](CONTRIBUTING.md) for the full reasoning and what we commit to in exchange.
**Are you going to switch to AGPL / BSL / SSPL?**
No plans. If that ever changes it'll be proposed publicly and discussed before anything happens,
not announced after the fact. And in any case, every release published under the GPLv2 stays
GPLv2 permanently — that can't be revoked and we wouldn't want to.
**Do I need a commercial license to contribute?**
No. Contributions are welcome under the GPL. See [CONTRIBUTING.md](CONTRIBUTING.md).
---
*Nothing on this page is legal advice. If your situation is complicated, talk to a lawyer — and
if you tell us what you're trying to do, we'll usually be able to tell you quickly whether you
need a commercial license or not.*
+355
View File
@@ -0,0 +1,355 @@
# Moving from ProjectSend Legacy (v1)
This guide takes an existing **ProjectSend Legacy** install — the r2098-era PHP app, the one whose
database tables are named `tbl_users`, `tbl_files` and so on — and brings its accounts, clients,
groups, categories, folders, files and history into a new **ProjectSend** install.
It is done by a separate tool, [`projectsend/v1-migration-tool`](https://github.com/projectsend/v1-migration-tool),
which you install into your new install when you want it and remove when you are done. It is
deliberately not built in: migrating happens once, if ever, and the engine it needs — arbitrary
database connections and direct writes across the whole schema — is not something every install
should carry idle.
**Your old install is never written to.** Not a marker row, not a lock file, not a maintenance
flag. It keeps running exactly as it did while you try the migration, look at the result, undo it,
adjust and try again. Nothing about this is a one-way door until you decide it is.
The whole thing takes about twenty minutes on a small install. Large ones take longer to plan than
to run — see [Large installs](#large-installs).
---
## Read this part first
Three things decide whether this will go smoothly, and all three are easier to deal with now than
halfway through.
### Your clients will sign in with their email address
Legacy signs in with a **username**. ProjectSend signs in with an **email address**. Every client's
login therefore changes.
Their **passwords do not** — the hashes come across as they are, so nobody gets a reset email and
nobody is forced to pick a new password. Only the thing they type in the first box changes.
The exception is an account whose stored hash ProjectSend cannot read: a blank password, or one
left by a Legacy install old enough to predate the hashing it uses now. Those accounts could not
sign into Legacy either, and they cannot be repaired — the password itself is long gone. The tool
names them before the run and counts them in the report; those people use **Forgot password** once,
after which their account behaves like any other.
Legacy also did not require email addresses to be unique, because it never signed in with them. The
tool refuses to start until duplicates, blanks and invalid addresses are fixed in your Legacy
install, and it names the accounts. That refusal is on purpose: picking a winner between two
accounts sharing an address is deciding which of your clients loses access, and that is not a
decision a tool should make quietly.
### The new install has to be empty
Set up, but not used. Create the administrator, then migrate — don't upload files or add clients
first. There are no merge semantics, and "empty" is exactly what makes the undo trustworthy.
### A background worker has to be running
A real import outlives any web request. The screen queues a job and polls; without a worker the run
sits at "pending" forever. In Docker the worker container is already running. On a manual install,
see [the worker section of INSTALL.md](INSTALL.md#the-background-worker).
---
## What comes across
Everything in this list, in this order — it follows the dependency graph, so accounts exist before
the things that point at them:
| | |
|---|---|
| **Settings** | The ~43 Legacy options that have a ProjectSend equivalent, plus your mail/SMTP configuration |
| **CAPTCHA keys** | Every reCAPTCHA and Turnstile key pair you had, and which service was switched on. They arrive encrypted, where Legacy kept them in plain text |
| **Roles and permissions** | Including the per-role permission sets |
| **Accounts** | Staff and clients, with their password hashes, disk quotas and custom fields |
| **Groups** | Members and pending membership requests |
| **Categories** | Flattened — see the note below |
| **Folders** | The tree, and who each folder is assigned to |
| **Files** | The rows *and* the bytes, with their descriptions, expiry dates, public flags and download limits |
| **Assignments** | Which clients and groups each file was shared with, and its categories |
| **History** | Every download, and the activity log |
**Categories are flattened.** Legacy nested them; ProjectSend does not. Every category that had a
parent takes its whole ancestry as its name — `Clients / Acme / Invoices` — and a root category
keeps its bare name. Nothing merges and nothing is dropped, so no file loses a tag.
**Download counts come with the downloads.** If a file had a download limit of 3 in Legacy and had
already been taken twice, it arrives here with one download left, not three.
### What does not, and why
The tool reports each of these before it starts and names every affected row. It never guesses.
| | |
|---|---|
| **Files encrypted at rest** | ProjectSend has no at-rest encryption, and the per-file keys are wrapped by a master key that exists only in Legacy's `sys.config.php` |
| **Files on S3, GCS or Azure** | Legacy configured external storage per file; ProjectSend has one bucket for everything |
| **Hidden assignments** | ProjectSend has no hidden state, and creating the assignment anyway would show people files that were hidden from them |
| **Two-factor secrets** | Encrypted with Legacy's key. Those users re-enrol |
| **Email templates** | The placeholder vocabulary is different, so importing them verbatim produces emails with broken tokens — worse than starting from the defaults |
| **Legacy options with no equivalent** | ProjectSend has ~43 settings where Legacy had ~180 |
| **Thumbnails** | A derived cache. ProjectSend regenerates them on first request |
---
## Step 1 — Install the tool
On the **new** install:
```sh
composer require projectsend/v1-migration-tool
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
host).
> While the repository is private, `composer require` needs to be told where to find it — add a
> `vcs` entry to your `composer.json` `repositories` and give Composer credentials for the repo:
>
> ```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
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 below can also be done entirely from the command line; the equivalent commands are
listed at each step.
---
## Step 2 — Pick your route
| Your situation | Use |
|---|---|
| Legacy and ProjectSend are on the **same machine** | [**Direct**](#step-3a--direct-same-machine) |
| Legacy is on **another server**, or on hosting you cannot reach from the new box | [**Bundle**](#step-3b--bundle-different-machines) |
Direct is faster and simpler, and on a single filesystem it does not copy your files at all — it
hardlinks them, so 400 GB migrates in seconds and both installs point at the same bytes until you
decide otherwise. Use it if you can.
---
## Step 3a — Direct (same machine)
Point the tool at your Legacy install directory. It reads the database credentials out of
`includes/sys.config.php` itself, so there is usually nothing to type but the path:
```sh
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:
| `--files=` | What it does |
|---|---|
| `hardlink` | A second directory entry for the same bytes. Instant, costs no disk, leaves Legacy completely intact. Only works within one filesystem, and falls back to copying across a boundary rather than failing |
| `copy` | **Default.** The only strategy that is always correct, and it checksums what it writes as it writes it |
| `move` | Takes the bytes out of Legacy. Fast and frees disk — and **cannot be undone** |
| `defer` | Writes no bytes at all. For importing the database now and moving half a terabyte overnight |
If ProjectSend runs in Docker, the Legacy directory has to be visible **inside the app container**
— bind-mount it there, and use the container's path, not the host's.
---
## Step 3b — Bundle (different machines)
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
package at `bin/projectsend-v1-export.php`.
On the **Legacy** server:
```sh
php projectsend-v1-export.php --preflight # look before you leap
php projectsend-v1-export.php --out=/tmp/ps-export # write the bundle
```
It searches upwards from itself for `includes/sys.config.php`; pass `--install=/var/www/projectsend`
if you put it somewhere else. It never writes to the Legacy install.
**In the `linuxserver/projectsend` container**, where the app lives at `/app/www/public`, uploads
are a symlink to `/data/projectsend` and the config is a symlink to
`/config/projectsend/sys.config.php`:
```sh
docker exec projectsend php /app/www/public/projectsend-v1-export.php --out=/data/ps-export
```
That lands the bundle on the host's own `/data` bind mount. Both symlinks are followed; nothing
else is needed.
### Bundles and file bytes
By default the exporter records an **inventory** of every file — path and size — without moving a
byte. That is the only sane choice above a few gigabytes, and it means the bundle is small enough
to copy anywhere.
Move the files separately, at your own pace, into a `files/` directory inside the bundle:
```sh
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
`--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,
the path *inside* the app container if you are using Docker):
```sh
php artisan projectsend:migrate:preflight --bundle=/srv/ps-export
```
---
## Step 4 — Read the preflight
Preflight changes nothing. It reports what it found — how many accounts, files, downloads and
activity rows — and separates its findings into three kinds:
- **Blockers.** Duplicate, blank or invalid email addresses; a schema mismatch. The run will not
start until these are fixed.
- **Acknowledgements.** Things with no equivalent here, from the list above. You confirm you have
read them; the run then skips those rows and lists them in its report.
- **Notes.** For information — file rows whose bytes are already gone from the Legacy disk, for
instance, which is common on old installs.
Fix the blockers **in your Legacy install** (that is the one place a duplicate email can be
resolved by a human who knows which client is which), then run preflight again.
---
## Step 5 — Import
Press the button, or:
```sh
php artisan projectsend:migrate:import --v1-path=/var/www/projectsend-legacy --files=hardlink
php artisan projectsend:migrate:import --bundle=/srv/ps-export
```
Add `--accept-skips` to acknowledge the findings from step 4 non-interactively.
The screen shows progress per phase and keeps working if you close the tab — the run is a database
row, not a browser session. If the worker is restarted mid-import it picks up where it left off;
chunks are checkpointed, and rows already written are never written twice.
History is imported last on purpose. It is the largest part by an order of magnitude, and it is the
one part you could reasonably abandon halfway with everything that matters already in.
---
## Step 6 — Check it
```sh
php artisan projectsend:migrate:verify
```
Every entity is checked as an equation: imported + deliberately skipped = what Legacy had. A number
that does not add up is reported as a bug in the tool, not as a warning about your data. It also
checks that imported files actually have bytes at the path they claim — the check that catches a
transfer failing quietly, which is the difference between a migration and a database full of broken
download links.
Then look at it yourself. Sign in, open the file library, open a client's portal, download
something.
---
## Undoing it
```sh
php artisan projectsend:migrate:reset
```
Every run records exactly what it created, so this puts the install back to how it was — including
anything that existed before the run, which is left alone. Try the migration, look at the result,
adjust, run it again.
The one exception is `--files=move`, which took the bytes out of your Legacy install. Deleting the
rows afterwards leaves them nowhere. The command tells you this before it does anything, not after.
---
## Cutting over
Once you are satisfied:
1. **Tell your clients their login changed** — email address instead of username, same password. The
run report gives you the list of who and what, including the handful (if any) whose password
could not be read and who need **Forgot password** once.
2. Keep the Legacy install running read-only for a while if you can. Nothing was taken from it
(unless you used `--files=move`), so it stays a working reference.
3. Remove the tool:
```sh
php artisan projectsend:migrate:reset --drop # also drops the tool's own tables
composer remove projectsend/v1-migration-tool
```
`--drop` throws away the Legacy → ProjectSend id map. **Keep it** if you may ever want to redirect
old `download.php?id=…` links, because it is the only thing that can resolve them. Removing the
package without `--drop` leaves the two tables behind harmlessly.
---
## Large installs
The awkward cases are not the complicated ones, they are the big ones: 200,000 small files, or
400 GB of one client's raw footage, or five million rows of download history. Three options exist
for exactly that.
| | |
|---|---|
| `--files=hardlink` | The 400 GB case. No bytes are copied; both installs point at the same inodes. Same filesystem only |
| `--files=defer` | Import the database tonight, `rsync` the files over the next two days, attach them afterwards |
| `--history=none` | Skip downloads and the activity log entirely. Everything that matters is still imported; you lose the counts and the log |
`--history=none` has one visible consequence worth knowing: a file that carried a download limit
arrives with a **full** allowance, because the downloads that had already been spent against it are
what the count is derived from.
Two practical notes for big runs:
- Give the queue worker room. One worker is enough; it is one long job, not many.
- On a bundle, the file inventory (`--files=inventory`, the default) is what keeps the export
itself small enough to move.
---
## When something goes wrong
**"This install already has content."** The tool imports into a fresh ProjectSend only. Either use a
genuinely new install, or `php artisan projectsend:migrate:reset` if the content came from a
previous run of the tool itself.
**The run sits at "pending" and nothing happens.** No queue worker. See
[INSTALL.md](INSTALL.md#the-background-worker).
**"No manifest.json in … — this is not a ProjectSend v1 export bundle."** The path points at the
wrong directory, or at the parent of the bundle rather than the bundle.
**A schema mismatch at preflight.** The tool declares every table and column it writes and checks
them before it starts, so this stops the run rather than failing halfway through 200,000 rows.
Update ProjectSend to a version that has what the tool expects — or update the tool.
**Files import but download as 404.** The bytes did not move. Run
`php artisan projectsend:migrate:verify`, which checks precisely this. A common cause in Docker is
a Legacy directory that is visible on the host but not inside the app container.
**Uploads and database seem to vanish after a Docker restart.** That is not the migration — read
[DOCKER.md](DOCKER.md) before putting real files into any Docker install.
+122
View File
@@ -0,0 +1,122 @@
<p align="center">
<img src="public/favicon.svg" alt="" width="84">
</p>
<h1 align="center">ProjectSend</h1>
<p align="center">
<strong>Share files with your clients, from your own server.</strong>
</p>
<p align="center">
<a href="LICENSE"><img alt="License: GPL v2 or later" src="https://img.shields.io/badge/license-GPLv2%2B-3b5bdb"></a>
<img alt="PHP 8.4+" src="https://img.shields.io/badge/PHP-8.4%2B-777bb4">
<img alt="Self-hosted" src="https://img.shields.io/badge/self--hosted-Docker%20or%20zip-0b7285">
</p>
---
ProjectSend is a self-hosted application for getting files to the people you work with. You upload
what you want to send, choose exactly who can see it, and each client signs in to their own private
page to download it.
No public link passed around by email, no third-party service holding your clients' documents, no
per-seat pricing. It runs on your server, and the files stay there.
## What it does
**For the people you send to**
- A private area per client, showing only what has been shared with them
- Sign in with an email address, with optional two-factor authentication
- Search, filter and sort their files; download one, several as a zip, or a whole folder
- Optional comments on a file, so questions live next to the thing they are about
- Email notifications when something new arrives, in their own language
**For you**
- Resumable uploads that survive a dropped connection, so large files actually arrive
- Organise with folders, categories and client groups
- Share with one client, a whole group, or publicly — and set an expiry date or a download limit
- Thumbnails and previews for images and documents
- Storage quotas per client, and custom fields for the details you need to keep on them
- A full activity log and download history: who got what, and when
**For the installation**
- Roles and permissions for your own team, so an uploader is not an administrator
- Sign-in the way you already work: LDAP, social sign-in, or plain email and password
- Themes for the client-facing pages and for outgoing email
- 16 languages
- A REST API with scoped tokens and generated OpenAPI docs
- Privacy controls, including GDPR-grade account erasure with a grace period
- Local disk or S3-compatible storage
## Screenshots
<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">
</p>
<p align="center"><em>The dashboard — what is in the installation, and what has been happening in it.</em></p>
<p align="center">
<img src=".github/screenshots/files.png" alt="The file library, showing folders and files with thumbnails, sharing status and download counts" width="900">
</p>
<p align="center"><em>Your library — folders, categories, and who each file is shared with.</em></p>
<p align="center">
<img src=".github/screenshots/portal.png" alt="A client's own page, listing the files shared with them with download buttons" width="900">
</p>
<p align="center"><em>What your client sees — only their files, nothing else.</em></p>
## Getting started
**With Docker** — the quickest path, and the one we recommend.
```sh
git clone https://github.com/projectsend/projectsend.git
cd projectsend
cp .env.example .env # set PROJECTSEND_EDITION=community
docker compose up -d
```
The app is at `http://localhost:8090`, and the first thing it shows you is a setup screen that
creates your administrator account.
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
take them with it.
**Without Docker** — for servers where it isn't an option, install from a release zip.
**[INSTALL.md](INSTALL.md)** covers requirements, `.env`, nginx, the background worker and cron,
updating and troubleshooting. You do not need Composer or npm on the server; the zip ships ready to
run.
## Coming from ProjectSend Legacy?
The previous generation of ProjectSend lives on at
[projectsend/legacy](https://github.com/projectsend/legacy). This is a rebuild rather than an
upgrade, so moving across is an import rather than an update — install fresh, then bring your old
site into it with the
[migration tool](https://github.com/projectsend/v1-migration-tool): accounts, clients, groups,
categories, folders, files and history.
It never writes to your old install, and any run can be undone with a single command.
**[MIGRATING-FROM-V1.md](MIGRATING-FROM-V1.md)** explains what comes across, the two routes
(same machine, or a portable export from a server you can't reach), and the one change your clients
will notice: they sign in with their email address now, using the same password.
## Contributing
Bug reports, translations and pull requests are all welcome — see
**[CONTRIBUTING.md](CONTRIBUTING.md)** for how to set up a development copy, what the checks are,
and the contributor agreement.
Found a security issue? Please report it privately through GitHub's security advisories rather than
opening a public issue.
## License
Free software under the **GNU General Public License v2, or (at your option) any later version** —
see [LICENSE](LICENSE). Use it, study it, change it, share it.
Commercial licenses are available for organizations that cannot work under copyleft terms;
[LICENSING.md](LICENSING.md) explains both options. Contributions require signing a CLA, for reasons
set out in [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use Inertia\Response;
class AuthenticatedSessionController extends Controller
{
/**
* Show the login page.
*/
public function create(Request $request): Response
{
return Inertia::render('auth/login', [
'canResetPassword' => Route::has('password.request'),
'canRegister' => app(Settings::class)->get(Setting::ClientsCanRegister) === true,
'status' => $request->session()->get('status'),
]);
}
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
{
if ($request->authenticate()) {
return redirect()->route('two-factor.challenge');
}
$request->session()->regenerate();
return redirect()->intended(route('dashboard', absolute: false));
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class ConfirmablePasswordController extends Controller
{
/**
* Show the confirm password page.
*/
public function show(): Response
{
return Inertia::render('auth/confirm-password');
}
/**
* Confirm the user's password.
*/
public function store(Request $request): RedirectResponse
{
$user = $request->user();
assert($user !== null);
if (! Auth::guard('web')->validate([
'email' => $user->email,
'password' => $request->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
$request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(route('dashboard', absolute: false));
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EmailVerificationNotificationController extends Controller
{
/**
* Send a new email verification notification.
*/
public function store(Request $request): RedirectResponse
{
$user = $request->user();
assert($user !== null);
if ($user->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false));
}
$user->sendEmailVerificationNotification();
return back()->with('status', 'verification-link-sent');
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class EmailVerificationPromptController extends Controller
{
/**
* Show the email verification prompt page.
*/
public function __invoke(Request $request): Response|RedirectResponse
{
$user = $request->user();
assert($user !== null);
return $user->hasVerifiedEmail()
? redirect()->intended(route('dashboard', absolute: false))
: Inertia::render('auth/verify-email', ['status' => $request->session()->get('status')]);
}
}
@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class NewPasswordController extends Controller
{
/**
* Show the password reset page.
*/
public function create(Request $request): Response
{
return Inertia::render('auth/reset-password', [
'email' => $request->email,
'token' => $request->route('token'),
]);
}
/**
* Handle an incoming new password request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'token' => 'required',
'email' => 'required|email',
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function ($user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
if ($status == Password::PasswordReset) {
return to_route('login')->with('status', __($status));
}
throw ValidationException::withMessages([
'email' => [__($status)],
]);
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Modules\Platform\Captcha\CaptchaForm;
use App\Support\Rules;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class PasswordResetLinkController extends Controller
{
/**
* Show the password reset link request page.
*/
public function create(Request $request): Response
{
return Inertia::render('auth/forgot-password', [
'status' => $request->session()->get('status'),
]);
}
/**
* Handle an incoming password reset link request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'email' => ['required', 'email'],
...Rules::captcha(CaptchaForm::PasswordReset),
]);
Password::sendResetLink(
$request->only('email')
);
return back()->with('status', __('A reset link will be sent if the account exists.'));
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
$user = $request->user();
assert($user !== null);
if ($user->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
if ($user->markEmailAsVerified()) {
/** @var MustVerifyEmail $verified */
$verified = $user;
event(new Verified($verified));
}
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}
@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Inertia\Response;
class PasswordController extends Controller
{
/**
* Show the user's password settings page.
*/
public function edit(Request $request): Response
{
return Inertia::render('settings/password', [
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
'status' => $request->session()->get('status'),
]);
}
/**
* Update the user's password.
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$user = $request->user();
assert($user !== null);
$user->update([
'password' => Hash::make($validated['password']),
]);
// Changing a password is how someone reacts to a session they think
// is stolen, so it has to actually end that session. AuthenticateSession
// (registered on the web group) compares each request's stored
// password hash against the current one and logs out on mismatch;
// this re-stamps the current session so the person doing the change
// stays signed in while every other session falls over on its next
// request.
Auth::logoutOtherDevices($validated['password']);
app(ActivityLogger::class)->log(Action::PasswordUpdated, $user);
return back();
}
}
@@ -0,0 +1,121 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\ProfileUpdateRequest;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientFieldContext;
use App\Modules\Clients\ClientPortalCustomFields;
use App\Modules\Platform\Localization\TimezoneRegistry;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Inertia\Response;
class ProfileController extends Controller
{
public function __construct(
private readonly ClientPortalCustomFields $customFields,
private readonly TimezoneRegistry $timezones,
) {}
/**
* Show the user's profile settings page.
*/
public function edit(Request $request): Response
{
$user = $request->user();
assert($user !== null);
return Inertia::render('settings/profile', [
'mustVerifyEmail' => $user instanceof MustVerifyEmail,
'status' => $request->session()->get('status'),
// Resolved, so the picker shows the zone dates are actually
// being rendered in — which for most people is the one their
// browser was detected as, not something they ever chose.
'timezone' => $this->timezones->resolve($user),
'timezones' => $this->timezones->options(),
'custom_fields' => $user->isClient() ? $this->customFields->rows(ClientFieldContext::AccountEdit, $user) : [],
'custom_field_values' => $user->isClient() ? $this->customFields->values(ClientFieldContext::AccountEdit, $user) : [],
]);
}
/**
* The account-deletion screen.
*
* Its own page rather than a block under the profile form: this is
* the one irreversible action a person can take on themselves, and it
* should be somewhere you navigate to on purpose instead of somewhere
* you scroll past on the way to saving your email address. The delete
* itself still goes to destroy() below.
*/
public function deleteAccount(): Response
{
return Inertia::render('settings/delete-account', [
'erasureGraceDays' => (int) app(Settings::class)->get(Setting::AccountErasureGraceDays),
]);
}
/**
* Update the user's profile settings.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$user = $request->user();
assert($user !== null);
$validated = $request->validated();
$customFieldValues = $validated['custom_field_values'] ?? [];
unset($validated['custom_field_values']);
$user->fill($validated);
if ($user->isDirty('email')) {
$user->email_verified_at = null;
}
$user->save();
if ($user->isClient()) {
$this->customFields->save($user, ClientFieldContext::AccountEdit, $customFieldValues);
}
app(ActivityLogger::class)->log(Action::ProfileUpdated, $user);
return to_route('profile.edit');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validate([
'password' => ['required', 'current_password'],
]);
$user = $request->user();
assert($user !== null);
Auth::logout();
// Self-deletion: soft delete now, permanent GDPR erasure after
// the disclosed grace period (Setting::AccountErasureGraceDays).
$graceDays = (int) app(Settings::class)->get(Setting::AccountErasureGraceDays);
$user->forceFill(['erase_after' => now()->addDays($graceDays)])->save();
$user->delete();
app(ActivityLogger::class)->log(Action::UserDeleted, $user, context: ['name' => $user->name]);
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}
@@ -0,0 +1,241 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use App\Modules\Comments\Access\VisibleCommentScope;
use App\Modules\Groups\Models\MembershipRequest;
use App\Modules\Identity\Passwords\PasswordPolicy;
use App\Modules\Identity\Permissions\Permission;
use App\Modules\Identity\Permissions\PermissionChecker;
use App\Modules\Identity\Social\SocialSettings;
use App\Modules\Identity\UserType;
use App\Modules\Notifications\InAppNotification;
use App\Modules\Platform\Attribution\Attribution;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use App\Modules\Platform\Captcha\Captcha;
use App\Modules\Platform\Installation\Installation;
use App\Modules\Platform\Localization\LocaleRegistry;
use App\Modules\Platform\Localization\TimezoneRegistry;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Updates\LatestReleaseInfo;
use Illuminate\Foundation\Inspiring;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
/**
* The root template that's loaded on the first page visit.
*
* @see https://inertiajs.com/server-side-setup#root-template
*
* @var string
*/
protected $rootView = 'app';
/**
* Determines the current asset version.
*
* @see https://inertiajs.com/asset-versioning
*/
public function version(Request $request): ?string
{
return parent::version($request);
}
/**
* Define the props that are shared by default.
*
* @see https://inertiajs.com/shared-data
*
* @return array<string, mixed>
*/
public function share(Request $request): array
{
[$message, $author] = str(Inspiring::quotes()->random())->explode('-');
$capabilities = app(CapabilityRegistry::class);
return array_merge(parent::share($request), [
...parent::share($request),
'name' => app(Settings::class)->get(Setting::SiteName),
'quote' => ['message' => trim((string) $message), 'author' => trim((string) $author)],
'auth' => [
'user' => $request->user(),
'permissions' => ($user = $request->user()) !== null
? app(PermissionChecker::class)->grantedKeys($user)
: [],
],
'edition' => $capabilities->edition()->value,
'noindex' => app(Settings::class)->get(Setting::DiscourageSearchIndexing),
'version' => config('projectsend.version'),
'links' => config('projectsend.links'),
// Whether the client- and visitor-facing surfaces name
// ProjectSend. True everywhere unless a package answers
// otherwise — see ResolvingAttribution. Staff surfaces
// ignore this and always show it.
'attribution' => app(Attribution::class)->visible(),
'capabilities' => $capabilities->enabledKeys(),
// Shared rather than passed by each page: the sign-in buttons,
// the registration form and the Connected accounts nav entry
// all need the same list, and a nav entry to a screen with
// nothing on it is worse than no entry.
'social_login' => SocialSettings::available(),
// Shared for the same reason: seven unrelated surfaces — three
// auth pages and the file page of each public theme — need the
// identical provider and site key. Null when nothing is
// configured, and never the secret.
'captcha' => app(Captcha::class)->forDisplay(),
// Shared for the same reason again: eight forms across the auth
// pages, the account settings and the staff/client editors all
// ask somebody to choose a password, and each has to be able to
// say what this installation will accept *before* the submit
// rather than only in the error afterwards.
'password_policy' => app(PasswordPolicy::class)->descriptor(),
'pending' => $this->pendingCounts($request),
'update_notice' => $this->updateNotice($request),
'locale' => app()->getLocale(),
// The clock this viewer reads dates by, and whether it is a
// choice or a fallback. The frontend needs both: the first to
// format with, the second because a viewer still on the
// fallback is one whose browser we have not asked yet — see
// timezone-detector.tsx.
'timezone' => app(TimezoneRegistry::class)->resolve($request->user()),
'timezone_is_explicit' => $request->user()?->timezone !== null,
'locales' => app(LocaleRegistry::class)->enabled(),
'locales_disabled' => $this->disabledLocaleCount($request),
'translations' => $this->translations(app()->getLocale()),
'flash' => [
'success' => $request->session()->get('success'),
'error' => $request->session()->get('error'),
],
]);
}
/**
* Pending-approval counts for sidebar badges, computed only for
* viewers holding the matching approval permission.
*
* @return array<string, int>
*/
protected function pendingCounts(Request $request): array
{
$user = $request->user();
if ($user === null) {
return [];
}
$checker = app(PermissionChecker::class);
$counts = [];
if ($checker->allows($user, Permission::ApproveAccountRequests)) {
$counts['account_requests'] = User::query()
->where('type', UserType::Client)
->where('account_requested', true)
->count();
}
if ($checker->allows($user, Permission::ApproveGroupsMembershipsRequests)) {
$counts['membership_requests'] = MembershipRequest::query()
->pending()
->whereHas('user')
->whereHas('group')
->count();
}
if ($checker->allows($user, Permission::ModerateComments)) {
// Library-scoped, like the screen it badges: a client-scoped
// staff member is not shown a number they cannot act on. The
// permission check inside pendingTotal is therefore redundant
// here and deliberately kept — the scope owns that rule, and
// this middleware should not be a second place it lives.
$counts['comments'] = app(VisibleCommentScope::class)->pendingTotal($user);
}
// Unlike the counts above, every authenticated user (staff or
// client) has their own personal notifications — no permission
// gate here.
$counts['notifications_unread'] = InAppNotification::query()
->where('user_id', $user->id)
->whereNull('read_at')
->count();
return $counts;
}
/**
* How many installed translation catalogues are currently switched off,
* for the "N more languages available" line the switcher shows above its
* link to the Languages screen.
*
* Zero for everyone who cannot act on it — clients, anonymous visitors on
* the public pages and the login screen, and staff without edit_settings.
* A dead-end link is worse than none, and how an installation is
* configured is nobody else's business.
*/
protected function disabledLocaleCount(Request $request): int
{
$user = $request->user();
if ($user === null || ! $user->isStaff() || ! app(PermissionChecker::class)->allows($user, Permission::EditSettings)) {
return 0;
}
$locales = app(LocaleRegistry::class);
return count($locales->installed()) - count($locales->enabled());
}
/**
* The topbar's persistent "update available" icon — unlike the
* dashboard System card (informational, gated only on
* view_system_info), this is the actionable surface, so it's
* restricted to staff who actually hold manage_updates.
*
* Carries install_kind so the dialog can print instructions this
* particular server can actually follow — see Installation. Attached
* here rather than shared globally: it describes the deployment, which
* is nobody's business but the staff who maintain it.
*
* @return array{version: string, title: string, notes: string, url: string, published_at: string, install_kind: string}|null
*/
protected function updateNotice(Request $request): ?array
{
$user = $request->user();
if ($user === null || ! app(PermissionChecker::class)->allows($user, Permission::ManageUpdates)) {
return null;
}
$release = app(LatestReleaseInfo::class)->current();
return $release === null
? null
: [...$release, 'install_kind' => app(Installation::class)->kind()->value];
}
/**
* App strings use English text as the translation key, so "en" ships no
* messages — the key itself is the fallback.
*
* @return array<string, string>
*/
protected function translations(string $locale): array
{
if ($locale === 'en') {
return [];
}
$path = lang_path("{$locale}.json");
if (! is_file($path)) {
return [];
}
/** @var array<string, string> */
return json_decode((string) file_get_contents($path), true, flags: JSON_THROW_ON_ERROR);
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken as Middleware;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Cookie;
/**
* The CSRF cookie, named after this installation instead of after Laravel.
*
* The framework hardcodes `XSRF-TOKEN`. Cookies are scoped by host and
* ignore the port, so every Laravel application on one hostname writes
* that same cookie — each holding its own session's token, encrypted with
* its own key. Whichever answered a request most recently owns it.
*
* The failure that produces is genuinely confusing to diagnose: the
* session is untouched and valid, so reads keep working, and only writes
* fail — with a 419, which reads as "your session expired" when the
* session is perfectly alive. Reloading appears to fix it, until the
* neighbour answers another request. Any page that polls (this one polls
* for unread notifications every thirty seconds) makes that window small
* enough that writes fail almost every time.
*
* The session cookie is already named per installation, so this is that
* decision finished rather than a new one: see `session.xsrf_cookie`.
*
* Only the cookie's *name* changes. The request header stays
* `X-XSRF-TOKEN`, which is what the framework reads and what axios sends,
* and header names do not collide between applications the way cookies do.
*/
class ValidateCsrfToken extends Middleware
{
/**
* @param Request $request
* @param array<string, mixed> $config
*/
protected function newCookie($request, $config): Cookie
{
return new Cookie(
self::cookieName(),
$request->session()->token(),
$this->availableAt(60 * $config['lifetime']),
$config['path'],
$config['domain'],
$config['secure'],
false,
false,
$config['same_site'] ?? null,
$config['partitioned'] ?? false
);
}
/**
* Shared with the Blade layout, which tells the frontend which cookie
* to read — there is nothing on the client that could derive this.
*/
public static function cookieName(): string
{
$name = config('session.xsrf_cookie');
return is_string($name) && $name !== '' ? $name : 'XSRF-TOKEN';
}
}
+234
View File
@@ -0,0 +1,234 @@
<?php
namespace App\Http\Requests\Auth;
use App\Models\User;
use App\Modules\Identity\Ldap\LdapAuthenticator;
use App\Modules\Identity\Ldap\LdapProvisioner;
use App\Modules\Identity\SignIn;
use App\Modules\Platform\Captcha\CaptchaForm;
use App\Support\Rules;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Auth\SessionGuard;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
// Deliberately here rather than inside authenticate(): rules
// run first, so a bot never reaches the credential check, and
// an honest visitor whose token expired never burns one of
// their five attempts.
...Rules::captcha(CaptchaForm::Login),
];
}
/**
* Attempt to authenticate the request's credentials.
*
* Returns true when the credentials are valid but the account has
* two-factor authentication enabled: no session is created and the
* pending user id is stored for the challenge step.
*
* Three phases, deliberately in this order:
*
* 1. Identify and verify — is this password correct, from any source
* this installation accepts?
* 2. Account state — is this account allowed to sign in at all?
* 3. Two-factor, then the session.
*
* Splitting 1 from 2 is what lets a directory be consulted without
* restating anything. The property that account state is only revealed
* to somebody holding the right password now falls out of the ordering,
* rather than being re-established by a second Auth::validate() inside
* each branch — and rate limiting covers every credential source,
* because every failure funnels through one refusal.
*
* @throws ValidationException
*/
public function authenticate(): bool
{
$this->ensureIsNotRateLimited();
$user = User::query()->where('email', $this->string('email'))->first();
// A directory identity with no local account yet. Returns null
// unless LDAP is on, auto-provisioning is on, and the bind
// succeeds — so an unknown email costs nothing on an installation
// that does not use a directory.
if ($user === null) {
$user = app(LdapProvisioner::class)->provision(
(string) $this->string('email'),
(string) $this->string('password'),
);
}
$verified = $this->verifyCredentials($user);
if ($verified === null) {
$this->failWithInvalidCredentials();
}
$signIn = app(SignIn::class);
$refusal = $signIn->refusalReason($verified);
if ($refusal !== null) {
// Reached only with correct credentials, so this reveals the
// account state to its owner and to nobody else.
throw ValidationException::withMessages(['email' => $refusal]);
}
// Phases 2 and 3 are shared with every other way into this
// application — see SignIn. Rate limiting stays here, because it
// is a property of this form (keyed on email and IP) rather than
// of signing in.
$pendingTwoFactor = $signIn->begin($verified, $this->boolean('remember'));
RateLimiter::clear($this->throttleKey());
return $pendingTwoFactor;
}
/**
* The account whose password checks out, or null.
*
* The local hash is tried first and the directory only on failure, so
* a login that succeeds locally never generates directory traffic.
* The exception is an account whose credentials are known to live in
* the directory, where the local hash is a placeholder nobody holds.
*/
private function verifyCredentials(?User $user): ?User
{
if ($user === null) {
return null;
}
$ldap = app(LdapAuthenticator::class);
if (! $ldap->isDirectoryAccount($user)
&& Auth::validate($this->only('email', 'password'))) {
$this->upgradeHashIfStale($user);
return $user;
}
$identity = $ldap->attempt(
(string) $this->string('email'),
(string) $this->string('password'),
$user,
);
if ($identity === null) {
return null;
}
$ldap->stamp($user, $identity);
return $user;
}
/**
* Re-hash a password stored under weaker settings than this
* installation now uses.
*
* Laravel does this for you inside SessionGuard::attempt(), but this
* form does not use attempt() — it verifies with Auth::validate() and
* hands the account to SignIn, which calls Auth::login(). Neither
* re-hashes, so without this an account keeps whatever cost it was
* created under forever, and raising BCRYPT_ROUNDS would quietly
* apply to new accounts only.
*
* That is not hypothetical: every account the v1 migration carries
* across arrives as `$2y$08$…`, because v1 hashed at cost 8, and
* would otherwise stay four times cheaper to attack than an account
* created here.
*
* **Only ever called on the local branch.** On the directory branch
* the submitted plaintext is the *LDAP* password and the local hash
* is a `Str::password(64)` placeholder nobody holds; writing the
* directory credential into it would mint a second way into the
* account that keeps working after LDAP is switched off.
*/
private function upgradeHashIfStale(User $user): void
{
$guard = Auth::guard('web');
// getProvider() is on SessionGuard rather than on the StatefulGuard
// contract. This guard is a SessionGuard in every configuration this
// application ships; the check is here so a custom driver degrades
// to "no re-hash" instead of a fatal on the login path.
if (! $guard instanceof SessionGuard) {
return;
}
// No-ops unless the hasher says the stored digest needs it, so
// this costs an already-current account nothing.
$guard->getProvider()->rehashPasswordIfRequired($user, $this->only('password'));
}
/**
* @throws ValidationException
*/
protected function failWithInvalidCredentials(): never
{
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.failed'),
]);
}
/**
* Ensure the login request is not rate limited.
*
* @throws ValidationException
*/
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout($this));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the rate limiting throttle key for the request.
*/
public function throttleKey(): string
{
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Http\Requests\Settings;
use App\Models\User;
use App\Modules\Clients\ClientFieldContext;
use App\Modules\Clients\ClientPortalCustomFields;
use App\Support\Rules;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$rules = [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($this->user()?->id),
],
// Saved with the rest of the profile so the screen keeps one
// Save button. `timezone` is fillable, so ProfileController's
// fill() picks it up with no special handling.
//
// `sometimes`, not `required`: the form always sends it, but a
// caller that doesn't should leave the stored zone alone
// rather than be rejected — and there is no "no timezone" to
// clear it to.
'timezone' => ['sometimes', ...Rules::timezone()],
];
$user = $this->user();
if ($user?->isClient() === true) {
$rules = [
...$rules,
...app(ClientPortalCustomFields::class)->rules(ClientFieldContext::AccountEdit, $user),
];
}
return $rules;
}
}
+176
View File
@@ -0,0 +1,176 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\AuthSource;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Notifications\ResetPasswordNotification;
use App\Modules\Identity\UserType;
use Database\Factories\UserFactory;
use Illuminate\Contracts\Translation\HasLocalePreference;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon;
use Laravel\Sanctum\HasApiTokens;
/**
* @property UserType $type
* @property AuthSource $auth_source
* @property string|null $ldap_dn
* @property Carbon|null $ldap_synced_at
* @property int|null $role_id
* @property bool $active
* @property bool $account_requested
* @property string|null $locale
* @property string|null $timezone
* @property int|null $dashboard_columns
* @property int $storage_quota_mb
* @property Carbon|null $erase_after
* @property-read Role|null $role
*/
class User extends Authenticatable implements HasLocalePreference
{
/** @use HasFactory<UserFactory> */
use HasApiTokens, HasFactory, Notifiable, SoftDeletes;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'type',
'role_id',
'active',
'account_requested',
'name',
'email',
'password',
'locale',
'timezone',
'dashboard_columns',
'storage_quota_mb',
];
/**
* @return BelongsTo<Role, $this>
*/
public function role(): BelongsTo
{
return $this->belongsTo(Role::class);
}
/**
* Groups this account belongs to (clients only in practice).
*
* @return BelongsToMany<Group, $this>
*/
public function memberOfGroups(): BelongsToMany
{
return $this->belongsToMany(Group::class, 'group_members')->withTimestamps();
}
/**
* The clients a client-scoped staff member manages. Their library
* scope (what they see and may share) derives from this list.
*
* @return BelongsToMany<User, $this>
*/
public function assignedClients(): BelongsToMany
{
return $this->belongsToMany(User::class, 'staff_client_assignments', 'staff_id', 'client_id')->withTimestamps();
}
/**
* A staff member whose role restricts them to their assigned clients'
* library content (plus their own uploads).
*/
public function isClientScoped(): bool
{
return $this->isStaff() && $this->role?->client_scoped === true;
}
public function hasTwoFactorEnabled(): bool
{
return $this->two_factor_confirmed_at !== null;
}
public function isStaff(): bool
{
return $this->type === UserType::Staff;
}
public function isClient(): bool
{
return $this->type === UserType::Client;
}
public function preferredLocale(): ?string
{
return $this->locale;
}
/**
* @param string $token
*/
public function sendPasswordResetNotification($token)
{
$this->notify(new ResetPasswordNotification($token));
}
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
'two_factor_secret',
'two_factor_recovery_codes',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
/**
* The column default only applies on INSERT, so it never reaches an
* instance the database did not just hand back — and code that asks
* "does this account have a password of its own?" would then read
* null and answer wrongly. This makes `local` the answer everywhere.
*
* @var array<string, mixed>
*/
protected $attributes = [
'auth_source' => 'local',
];
protected function casts(): array
{
return [
'type' => UserType::class,
// Deliberately absent from $fillable: where an account's
// credentials live is a security decision, not an attribute a
// form or an API payload may set. Written with forceFill by
// the code that provisions the account.
'auth_source' => AuthSource::class,
'ldap_synced_at' => 'datetime',
'active' => 'boolean',
'account_requested' => 'boolean',
'erase_after' => 'datetime',
'email_verified_at' => 'datetime',
'password' => 'hashed',
'two_factor_secret' => 'encrypted',
'two_factor_recovery_codes' => 'encrypted:array',
'two_factor_confirmed_at' => 'datetime',
];
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Modules\Api;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\Route as Router;
/**
* Which module endpoints this install actually exposes, for GET /api/v1/me.
*
* Derived from the registered routes rather than remembered from the
* RegisteringApiModules dispatch, because that dispatch does not happen on
* a route-cached install: `route:cache` loads routes from the cache file
* and never executes routes/api.php. Route *names* survive caching, so
* reading them back is the one source that is correct in both cases.
*/
class ApiModuleRegistry
{
/** @var list<string>|null */
private ?array $slugs = null;
/**
* @return list<string>
*/
public function slugs(): array
{
if ($this->slugs !== null) {
return $this->slugs;
}
$slugs = [];
foreach (Router::getRoutes()->getRoutes() as $route) {
/** @var Route $route */
$name = $route->getName();
if ($name === null || ! str_starts_with($name, 'api.modules.')) {
continue;
}
$slug = explode('.', substr($name, strlen('api.modules.')))[0];
if ($slug !== '') {
$slugs[$slug] = true;
}
}
return $this->slugs = array_keys($slugs);
}
}
+182
View File
@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace App\Modules\Api;
use App\Modules\Platform\Capabilities\Edition;
use Dedoc\Scramble\Scramble;
use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\SecurityScheme;
use Dedoc\Scramble\Support\Generator\Server;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Routing\Route as RouteInstance;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route as Router;
use Illuminate\Support\ServiceProvider;
/**
* Cross-cutting API concerns only. Domain endpoints live in their own
* module under an Api sub-namespace — this provider knows nothing about
* files, clients or groups.
*/
class ApiServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(ApiModuleRegistry::class);
}
public function boot(): void
{
$this->registerRateLimiters();
$this->describeOpenApi();
if ($this->app->runningInConsole()) {
$this->commands([
Console\PurgeApiRequestLogsCommand::class,
]);
}
}
/**
* Two things Scramble cannot infer from the code, both of which a
* consumer needs before it can make a single successful call.
*
* Scramble's own docs routes stay unmounted: the reference lives in the
* admin UI behind the same gate as every other system settings page,
* and a second, separately-gated copy of the same document is a
* surface nobody would remember to check.
*/
private function describeOpenApi(): void
{
if (! class_exists(Scramble::class)) {
return;
}
Scramble::ignoreDefaultRoutes();
// Replaces the config's `api_path` matcher so two routes can be
// left out: the document must not describe how to fetch itself,
// and module endpoints belong in their own package's document —
// the committed core spec has to be identical on every install
// regardless of which optional packages are present.
Scramble::routes(fn (RouteInstance $route): bool => str_starts_with($route->uri(), 'api/v1/')
&& $route->uri() !== 'api/v1/openapi.json'
&& ! str_starts_with($route->uri(), 'api/v1/modules/'));
Scramble::extendOpenApi(function (OpenApi $document): void {
// Every route in this document sits behind auth:sanctum, so the
// scheme is declared once and applied globally.
$document->secure(SecurityScheme::http('bearer'));
});
Scramble::afterOpenApiGenerated(function (OpenApi $document): void {
// A *relative* server URL. Scramble defaults to an absolute one
// built from APP_URL, which bakes whichever machine ran the
// export into a document that then ships to every installation
// — every importing client would have been pointed at the
// exporter's host. Relative resolves against wherever the spec
// was fetched from, which is always the right server.
$document->servers = [Server::make('/api/v1')];
// The token abilities each endpoint needs live in `token-can:`
// route middleware, which no amount of return-type inference
// will reveal. Without them a reader can see the shape of a
// call but not which permissions to tick when creating the
// token that makes it — the single most common reason a first
// request 403s.
$abilitiesByRoute = $this->abilitiesByRoute();
foreach ($document->paths as $path) {
foreach ($path->operations as $operation) {
$key = strtoupper($operation->method).' '.ltrim($path->path, '/');
$abilities = $abilitiesByRoute[$key] ?? null;
if ($abilities === null) {
continue;
}
$operation->description = trim(
$operation->description
."\n\nRequires a token with "
.(count($abilities) > 1 ? 'any of these abilities' : 'the ability')
.': `'.implode('`, `', $abilities).'`.'
);
}
}
});
}
/**
* "METHOD uri" (uri relative to the api/v1 prefix) => the abilities its
* `token-can:` middleware names.
*
* Keyed by method as well as path, because several endpoints share a
* URI with different requirements — `GET /files` needs any of the three
* view permissions while `POST /files` needs `upload`, and keying by
* path alone silently gave every operation on a shared path whichever
* route happened to be registered last.
*
* @return array<string, list<string>>
*/
private function abilitiesByRoute(): array
{
$map = [];
foreach (Router::getRoutes()->getRoutes() as $route) {
if (! str_starts_with($route->uri(), 'api/v1/')) {
continue;
}
foreach ($route->gatherMiddleware() as $middleware) {
if (! is_string($middleware) || ! str_starts_with($middleware, 'token-can:')) {
continue;
}
$abilities = array_values(array_filter(
array_map('trim', explode(',', substr($middleware, strlen('token-can:'))))
));
foreach ($route->methods() as $method) {
if ($method === 'HEAD') {
continue;
}
$map[$method.' '.substr($route->uri(), strlen('api/v1/'))] = $abilities;
}
}
}
return $map;
}
private function registerRateLimiters(): void
{
// Keyed by token, not by user: two integrations belonging to the
// same admin get independent allowances, so a runaway Zapier zap
// cannot throttle that person's phone. IP is only the fallback for
// requests that never authenticated.
RateLimiter::for('api', fn (Request $request): Limit => $this->limit($request, 'default'));
RateLimiter::for('api-uploads', fn (Request $request): Limit => $this->limit($request, 'uploads'));
}
private function limit(Request $request, string $bucket): Limit
{
$edition = config('projectsend.edition');
$key = $edition instanceof Edition ? $edition->value : Edition::Community->value;
$perMinute = (int) config(
"api.rate_limits.{$key}.{$bucket}",
config("api.rate_limits.community.{$bucket}", 60)
);
$token = $request->user()?->currentAccessToken();
return Limit::perMinute($perMinute)->by(
$token !== null ? "token:{$token->getKey()}:{$bucket}" : "ip:{$request->ip()}:{$bucket}"
);
}
}
+250
View File
@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace App\Modules\Api;
use App\Models\User;
use App\Modules\Api\Auth\ApiTokens;
use App\Modules\Api\Models\ApiRequestLog;
use App\Modules\Audit\ActivityLog;
use App\Modules\Audit\ActivityOrigin;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Laravel\Sanctum\PersonalAccessToken;
/**
* The numbers behind the API dashboard.
*
* Every method takes a `$scope`: either the viewer's own tokens, or the
* whole installation for a viewer permitted to see it. The scoping lives
* here rather than in the controller so no query can accidentally skip it —
* showing every staff member's integrations to every other staff member
* would be a new leak, and it would be one query's oversight away.
*/
class ApiUsage
{
public function __construct(
private readonly ApiUsageScope $scope,
) {}
/**
* @return array<string, int|float|null>
*/
public function summary(User $viewer, bool $installWide): array
{
$requests = $this->requests($viewer, $installWide);
$since = now()->subDays(7);
$recent = (clone $requests)->where('created_at', '>=', $since);
return [
'tokens' => $this->tokens($viewer, $installWide)->count(),
'tokens_expired' => $this->tokens($viewer, $installWide)
->whereNotNull('expires_at')->where('expires_at', '<', now())->count(),
'requests_7d' => (clone $recent)->count(),
'failed_7d' => (clone $recent)->failed()->count(),
// Null rather than 0 when nothing has been called: "no requests"
// and "no failures out of many" are different states, and a
// 0% badge on an unused API reads as a health claim it cannot make.
'median_ms' => $this->medianDuration((clone $recent)),
];
}
/**
* Daily request counts for the chart, zero-filled so gaps read as
* quiet days rather than as missing data.
*
* @return list<array{date: string, requests: int, failed: int}>
*/
public function daily(User $viewer, bool $installWide, int $days = 30): array
{
$from = now()->subDays($days - 1)->startOfDay();
$rows = $this->requests($viewer, $installWide)
->where('created_at', '>=', $from)
->selectRaw('date(created_at) as day')
->selectRaw('count(*) as requests')
->selectRaw('sum(case when status >= 400 then 1 else 0 end) as failed')
->groupBy('day')
->pluck('requests', 'day');
$failures = $this->requests($viewer, $installWide)
->where('created_at', '>=', $from)
->failed()
->selectRaw('date(created_at) as day')
->selectRaw('count(*) as failed')
->groupBy('day')
->pluck('failed', 'day');
$series = [];
for ($day = $from->copy(); $day <= now(); $day->addDay()) {
$key = $day->toDateString();
$series[] = [
'date' => $key,
'requests' => (int) ($rows[$key] ?? 0),
'failed' => (int) ($failures[$key] ?? 0),
];
}
return $series;
}
/**
* Per-token usage: what each token is, and what it has been doing.
*
* @return list<array<string, mixed>>
*/
public function tokenUsage(User $viewer, bool $installWide): array
{
$tokens = $this->tokens($viewer, $installWide)
->with('tokenable')
->orderByDesc('last_used_at')
->orderByDesc('created_at')
->limit(50)
->get();
$counts = $this->requests($viewer, $installWide)
->where('created_at', '>=', now()->subDays(7))
->selectRaw('api_token_id, count(*) as total')
->selectRaw('sum(case when status >= 400 then 1 else 0 end) as failed')
->groupBy('api_token_id')
->get()
->keyBy('api_token_id');
return array_values($tokens->map(function (PersonalAccessToken $token) use ($counts): array {
$row = $counts->get($token->getKey());
$owner = $token->tokenable;
return [
'id' => (string) $token->getKey(),
'name' => $token->name,
'owner' => $owner instanceof User ? $owner->name : null,
'abilities' => $token->abilities ?? [],
'last_used_at' => $token->last_used_at?->toIso8601String(),
'expires_at' => $token->expires_at?->toIso8601String(),
'expired' => ! ApiTokens::isActive($token),
'requests_7d' => (int) ($row->total ?? 0),
'failed_7d' => (int) ($row->failed ?? 0),
];
})->all());
}
/**
* The most recent *domain actions* taken through the API — what a token
* actually changed, as opposed to how many requests it made.
*
* Read from the activity log rather than the request log on purpose:
* this answers "what did it do", which is the audit trail's question,
* and each row links back into the full log.
*
* @return list<array<string, mixed>>
*/
public function recentActions(User $viewer, bool $installWide, int $limit = 15): array
{
$query = ActivityLog::query()->where('origin', ActivityOrigin::Api);
if (! $installWide) {
$query->where('actor_id', $viewer->id);
}
return array_values($query->orderByDesc('created_at')
->orderByDesc('id')
->limit($limit)
->get()
->map(fn (ActivityLog $entry): array => [
'id' => $entry->id,
'created_at' => $entry->created_at->toIso8601String(),
'actor_name' => $entry->actor_name,
'token_name' => $entry->api_token_name,
'template' => $entry->action->template(),
'replacements' => $this->replacements($entry),
])
->all());
}
/**
* @return array<string, string>
*/
private function replacements(ActivityLog $entry): array
{
$replacements = ['subject' => $entry->subject_name ?? ''];
foreach ($entry->context ?? [] as $key => $value) {
if (is_scalar($value)) {
$replacements[$key] = (string) $value;
}
}
return $replacements;
}
/**
* @return Builder<ApiRequestLog>
*/
private function requests(User $viewer, bool $installWide): Builder
{
return $this->scope->requests($viewer, $installWide);
}
/**
* @return Builder<PersonalAccessToken>
*/
private function tokens(User $viewer, bool $installWide): Builder
{
return $this->scope->tokens($viewer, $installWide);
}
/**
* @param Builder<ApiRequestLog> $requests
*/
private function medianDuration(Builder $requests): ?int
{
$count = (clone $requests)->count();
if ($count === 0) {
return null;
}
// Median rather than mean: one slow upload should not make every
// other call look slow, which is exactly what an average does on a
// long-tailed distribution like request duration.
$offset = (int) floor(($count - 1) / 2);
return (int) $requests->orderBy('duration_ms')->offset($offset)->limit(1)->value('duration_ms');
}
/**
* Convenience for the dashboard widget, which shows one number.
*/
public function requestsSince(User $viewer, bool $installWide, Carbon $since): int
{
return $this->requests($viewer, $installWide)->where('created_at', '>=', $since)->count();
}
/**
* The busiest endpoints, so an operator can see what an integration
* actually leans on.
*
* @return list<array{route: string, method: string, requests: int}>
*/
public function topEndpoints(User $viewer, bool $installWide, int $limit = 8): array
{
return array_values($this->requests($viewer, $installWide)
->where('created_at', '>=', now()->subDays(7))
->select('route', 'method', DB::raw('count(*) as requests'))
->groupBy('route', 'method')
->orderByDesc('requests')
->limit($limit)
->get()
->map(fn (ApiRequestLog $row): array => [
'route' => $row->route,
'method' => $row->method,
'requests' => (int) $row->getAttribute('requests'),
])
->all());
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Modules\Api;
use App\Models\User;
use App\Modules\Api\Models\ApiRequestLog;
use Illuminate\Database\Eloquent\Builder;
use Laravel\Sanctum\PersonalAccessToken;
/**
* The single place that decides *whose* API usage a viewer may see.
*
* A token is a personal credential. Without a boundary, an API dashboard
* would show every staff member which integrations their colleagues run,
* how often, and against what — a new disclosure that no existing screen
* makes. So the rule is: your own tokens always, everyone's only with
* `view_actions_log`, which is already the permission that grants a
* whole-installation view of who did what.
*
* Every query the dashboard runs goes through here. Keeping it in one
* class rather than repeating a `when($installWide)` in each method is the
* difference between a boundary and a convention.
*/
class ApiUsageScope
{
/**
* Whether this viewer may see beyond their own tokens.
*/
public function mayViewInstallWide(User $viewer): bool
{
return $viewer->can('view_actions_log');
}
/**
* Resolves what the caller asked for against what they may have, so a
* controller cannot widen the scope by passing `true`.
*/
public function resolve(User $viewer, bool $requested): bool
{
return $requested && $this->mayViewInstallWide($viewer);
}
/**
* @return Builder<ApiRequestLog>
*/
public function requests(User $viewer, bool $installWide): Builder
{
$query = ApiRequestLog::query();
return $installWide ? $query : $query->ownedBy($viewer);
}
/**
* @return Builder<PersonalAccessToken>
*/
public function tokens(User $viewer, bool $installWide): Builder
{
$query = PersonalAccessToken::query();
return $installWide
? $query
: $query->where('tokenable_type', $viewer->getMorphClass())->where('tokenable_id', $viewer->id);
}
}
+152
View File
@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Modules\Api\Auth;
use App\Models\User;
use App\Modules\Identity\Permissions\Permission;
use Illuminate\Support\Collection;
use Laravel\Sanctum\PersonalAccessToken;
/**
* What tokens an account holds, for screens that describe somebody
* *else's* — the staff list's indicator and the API tab on a staff
* account.
*
* Read-only on purpose. ApiTokensController deliberately scopes every
* mutation to the caller's own tokens ("managing them is not an
* administrative power here"), and nothing in this class widens that:
* an administrator can see that an integration exists and what it is
* allowed to do, which is their installation's security posture, but
* renaming, re-scoping or revoking somebody's token is still only the
* owner's to do. No secret is exposed either way — the database holds a
* hash.
*/
class ApiTokens
{
public function __construct(
private readonly TokenAbilities $abilities,
) {}
/**
* A token is "active" when it has not expired. A null expiry means it
* never does.
*
* The single definition of the word: it was written out by hand in
* ApiTokensController and again in ApiUsage before this, and adding a
* third copy for the staff list is what prompted collecting it here.
*/
public static function isActive(PersonalAccessToken $token): bool
{
return $token->expires_at === null || $token->expires_at->isFuture();
}
/**
* Totals for one account.
*
* @return array{total: int, active: int}
*/
public function summarize(User $user): array
{
return $this->summarizeMany([$user->id])[$user->id] ?? ['total' => 0, 'active' => 0];
}
/**
* Totals for a page of accounts, in one query — a listing that asked
* per row would be an N+1 on a screen that already paginates 25 at a
* time.
*
* Counted in the database rather than by loading the rows: nothing
* here needs a token's name or abilities, only how many there are.
* Expiry is compared in SQL for the same reason.
*
* @param iterable<int> $userIds
* @return array<int, array{total: int, active: int}>
*/
public function summarizeMany(iterable $userIds): array
{
$ids = Collection::make($userIds)->map(fn ($id): int => (int) $id)->unique()->values();
if ($ids->isEmpty()) {
return [];
}
return PersonalAccessToken::query()
->where('tokenable_type', User::class)
->whereIn('tokenable_id', $ids)
->selectRaw('tokenable_id')
->selectRaw('count(*) as total')
->selectRaw('sum(case when expires_at is null or expires_at > ? then 1 else 0 end) as active', [now()])
->groupBy('tokenable_id')
->get()
->mapWithKeys(fn (PersonalAccessToken $row): array => [
(int) $row->getAttribute('tokenable_id') => [
'total' => (int) $row->getAttribute('total'),
'active' => (int) $row->getAttribute('active'),
],
])
->all();
}
/**
* Every token an account holds, with its abilities resolved to the
* labels the permission screens use — bare keys like
* `edit_others_files` are not what an administrator should have to
* read to answer "what can this integration do".
*
* @return list<array<string, mixed>>
*/
public function detailFor(User $user): array
{
$stillGranted = $this->abilities->availableFor($user);
return array_values($user->tokens()
->orderByDesc('created_at')
->get()
->map(fn (PersonalAccessToken $token): array => [
'id' => (string) $token->getKey(),
'name' => $token->name,
'active' => self::isActive($token),
'created_at' => $token->created_at?->toIso8601String(),
'last_used_at' => $token->last_used_at?->toIso8601String(),
'expires_at' => $token->expires_at?->toIso8601String(),
// array_values because Sanctum casts the column straight
// from JSON, so nothing guarantees the keys are sequential.
'abilities' => $this->describeAbilities(array_values($token->abilities ?? []), $stillGranted),
])
->all());
}
/**
* @param list<string> $keys
* @param list<string> $stillGranted
* @return list<array{key: string, label: string, category: string, effective: bool}>
*/
private function describeAbilities(array $keys, array $stillGranted): array
{
$described = [];
foreach ($keys as $key) {
$permission = Permission::tryFrom($key);
$described[] = [
'key' => $key,
// An unrecognised key is still worth showing rather than
// hiding: it means the token carries something this
// vocabulary no longer has, which is exactly the sort of
// leftover an administrator is looking at this list for.
'label' => $permission?->label() ?? $key,
'category' => $permission?->category()->label() ?? '',
// Whether it does anything today. A token keeps the
// abilities it was issued with, but EnsureTokenCan
// re-checks the owner's live permissions on every
// request, so an ability the owner has since lost is
// carried and ignored.
'effective' => in_array($key, $stillGranted, true),
];
}
return $described;
}
}
+154
View File
@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace App\Modules\Api\Auth;
use App\Models\User;
use App\Modules\Identity\Permissions\Permission;
use App\Modules\Identity\Permissions\PermissionChecker;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use Illuminate\Support\Facades\Route as Router;
/**
* Which abilities a given user may actually attach to a token, here, now.
*
* Three independent constraints, all of which must hold:
*
* 1. **Permission** — what this user's role grants. A token must never be
* a way to acquire an ability its owner does not have.
* 2. **Capability** — what this edition has at all. `manage_users` on a
* cloud install is not a permission the user is merely prevented from
* using; the feature is absent, and every route behind it 404s.
* 3. **Implemented** — whether any API route actually consumes the
* ability. The API covers a fraction of what the web UI does, and
* offering a checkbox for `manage_groups` before a groups endpoint
* exists invites someone to grant an ability that silently does
* nothing. A permission is not an API ability until a route asks for it.
*
* The third is derived from the routes rather than kept as a list here,
* which is the point: `token-can:` on each API route is already the
* authoritative statement of what that endpoint needs, so a new endpoint
* makes its abilities selectable the moment it is registered, and no
* parallel list can fall out of step. Package routes registered through
* RegisteringApiModules are covered by the same scan.
*
* Deliberately not memoised across resolutions: capability comes from a
* registry bound per resolution so tests can flip the edition, and the
* permission side is already memoised inside PermissionChecker. The route
* scan is memoised per instance since the route table cannot change
* mid-request.
*/
class TokenAbilities
{
/** @var list<string>|null */
private ?array $inUse = null;
public function __construct(
private readonly PermissionChecker $permissions,
private readonly CapabilityRegistry $capabilities,
) {}
/**
* Every ability key this user may be granted, as strings.
*
* @return list<string>
*/
public function availableFor(User $user): array
{
$granted = $this->permissions->grantedKeys($user);
return array_values(array_filter(
$granted,
fn (string $key): bool => $this->isAvailable($key) && $this->isImplemented($key),
));
}
/**
* The same list as Permission cases, for a UI that needs labels and
* categories rather than bare keys.
*
* @return list<Permission>
*/
public function casesFor(User $user): array
{
$available = $this->availableFor($user);
return array_values(array_filter(
Permission::cases(),
fn (Permission $permission): bool => in_array($permission->value, $available, true),
));
}
/**
* Whether the ability is usable in this edition at all, ignoring who is
* asking. An unknown key is not available — a token may only ever carry
* abilities drawn from the Permission vocabulary.
*
* Note this does NOT consider whether an endpoint exists: it is the
* check EnsureTokenCan makes, and there the question is already settled
* — a route asking for an ability is itself the endpoint.
*/
public function isAvailable(string $key): bool
{
$permission = Permission::tryFrom($key);
if ($permission === null) {
return false;
}
$capability = $permission->capability();
return $capability === null || $this->capabilities->has($capability);
}
/**
* Whether any API endpoint consumes this ability today.
*/
public function isImplemented(string $key): bool
{
return in_array($key, $this->inUse(), true);
}
/**
* Abilities named by a `token-can:` middleware on any registered
* /api/v1 route — core or module.
*
* Read off the route table rather than declared in a list, so this can
* never disagree with what the endpoints actually require. Route
* caching preserves middleware, so it is correct on a cached install
* too.
*
* @return list<string>
*/
public function inUse(): array
{
if ($this->inUse !== null) {
return $this->inUse;
}
$abilities = [];
foreach (Router::getRoutes()->getRoutes() as $route) {
if (! str_starts_with($route->uri(), 'api/')) {
continue;
}
foreach ($route->gatherMiddleware() as $middleware) {
if (! is_string($middleware) || ! str_starts_with($middleware, 'token-can:')) {
continue;
}
foreach (explode(',', substr($middleware, strlen('token-can:'))) as $ability) {
$ability = trim($ability);
if ($ability !== '') {
$abilities[$ability] = true;
}
}
}
}
return $this->inUse = array_keys($abilities);
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Modules\Api\Console;
use App\Modules\Api\Models\ApiRequestLog;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Console\Command;
class PurgeApiRequestLogsCommand extends Command
{
protected $signature = 'projectsend:purge-api-request-logs';
protected $description = 'Delete API request telemetry older than the configured retention window (runs daily)';
public function handle(Settings $settings): int
{
$days = (int) $settings->get(Setting::ApiRequestLogRetentionDays);
// 0 means keep indefinitely — an explicit choice an operator can
// make, and the reason this reads the setting rather than assuming.
if ($days <= 0) {
$this->info('API request log retention is disabled; nothing pruned.');
return self::SUCCESS;
}
// Deleted in chunks: this is the highest-volume table in the
// application, and a single unbounded DELETE on a busy install
// would hold locks for as long as it took.
$cutoff = now()->subDays($days);
$deleted = 0;
do {
$batch = ApiRequestLog::query()->where('created_at', '<', $cutoff)->limit(5000)->delete();
$deleted += $batch;
} while ($batch > 0);
$this->info("Pruned {$deleted} API request log entries older than {$days} days.");
return self::SUCCESS;
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Modules\Api\Events;
use Closure;
/**
* One registered module's API surface. Value object, built only by
* RegisteringApiModules::register() so the invariants it validates are the
* only way to produce one.
*/
final class ApiModule
{
/**
* @param string $slug URL segment and route-name segment: /api/v1/modules/{slug}
* @param string|Closure $routes route file path, or a closure that declares routes
* @param string|null $capability Capability key gating the whole group, null if edition-agnostic
*/
public function __construct(
public readonly string $slug,
public readonly string|Closure $routes,
public readonly ?string $capability,
) {}
}
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Modules\Api\Events;
use App\Modules\Platform\Capabilities\Capability;
use Closure;
use InvalidArgumentException;
/**
* The extension point through which cloud-modules and community-modules
* add their own API endpoints.
*
* Core dispatches this once while loading routes/api.php, collects whatever
* the listeners registered, and mounts each module itself — under a prefix
* core chooses, inside the middleware stack core assembled, behind the
* capability the module declared. A module supplies paths and controllers;
* it does not get to say how they are authenticated.
*
* A package listens by *string* class name, never by importing this class:
*
* Event::listen('App\Modules\Api\Events\RegisteringApiModules', function ($event): void {
* $event->register(
* slug: 'branding',
* routes: __DIR__.'/api-routes.php',
* capability: 'branding.customize',
* );
* });
*
* That indirection is what keeps the packages buildable and testable with
* no host application present — the same constraint that ruled out an
* interface or a shared base controller. See docs/extension-points-architecture.md.
*
* This is a guard against mistakes, not a sandbox. Nothing at runtime stops
* a package from calling Route::post('api/v1/files/...') directly; what
* stops it is ModuleRouteBoundaryTest, which is the same enforcement level
* the rest of the extension system runs on.
*/
final class RegisteringApiModules
{
/**
* Keyed by slug so a collision is detectable rather than last-write-wins.
*
* @var array<string, ApiModule>
*/
private array $modules = [];
/**
* @param string $slug lowercase, hyphen-separated; becomes /api/v1/modules/{slug}
* @param string|Closure $routes path to a route file, or a closure declaring routes
* @param string|null $capability a Capability key, or null if the module is
* available in every edition. Required — not
* defaulted — so edition gating is a decision
* each module makes out loud rather than one it
* can forget. CustomAssets shipped routes that
* answered 200 in the wrong edition precisely
* because restating the gate was left to the
* package to remember.
*
* @throws InvalidArgumentException on a malformed slug, a duplicate slug, or an
* unknown capability key. Thrown at route-load
* time, so a misconfigured module fails loudly on
* the first request in development and in CI
* rather than silently not mounting.
*/
public function register(string $slug, string|Closure $routes, ?string $capability): void
{
if (preg_match('/^[a-z0-9]+(-[a-z0-9]+)*$/', $slug) !== 1 || strlen($slug) > 40) {
throw new InvalidArgumentException(
"Invalid API module slug [{$slug}]: expected lowercase letters, digits and single hyphens, at most 40 characters."
);
}
if (isset($this->modules[$slug])) {
throw new InvalidArgumentException(
"API module slug [{$slug}] is already registered. Two packages cannot share a slug — one of them must rename."
);
}
if (is_string($routes) && ! is_file($routes)) {
throw new InvalidArgumentException(
"API module [{$slug}] was registered with route file [{$routes}], which does not exist."
);
}
if ($capability !== null && Capability::tryFrom($capability) === null) {
throw new InvalidArgumentException(
"API module [{$slug}] declared unknown capability [{$capability}]. Add the case to Capability first."
);
}
$this->modules[$slug] = new ApiModule($slug, $routes, $capability);
}
/**
* @return list<ApiModule>
*/
public function modules(): array
{
return array_values($this->modules);
}
/**
* The slugs an API client can expect to find under /api/v1/modules,
* surfaced by GET /api/v1/me so a caller can discover what this
* particular install offers before calling it.
*
* @return list<string>
*/
public function slugs(): array
{
return array_keys($this->modules);
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Modules\Api\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Api\ApiUsage;
use App\Modules\Api\ApiUsageScope;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
/**
* What the API has been doing: token inventory, request volume, and the
* domain actions taken through it.
*
* Two sources, on purpose. Volume, latency and error rates come from
* `api_request_logs`, which records every call; "what did this token
* change" comes from the activity log, which records domain events and
* links back to the full history. Neither could answer the other's
* question.
*/
class ApiDashboardController extends Controller
{
public function __construct(
private readonly ApiUsage $usage,
private readonly ApiUsageScope $scope,
private readonly Settings $settings,
) {}
public function __invoke(Request $request): Response
{
$viewer = $request->user();
assert($viewer !== null);
// The request may ask for the install-wide view; the scope decides
// whether it gets one. A viewer without the permission silently
// falls back to their own tokens rather than being refused — the
// page is theirs either way, only its reach differs.
$installWide = $this->scope->resolve($viewer, $request->boolean('all'));
return Inertia::render('api/dashboard', [
'summary' => $this->usage->summary($viewer, $installWide),
'daily' => $this->usage->daily($viewer, $installWide),
'tokens' => $this->usage->tokenUsage($viewer, $installWide),
'recent_actions' => $this->usage->recentActions($viewer, $installWide),
'top_endpoints' => $this->usage->topEndpoints($viewer, $installWide),
'scope' => [
'install_wide' => $installWide,
'can_view_install_wide' => $this->scope->mayViewInstallWide($viewer),
],
'retention_days' => (int) $this->settings->get(Setting::ApiRequestLogRetentionDays),
]);
}
}
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Modules\Api\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\Table\TableExtension;
use League\CommonMark\MarkdownConverter;
/**
* The API reference, inside the admin UI.
*
* Rendered from the two files that are already the source of truth — the
* committed OpenAPI document and docs/api-guide.md — rather than embedding
* a third-party documentation UI. An iframe or a CDN-hosted renderer would
* mean a page that ignores the app's theme, breaks its links, and goes
* blank on an install with no outbound internet access, which self-hosted
* installations regularly are.
*
* The markdown is converted server-side with league/commonmark, already a
* framework dependency, so no JavaScript renderer joins the bundle.
*/
class ApiDocsController extends Controller
{
public function __invoke(Request $request): Response
{
return Inertia::render('api/docs', [
'guide_html' => $this->guideHtml(),
'endpoints' => $this->endpoints(),
'spec_url' => route('api.openapi'),
'version' => $this->spec()['info']['version'] ?? null,
]);
}
/**
* @return array<string, mixed>
*/
private function spec(): array
{
$path = base_path(OpenApiController::PATH);
if (! is_file($path)) {
return [];
}
return json_decode((string) file_get_contents($path), true) ?: [];
}
/**
* Flattened operation list for the summary table: one row per method
* and path, with the abilities pulled back out of the description
* where describeOpenApi() put them.
*
* @return list<array<string, mixed>>
*/
private function endpoints(): array
{
$rows = [];
foreach ($this->spec()['paths'] ?? [] as $path => $operations) {
foreach ($operations as $method => $operation) {
if (! in_array($method, ['get', 'post', 'put', 'patch', 'delete'], true)) {
continue;
}
preg_match_all('/`([a-z_]+)`/', $this->abilitySentence($operation), $matches);
$rows[] = [
'method' => strtoupper($method),
'path' => '/api/v1'.$path,
'summary' => $operation['summary'] ?? '',
'abilities' => $matches[1],
];
}
}
usort($rows, fn (array $a, array $b): int => [$a['path'], $a['method']] <=> [$b['path'], $b['method']]);
return $rows;
}
/**
* @param array<string, mixed> $operation
*/
private function abilitySentence(array $operation): string
{
$description = is_string($operation['description'] ?? null) ? $operation['description'] : '';
$position = strpos($description, 'Requires a token with');
return $position === false ? '' : substr($description, $position);
}
private function guideHtml(): string
{
$path = base_path('docs/api-guide.md');
if (! is_file($path)) {
return '';
}
// A deliberately small extension set. The guide is a file shipped
// with the application, not user input — but rendering it with the
// narrowest converter that does the job keeps it that way even if
// someone later points this at something less trustworthy.
$environment = new Environment([
'html_input' => 'escape',
'allow_unsafe_links' => false,
]);
$environment->addExtension(new CommonMarkCoreExtension);
$environment->addExtension(new TableExtension);
return (string) (new MarkdownConverter($environment))->convert((string) file_get_contents($path));
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Modules\Api\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Laravel\Sanctum\PersonalAccessToken;
/**
* Self-revocation: an integration that is being decommissioned, or one that
* suspects it has leaked its own credential, can retire it without anyone
* logging into the web UI.
*
* Scoped to the calling token on purpose — see routes/api.php.
*/
class CurrentTokenController extends Controller
{
public function __construct(
private readonly ActivityLogger $activity,
) {}
public function __invoke(Request $request): JsonResponse
{
$user = $request->user();
assert($user !== null);
// Sanctum's own docblock types this as non-nullable, but the
// underlying property is simply unset when the request was not
// token-authenticated. Narrowing it here keeps the null branch
// honest instead of letting the analyser delete it.
/** @var PersonalAccessToken|null $token */
$token = $user->currentAccessToken();
if ($token !== null) {
// `via` and `api_token` are stamped by ActivityLogger itself.
$this->activity->log(Action::ApiTokenRevoked, $user, context: ['token_name' => $token->name]);
$token->delete();
}
return response()->json(status: 204);
}
}
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Modules\Api\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Api\ApiModuleRegistry;
use App\Modules\Api\Auth\TokenAbilities;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Laravel\Sanctum\PersonalAccessToken;
/**
* Who am I, what may I do, and what does this install offer.
*
* The first call any integration makes: it lets a client discover the
* effective permission set and the available modules instead of probing
* endpoints and reading 403s.
*/
class MeController extends Controller
{
public function __construct(
private readonly TokenAbilities $abilities,
private readonly CapabilityRegistry $capabilities,
private readonly ApiModuleRegistry $modules,
) {}
public function __invoke(Request $request): JsonResponse
{
$user = $request->user();
assert($user !== null);
// See CurrentTokenController: Sanctum types this as non-nullable
// although the property is unset without token authentication.
/** @var PersonalAccessToken|null $token */
$token = $user->currentAccessToken();
return response()->json([
'data' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'type' => $user->type->value,
'role' => $user->role?->name,
'locale' => $user->locale,
// The *effective* set, not the token's stored list: a token
// minted before a demotion still carries abilities its owner
// has since lost, and reporting those would tell an
// integration it can do things every request will refuse.
// EnsureTokenCan enforces exactly this intersection.
'abilities' => $this->effectiveAbilities($user, $token),
'token' => [
'name' => $token?->name,
'expires_at' => $token?->expires_at?->toIso8601String(),
],
],
'meta' => [
'edition' => $this->capabilities->edition()->value,
'capabilities' => $this->capabilities->enabledKeys(),
'modules' => $this->modules->slugs(),
'version' => config('projectsend.version'),
],
]);
}
/**
* @return list<string>
*/
private function effectiveAbilities(User $user, ?PersonalAccessToken $token): array
{
// Permission ∩ capability, so this never advertises an ability that
// the edition has no feature behind — see TokenAbilities.
$granted = $this->abilities->availableFor($user);
if ($token === null) {
return [];
}
// Sanctum's wildcard: a token created without an explicit list may
// do anything its owner may do. Tokens minted through the settings
// page always carry an explicit list, so this is only reachable for
// tokens created in code — tests, tinker, a future first-party flow.
if (in_array('*', $token->abilities ?? [], true)) {
return $granted;
}
return array_values(array_intersect($granted, $token->abilities ?? []));
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Modules\Api\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* The OpenAPI document, served from the copy committed to the repository
* rather than generated per request.
*
* Generating on request would make the document a property of whichever
* packages happen to be installed on that particular server, and would put
* route reflection on the hot path of a public, unauthenticated endpoint.
* The committed file is the contract; `php artisan scramble:export`
* regenerates it and a test fails if the two drift.
*
* Unauthenticated on purpose: a client needs the spec *before* it has a
* token, and the document is identical on every install — it describes the
* shape of the API, never any of this installation's data. A test asserts
* that second part, since it is the whole reason this is safe to leave open.
*/
class OpenApiController extends Controller
{
public const PATH = 'docs/api/openapi.json';
public function __invoke(): Response
{
$path = base_path(self::PATH);
if (! is_file($path)) {
// A deployment that skipped the export step. Better a clear 404
// than an empty document a client would cache as the truth.
return new JsonResponse([
'type' => 'not_found',
'title' => 'The OpenAPI document has not been generated for this installation.',
'status' => 404,
], 404, ['Content-Type' => 'application/problem+json']);
}
return new Response((string) file_get_contents($path), 200, [
'Content-Type' => 'application/json',
'Cache-Control' => 'public, max-age=300',
]);
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Modules\Api\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* The token twin of Identity's EnsureAccountIsActive: deactivating an
* account revokes its API access on the very next request, without anyone
* having to hunt down the tokens it minted.
*
* Deleted accounts need no equivalent — users are soft-deleted and the
* default query scope means Sanctum simply fails to resolve the tokenable,
* which surfaces as a 401.
*
* Unlike the web version there is no session to tear down and nowhere to
* redirect to, so this is a flat 401: the credential is no longer good.
*/
class EnsureApiAccountIsActive
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user !== null && ! $user->active) {
abort(401);
}
return $next($request);
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Modules\Api\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* The API is staff-only in v1.
*
* Client accounts have no way to mint a token (the settings page that
* issues them is behind `staff`), so this is the second half of a belt-and
* -braces pair rather than the only control: if a client ever ends up
* holding a token — a seeded fixture, a support script, an account whose
* type was changed after issuance — every API route still refuses it.
*
* The reason clients are excluded is scope, not capability: a token acting
* for a client is the largest privacy surface this API could have, and it
* needs its own ability vocabulary before it can exist safely. Until that
* is built, it simply doesn't.
*/
class EnsureStaffToken
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user === null) {
abort(401);
}
if (! $user->isStaff()) {
abort(403);
}
return $next($request);
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Modules\Api\Http\Middleware;
use App\Modules\Api\Auth\TokenAbilities;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpFoundation\Response;
/**
* Route-level ability enforcement: `->middleware('token-can:edit_files,edit_others_files')`.
* Any one of the listed abilities is enough, matching how the web routes
* treat own/others permission pairs.
*
* Why this exists instead of Sanctum's stock `ability` middleware: Sanctum
* only asks what was baked into the token when it was minted. Roles change.
* A staff member who held `delete_files` in March, minted a token, and was
* moved to a read-only role in April still carries a token that claims the
* ability — and stock Sanctum honours the claim. Every request here checks
* both halves:
*
* 1. the token was granted the ability, and
* 2. the user still holds the underlying permission right now.
*
* The Gate side is the live one, registered per Permission case in
* IdentityServiceProvider, so demotion takes effect on the next request
* rather than whenever someone remembers to revoke the token.
*
* This is an additional gate, never a substitute for the domain's own
* policy checks — controllers still call Gate::authorize() on the model.
*/
class EnsureTokenCan
{
public function handle(Request $request, Closure $next, string ...$abilities): Response
{
$user = $request->user();
if ($user === null) {
abort(401);
}
$available = app(TokenAbilities::class);
foreach ($abilities as $ability) {
// Three checks, all of which must hold:
//
// - the edition has the feature at all (a token minted on a
// community install and carried to a cloud one must not keep
// working — route-level `capability:` middleware is the
// primary gate, this is the belt-and-braces half);
// - the token was granted the ability;
// - the owner still holds the permission today.
//
// tokenCan() is false when there is no access token — a
// first-party session, which config/sanctum.php makes
// unreachable here but which must fail closed if it ever
// becomes reachable: "proved no ability", not "may do anything".
if ($available->isAvailable($ability)
&& $user->tokenCan($ability)
&& Gate::forUser($user)->allows($ability)) {
return $next($request);
}
}
abort(403);
}
}
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Modules\Api\Http\Middleware;
use App\Modules\Api\Models\ApiRequestLog;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
/**
* Records every API request.
*
* Applied to the whole `api` middleware group rather than per route, so an
* endpoint added later is measured without anyone remembering to opt in —
* the same reasoning as stamping origin inside ActivityLogger.
*
* The write happens in terminate(), not handle(), for two reasons. A
* failed request never returns through handle() at all: `auth:sanctum`
* throws, and the exception travels past this middleware to be turned into
* a 401 by the handler, so recording after `$next` would have logged only
* the successes — the flattering half, and useless in an incident.
* terminate() also runs after the response has been sent, so telemetry
* costs the caller nothing.
*/
class RecordApiRequest
{
private const STARTED_AT = 'api_request_started_at';
public function handle(Request $request, Closure $next): Response
{
// On the request rather than on `$this`: terminable middleware is
// re-resolved from the container, so instance state is not
// guaranteed to survive to terminate().
$request->attributes->set(self::STARTED_AT, microtime(true));
return $next($request);
}
public function terminate(Request $request, Response $response): void
{
try {
$startedAt = $request->attributes->get(self::STARTED_AT);
$token = $request->user()?->currentAccessToken();
ApiRequestLog::query()->create([
'api_token_id' => $token?->getKey(),
// Snapshotted so a revoked token's history is still readable
// — which is precisely when someone reviews it.
'api_token_name' => $token?->getAttribute('name'),
'user_id' => $request->user()?->getKey(),
'method' => $request->getMethod(),
'route' => $this->routePattern($request),
'status' => $response->getStatusCode(),
'duration_ms' => is_float($startedAt) ? (int) round((microtime(true) - $startedAt) * 1000) : 0,
'created_at' => now(),
]);
} catch (Throwable $e) {
// Telemetry must never turn a successful API call into a failed
// one. A full disk or a locked table is an operations problem,
// not the caller's — and by this point the response has already
// gone out regardless.
Log::warning('Failed to record an API request: '.$e->getMessage());
}
}
/**
* The matched route's *pattern*, never the resolved URI: a URI carries
* the ids of the clients and files a caller touched, and that belongs
* in the audit log rather than in volume telemetry. A request that
* matched nothing is recorded under a placeholder, so 404 noise stays
* countable without recording what was probed.
*/
private function routePattern(Request $request): string
{
$route = $request->route();
return $route === null ? '(unmatched)' : $route->uri();
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Modules\Api\Http\Middleware;
use App\Modules\Platform\Localization\LocaleRegistry;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Platform's SetLocale reads the session, which API requests don't have —
* calling it here would throw. Same resolution order minus that step:
* the token owner's own preference, then Accept-Language, then the app
* default.
*
* Worth doing rather than defaulting everything to English: validation
* messages are the one part of an API response written for a human, and a
* 422 is usually read by the same person who set the locale in the UI.
*/
class SetApiLocale
{
public function __construct(
private readonly LocaleRegistry $locales,
) {}
public function handle(Request $request, Closure $next): Response
{
$locale = $request->user()?->locale;
if (! is_string($locale) || ! $this->locales->isEnabled($locale)) {
$locale = $request->getPreferredLanguage($this->locales->preferenceOrder())
?? $this->locales->defaultLocale();
}
app()->setLocale($locale);
return $next($request);
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Modules\Api\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* One row per API request. See the migration for why this is separate from
* the activity log, and why it stores a route pattern and no IP.
*
* @property int $id
* @property int|null $api_token_id
* @property string|null $api_token_name
* @property int|null $user_id
* @property string $method
* @property string $route
* @property int $status
* @property int $duration_ms
* @property Carbon $created_at
*/
class ApiRequestLog extends Model
{
protected $table = 'api_request_logs';
// Written once and never touched again, like the activity log.
public $timestamps = false;
protected $guarded = [];
protected function casts(): array
{
return ['created_at' => 'datetime'];
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* @param Builder<$this> $query
* @return Builder<$this>
*/
public function scopeFailed(Builder $query): Builder
{
return $query->where('status', '>=', 400);
}
/**
* Everything a given user's own tokens did.
*
* Matched on the token's owner rather than on `user_id` alone so a
* revoked token's history stays visible to the person who created it —
* which is exactly when someone goes looking.
*
* @param Builder<$this> $query
* @return Builder<$this>
*/
public function scopeOwnedBy(Builder $query, User $user): Builder
{
return $query->where('user_id', $user->id);
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Modules\Api\Support;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Support\Carbon;
/**
* The shape every list endpoint shares, so an integration learns it once.
*
* Two modes, chosen by whether the caller passed `updated_since`:
*
* - **Polling.** Ordered by (updated_at, id) *ascending* and filtered to
* rows touched at or after the given time. That ordering is what makes
* a repeated poll safe: new and edited rows always land at the end of
* the walk, so paging forward with a cursor visits every row exactly
* once. Newest-first would insert new rows at the front and silently
* shift everything the caller had not read yet.
* - **Browsing.** Newest-first, for a human looking at a list.
*
* `updated_since` is inclusive on purpose. A caller is told to poll with
* the highest `updated_at` it has seen, and two rows can share a
* timestamp to the second; excluding the boundary would drop the second
* one forever. The cost is re-seeing the boundary row, which a client
* de-duplicates by id — the safe direction of the trade.
*
* Known limitation, documented rather than papered over: polling cannot
* observe deletions. A soft-deleted row simply stops appearing. Webhooks
* are the fix, and are deliberately a later phase.
*/
class PollingQuery
{
/**
* @template TModel of Model
*
* @param Builder<TModel> $query
* @return CursorPaginator<int, TModel>
*/
public function paginate(Request $request, Builder $query, string $table): CursorPaginator
{
$since = $request->query('updated_since');
if (is_string($since) && $since !== '') {
// Parsed, never passed through as a string. Callers send proper
// ISO 8601 ("2026-08-06T05:00:00+02:00"), and the database will
// not compare that against a datetime column — MySQL fails to
// cast the `T` and the offset and silently matches nothing, so
// a polling client would see an empty result forever instead of
// an error. Carbon also normalises the offset into the app's
// timezone, so a caller in any timezone gets the same rows.
$query->where("{$table}.updated_at", '>=', Carbon::parse($since)->timezone(config('app.timezone')))
->orderBy("{$table}.updated_at")
->orderBy("{$table}.id");
} else {
$query->orderByDesc("{$table}.updated_at")
->orderByDesc("{$table}.id");
}
return $query->cursorPaginate($this->perPage($request))->withQueryString();
}
/**
* The cap exists so one caller cannot turn a list endpoint into a
* full-table export in a single request.
*/
public function perPage(Request $request): int
{
$requested = (int) $request->query('per_page', (string) config('api.pagination.per_page'));
$max = (int) config('api.pagination.max_per_page');
return max(1, min($requested, $max));
}
/**
* Validation rules a controller merges into its own, so `updated_since`
* is rejected consistently rather than silently ignored when malformed
* — a caller polling with a bad timestamp would otherwise re-read the
* whole table on every tick and never notice.
*
* @return array<string, list<string>>
*/
public function rules(): array
{
return [
'updated_since' => ['nullable', 'date'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:'.(int) config('api.pagination.max_per_page')],
'cursor' => ['nullable', 'string'],
];
}
}
+192
View File
@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Modules\Api\Support;
use App\Modules\Platform\Capabilities\CapabilityUnavailable;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable;
/**
* RFC 7807 error bodies for /api/* only.
*
* Two properties this class exists to guarantee:
*
* - Every API failure has the same shape, so a client can parse errors
* once. `type` is a stable slug a caller may branch on; `title` and
* `detail` are prose that may be reworded without it being a breaking
* change.
* - Nothing leaks. Laravel's default JSON renderer happily returns an
* exception message and stack trace; here the message is only ever
* echoed for exceptions that are meant to be read by the caller
* (validation, explicit HTTP aborts), and anything else collapses to a
* generic 500 unless APP_DEBUG is on.
*/
class ProblemDetails
{
/**
* Slugs are part of the API contract — rename one and you have made a
* breaking change. Keyed by status code for everything that doesn't
* carry a more specific slug of its own.
*
* @var array<int, string>
*/
private const TYPES = [
400 => 'bad_request',
401 => 'unauthenticated',
403 => 'forbidden',
404 => 'not_found',
405 => 'method_not_allowed',
409 => 'conflict',
413 => 'payload_too_large',
422 => 'validation_failed',
429 => 'too_many_requests',
500 => 'server_error',
503 => 'service_unavailable',
];
public function shouldHandle(Request $request): bool
{
return $request->is('api/*');
}
public function render(Request $request, Throwable $e): JsonResponse
{
[$status, $type, $title, $detail, $extra] = $this->describe($e);
$body = array_filter([
'type' => $type,
'title' => $title,
'status' => $status,
'detail' => $detail,
], static fn (mixed $value): bool => $value !== null) + $extra;
$response = new JsonResponse($body, $status);
$response->headers->set('Content-Type', 'application/problem+json');
// Throttling and 405s carry headers the caller genuinely needs
// (Retry-After, Allow); dropping them would make the error
// unactionable, so copy whatever the exception already decided.
if ($e instanceof HttpExceptionInterface) {
foreach ($e->getHeaders() as $name => $value) {
$response->headers->set($name, $value);
}
}
return $response;
}
/**
* @return array{int, string, string, string|null, array<string, mixed>}
*/
private function describe(Throwable $e): array
{
if ($e instanceof ValidationException) {
return [
422,
'validation_failed',
'The given data was invalid.',
null,
['errors' => $e->errors()],
];
}
if ($e instanceof AuthenticationException) {
return [
401,
'unauthenticated',
'Authentication required.',
'Send a valid API token in the Authorization header as "Bearer <token>".',
[],
];
}
if ($e instanceof AuthorizationException) {
return [
403,
'forbidden',
'This action is unauthorized.',
// An authorization failure must not explain itself in
// detail: "you lack delete_others_files" and "that file
// belongs to someone else" are both facts about data the
// caller was just told it cannot see.
null,
[],
];
}
// Before the generic HttpExceptionInterface branch, which would
// flatten this to a bare `forbidden` and drop the two fields that
// tell a caller *why* — an integration wants to distinguish "your
// token may not do that" from "this installation does not have
// that feature", and only the second is worth giving up on.
if ($e instanceof CapabilityUnavailable) {
return [
403,
'capability_unavailable',
'Not available in this edition.',
$e->getMessage(),
['capability' => $e->capability->value, 'edition' => $e->edition->value],
];
}
// Note what this does NOT do: a file that exists but is out of the
// caller's scope answers 403, while an unknown id answers 404, so
// the pair does reveal whether a given id exists. That is the same
// answer the web UI gives (routes/web.php binds the model, then the
// policy refuses), and mirroring it is deliberate — the API adds no
// exposure a staff member does not already have by visiting
// /files/{id}. Collapsing both to 404 would be a stricter boundary
// than the app applies anywhere else, and only in one surface.
if ($e instanceof ModelNotFoundException || $e instanceof NotFoundHttpException) {
return [404, 'not_found', 'Resource not found.', null, []];
}
if ($e instanceof HttpExceptionInterface) {
$status = $e->getStatusCode();
$message = $e->getMessage();
return [
$status,
self::TYPES[$status] ?? 'http_error',
$this->titleFor($status),
$message !== '' ? $message : null,
[],
];
}
return [
500,
'server_error',
'Server error.',
// The only place a raw exception message may surface, and only
// when the operator has explicitly asked for it.
config('app.debug') === true ? $e->getMessage() : null,
[],
];
}
private function titleFor(int $status): string
{
return match ($status) {
400 => 'Bad request.',
401 => 'Authentication required.',
403 => 'This action is unauthorized.',
404 => 'Resource not found.',
405 => 'Method not allowed.',
409 => 'Conflict.',
413 => 'Payload too large.',
429 => 'Too many requests.',
503 => 'Service unavailable.',
default => 'Request failed.',
};
}
}
+334
View File
@@ -0,0 +1,334 @@
<?php
declare(strict_types=1);
namespace App\Modules\Audit;
/**
* Every action the activity log can record — v2's replacement for v1's
* ~45 numbered action types in ActionsLog. Modules add their own cases
* (file.uploaded, group.created, …) as they land; the string values are
* stable identifiers stored in the database.
*/
enum Action: string
{
// Platform / lifecycle (v1 action 0: "ProjectSend has been installed")
case SetupCompleted = 'setup.completed';
case SettingsUpdated = 'settings.updated';
// Identity
case Login = 'auth.login';
case Logout = 'auth.logout';
case UserCreated = 'user.created';
case UserUpdated = 'user.updated';
case UserDeleted = 'user.deleted';
case UserActivated = 'user.activated';
case UserDeactivated = 'user.deactivated';
case AccountErased = 'account.erased';
case AccountContentCascadeDeleted = 'account_content.cascade_deleted';
case AccountContentReassigned = 'account_content.reassigned';
// Deliberately their own cases rather than reusing user.updated: v1
// wrote its LDAP events onto the codes for "approved/denied an account
// request", so every LDAP login rendered in the log as an approval.
case AccountConvertedToClient = 'account.converted_to_client';
case AccountConvertedToStaff = 'account.converted_to_staff';
case ClientSelfRegistered = 'client.self_registered';
case LdapClientProvisioned = 'ldap.client_provisioned';
case SocialClientProvisioned = 'social.client_provisioned';
case SocialAccountLinked = 'social.account_linked';
case SocialAccountUnlinked = 'social.account_unlinked';
case ClientApproved = 'client.approved';
case ClientDenied = 'client.denied';
// Files
case FileUploaded = 'file.uploaded';
case FileUpdated = 'file.updated';
case FileDeleted = 'file.deleted';
case FileDownloaded = 'file.downloaded';
case FilePreviewed = 'file.previewed';
case FileAssigned = 'file.assigned';
case FileUnassigned = 'file.unassigned';
case FileVersionLinked = 'file.version_linked';
case FileVersionUnlinked = 'file.version_unlinked';
case ShareLinkCreated = 'share_link.created';
case ShareLinkRevoked = 'share_link.revoked';
case ShareLinkDownloaded = 'share_link.downloaded';
case PublicFileDownloaded = 'public_file.downloaded';
case FolderCreated = 'folder.created';
case FolderRenamed = 'folder.renamed';
case FolderMoved = 'folder.moved';
case FolderDeleted = 'folder.deleted';
case FolderShared = 'folder.shared';
case FolderUnshared = 'folder.unshared';
case FileMadePublic = 'file.made_public';
case FileMadePrivate = 'file.made_private';
case FolderMadePublic = 'folder.made_public';
case FolderMadePrivate = 'folder.made_private';
case UploadAborted = 'upload.aborted';
case FileImported = 'file.imported';
case OrphanFileDeleted = 'orphan_file.deleted';
case OrphanFileAutoDeleted = 'orphan_file.auto_deleted';
case ExpiredFileDeleted = 'file.expired_deleted';
case GroupMembershipLeft = 'group.membership_left';
case GroupMembershipRequested = 'group.membership_requested';
case GroupMembershipApproved = 'group.membership_approved';
case GroupMembershipDenied = 'group.membership_denied';
case GroupCreated = 'group.created';
case GroupUpdated = 'group.updated';
case GroupDeleted = 'group.deleted';
case GroupMadePublic = 'group.made_public';
case GroupMadePrivate = 'group.made_private';
case GroupMemberAdded = 'group.member_added';
case GroupMemberRemoved = 'group.member_removed';
case RoleCreated = 'role.created';
case RoleUpdated = 'role.updated';
case RoleDeleted = 'role.deleted';
case CategoryCreated = 'category.created';
case CategoryRenamed = 'category.renamed';
case CategoryDeleted = 'category.deleted';
case ClientCustomFieldCreated = 'client_custom_field.created';
case ClientCustomFieldUpdated = 'client_custom_field.updated';
case ClientCustomFieldDeleted = 'client_custom_field.deleted';
case ProfileUpdated = 'profile.updated';
case PasswordUpdated = 'password.updated';
case TwoFactorEnabled = 'two_factor.enabled';
case TwoFactorDisabled = 'two_factor.disabled';
case TwoFactorRecoveryCodesRegenerated = 'two_factor.recovery_codes_regenerated';
// Distinct from TwoFactorDisabled: that is somebody turning off their
// own second factor, this is an administrator turning off somebody
// else's. Same end state, very different thing to find in an audit
// trail — one of them is the shape an account takeover would take.
case TwoFactorReset = 'two_factor.reset';
// API tokens. Minting one creates a long-lived credential that acts
// with the owner's permissions outside any browser session, so both
// ends of its life are audit events in their own right.
case ApiTokenCreated = 'api_token.created';
case ApiTokenUpdated = 'api_token.updated';
case ApiTokenRevoked = 'api_token.revoked';
// Custom assets (community-modules package — see
// App\Modules\Audit\Listeners\LogCustomAssetActivity, which
// translates that package's events into these cases; the package
// itself has no dependency on this enum or on ActivityLogger).
case CustomAssetCreated = 'custom_asset.created';
case CustomAssetUpdated = 'custom_asset.updated';
case CustomAssetDeleted = 'custom_asset.deleted';
case CustomAssetEnabled = 'custom_asset.enabled';
case CustomAssetDisabled = 'custom_asset.disabled';
// Comments. The body is deliberately never recorded in the log's
// context: a comment can be visible to one client only, and the
// activity log is read by staff whose file scope may not include
// that client's thread.
case CommentPosted = 'comment.posted';
// A visitor's comment has no account behind it, so it would otherwise
// log with a null actor and read as "System" — the audit trail saying
// the installation commented on its own file, with the one detail
// moderation cares about (who claimed to write it) thrown away. Same
// reason FileDownloaded, PublicFileDownloaded and ShareLinkDownloaded
// are three cases rather than one: same event, different origin.
case CommentPostedByVisitor = 'comment.posted_by_visitor';
case CommentEdited = 'comment.edited';
case CommentDeleted = 'comment.deleted';
case CommentApproved = 'comment.approved';
/**
* Full sentence for a log row, with :placeholders resolved from the
* entry's subject name (:subject) and context keys. English text is
* the translation key; translations keep the placeholders.
*/
public function template(): string
{
return match ($this) {
self::SetupCompleted => 'Installed ProjectSend',
self::SettingsUpdated => 'Updated the system settings (:section)',
self::Login => 'Logged in',
self::Logout => 'Logged out',
self::UserCreated => 'Created the account ":subject"',
self::UserUpdated => 'Updated the account ":subject"',
self::UserDeleted => 'Deleted the account ":name"',
self::AccountConvertedToClient => 'Converted the staff account ":subject" to a client',
self::AccountConvertedToStaff => 'Converted the client account ":subject" to staff (:role)',
self::LdapClientProvisioned => 'Created a client account from the directory on first sign-in',
self::SocialClientProvisioned => 'Created a client account from :provider on first sign-in',
self::SocialAccountLinked => 'Connected the :provider account of ":subject"',
self::SocialAccountUnlinked => 'Disconnected the :provider account of ":subject"',
self::UserActivated => 'Activated the account ":subject"',
self::UserDeactivated => 'Deactivated the account ":subject"',
self::AccountErased => 'Permanently erased a deleted account',
self::AccountContentCascadeDeleted => 'Deleted :files file(s) and :folders folder(s) belonging to the deleted account ":name"',
self::AccountContentReassigned => 'Reassigned :files file(s) and :folders folder(s) from the deleted account ":name" to :target',
self::ClientSelfRegistered => 'Registered a new client account',
self::ClientApproved => 'Approved the account request of ":subject"',
self::ClientDenied => 'Denied the account request of ":name"',
self::FileUploaded => 'Uploaded the file ":subject"',
self::FileUpdated => 'Updated the file ":subject"',
self::FileDeleted => 'Deleted the file ":name"',
self::FileDownloaded => 'Downloaded the file ":subject"',
self::FilePreviewed => 'Previewed the file ":subject"',
self::FileAssigned => 'Assigned the file ":subject" to :target',
self::FileUnassigned => 'Removed the file ":subject" from :target',
// :previous is a name snapshot in context, not a lookup — same
// reason FileDeleted carries :name: the entry has to still read
// correctly once the original is gone.
self::FileVersionLinked => 'Marked the file ":subject" as a revision of ":previous"',
self::FileVersionUnlinked => 'Removed the version link from the file ":subject"',
self::ShareLinkCreated => 'Created 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::PublicFileDownloaded => 'Downloaded the file ":subject" via the public group listing',
self::FolderCreated => 'Created the folder ":subject"',
self::FolderRenamed => 'Renamed the folder ":subject"',
self::FolderMoved => 'Moved the folder ":subject"',
self::FolderDeleted => 'Deleted the folder ":name" and its contents',
self::FolderShared => 'Shared the folder ":subject" with :target',
self::FolderUnshared => 'Stopped sharing the folder ":subject" with :target',
self::FileMadePublic => 'Made the file ":subject" public',
self::FileMadePrivate => 'Made the file ":subject" private',
self::FolderMadePublic => 'Made the folder ":subject" public',
self::FolderMadePrivate => 'Made the folder ":subject" private',
self::UploadAborted => 'Cancelled uploading the file ":name"',
self::CommentPosted => 'Commented on the file ":subject"',
self::CommentPostedByVisitor => ':guest commented on the file ":subject"',
self::CommentEdited => 'Edited a comment on the file ":subject"',
self::CommentDeleted => 'Deleted a comment on the file ":subject"',
self::CommentApproved => 'Approved a comment on the file ":subject"',
self::FileImported => 'Imported the orphan file ":subject"',
self::OrphanFileDeleted => 'Deleted the orphan file ":name"',
self::OrphanFileAutoDeleted => 'Deleted the orphan file ":name"',
self::ExpiredFileDeleted => 'Deleted the expired file ":name"',
self::GroupMembershipLeft => 'Left the group ":subject"',
self::GroupMembershipRequested => 'Requested membership to the group ":subject"',
self::GroupMembershipApproved => 'Approved the membership of ":member" to the group ":subject"',
self::GroupMembershipDenied => 'Denied the membership of ":member" to the group ":subject"',
self::GroupCreated => 'Created the group ":subject"',
self::GroupUpdated => 'Updated the group ":subject"',
self::GroupDeleted => 'Deleted the group ":name"',
self::GroupMadePublic => 'Made the group ":subject" public',
self::GroupMadePrivate => 'Made the group ":subject" private',
self::GroupMemberAdded => 'Added ":member" to the group ":subject"',
self::GroupMemberRemoved => 'Removed ":member" from the group ":subject"',
self::RoleCreated => 'Created the role ":subject"',
self::RoleUpdated => 'Updated the role ":subject"',
self::RoleDeleted => 'Deleted the role ":name"',
self::CategoryCreated => 'Created the category ":subject"',
self::CategoryRenamed => 'Renamed the category to ":subject"',
self::CategoryDeleted => 'Deleted the category ":name"',
self::ClientCustomFieldCreated => 'Created the custom field ":name"',
self::ClientCustomFieldUpdated => 'Updated the custom field ":name"',
self::ClientCustomFieldDeleted => 'Deleted the custom field ":name"',
self::ProfileUpdated => 'Updated their profile information',
self::PasswordUpdated => 'Changed their password',
self::TwoFactorEnabled => 'Enabled two-factor authentication',
self::TwoFactorDisabled => 'Disabled two-factor authentication',
self::TwoFactorRecoveryCodesRegenerated => 'Regenerated two-factor recovery codes',
self::TwoFactorReset => 'Removed two-factor authentication from ":subject"',
self::ApiTokenCreated => 'Created the API token ":token_name"',
self::ApiTokenUpdated => 'Updated the API token ":token_name"',
self::ApiTokenRevoked => 'Revoked the API token ":token_name"',
self::CustomAssetCreated => 'Created the custom asset ":subject"',
self::CustomAssetUpdated => 'Updated the custom asset ":subject"',
self::CustomAssetDeleted => 'Deleted the custom asset ":name"',
self::CustomAssetEnabled => 'Enabled the custom asset ":subject"',
self::CustomAssetDisabled => 'Disabled the custom asset ":subject"',
};
}
/**
* Generic description for filter dropdowns (also the translation key).
*/
public function description(): string
{
return match ($this) {
self::SetupCompleted => 'ProjectSend was installed',
self::SettingsUpdated => 'System settings were updated',
self::Login => 'Logged in',
self::Logout => 'Logged out',
self::UserCreated => 'An account was created',
self::UserUpdated => 'An account was updated',
self::UserDeleted => 'An account was deleted',
self::AccountConvertedToClient => 'A staff account was converted to a client',
self::AccountConvertedToStaff => 'A client account was converted to staff',
self::LdapClientProvisioned => 'A client account was created from the directory',
self::SocialClientProvisioned => 'A client account was created from :provider',
self::SocialAccountLinked => 'A :provider account was connected',
self::SocialAccountUnlinked => 'A :provider account was disconnected',
self::UserActivated => 'An account was activated',
self::UserDeactivated => 'An account was deactivated',
self::AccountErased => 'An account was permanently erased',
self::AccountContentCascadeDeleted => 'A deleted account\'s files and folders were deleted',
self::AccountContentReassigned => 'A deleted account\'s files and folders were reassigned',
self::ClientSelfRegistered => 'A client registered an account',
self::ClientApproved => 'A client account request was approved',
self::ClientDenied => 'A client account request was denied',
self::FileUploaded => 'A file was uploaded',
self::FileUpdated => 'A file was updated',
self::FileDeleted => 'A file was deleted',
self::FileDownloaded => 'A file was downloaded',
self::FilePreviewed => 'A file was previewed',
self::FileAssigned => 'A file was assigned',
self::FileUnassigned => 'A file assignment was removed',
self::FileVersionLinked => 'A file was marked as a revision of another',
self::FileVersionUnlinked => 'A file version link was removed',
self::ShareLinkCreated => 'A public link was created',
self::ShareLinkRevoked => 'A public link was revoked',
self::ShareLinkDownloaded => 'A file was downloaded via a public link',
self::PublicFileDownloaded => 'A file was downloaded via the public group listing',
self::FolderCreated => 'A folder was created',
self::FolderRenamed => 'A folder was renamed',
self::FolderMoved => 'A folder was moved',
self::FolderDeleted => 'A folder was deleted',
self::FolderShared => 'A folder was shared',
self::FolderUnshared => 'A folder was unshared',
self::FileMadePublic => 'A file was made public',
self::FileMadePrivate => 'A file was made private',
self::FolderMadePublic => 'A folder was made public',
self::FolderMadePrivate => 'A folder was made private',
self::UploadAborted => 'An upload was cancelled',
self::CommentPosted => 'A comment was posted on a file',
self::CommentPostedByVisitor => 'A visitor commented on a file',
self::CommentEdited => 'A comment was edited',
self::CommentDeleted => 'A comment was deleted',
self::CommentApproved => 'A comment was approved',
self::FileImported => 'An orphan file was imported',
self::OrphanFileDeleted => 'An orphan file was deleted',
self::OrphanFileAutoDeleted => 'An orphan file was automatically deleted after its retention grace period passed',
self::ExpiredFileDeleted => 'An expired file was automatically deleted after its retention grace period passed',
self::GroupMembershipLeft => 'A client left a group',
self::GroupMembershipRequested => 'A group membership was requested',
self::GroupMembershipApproved => 'A group membership request was approved',
self::GroupMembershipDenied => 'A group membership request was denied',
self::GroupCreated => 'A group was created',
self::GroupUpdated => 'A group was updated',
self::GroupDeleted => 'A group was deleted',
self::GroupMadePublic => 'A group was made public',
self::GroupMadePrivate => 'A group was made private',
self::GroupMemberAdded => 'A client was added to a group',
self::GroupMemberRemoved => 'A client was removed from a group',
self::RoleCreated => 'A role was created',
self::RoleUpdated => 'A role was updated',
self::RoleDeleted => 'A role was deleted',
self::CategoryCreated => 'A category was created',
self::CategoryRenamed => 'A category was renamed',
self::CategoryDeleted => 'A category was deleted',
self::ClientCustomFieldCreated => 'A custom field was created',
self::ClientCustomFieldUpdated => 'A custom field was updated',
self::ClientCustomFieldDeleted => 'A custom field was deleted',
self::ProfileUpdated => 'Profile information was updated',
self::PasswordUpdated => 'The account password was changed',
self::TwoFactorEnabled => 'Two-factor authentication was enabled',
self::TwoFactorDisabled => 'Two-factor authentication was disabled',
self::TwoFactorRecoveryCodesRegenerated => 'Two-factor recovery codes were regenerated',
self::TwoFactorReset => 'Two-factor authentication was removed by an administrator',
self::ApiTokenCreated => 'An API token was created',
self::ApiTokenUpdated => 'An API token was updated',
self::ApiTokenRevoked => 'An API token was revoked',
self::CustomAssetCreated => 'A custom asset was created',
self::CustomAssetUpdated => 'A custom asset was updated',
self::CustomAssetDeleted => 'A custom asset was deleted',
self::CustomAssetEnabled => 'A custom asset was enabled',
self::CustomAssetDisabled => 'A custom asset was disabled',
};
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Modules\Audit;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property int|null $actor_id
* @property string|null $actor_name
* @property string|null $actor_type
* @property ActivityOrigin $origin
* @property int|null $api_token_id
* @property string|null $api_token_name
* @property string|null $ip_address
* @property Action $action
* @property string|null $subject_type
* @property int|null $subject_id
* @property string|null $subject_name
* @property array<string, mixed>|null $context
* @property Carbon $created_at
*/
class ActivityLog extends Model
{
protected $table = 'activity_log';
// Log entries are immutable: created_at only, written by the logger.
public $timestamps = false;
protected $guarded = [];
protected function casts(): array
{
return [
'action' => Action::class,
'origin' => ActivityOrigin::class,
'context' => 'json',
'created_at' => 'datetime',
];
}
/**
* @return MorphTo<Model, $this>
*/
public function subject(): MorphTo
{
return $this->morphTo();
}
}
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Modules\Audit;
use App\Models\User;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use Illuminate\Database\Eloquent\Builder;
/**
* Which activity-log entries a viewer may read.
*
* `view_actions_log` alone is not the whole answer for a client-scoped
* staff member (see User::isClientScoped). Scoping restricts which library
* content they can reach, but an activity row carries the subject's *name*
* — and, for downloads, who fetched it and from which IP — so an unscoped
* log hands over exactly the information the scope exists to withhold:
* a Client Manager could read the names of every file in the installation
* and who touched them, while getting a 403 on the files themselves. The
* Client Manager system role ships with `view_actions_log`, so this is the
* default configuration, not an exotic one.
*
* A scoped viewer therefore sees an entry when it is about something in
* their scope, or when they did it themselves:
*
* - File / Folder subjects, limited to StaffLibraryScope
* - User subjects, limited to the clients assigned to them
* - anything they are the actor of (their own audit trail stays whole)
*
* Everything else — other staff's logins, settings changes, entries about
* files since deleted (no live row left to test the scope against) —
* is not theirs to read.
*
* An unscoped viewer's log is unchanged: installation-wide, as before.
*/
class ActivityLogScope
{
public function __construct(
private readonly StaffLibraryScope $library,
) {}
/**
* @param Builder<ActivityLog> $query
* @return Builder<ActivityLog>
*/
public function apply(Builder $query, User $viewer): Builder
{
if (! $viewer->isClientScoped()) {
return $query;
}
$fileMorph = (new File)->getMorphClass();
$folderMorph = (new Folder)->getMorphClass();
$userMorph = (new User)->getMorphClass();
$clientIds = $this->library->assignableClientIds($viewer) ?? [];
return $query->where(function (Builder $outer) use ($viewer, $fileMorph, $folderMorph, $userMorph, $clientIds): void {
$outer->where('actor_id', $viewer->id);
$outer->orWhere(fn (Builder $files) => $files
->where('subject_type', $fileMorph)
->whereIn('subject_id', $this->library->files($viewer)->select('id')));
$outer->orWhere(fn (Builder $folders) => $folders
->where('subject_type', $folderMorph)
->whereIn('subject_id', $this->library->folders($viewer)->select('id')));
if ($clientIds !== []) {
$outer->orWhere(fn (Builder $users) => $users
->where('subject_type', $userMorph)
->whereIn('subject_id', $clientIds));
}
});
}
/**
* File ids from $ids the viewer may actually open — used to decide
* whether a row links anywhere. Permission alone was the old test,
* which produced links to files the viewer would get a 403 on.
*
* @param iterable<int> $ids
* @return array<int, bool> keyed by id; presence is the answer
*/
public function openableFileIds(User $viewer, iterable $ids): array
{
$ids = collect($ids)->filter()->unique();
if ($ids->isEmpty()) {
return [];
}
return $this->library->files($viewer)
->whereIn('id', $ids)
->pluck('id')
->mapWithKeys(fn ($id): array => [(int) $id => true])
->all();
}
/**
* @param iterable<int> $ids
* @return array<int, bool> keyed by id; presence is the answer
*/
public function openableFolderIds(User $viewer, iterable $ids): array
{
$ids = collect($ids)->filter()->unique();
if ($ids->isEmpty()) {
return [];
}
return $this->library->folders($viewer)
->whereIn('id', $ids)
->pluck('id')
->mapWithKeys(fn ($id): array => [(int) $id => true])
->all();
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Modules\Audit;
use App\Models\User;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
class ActivityLogger
{
public function __construct(
private readonly Settings $settings,
) {}
/**
* Record an action. The actor defaults to the authenticated user;
* pass one explicitly for flows without a session (CLI, setup).
* Actor and subject names are snapshotted so entries survive
* deletions.
*
* @param array<string, mixed> $context
*/
public function log(Action $action, ?User $actor = null, ?Model $subject = null, array $context = []): void
{
$user = $actor ?? Auth::user();
// How the action arrived is resolved here rather than at each call
// site: the API reuses the same controllers and services the UI does
// (FileDownloadController and StoreUploadedFile are both shared
// verbatim), so asking every caller to remember would guarantee
// gaps. Reading the current request's credential is the same kind of
// implicit lookup this class already does for the actor and the IP.
$token = $user?->currentAccessToken();
ActivityLog::query()->create([
'actor_id' => $user?->getKey(),
'actor_name' => $user?->name,
'actor_type' => $user?->type->value,
'origin' => $this->originFor($user, $token),
'api_token_id' => $token?->getKey(),
// Snapshotted beside the id for the same reason actor_name is:
// a revoked token must not leave its entries pointing at nothing.
'api_token_name' => $token?->getAttribute('name'),
'action' => $action,
'subject_type' => $subject?->getMorphClass(),
'subject_id' => $subject?->getKey(),
'subject_name' => $this->subjectName($subject),
'context' => $context === [] ? null : $context,
'ip_address' => $this->shouldRecordIp($action, $user) ? request()->ip() : null,
'created_at' => now(),
]);
}
/**
* Setting::DownloadIpLogging governs download-shaped entries and
* file previews alike — both are ways of viewing a file's contents,
* so previews would otherwise leak IPs through a privacy setting a
* client believes covers "viewing my files." A security audit trail
* (staff actions, logins, …) always records IP regardless, since
* that's an operational concern, not a client-privacy one.
*/
/**
* A token means the API; an actor without one means a browser session.
* No actor at all is either a console command or a request from
* somebody not signed in — and those are not the same thing, so
* something has to tell them apart rather than both landing on System.
* (Scheduled tasks do not reach here at all: they call logSystem(),
* which sets System outright.)
*
* That something is a matched route, not App::runningInConsole():
* the whole test suite runs in console, so the console check would
* classify every HTTP test as System and quietly make this
* untestable — the failure mode being that it looks right in
* production and nothing proves it. A console command and a queued job
* have no route; a request does.
*/
private function originFor(?User $actor, mixed $token): ActivityOrigin
{
if ($token !== null) {
return ActivityOrigin::Api;
}
if ($actor !== null) {
return ActivityOrigin::Ui;
}
return request()->route() === null ? ActivityOrigin::System : ActivityOrigin::Public;
}
private function shouldRecordIp(Action $action, ?User $actor): bool
{
if (! in_array($action, [Action::FileDownloaded, Action::FilePreviewed, Action::ShareLinkDownloaded, Action::PublicFileDownloaded], true)) {
return true;
}
return match ($this->settings->get(Setting::DownloadIpLogging)) {
'none' => false,
'anonymous_only' => $actor === null,
default => true,
};
}
/**
* Record an action as the system itself, never attributing the
* authenticated user (compliance jobs, scheduled work).
*
* @param array<string, mixed> $context
*/
public function logSystem(Action $action, array $context = []): void
{
ActivityLog::query()->create([
'actor_id' => null,
'actor_name' => null,
'actor_type' => null,
'origin' => ActivityOrigin::System,
'action' => $action,
'subject_type' => null,
'subject_id' => null,
'subject_name' => null,
'context' => $context === [] ? null : $context,
'created_at' => now(),
]);
}
private function subjectName(?Model $subject): ?string
{
if ($subject === null) {
return null;
}
$name = $subject->getAttribute('name') ?? $subject->getAttribute('title');
return is_string($name) ? $name : null;
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Modules\Audit;
/**
* How an audited action reached the application.
*
* Distinct from `actor_type`, which says *who* acted (staff, client, or
* nobody). Origin says *through what*: the same staff member deleting the
* same file from the web UI and from an integration produces two entries
* that are otherwise identical, and an administrator reviewing the log
* needs to tell them apart — "did I do that, or did the Zapier token?" is
* the first question asked when something unexpected shows up.
*/
enum ActivityOrigin: string
{
/** A browser session — the web UI. */
case Ui = 'ui';
/** An API token. `api_token_id` and `api_token_name` are set alongside. */
case Api = 'api';
/**
* A web request with nobody signed in — a visitor commenting on a
* public file today, and whatever else the public surface grows.
*
* Split out of System because the two are not the same thing and were
* being shown with the same word: the scheduler deleting an expired
* file and a stranger leaving a comment both read as "System", which
* made the audit trail claim the installation had commented on its own
* file. Scheduled tasks keep System — they go through
* ActivityLogger::logSystem(), which never asks this method.
*/
case Public = 'public';
/** Scheduled tasks and console commands. */
case System = 'system';
/**
* English label — also the translation key.
*/
public function label(): string
{
return match ($this) {
self::Ui => 'Web UI',
self::Api => 'API',
self::Public => 'Not signed in',
self::System => 'System',
};
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Modules\Audit;
/**
* Turns a log entry into the sentence-ready array the frontend renders,
* so the dashboard, the activity page, and per-item detail panels all
* present entries identically.
*/
class ActivityPresenter
{
/**
* @return array<string, mixed>
*/
public function present(ActivityLog $entry): array
{
return [
'id' => $entry->id,
'created_at' => $entry->created_at->toIso8601String(),
'actor_name' => $entry->actor_name,
'actor_type' => $entry->actor_type,
// Needed to render an actorless entry: "System" and "Anonymous"
// are both actor_name null, and only the origin separates them.
'origin' => $entry->origin->value,
'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(),
],
];
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Modules\Audit;
use App\Modules\Audit\Listeners\LogAuthenticationActivity;
use App\Modules\Audit\Listeners\LogCustomAssetActivity;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
class AuditServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(ActivityLogger::class);
}
public function boot(): void
{
Event::subscribe(LogAuthenticationActivity::class);
Event::subscribe(LogCustomAssetActivity::class);
}
}
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Modules\Audit;
use App\Models\User;
use App\Modules\Audit\Models\DashboardWidgetPreference;
/**
* Per-user dashboard layout: which widgets are shown, which column each
* sits in, and their order within it. Doesn't fit Setting (that's one row
* per key, install-wide — no per-user dimension), same reasoning as
* NotificationPreferences, hence the dedicated table.
*/
class DashboardWidgetPreferences
{
private const DEFAULT_COLUMNS = 2;
/**
* Default (column_index, position) for a widget with no stored row
* yet — a reasonable starting spread, immediately rearrangeable.
*
* @var array<string, array{0: int, 1: int}>
*/
private const DEFAULT_LAYOUT = [
'counters' => [0, 0],
'transfers' => [0, 1],
'recent' => [0, 2],
'top_clients_by_storage' => [1, 0],
'largest_files' => [1, 1],
'system' => [1, 2],
'news' => [1, 3],
'expired_files' => [0, 3],
'api' => [1, 4],
];
public function isEnabled(User $user, string $widgetKey): bool
{
$row = DashboardWidgetPreference::query()
->where('user_id', $user->id)
->where('widget_key', $widgetKey)
->first();
return $row->enabled ?? true;
}
public function columnsFor(User $user): int
{
return $user->dashboard_columns ?? self::DEFAULT_COLUMNS;
}
/**
* @param list<string> $permittedKeys Only widget keys the viewer
* actually holds permission for
* — never reveal the layout of
* one they can't see at all.
* @return array<string, array{enabled: bool, column_index: int, position: int}>
*/
public function layoutFor(User $user, array $permittedKeys): array
{
$rows = DashboardWidgetPreference::query()
->where('user_id', $user->id)
->whereIn('widget_key', $permittedKeys)
->get()
->keyBy('widget_key');
$layout = [];
foreach ($permittedKeys as $key) {
$row = $rows->get($key);
[$defaultColumn, $defaultPosition] = self::DEFAULT_LAYOUT[$key] ?? [0, 0];
$layout[$key] = [
'enabled' => $row->enabled ?? true,
'column_index' => $row->column_index ?? $defaultColumn,
'position' => $row->position ?? $defaultPosition,
];
}
return $layout;
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Modules\Audit;
/**
* Turns a download-action ActivityLog entry (FileDownloaded /
* ShareLinkDownloaded / PublicFileDownloaded) into the flat shape every
* downloads view renders, overriding the actor label for the two
* anonymous public-download actions so callers don't re-derive it.
*/
class DownloadPresenter
{
/**
* @return array{id: int, created_at: string, actor_name: string, actor_type: ?string, ip_address: ?string}
*/
public function present(ActivityLog $entry): array
{
$isPublic = in_array($entry->action, [Action::ShareLinkDownloaded, Action::PublicFileDownloaded], true);
return [
'id' => $entry->id,
'created_at' => $entry->created_at->toIso8601String(),
'actor_name' => match ($entry->action) {
Action::ShareLinkDownloaded => __('Public link'),
Action::PublicFileDownloaded => __('Public listing'),
default => $entry->actor_name ?? __('(deleted account)'),
},
'actor_type' => $isPublic ? null : $entry->actor_type,
'ip_address' => $entry->ip_address,
];
}
}
@@ -0,0 +1,307 @@
<?php
declare(strict_types=1);
namespace App\Modules\Audit\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Audit\ActivityLogScope;
use App\Modules\Audit\ActivityOrigin;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Capabilities\Capability;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use App\Modules\Platform\Localization\LocalDay;
use App\Modules\Platform\Localization\TimezoneRegistry;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ActivityLogController extends Controller
{
public function __construct(
private readonly CapabilityRegistry $capabilities,
private readonly ActivityLogScope $scope,
private readonly TimezoneRegistry $timezones,
) {}
public function index(Request $request): Response
{
$filters = $this->validatedFilters($request);
$viewer = $request->user();
assert($viewer !== null);
$entries = $this->filteredQuery($filters, $viewer)
->paginate(25)
->withQueryString();
$links = $this->linkResolver($entries->getCollection(), $viewer);
return Inertia::render('activity/index', [
'entries' => $entries->getCollection()->map(fn (ActivityLog $entry): array => [
'id' => $entry->id,
'created_at' => $entry->created_at->toIso8601String(),
'actor_name' => $entry->actor_name,
'actor_type' => $entry->actor_type,
'origin' => $entry->origin->value,
'origin_label' => $entry->origin->label(),
'api_token_name' => $entry->api_token_name,
'action' => $entry->action->value,
'template' => $entry->action->template(),
'replacements' => $this->replacements($entry),
'actor_url' => $links($entry->actor_id, User::class),
'subject_url' => $links($entry->subject_id, $entry->subject_type),
])->all(),
'pagination' => [
'page' => $entries->currentPage(),
'last_page' => $entries->lastPage(),
'prev' => $entries->previousPageUrl(),
'next' => $entries->nextPageUrl(),
'total' => $entries->total(),
],
'filters' => $filters,
'actions' => array_map(fn (Action $action): array => [
'key' => $action->value,
'description' => $action->description(),
], Action::cases()),
'origins' => array_map(fn (ActivityOrigin $origin): array => [
'key' => $origin->value,
'label' => $origin->label(),
], ActivityOrigin::cases()),
]);
}
/**
* Streamed CSV export honoring the same filters as the list.
*/
public function export(Request $request): StreamedResponse
{
$filters = $this->validatedFilters($request);
$viewer = $request->user();
assert($viewer !== null);
// Both the filename and every row are stamped in the exporter's
// own zone, so a file named for "today" holds the rows the screen
// showed under that same word.
$timezone = $this->timezones->resolve($viewer);
$filename = 'activity-log-'.now()->setTimezone($timezone)->format('Y-m-d').'.csv';
return response()->streamDownload(function () use ($filters, $viewer, $timezone): void {
$out = fopen('php://output', 'w');
assert($out !== false);
fputcsv($out, ['Date', 'Account', 'Account type', 'Origin', 'API token', 'Action', 'Description', 'Subject', 'Details']);
foreach ($this->filteredQuery($filters, $viewer)->lazy() as $entry) {
fputcsv($out, array_map($this->csvSafe(...), [
// Kept as ISO 8601 — a spreadsheet parses it and the
// offset makes the zone self-describing, so the column
// stays unambiguous once it leaves the app.
$entry->created_at->setTimezone($timezone)->toIso8601String(),
$entry->actor_name ?? 'System',
$entry->actor_type ?? 'system',
$entry->origin->label(),
$entry->api_token_name ?? '',
$entry->action->value,
__($entry->action->template(), $this->replacements($entry)),
$entry->subject_name,
$entry->context === null ? '' : json_encode($entry->context),
]));
}
fclose($out);
}, $filename, ['Content-Type' => 'text/csv']);
}
/**
* Neutralize spreadsheet formula injection. Half the columns here carry
* names the subject chose themselves — a self-registering client picks
* their own, and it lands in `actor_name` — so a value like
* `=HYPERLINK("http://evil/?"&A1,"x")` would execute when an admin opens
* the export. Excel/Sheets/LibreOffice all treat a leading =, +, -, @,
* tab or CR as the start of a formula; prefixing with an apostrophe
* makes the cell literal text while still displaying the value.
*/
private function csvSafe(mixed $value): string
{
$value = is_scalar($value) ? (string) $value : '';
return preg_match('/^[=+\-@\t\r]/', $value) === 1 ? "'".$value : $value;
}
/**
* Builds a resolver mapping (id, morph class) to the edit URL of a
* still-existing object — only when the viewer is allowed to open
* it. Lookups are batched for the current page.
*
* @param Collection<int, ActivityLog> $entries
* @return callable(int|null, string|null): ?string
*/
private function linkResolver(Collection $entries, User $viewer): callable
{
$userIds = $entries->pluck('actor_id')
->merge($entries->where('subject_type', User::class)->pluck('subject_id'))
->filter()
->unique();
$roleIds = $entries->where('subject_type', Role::class)->pluck('subject_id')->filter()->unique();
$groupIds = $entries->where('subject_type', Group::class)->pluck('subject_id')->filter()->unique();
$fileIds = $entries->where('subject_type', File::class)->pluck('subject_id')->filter()->unique();
$folderIds = $entries->where('subject_type', Folder::class)->pluck('subject_id')->filter()->unique();
/** @var array<int, UserType> $users */
$users = $userIds->isEmpty()
? []
: User::query()->whereIn('id', $userIds)->pluck('type', 'id')->all();
/** @var array<int, true> $roles */
$roles = $roleIds->isEmpty()
? []
: Role::query()->whereIn('id', $roleIds)->pluck('id')->mapWithKeys(fn ($id) => [(int) $id => true])->all();
/** @var array<int, true> $groups */
$groups = $groupIds->isEmpty()
? []
: Group::query()->whereIn('id', $groupIds)->pluck('id')->mapWithKeys(fn ($id) => [(int) $id => true])->all();
// Resolved through the viewer's library scope, not a bare existence
// check: permission alone said "this viewer may open files", which
// for a client-scoped viewer produced links to files they would get
// a 403 on.
$files = $this->scope->openableFileIds($viewer, $fileIds);
$folders = $this->scope->openableFolderIds($viewer, $folderIds);
$staffModule = $this->capabilities->has(Capability::UsersManage) && $viewer->can('manage_users');
$canStaff = $staffModule && $viewer->can('edit_users');
$canClients = $viewer->can('edit_clients');
$canGroups = $viewer->can('edit_groups');
$canFiles = $viewer->can('upload') || $viewer->can('edit_files') || $viewer->can('edit_others_files');
return function (?int $id, ?string $morphClass) use ($users, $roles, $groups, $files, $folders, $canStaff, $canClients, $canGroups, $canFiles, $staffModule): ?string {
if ($id === null) {
return null;
}
if ($morphClass === User::class && isset($users[$id])) {
return match (true) {
$users[$id] === UserType::Staff && $canStaff => route('users.edit', $id, false),
$users[$id] === UserType::Client && $canClients => route('clients.edit', $id, false),
default => null,
};
}
if ($morphClass === Role::class && isset($roles[$id]) && $staffModule) {
return route('roles.edit', $id, false);
}
if ($morphClass === Group::class && isset($groups[$id]) && $canGroups) {
return route('groups.edit', $id, false);
}
if ($morphClass === File::class && isset($files[$id]) && $canFiles) {
return route('files.edit', $id, false);
}
if ($morphClass === Folder::class && isset($folders[$id]) && $canFiles) {
return route('files.index', ['folder' => $id], false);
}
return null;
};
}
/**
* Placeholder values for the entry's sentence template: the subject
* name plus any scalar context values.
*
* @return array<string, string>
*/
private function replacements(ActivityLog $entry): array
{
$replacements = [
'subject' => $entry->subject_name
?? ($entry->subject_id !== null ? __('(deleted account)') : ''),
];
foreach ($entry->context ?? [] as $key => $value) {
if (is_scalar($value)) {
$replacements[$key] = (string) $value;
}
}
return $replacements;
}
/**
* @return array{action: ?string, actor_type: ?string, origin: ?string, api_token: ?string, actor: ?string, from: ?string, to: ?string}
*/
private function validatedFilters(Request $request): array
{
$validated = $request->validate([
'action' => ['nullable', Rule::enum(Action::class)],
'actor_type' => ['nullable', Rule::in(['staff', 'client', 'system'])],
'origin' => ['nullable', Rule::enum(ActivityOrigin::class)],
'api_token' => ['nullable', 'string', 'max:255'],
'actor' => ['nullable', 'string', 'max:255'],
'from' => ['nullable', 'date'],
'to' => ['nullable', 'date', 'after_or_equal:from'],
]);
return [
'action' => $validated['action'] ?? null,
'actor_type' => $validated['actor_type'] ?? null,
'origin' => $validated['origin'] ?? null,
'api_token' => $validated['api_token'] ?? null,
'actor' => $validated['actor'] ?? null,
'from' => $validated['from'] ?? null,
'to' => $validated['to'] ?? null,
];
}
/**
* @param array{action: ?string, actor_type: ?string, origin: ?string, api_token: ?string, actor: ?string, from: ?string, to: ?string} $filters
* @return Builder<ActivityLog>
*/
private function filteredQuery(array $filters, User $viewer): Builder
{
$timezone = $this->timezones->resolve($viewer);
return $this->scope->apply(ActivityLog::query(), $viewer)
->when($filters['action'], fn (Builder $query, string $action) => $query->where('action', $action))
->when($filters['origin'], fn (Builder $query, string $origin) => $query->where('origin', $origin))
// Matched on the snapshotted name rather than the id, so a
// revoked token's history stays reachable — which is exactly
// when someone follows this link.
->when($filters['api_token'], fn (Builder $query, string $token) => $query->where('api_token_name', $token))
->when($filters['actor_type'], fn (Builder $query, string $type) => $type === 'system'
? $query->whereNull('actor_type')
: $query->where('actor_type', $type))
->when($filters['actor'], fn (Builder $query, string $actor) => $query->where('actor_name', 'like', "%{$actor}%"))
// Bounded by the viewer's own day rather than the UTC one —
// see LocalDay for why whereDate() cannot do this.
->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,479 @@
<?php
declare(strict_types=1);
namespace App\Modules\Audit\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Api\ApiUsage;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Audit\DashboardWidgetPreferences;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Files\Models\File;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Capabilities\Capability;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use App\Modules\Platform\Installation\Installation;
use App\Modules\Platform\Localization\TimezoneRegistry;
use App\Modules\Platform\News\NewsItems;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Storage\StorageDurability;
use App\Modules\Platform\System\SystemEnvironment;
use App\Modules\Platform\Updates\LatestReleaseInfo;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
/**
* The dashboard (brief §6.11: dashboard & reporting live in Audit).
* Staff widgets are permission-gated individually (v1 keys:
* view_dashboard_counters, view_statistics, view_actions_log,
* view_system_info); clients get their own portal variant.
*/
class DashboardController extends Controller
{
public function __construct(
private readonly CapabilityRegistry $capabilities,
private readonly ClientStorageUsage $storageUsage,
private readonly LatestReleaseInfo $latestRelease,
private readonly NewsItems $newsItems,
private readonly DashboardWidgetPreferences $widgetPrefs,
private readonly Settings $settings,
private readonly ApiUsage $apiUsage,
private readonly StorageDurability $storageDurability,
private readonly Installation $installation,
private readonly TimezoneRegistry $timezones,
private readonly SystemEnvironment $environment,
) {}
public function __invoke(Request $request): Response
{
$user = $request->user();
assert($user !== null);
if ($user->isClient()) {
return $this->clientDashboard($user);
}
$timezone = $this->timezones->resolve($user);
[$from, $to, $preset] = $this->resolveTransferRange($request, $timezone);
$canCounters = $user->can('view_dashboard_counters');
$canStatistics = $user->can('view_statistics');
$canActionsLog = $user->can('view_actions_log');
$canSystem = $user->can('view_system_info') && $this->capabilities->has(Capability::SystemUpdates);
$canNews = $user->can('view_news');
$canExpiredFiles = $user->can('view_statistics');
$prefs = $this->widgetPrefs;
return Inertia::render('dashboard', [
'counters' => $canCounters && $prefs->isEnabled($user, 'counters') ? $this->counters() : null,
'transfers' => $canStatistics && $prefs->isEnabled($user, 'transfers') ? $this->transferSeries($from, $to, $timezone) : null,
'transfers_range' => $canStatistics && $prefs->isEnabled($user, 'transfers')
? ['preset' => $preset, 'from' => $from->toDateString(), 'to' => $to->toDateString()]
: null,
'top_clients_by_storage' => $canStatistics && $prefs->isEnabled($user, 'top_clients_by_storage')
? $this->topClientsByStorage()
: null,
'largest_files' => $canStatistics && $prefs->isEnabled($user, 'largest_files') ? $this->largestFiles($user) : null,
'recent' => $canActionsLog && $prefs->isEnabled($user, 'recent') ? $this->recentActivity() : null,
'system' => $canSystem && $prefs->isEnabled($user, 'system') ? $this->systemInfo() : null,
// Both editions — informational content, not an update action,
// so no Capability check alongside the permission (unlike
// 'system' above).
'news' => $canNews && $prefs->isEnabled($user, 'news') ? $this->newsItems->current() : null,
'expired_files' => $canExpiredFiles && $prefs->isEnabled($user, 'expired_files') ? $this->expiredFiles($user) : null,
// Not permission-gated: every staff member has tokens to look
// after, and it scopes itself to the viewer's own — the same
// rule the API dashboard applies.
'api' => $prefs->isEnabled($user, 'api') ? $this->apiUsage($user) : null,
// The layout is filtered to only the keys this viewer holds
// permission for — never reveal a permission-hidden widget's
// existence to the Widgets modal, same "don't tease
// unavailable features" convention as EnsureCapability's 404.
'widget_layout' => $prefs->layoutFor($user, $this->permittedWidgetKeys([
'counters' => $canCounters,
'transfers' => $canStatistics,
'top_clients_by_storage' => $canStatistics,
'largest_files' => $canStatistics,
'recent' => $canActionsLog,
'system' => $canSystem,
'news' => $canNews,
'expired_files' => $canExpiredFiles,
'api' => true,
])),
'dashboard_columns' => $prefs->columnsFor($user),
]);
}
/**
* A glance at the viewer's own API usage, with the detail a click away
* on the API dashboard.
*
* @return array<string, mixed>
*/
private function apiUsage(User $user): array
{
return [
'requests_7d' => $this->apiUsage->requestsSince($user, false, now()->subDays(7)),
'tokens' => $user->tokens()->count(),
'expired_tokens' => $user->tokens()->whereNotNull('expires_at')->where('expires_at', '<', now())->count(),
'last_used_at' => $user->tokens()->max('last_used_at'),
];
}
/**
* @param array<string, bool> $permissions
* @return list<string>
*/
private function permittedWidgetKeys(array $permissions): array
{
return array_keys(array_filter($permissions));
}
/**
* Reads the Transfers widget's date-range controls off the query
* string (?range=last_week|last_month|previous_month|custom, plus
* ?from=&to= for custom). Defaults to last_month — the same rolling
* 30-day window the widget always showed before the selector existed.
*
* Every boundary is built in the viewer's zone, so "last week" ends
* when their evening does and not at whatever hour UTC midnight falls
* on for them. The returned instants are still absolute — only the
* day edges moved — so they compare against the UTC column directly.
*
* @return array{0: Carbon, 1: Carbon, 2: string}
*/
private function resolveTransferRange(Request $request, string $timezone): array
{
$preset = $request->query('range');
$now = fn (): Carbon => now()->setTimezone($timezone);
return match ($preset) {
'last_week' => [$now()->subDays(6)->startOfDay(), $now()->endOfDay(), 'last_week'],
'previous_month' => (function () use ($now): array {
$previousMonth = $now()->subMonth();
return [$previousMonth->copy()->startOfMonth(), $previousMonth->copy()->endOfMonth(), 'previous_month'];
})(),
'custom' => $this->resolveCustomTransferRange($request, $timezone),
default => [$now()->subDays(29)->startOfDay(), $now()->endOfDay(), 'last_month'],
};
}
/**
* @return array{0: Carbon, 1: Carbon, 2: string}
*/
private function resolveCustomTransferRange(Request $request, string $timezone): array
{
$now = fn (): Carbon => now()->setTimezone($timezone);
try {
$from = $request->query('from') !== null ? Carbon::parse($request->query('from'), $timezone)->startOfDay() : null;
$to = $request->query('to') !== null ? Carbon::parse($request->query('to'), $timezone)->endOfDay() : null;
} catch (\Throwable) {
return [$now()->subDays(29)->startOfDay(), $now()->endOfDay(), 'last_month'];
}
$from ??= $now()->subDays(29)->startOfDay();
$to ??= $now()->endOfDay();
if ($from->gt($to)) {
[$from, $to] = [$to->copy()->startOfDay(), $from->copy()->endOfDay()];
}
// A year is plenty for a "custom range" on a daily-granularity
// chart — anything longer just clips to the most recent year
// rather than rendering an unreadable multi-year line.
if ($from->diffInDays($to) > 366) {
$from = $to->copy()->subDays(366)->startOfDay();
}
return [$from, $to, 'custom'];
}
/**
* @return array<string, int>
*/
private function counters(): array
{
return [
'files' => File::query()->count(),
'files_bytes' => (int) File::query()->sum('size'),
'clients' => User::query()->where('type', UserType::Client)->count(),
'groups' => Group::query()->count(),
'users' => User::query()->where('type', UserType::Staff)->count(),
];
}
/**
* Uploads vs downloads per day across the given range, zero-filled.
* Downloads split into "by clients" and "anonymous" — anonymous
* covers unauthenticated share-link/public-listing downloads, the
* traffic an admin has no other visibility into. Staff downloads
* (an admin grabbing a file to check it) count toward neither
* bucket; they're not the audience-facing traffic this chart tracks.
*
* @return list<array{date: string, uploads: int, downloads_clients: int, downloads_anonymous: int}>
*/
private function transferSeries(Carbon $from, Carbon $to, string $timezone): array
{
// Two forms of the same set: string values for the SQL whereIn,
// enum cases for filtering the already-cast `action` attribute
// once loaded — ActivityLog::casts() turns it into an Action
// instance, which a raw string never loosely equals.
$downloadActions = [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded];
$rows = ActivityLog::query()
->whereIn('action', [Action::FileUploaded->value, ...array_map(fn (Action $a): string => $a->value, $downloadActions)])
->whereBetween('created_at', [$from, $to])
->get(['action', 'actor_type', 'created_at'])
// Bucketed by the viewer's calendar day. Grouping on the UTC
// one puts an evening upload from anywhere west of Greenwich
// on tomorrow's bar, which the person who made it reads as
// the chart being a day out.
->groupBy(fn (ActivityLog $entry): string => $entry->created_at->copy()->setTimezone($timezone)->format('Y-m-d'));
$series = [];
// $from and $to already carry the viewer's zone (see
// resolveTransferRange), so these day keys line up with the
// grouping above.
$cursor = $from->copy()->startOfDay();
$lastDay = $to->copy()->startOfDay();
while ($cursor->lte($lastDay)) {
$date = $cursor->format('Y-m-d');
$entries = $rows->get($date, collect());
$downloads = $entries->whereIn('action', $downloadActions);
$series[] = [
'date' => $date,
'uploads' => $entries->where('action', Action::FileUploaded)->count(),
'downloads_clients' => $downloads->where('actor_type', UserType::Client->value)->count(),
'downloads_anonymous' => $downloads->whereNull('actor_type')->count(),
];
$cursor->addDay();
}
return $series;
}
/**
* The 5 clients using the most storage, each against their own
* effective quota (own override, or the site default — see
* ClientStorageUsage::quotaMb()) so the widget reads the same way
* the client-facing usage box does.
*
* @return list<array{id: int, name: string, used_bytes: int, quota_mb: int}>
*/
private function topClientsByStorage(): array
{
$rows = File::query()
->select('uploaded_by', DB::raw('SUM(size) as total_bytes'))
->whereHas('uploader', fn ($query) => $query->where('type', UserType::Client))
->groupBy('uploaded_by')
->orderByDesc('total_bytes')
->limit(5)
->get();
if ($rows->isEmpty()) {
return [];
}
$clients = User::query()->whereIn('id', $rows->pluck('uploaded_by'))->get()->keyBy('id');
$result = [];
foreach ($rows as $row) {
$client = $clients->get($row->uploaded_by);
if ($client === null) {
continue;
}
$result[] = [
'id' => (int) $row->uploaded_by,
'name' => $client->name,
'used_bytes' => (int) $row->getAttribute('total_bytes'),
'quota_mb' => $this->storageUsage->quotaMb($client),
];
}
return $result;
}
/**
* The 10 largest individual files on the installation, regardless of
* uploader — catches a single space hog that per-client aggregates
* (topClientsByStorage()) can't surface on their own.
*
* Edit/download/uploader links are coarse-gated on the viewer's own
* permissions only (same level of precision ActivityLogController's
* own link resolver uses) — not a per-file StaffLibraryScope check,
* so a client-scoped staff member could still see a link here that
* 403s if clicked. Accepted, matching existing precedent, rather
* than adding per-row scope checks to a 10-row dashboard widget.
*
* @return list<array{id: int, name: string, size: int, uploader_name: ?string, created_at: string, edit_url: ?string, download_url: ?string, uploader_edit_url: ?string}>
*/
private function largestFiles(User $viewer): array
{
// FilePolicy::view() — the ability both files.edit and
// files.download authorize against — requires one of these for
// staff. Client-facing files never appear here as an uploader
// (clients don't have "view" gated the same way), so this is the
// one check both links share.
$canFiles = $viewer->can('upload') || $viewer->can('edit_files') || $viewer->can('edit_others_files');
$canClients = $viewer->can('edit_clients');
$staffModule = $this->capabilities->has(Capability::UsersManage) && $viewer->can('manage_users');
$canStaffUsers = $staffModule && $viewer->can('edit_users');
return array_values(File::query()
->with('uploader:id,name,type')
->orderByDesc('size')
->limit(10)
->get(['id', 'name', 'size', 'uploaded_by', 'created_at'])
->map(function (File $file) use ($canFiles, $canClients, $canStaffUsers): array {
$uploader = $file->uploader; // uploaded_by is nullOnDelete — may be gone.
return [
'id' => $file->id,
'name' => $file->name,
'size' => $file->size,
'uploader_name' => $uploader?->name,
'created_at' => $file->created_at?->toIso8601String() ?? '',
'edit_url' => $canFiles ? route('files.edit', $file->id, false) : null,
'download_url' => $canFiles ? route('files.download', $file->id, false) : null,
'uploader_edit_url' => match (true) {
$uploader === null => null,
$uploader->type === UserType::Client && $canClients => route('clients.edit', $uploader->id, false),
$uploader->type === UserType::Staff && $canStaffUsers => route('users.edit', $uploader->id, false),
default => null,
},
];
})->all());
}
/**
* The 10 soonest-expired files still awaiting the daily purge, plus
* enough context (is auto-delete even on, when does the job next run)
* for the widget to explain what's about to happen to them.
*
* @return array{count: int, files: list<array{id: int, name: string, expires_at: ?string, edit_url: ?string}>, auto_delete_enabled: bool, next_run_at: string}
*/
private function expiredFiles(User $viewer): array
{
// Same coarse gate largestFiles() uses for its edit links.
$canFiles = $viewer->can('upload') || $viewer->can('edit_files') || $viewer->can('edit_others_files');
return [
'count' => File::query()->expired()->count(),
'files' => array_values(File::query()->expired()->orderBy('expires_at')->limit(10)
->get(['id', 'name', 'expires_at'])
->map(fn (File $file): array => [
'id' => $file->id,
'name' => $file->name,
'expires_at' => $file->expires_at?->toIso8601String(),
'edit_url' => $canFiles ? route('files.edit', $file->id, false) : null,
])->all()),
'auto_delete_enabled' => (bool) $this->settings->get(Setting::ExpiredFilesAutoDeleteEnabled),
// Schedule::command('projectsend:purge-expired-files')->daily()
// runs at 00:00 — always "tonight" from whenever this loads.
'next_run_at' => now()->copy()->startOfDay()->addDay()->toIso8601String(),
];
}
/**
* @return array<int, array<string, mixed>>
*/
private function recentActivity(): array
{
return ActivityLog::query()
->orderByDesc('created_at')
->orderByDesc('id')
->limit(8)
->get()
->map(fn (ActivityLog $entry): array => [
'id' => $entry->id,
'created_at' => $entry->created_at->toIso8601String(),
'actor_name' => $entry->actor_name,
'actor_type' => $entry->actor_type,
'template' => $entry->action->template(),
'replacements' => [
'subject' => $entry->subject_name
?? ($entry->subject_id !== null ? __('(deleted account)') : ''),
...collect($entry->context ?? [])
->filter(fn ($value): bool => is_scalar($value))
->map(fn ($value): string => (string) $value)
->all(),
],
])->all();
}
/**
* @return array<string, string|int|bool|array<string, string|null>|null>
*/
private function systemInfo(): array
{
$freeBytes = @disk_free_space(storage_path('app/files'));
// Cached by CheckForUpdatesCommand (daily) — never a live HTTP
// call from the request path. null means either no successful
// check yet, or the current version is already the latest.
$release = $this->latestRelease->current();
return [
...$this->environment->toArray(),
'storage_used_bytes' => (int) File::query()->sum('size'),
'storage_free_bytes' => $freeBytes === false ? -1 : (int) $freeBytes,
'update_available' => $release !== null,
'latest_version' => $release['version'] ?? null,
'release_url' => $release['url'] ?? null,
// Null unless this is a container whose uploads still go to the
// local disk — see StorageDurability.
'storage_durability' => $this->storageDurability->inspect(),
// Decides which upgrade instructions the card prints — see
// Installation. Always present, unlike storage_durability, which
// is null whenever the durability question does not apply.
'install_kind' => $this->installation->kind()->value,
];
}
private function clientDashboard(User $client): Response
{
$assignedFiles = File::query()->whereHas('assignments', function ($query) use ($client): void {
$query->where(function ($direct) use ($client): void {
$direct->where('assignable_type', User::class)->where('assignable_id', $client->id);
})->orWhere(function ($viaGroup) use ($client): void {
$viaGroup->where('assignable_type', Group::class)
->whereIn('assignable_id', $client->memberOfGroups()->pluck('groups.id'));
});
});
return Inertia::render('portal/dashboard', [
'files_count' => (clone $assignedFiles)->count(),
'groups_count' => $client->memberOfGroups()->where('public', true)->count(),
'storage' => [
'used_bytes' => $this->storageUsage->usedBytes($client),
'quota_bytes' => $this->storageUsage->quotaBytes($client) ?: null,
],
'latest_files' => $assignedFiles->orderByDesc('created_at')->limit(5)->get()
->map(fn (File $file): array => [
'id' => $file->id,
'name' => $file->name,
'size' => $file->size,
'created_at' => $file->created_at?->toIso8601String(),
])->all(),
]);
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Modules\Audit\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Audit\Models\DashboardWidgetPreference;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
/**
* Saves a staff member's dashboard layout (per-widget enabled/column/
* position, plus column count) — every account manages their own, same
* "self-scoped, no extra permission needed" shape as
* NotificationPreferencesController. Which widgets a viewer can even
* toggle is enforced entirely by DashboardController's own permission
* checks on read — this endpoint only ever stores a preference, it never
* grants visibility a viewer's Gates don't already allow.
*/
class DashboardWidgetPreferencesController extends Controller
{
/**
* Every key DashboardController::__invoke() knows how to render —
* kept here as the single validation allowlist so a stray/typo'd key
* can't accumulate a dead row.
*/
private const WIDGET_KEYS = [
'counters',
'transfers',
'top_clients_by_storage',
'largest_files',
'recent',
'system',
'news',
'expired_files',
'api',
];
public function update(Request $request): RedirectResponse
{
$user = $request->user();
assert($user !== null);
$validated = $request->validate([
'columns' => ['required', 'integer', 'between:1,4'],
'widgets' => ['required', 'array'],
'widgets.*.widget_key' => ['required', 'string', Rule::in(self::WIDGET_KEYS)],
'widgets.*.enabled' => ['required', 'boolean'],
'widgets.*.column_index' => ['required', 'integer', 'between:0,3'],
'widgets.*.position' => ['required', 'integer', 'min:0'],
]);
foreach ($validated['widgets'] as $widget) {
DashboardWidgetPreference::query()->updateOrCreate(
['user_id' => $user->id, 'widget_key' => $widget['widget_key']],
[
'enabled' => $widget['enabled'],
'column_index' => $widget['column_index'],
'position' => $widget['position'],
],
);
}
$user->update(['dashboard_columns' => $validated['columns']]);
return back();
}
}
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Modules\Audit\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Audit\ActivityLogScope;
use App\Modules\Audit\DownloadPresenter;
use App\Modules\Files\Models\File;
use App\Support\Pagination;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
/**
* Installation-wide download history — every FileDownloaded /
* ShareLinkDownloaded / PublicFileDownloaded entry across every file,
* newest first. The per-file downloads tab/history (FileDetailsController)
* covers a single file; this is the "all of them" view linked from the
* sidebar.
*/
class DownloadsController extends Controller
{
public function __construct(
private readonly DownloadPresenter $presenter,
private readonly ActivityLogScope $scope,
) {}
public function index(Request $request): Response
{
$viewer = $request->user();
assert($viewer !== null);
// A download row names the file and says who fetched it from which
// IP, so it needs the viewer's library scope applied — not just
// `view_actions_log`. See ActivityLogScope for the full reasoning.
$entries = $this->scope
->apply(ActivityLog::query(), $viewer)
->where('subject_type', (new File)->getMorphClass())
->whereIn('action', [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded])
->orderByDesc('created_at')
->orderByDesc('id')
->paginate(25)
->withQueryString();
$canOpenFiles = $viewer->can('upload') || $viewer->can('edit_files') || $viewer->can('edit_others_files');
// Openable, not merely existing — the scope decides, so a row never
// links to a file the viewer would be refused.
$openableFileIds = $this->scope->openableFileIds($viewer, $entries->getCollection()->pluck('subject_id'));
return Inertia::render('activity/downloads', [
'entries' => $entries->getCollection()->map(function (ActivityLog $entry) use ($openableFileIds, $canOpenFiles): array {
$openable = $entry->subject_id !== null && isset($openableFileIds[$entry->subject_id]);
return [
...$this->presenter->present($entry),
'file_name' => $entry->subject_name ?? __('(deleted file)'),
'file_url' => $openable && $canOpenFiles ? route('files.edit', $entry->subject_id, false) : null,
];
})->all(),
'pagination' => Pagination::meta($entries),
]);
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Modules\Audit\Listeners;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use Illuminate\Auth\Events\Login;
use Illuminate\Auth\Events\Logout;
use Illuminate\Events\Dispatcher;
class LogAuthenticationActivity
{
public function __construct(
private readonly ActivityLogger $activity,
) {}
public function handleLogin(Login $event): void
{
if ($event->user instanceof User) {
$this->activity->log(Action::Login, $event->user);
}
}
public function handleLogout(Logout $event): void
{
if ($event->user instanceof User) {
$this->activity->log(Action::Logout, $event->user);
}
}
/**
* @return array<class-string, string>
*/
public function subscribe(Dispatcher $events): array
{
return [
Login::class => 'handleLogin',
Logout::class => 'handleLogout',
];
}
}
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Modules\Audit\Listeners;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Events\Dispatcher;
use ProjectSend\CommunityModules\Modules\CustomAssets\Events\CustomAssetCreated;
use ProjectSend\CommunityModules\Modules\CustomAssets\Events\CustomAssetDeleted;
use ProjectSend\CommunityModules\Modules\CustomAssets\Events\CustomAssetToggled;
use ProjectSend\CommunityModules\Modules\CustomAssets\Events\CustomAssetUpdated;
/**
* Translates the community-modules Custom Assets package's own events
* into real ActivityLog entries. The package can't do this itself: its
* events carry no dependency on this app's Action enum or
* ActivityLogger (see CustomAssetCreated's docblock) so it stays
* testable standalone — this listener is the other half of that
* contract, living here instead.
*/
class LogCustomAssetActivity
{
public function __construct(
private readonly ActivityLogger $activity,
) {}
public function handleCreated(CustomAssetCreated $event): void
{
$this->activity->log(Action::CustomAssetCreated, $this->actor($event->actor), $event->asset);
}
public function handleUpdated(CustomAssetUpdated $event): void
{
$this->activity->log(Action::CustomAssetUpdated, $this->actor($event->actor), $event->asset);
}
public function handleToggled(CustomAssetToggled $event): void
{
$this->activity->log(
$event->enabled ? Action::CustomAssetEnabled : Action::CustomAssetDisabled,
$this->actor($event->actor),
$event->asset,
);
}
public function handleDeleted(CustomAssetDeleted $event): void
{
$this->activity->log(Action::CustomAssetDeleted, $this->actor($event->actor), context: ['name' => $event->assetTitle]);
}
private function actor(?Authenticatable $actor): ?User
{
return $actor instanceof User ? $actor : null;
}
/**
* @return array<class-string, string>
*/
public function subscribe(Dispatcher $events): array
{
return [
CustomAssetCreated::class => 'handleCreated',
CustomAssetUpdated::class => 'handleUpdated',
CustomAssetToggled::class => 'handleToggled',
CustomAssetDeleted::class => 'handleDeleted',
];
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Modules\Audit\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* A user's explicit choice for one dashboard widget's visibility and
* position. An absent row for a given (user, widget_key) pair means "use
* the documented default layout" — see
* DashboardWidgetPreferences::layoutFor().
*
* @property int $id
* @property int $user_id
* @property string $widget_key
* @property bool $enabled
* @property int $column_index
* @property int $position
*/
class DashboardWidgetPreference extends Model
{
protected $table = 'dashboard_widget_preferences';
protected $guarded = [];
protected function casts(): array
{
return [
'enabled' => 'bool',
'column_index' => 'integer',
'position' => 'integer',
];
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients;
/**
* The input types a custom field definition can render as. "Select"
* fields carry their choices in the field's `options` column; the rest
* are self-describing.
*/
enum ClientCustomFieldType: string
{
case Text = 'text';
case Textarea = 'textarea';
case Select = 'select';
case Checkbox = 'checkbox';
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients;
/**
* The client-facing forms a custom field can be placed on.
*/
enum ClientFieldContext: string
{
case Registration = 'registration';
case AccountEdit = 'account_edit';
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients;
/**
* Whether a custom field is exposed to the client themself, and if so,
* whether they can keep changing it or only set it once. "EditableOnce"
* locks the field read-only as soon as the client has a stored value —
* useful for things like a terms-acceptance checkbox.
*/
enum ClientFieldEditability: string
{
case Hidden = 'hidden';
case Editable = 'editable';
case EditableOnce = 'editable_once';
}
@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients;
use App\Models\User;
use App\Modules\Clients\Models\ClientCustomField;
use App\Modules\Clients\Models\ClientCustomFieldValue;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Collection as BaseCollection;
use Illuminate\Validation\Rule;
/**
* The client-facing counterpart to `ClientsController`'s custom-field
* handling: which field definitions a client sees on the registration
* form and/or their account page, whether any are locked (see
* `ClientFieldEditability::EditableOnce`), and the validation/save logic
* shared by both surfaces.
*
* Locked fields are never validated or written to, even if a submission
* includes a value for one — the disabled input on the frontend is backed
* by a real server-side guarantee, not just UI convention.
*/
class ClientPortalCustomFields
{
/**
* @return Collection<int, ClientCustomField>
*/
public function fieldsFor(ClientFieldContext $context): Collection
{
return ClientCustomField::query()
->where('client_editability', '!=', ClientFieldEditability::Hidden->value)
->whereJsonContains('client_contexts', $context->value)
->orderBy('sort_order')
->orderBy('id')
->get();
}
/**
* @return list<array<string, mixed>>
*/
public function rows(ClientFieldContext $context, ?User $client): array
{
$values = $this->storedValues($client);
$rows = [];
foreach ($this->fieldsFor($context) as $field) {
$rows[] = [
'id' => $field->id,
'label' => $field->label,
'type' => $field->type->value,
'options' => $field->options,
'required' => $field->required,
'locked' => $this->isLocked($field, $values),
];
}
return $rows;
}
/**
* Keyed by field id — PHP normalizes the numeric string key back to an
* int, same as every other numeric-keyed array in this class.
*
* @return array<int, string>
*/
public function values(ClientFieldContext $context, ?User $client): array
{
if ($client === null) {
return [];
}
$values = $this->storedValues($client);
$rows = [];
foreach ($this->fieldsFor($context) as $field) {
$rows[$field->id] = $values->get($field->id, '');
}
return $rows;
}
/**
* @return array<string, array<int, mixed>>
*/
public function rules(ClientFieldContext $context, ?User $client): array
{
$values = $this->storedValues($client);
$rules = [];
foreach ($this->fieldsFor($context) as $field) {
if ($this->isLocked($field, $values)) {
continue;
}
$key = "custom_field_values.{$field->id}";
if ($field->type === ClientCustomFieldType::Checkbox) {
// Unlike the admin-side rules, a required checkbox here
// must actually be checked — "required" alone doesn't
// enforce that (a '0' string satisfies it).
$rules[$key] = $field->required ? ['accepted'] : ['nullable', 'boolean'];
continue;
}
$rules[$key] = [$field->required ? 'required' : 'nullable', 'string', 'max:2000'];
if ($field->type === ClientCustomFieldType::Select && is_array($field->options)) {
$rules[$key][] = Rule::in($field->options);
}
}
return $rules;
}
/**
* @param array<int, mixed> $submitted field id => submitted value
*/
public function save(User $client, ClientFieldContext $context, array $submitted): void
{
$values = $this->storedValues($client);
foreach ($this->fieldsFor($context) as $field) {
if ($this->isLocked($field, $values)) {
continue;
}
$value = $submitted[$field->id] ?? null;
$stored = $field->type === ClientCustomFieldType::Checkbox
? ($value ? '1' : '0')
: (is_string($value) ? $value : null);
ClientCustomFieldValue::query()->updateOrCreate(
['client_custom_field_id' => $field->id, 'user_id' => $client->id],
['value' => $stored === '' ? null : $stored],
);
}
}
/**
* @return BaseCollection<int, string>
*/
private function storedValues(?User $client): BaseCollection
{
if ($client === null) {
return collect();
}
return ClientCustomFieldValue::query()
->where('user_id', $client->id)
->pluck('value', 'client_custom_field_id');
}
/**
* @param BaseCollection<int, string> $values
*/
private function isLocked(ClientCustomField $field, BaseCollection $values): bool
{
return $field->client_editability === ClientFieldEditability::EditableOnce
&& filled($values->get($field->id));
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\Notifications\AdminClientRegisteredNotification;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\AuthSource;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Support\Facades\Notification;
/**
* A client account coming into existence without a staff member creating
* it by hand.
*
* Two entry points now reach this — the public registration form and a
* first successful LDAP sign-in — and they must agree on the parts that
* are policy rather than presentation: whether the account is active or
* waits for approval, which group it joins, and who gets told. Keeping one
* definition is the same reasoning FileSharing and StoreUploadedFile
* already follow.
*
* What stays with each caller is what genuinely differs: the registration
* form's custom fields and group requests, and LDAP's directory stamp.
*/
class ClientProvisioning
{
public function __construct(
private readonly Settings $settings,
private readonly ActivityLogger $activity,
) {}
/**
* Whether a newly provisioned client can sign in straight away.
*/
public function autoApproves(): bool
{
return $this->settings->get(Setting::ClientsAutoApprove) === true;
}
/**
* @param bool|null $autoApprove Null asks Setting::ClientsAutoApprove,
* which is the right question for the
* public registration form. A caller
* that has already established who
* somebody is — LDAP, an identity
* provider — passes its own answer
* instead.
* @param array<string, mixed> $context Placeholders for the action's
* log template, e.g. which
* provider an account came from.
*/
public function provision(
string $name,
string $email,
string $password,
Action $action,
AuthSource $source = AuthSource::Local,
?string $ldapDn = null,
?bool $autoApprove = null,
array $context = [],
): User {
$autoApprove ??= $this->autoApproves();
$client = User::create([
'type' => UserType::Client,
'active' => $autoApprove,
'account_requested' => ! $autoApprove,
'role_id' => Role::query()->where('name', SystemRole::Client->value)->value('id'),
'name' => $name,
'email' => $email,
'password' => $password,
]);
// Not mass-assignable: where an account's credentials live is a
// security decision, not an attribute a form may set.
if ($source !== AuthSource::Local || $ldapDn !== null) {
$client->forceFill([
'auth_source' => $source,
'ldap_dn' => $ldapDn,
'ldap_synced_at' => $ldapDn === null ? null : now(),
])->save();
}
$this->activity->log($action, $client, $client, $context);
$this->joinAutoGroup($client);
$this->notifyAdministrators($client, pending: ! $autoApprove);
return $client;
}
/**
* The group every self-provisioned client joins, if one is configured.
* Direct membership, no approval — an administrator chose this in
* settings.
*/
private function joinAutoGroup(User $client): void
{
$autoGroupId = (int) $this->settings->get(Setting::ClientsAutoGroup);
if ($autoGroupId <= 0) {
return;
}
$group = Group::query()->find($autoGroupId);
$group?->members()->syncWithoutDetaching([$client->id]);
}
private function notifyAdministrators(User $client, bool $pending): void
{
if ($this->settings->get(Setting::EmailNotificationsEnabled) !== true) {
return;
}
$addresses = $this->settings->get(Setting::AdminNotificationEmails);
foreach (is_array($addresses) ? $addresses : [] as $address) {
Notification::route('mail', $address)->notify(
new AdminClientRegisteredNotification($client->name, $client->email, $pending)
);
}
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients;
use App\Models\User;
use App\Modules\Files\Models\File;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
/**
* A client's cumulative storage usage against their quota. Usage is a
* live sum (not a maintained counter) over files the client uploaded
* themselves — matches how the quota is enforced (portal self-uploads
* only, see ChunkedUploadsController::store()/complete()).
*/
class ClientStorageUsage
{
public function __construct(
private readonly Settings $settings,
) {}
public function usedBytes(User $client): int
{
return (int) File::query()->where('uploaded_by', $client->id)->sum('size');
}
/**
* A client's own storage_quota_mb of 0 means "no custom quota set" —
* it inherits Setting::DefaultClientStorageQuotaMb instead of being
* unlimited, so a site-wide default (once set) also protects clients
* who never got an explicit quota, including self-registered ones.
* The site default itself being 0 is what actually means unlimited.
*
* @return int 0 means unlimited.
*/
public function quotaMb(User $client): int
{
return $client->storage_quota_mb > 0
? $client->storage_quota_mb
: (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb);
}
/**
* @return int 0 means unlimited.
*/
public function quotaBytes(User $client): int
{
return $this->quotaMb($client) * 1024 * 1024;
}
/**
* @return int|null Null means unlimited.
*/
public function remainingBytes(User $client): ?int
{
$quota = $this->quotaBytes($client);
return $quota === 0 ? null : max(0, $quota - $this->usedBytes($client));
}
}
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\Notifications\ClientAccountApprovedNotification;
use App\Modules\Clients\Notifications\ClientAccountDeniedNotification;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Support\Pagination;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Notification;
use Inertia\Inertia;
use Inertia\Response;
/**
* The approval queue for self-registered clients (v1's
* clients-requests.php): approve activates the account, deny deletes it.
*/
class AccountRequestsController extends Controller
{
public function __construct(
private readonly ActivityLogger $activity,
private readonly Settings $settings,
) {}
public function index(Request $request): Response
{
$validated = $request->validate([
'search' => ['nullable', 'string', 'max:255'],
]);
$filters = ['search' => $validated['search'] ?? null];
$requests = User::query()
->where('type', UserType::Client)
->where('account_requested', true)
->when($filters['search'], fn (Builder $query, string $search) => $query->where(fn (Builder $q) => $q
->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")))
->orderBy('created_at')
->paginate(25)
->withQueryString()
->through(fn (User $client): array => [
'id' => $client->id,
'name' => $client->name,
'email' => $client->email,
'created_at' => $client->created_at?->toIso8601String(),
]);
return Inertia::render('clients/requests', [
'requests' => $requests->items(),
'pagination' => Pagination::meta($requests),
'filters' => $filters,
]);
}
public function approve(User $client): RedirectResponse
{
abort_unless($client->isClient() && $client->account_requested, 404);
$client->forceFill([
'active' => true,
'account_requested' => false,
])->save();
$this->activity->log(Action::ClientApproved, subject: $client);
if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) {
$client->notify(new ClientAccountApprovedNotification);
}
return back()->with('success', __('Account request approved.'));
}
public function deny(User $client): RedirectResponse
{
abort_unless($client->isClient() && $client->account_requested, 404);
$name = $client->name;
$email = $client->email;
$locale = $client->locale;
// A denied request is removed outright (not soft-deleted) so the
// person can register again with the same email address.
$client->forceDelete();
$this->activity->log(Action::ClientDenied, context: ['name' => $name]);
if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) {
Notification::route('mail', $email)->notify(
(new ClientAccountDeniedNotification($name))->locale($locale ?? app()->getLocale())
);
}
return back()->with('success', __('Account request denied.'));
}
}
@@ -0,0 +1,333 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Api\Support\PollingQuery;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientCustomFieldType;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Clients\Http\Resources\Api\ClientResource;
use App\Modules\Clients\Models\ClientCustomField;
use App\Modules\Clients\Models\ClientCustomFieldValue;
use App\Modules\Clients\Notifications\ClientAccountEditedNotification;
use App\Modules\Clients\Notifications\ClientWelcomeNotification;
use App\Modules\Files\DeletedAccountContent;
use App\Modules\Identity\AccountContentDeletion;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
/**
* Client accounts over the API.
*
* Clients are `users` rows with type = client, so every response here goes
* through ClientResource's allowlist rather than the model. `abort_unless
* ($client->isClient(), 404)` on each single-client route mirrors the web
* controller: a staff account is not addressable through this surface even
* by id.
*
* Validation rules, custom-field handling and the deletion flow are the
* web controller's, reused or mirrored field for field — a client created
* through the API must be indistinguishable from one created in the UI.
*/
class ClientsController extends Controller
{
public function __construct(
private readonly PollingQuery $polling,
private readonly ActivityLogger $activity,
private readonly Settings $settings,
private readonly ClientStorageUsage $storageUsage,
private readonly DeletedAccountContent $accountContent,
private readonly AccountContentDeletion $accountDeletion,
) {}
public function index(Request $request): AnonymousResourceCollection
{
$filters = $request->validate($this->polling->rules() + [
'search' => ['nullable', 'string', 'max:255'],
'status' => ['nullable', Rule::in(['active', 'inactive'])],
]);
$query = User::query()->where('type', UserType::Client);
if (($filters['search'] ?? null) !== null) {
$search = $filters['search'];
$query->where(fn (Builder $inner) => $inner
->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%"));
}
if (($filters['status'] ?? null) !== null) {
$query->where('active', $filters['status'] === 'active');
}
return ClientResource::collection($this->polling->paginate($request, $query, 'users'));
}
public function show(User $client): ClientResource
{
abort_unless($client->isClient(), 404);
return $this->resourceFor($client);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users,email'],
// No `confirmed`: repeating a password is a defence against a
// human mistyping into a form, and an API caller has no second
// field to mistype. This installation's password policy still
// applies — a minimum length, and optionally a check against
// known breaches. Both are configured under Settings →
// Security, so read them from there rather than assuming the
// defaults; a password this endpoint accepts on one
// installation may be refused on another.
'password' => ['required', Password::defaults()],
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
'custom_field_values' => ['array'],
]);
$validated['custom_field_values'] = $this->validateCustomFieldValues($request);
$client = User::create([
'type' => UserType::Client,
'active' => true,
'account_requested' => false,
'role_id' => Role::query()->where('name', SystemRole::Client->value)->value('id'),
'name' => $validated['name'],
'email' => $validated['email'],
'password' => $validated['password'],
// 0 means "no custom quota" and inherits the site default at
// enforcement time — see ClientStorageUsage::quotaMb().
'storage_quota_mb' => $validated['storage_quota_mb'] ?? 0,
'email_verified_at' => now(),
]);
$this->activity->log(Action::UserCreated, subject: $client);
$this->saveCustomFieldValues($client, $validated['custom_field_values'] ?? []);
if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) {
$client->notify(new ClientWelcomeNotification);
}
return $this->resourceFor($client->refresh())->response()->setStatusCode(201);
}
public function update(Request $request, User $client): ClientResource
{
abort_unless($client->isClient(), 404);
$validated = $request->validate([
'name' => ['sometimes', 'string', 'max:255'],
'email' => ['sometimes', 'string', 'lowercase', 'email', 'max:255', Rule::unique('users', 'email')->ignore($client->id)],
'active' => ['sometimes', 'boolean'],
'password' => ['sometimes', 'nullable', Password::defaults()],
'storage_quota_mb' => ['sometimes', 'nullable', 'integer', 'min:0'],
'custom_field_values' => ['sometimes', 'array'],
]);
if ($request->has('custom_field_values')) {
$validated['custom_field_values'] = $this->validateCustomFieldValues($request, required: false);
}
$wasActive = $client->active;
$passwordChanged = is_string($validated['password'] ?? null) && $validated['password'] !== '';
// PATCH semantics, unlike the web form which always submits every
// field: an absent key means "leave alone", not "clear".
$client->fill(array_intersect_key($validated, array_flip(['name', 'email', 'active'])));
if (array_key_exists('storage_quota_mb', $validated)) {
$client->storage_quota_mb = $validated['storage_quota_mb'] ?? 0;
}
if (($validated['active'] ?? false) && $client->account_requested) {
$client->account_requested = false;
}
if ($passwordChanged) {
$client->password = $validated['password'];
}
$client->save();
if (array_key_exists('custom_field_values', $validated)) {
$this->saveCustomFieldValues($client, $validated['custom_field_values']);
}
$this->activity->log(Action::UserUpdated, subject: $client);
if ($wasActive && ! $client->active) {
$this->activity->log(Action::UserDeactivated, subject: $client);
} elseif (! $wasActive && $client->active) {
$this->activity->log(Action::UserActivated, subject: $client);
}
if (($client->wasChanged(['name', 'email', 'active']) || $passwordChanged)
&& $this->settings->get(Setting::EmailNotificationsEnabled) === true) {
$client->notify(new ClientAccountEditedNotification);
}
return $this->resourceFor($client->refresh());
}
/**
* Remove a client's two-factor authentication.
*
* The remedy for a locked-out account: a client whose authenticator
* app and recovery codes are both gone cannot sign in, and nobody else
* can open the account for them either. Afterwards they sign in with
* their password alone, and — if this installation enforces two-factor
* authentication for clients — are asked to enrol again on their next
* request.
*
* The client is emailed that this happened, and the action is recorded
* in the activity log against the caller. Answers 204 whether or not a
* second factor was actually in force.
*/
public function destroyTwoFactor(User $client, TwoFactorAdministration $twoFactor): JsonResponse
{
abort_unless($client->isClient(), 404);
$twoFactor->reset($client);
return response()->json(status: 204);
}
/**
* Delete a client.
*
* If the client owns no files or folders, no body is needed.
*
* If they do, you must say what happens to that content: send
* `content_action` as either `cascade_delete` (delete it along with the
* account) or `reassign`, and in the latter case a `reassign_to_id`
* naming the active account that inherits it. Omitting the choice is a
* 422 — there is no default, because one would silently destroy a
* client's files and the other would silently hand them to somebody
* else.
*
* `GET /clients/{client}` reports the counts so you can decide before
* calling this.
*/
public function destroy(Request $request, User $client): JsonResponse
{
abort_unless($client->isClient(), 404);
$validated = $this->accountDeletion->validate($request, $client);
$name = $client->name;
$client->delete();
$this->activity->log(Action::UserDeleted, context: ['name' => $name]);
$this->accountDeletion->apply($validated, $client, $name);
return response()->json(status: 204);
}
private function resourceFor(User $client): ClientResource
{
return ClientResource::detailed(
$client,
customFieldValues: ClientCustomFieldValue::query()
->where('user_id', $client->id)
->pluck('value', 'client_custom_field_id')
->all(),
storage: $this->storageUsage,
content: $this->accountContent->summarize($client),
);
}
/**
* Validated separately from the main rule set, and deliberately so.
*
* The per-field rules are built by querying `client_custom_fields`, so
* they name this installation's actual field ids. Passing them to
* `$request->validate()` put those ids into the generated OpenAPI
* document — a document that is committed, served unauthenticated, and
* supposed to be identical on every install. It described one
* database's configuration and leaked which custom fields exist.
*
* The endpoint still validates exactly as before; only the shape the
* documentation generator can see has changed, to a plain object.
*
* @return array<int, mixed>
*/
private function validateCustomFieldValues(Request $request, bool $required = true): array
{
$rules = $this->customFieldRules($required);
if ($rules === []) {
return $request->input('custom_field_values', []);
}
return Validator::make($request->all(), $rules)->validate()['custom_field_values'] ?? [];
}
/**
* Mirrors ClientsController::customFieldRules(). On update the required
* flag is dropped, since PATCH may legitimately omit a field it is not
* changing — the value already stored satisfies the requirement.
*
* @return array<string, array<int, mixed>>
*/
private function customFieldRules(bool $required = true): array
{
$rules = [];
foreach (ClientCustomField::query()->get() as $field) {
$key = "custom_field_values.{$field->id}";
if ($field->type === ClientCustomFieldType::Checkbox) {
$rules[$key] = ['nullable', 'boolean'];
continue;
}
$rules[$key] = [$required && $field->required ? 'required' : 'nullable', 'string', 'max:2000'];
if ($field->type === ClientCustomFieldType::Select && is_array($field->options)) {
$rules[$key][] = Rule::in($field->options);
}
}
return $rules;
}
/**
* @param array<int, mixed> $values field id => submitted value
*/
private function saveCustomFieldValues(User $client, array $values): void
{
foreach (ClientCustomField::query()->get() as $field) {
$submitted = $values[$field->id] ?? null;
$value = $field->type === ClientCustomFieldType::Checkbox
? ($submitted ? '1' : '0')
: (is_string($submitted) ? $submitted : null);
ClientCustomFieldValue::query()->updateOrCreate(
['client_custom_field_id' => $field->id, 'user_id' => $client->id],
['value' => $value === '' ? null : $value],
);
}
}
}
@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientCustomFieldType;
use App\Modules\Clients\ClientFieldContext;
use App\Modules\Clients\ClientFieldEditability;
use App\Modules\Clients\Models\ClientCustomField;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
/**
* Admin-only definitions for custom fields shown on the staff-facing
* client create/edit screens (not exposed in the client portal).
* Configuration data: hard-deleted, cascading away each field's stored
* per-client values.
*/
class ClientCustomFieldsController extends Controller
{
public function __construct(
private readonly ActivityLogger $activity,
) {}
public function index(Request $request): Response
{
$validated = $request->validate(['search' => ['nullable', 'string', 'max:255']]);
$search = $validated['search'] ?? null;
$fields = ClientCustomField::query()
->when($search, fn (Builder $query, string $term) => $query->where('label', 'like', "%{$term}%"))
->orderBy('sort_order')
->orderBy('id')
->get()
->map(fn (ClientCustomField $field): array => $this->fieldRow($field));
return Inertia::render('clients/custom-fields/index', [
'fields' => $fields->all(),
'filters' => ['search' => $search],
]);
}
public function create(): Response
{
return Inertia::render('clients/custom-fields/create', [
'types' => array_map(fn (ClientCustomFieldType $type): string => $type->value, ClientCustomFieldType::cases()),
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $this->validated($request);
$field = ClientCustomField::query()->create([
'name' => $this->uniqueName($validated['label']),
'label' => $validated['label'],
'type' => $validated['type'],
'options' => $validated['options'] ?? null,
'required' => $validated['required'],
'sort_order' => (int) ClientCustomField::query()->max('sort_order') + 1,
'client_editability' => $validated['client_editability'],
'client_contexts' => $validated['client_editability'] === ClientFieldEditability::Hidden->value
? null
: ($validated['client_contexts'] ?? []),
]);
$this->activity->log(Action::ClientCustomFieldCreated, subject: $field, context: ['name' => $field->label]);
return redirect()->route('client-custom-fields.edit', $field)->with('success', __('Custom field created.'));
}
public function edit(ClientCustomField $customField): Response
{
return Inertia::render('clients/custom-fields/edit', [
'field' => $this->fieldRow($customField),
'types' => array_map(fn (ClientCustomFieldType $type): string => $type->value, ClientCustomFieldType::cases()),
]);
}
public function update(Request $request, ClientCustomField $customField): RedirectResponse
{
$validated = $this->validated($request);
$customField->update([
'label' => $validated['label'],
'type' => $validated['type'],
'options' => $validated['options'] ?? null,
'required' => $validated['required'],
'client_editability' => $validated['client_editability'],
'client_contexts' => $validated['client_editability'] === ClientFieldEditability::Hidden->value
? null
: ($validated['client_contexts'] ?? []),
]);
$this->activity->log(Action::ClientCustomFieldUpdated, subject: $customField, context: ['name' => $customField->label]);
return back()->with('success', __('Custom field updated.'));
}
public function destroy(ClientCustomField $customField): RedirectResponse
{
$label = $customField->label;
$customField->delete();
$this->activity->log(Action::ClientCustomFieldDeleted, context: ['name' => $label]);
return redirect()->route('client-custom-fields.index')->with('success', __('Custom field deleted.'));
}
/**
* @return array{label: string, type: string, options: list<string>|null, required: bool, client_editability: string, client_contexts: list<string>|null}
*/
private function validated(Request $request): array
{
// Normalized up front so "required_unless" below has a real value
// to compare against even when the field is omitted entirely
// (omitting it means "hidden", same as explicitly selecting it).
$request->merge(['client_editability' => $request->input('client_editability') ?? ClientFieldEditability::Hidden->value]);
return $request->validate([
'label' => ['required', 'string', 'max:255'],
'type' => ['required', Rule::enum(ClientCustomFieldType::class)],
'options' => ['nullable', 'array'],
'options.*' => ['string', 'max:255'],
'required' => ['required', 'boolean'],
'client_editability' => ['required', Rule::enum(ClientFieldEditability::class)],
'client_contexts' => ['array', 'required_unless:client_editability,'.ClientFieldEditability::Hidden->value],
'client_contexts.*' => [Rule::enum(ClientFieldContext::class)],
]);
}
/**
* @return array<string, mixed>
*/
private function fieldRow(ClientCustomField $field): array
{
return [
'id' => $field->id,
'label' => $field->label,
'type' => $field->type->value,
'options' => $field->options,
'required' => $field->required,
'client_editability' => $field->client_editability->value,
'client_contexts' => $field->client_contexts ?? [],
];
}
/**
* Auto-derives a machine key from the label — the admin never types
* one directly, and it's never shown again after creation.
*/
private function uniqueName(string $label): string
{
$base = Str::slug($label, '_') ?: 'field';
$name = $base;
$suffix = 2;
while (ClientCustomField::query()->where('name', $name)->exists()) {
$name = "{$base}_{$suffix}";
$suffix++;
}
return $name;
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Groups\Models\Group;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
class ClientSettingsController extends Controller
{
public function __construct(
private readonly Settings $settings,
private readonly ActivityLogger $activity,
) {}
public function edit(): Response
{
return Inertia::render('system/settings/clients', [
'clients_can_register' => $this->settings->get(Setting::ClientsCanRegister),
'clients_auto_approve' => $this->settings->get(Setting::ClientsAutoApprove),
'clients_auto_group' => $this->settings->get(Setting::ClientsAutoGroup),
'clients_can_select_group' => $this->settings->get(Setting::ClientsCanSelectGroup),
'clients_membership_deny_cooldown_days' => $this->settings->get(Setting::ClientsMembershipDenyCooldownDays),
'default_client_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
'groups' => Group::query()->orderBy('name')->get()
->map(fn (Group $group): array => ['id' => $group->id, 'name' => $group->name])
->all(),
]);
}
public function update(Request $request): RedirectResponse
{
$validated = $request->validate([
'clients_can_register' => ['required', 'boolean'],
'clients_auto_approve' => ['required', 'boolean'],
'clients_auto_group' => ['required', 'integer', Rule::in([0, ...Group::query()->pluck('id')->all()])],
'clients_can_select_group' => ['required', Rule::in(['none', 'public'])],
'clients_membership_deny_cooldown_days' => ['required', 'integer', 'min:0', 'max:365'],
'default_client_storage_quota_mb' => ['required', 'integer', 'min:0'],
]);
$this->settings->set(Setting::ClientsCanRegister, $validated['clients_can_register']);
$this->settings->set(Setting::ClientsAutoApprove, $validated['clients_auto_approve']);
$this->settings->set(Setting::ClientsAutoGroup, (int) $validated['clients_auto_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::DefaultClientStorageQuotaMb, (int) $validated['default_client_storage_quota_mb']);
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'clients']);
return back();
}
}
@@ -0,0 +1,311 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientCustomFieldType;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Clients\Models\ClientCustomField;
use App\Modules\Clients\Models\ClientCustomFieldValue;
use App\Modules\Clients\Notifications\ClientAccountEditedNotification;
use App\Modules\Clients\Notifications\ClientWelcomeNotification;
use App\Modules\Files\DeletedAccountContent;
use App\Modules\Identity\AccountContentDeletion;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Support\Pagination;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Inertia\Response;
/**
* Client management — the recipients files are shared with. Available
* in BOTH editions (clients are never portal-provisioned seats).
* Strictly clients: staff accounts 404 here.
*/
class ClientsController extends Controller
{
public function __construct(
private readonly ActivityLogger $activity,
private readonly Settings $settings,
private readonly ClientStorageUsage $storageUsage,
private readonly DeletedAccountContent $accountContent,
private readonly AccountContentDeletion $accountDeletion,
) {}
public function index(Request $request): Response
{
$validated = $request->validate([
'search' => ['nullable', 'string', 'max:255'],
'status' => ['nullable', Rule::in(['active', 'inactive'])],
]);
$filters = [
'search' => $validated['search'] ?? null,
'status' => $validated['status'] ?? null,
];
$clients = User::query()
->where('type', UserType::Client)
->when($filters['search'], fn (Builder $query, string $search) => $query->where(fn (Builder $q) => $q
->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")))
->when($filters['status'], fn (Builder $query, string $status) => $query->where('active', $status === 'active'))
->orderBy('name')
->paginate(25)
->withQueryString();
$content = $this->accountContent->summarizeMany($clients->pluck('id'));
$clients->through(fn (User $client): array => [
'id' => $client->id,
'name' => $client->name,
'email' => $client->email,
'active' => $client->active,
'account_requested' => $client->account_requested,
'created_at' => $client->created_at?->toIso8601String(),
'content' => $content[$client->id] ?? ['files' => 0, 'folders' => 0],
]);
return Inertia::render('clients/index', [
'clients' => $clients->items(),
'pagination' => Pagination::meta($clients),
'filters' => $filters,
'reassign_candidates' => $this->accountDeletion->candidates(),
]);
}
public function create(): Response
{
return Inertia::render('clients/create', [
'custom_fields' => $this->customFieldDefinitions(),
'default_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate(array_merge([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'confirmed', Password::defaults()],
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
], $this->customFieldRules()));
$client = User::create([
'type' => UserType::Client,
'active' => true,
'account_requested' => false,
'role_id' => Role::query()->where('name', SystemRole::Client->value)->value('id'),
'name' => $validated['name'],
'email' => $validated['email'],
'password' => $validated['password'],
// 0 (including an omitted field) means "no custom quota" —
// it inherits Setting::DefaultClientStorageQuotaMb at
// enforcement time (see ClientStorageUsage::quotaMb()), not
// baked in here, so a later change to the site default
// keeps applying to this client automatically.
'storage_quota_mb' => $validated['storage_quota_mb'] ?? 0,
'email_verified_at' => now(),
]);
$this->activity->log(Action::UserCreated, subject: $client);
$this->saveCustomFieldValues($client, $validated['custom_field_values'] ?? []);
if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) {
$client->notify(new ClientWelcomeNotification);
}
return redirect()->route('clients.edit', $client)->with('success', __('Client created.'));
}
public function edit(User $client): Response
{
abort_unless($client->isClient(), 404);
return Inertia::render('clients/edit', [
'client' => [
'id' => $client->id,
'name' => $client->name,
'email' => $client->email,
'active' => $client->active,
'account_requested' => $client->account_requested,
'storage_quota_mb' => $client->storage_quota_mb,
'two_factor_enabled' => $client->hasTwoFactorEnabled(),
],
'default_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
'storage_used_mb' => (int) ceil($this->storageUsage->usedBytes($client) / 1024 / 1024),
'custom_fields' => $this->customFieldDefinitions(),
'custom_field_values' => ClientCustomFieldValue::query()
->where('user_id', $client->id)
->pluck('value', 'client_custom_field_id'),
'content' => $this->accountContent->summarize($client),
'reassign_candidates' => $this->accountDeletion->candidates($client->id),
]);
}
public function update(Request $request, User $client): RedirectResponse
{
abort_unless($client->isClient(), 404);
$validated = $request->validate(array_merge([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', Rule::unique('users', 'email')->ignore($client->id)],
'active' => ['required', 'boolean'],
'password' => ['nullable', 'confirmed', Password::defaults()],
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
], $this->customFieldRules()));
$wasActive = $client->active;
$passwordChanged = is_string($validated['password'] ?? null) && $validated['password'] !== '';
$client->fill([
'name' => $validated['name'],
'email' => $validated['email'],
'active' => $validated['active'],
// The edit form always submits this field — an empty value
// means the admin explicitly cleared it (ConvertEmptyStringsToNull
// turns it into null before validation), not "leave unchanged".
// 0 = inherit the site default, same as a brand-new client.
'storage_quota_mb' => $validated['storage_quota_mb'] ?? 0,
]);
// Activating a pending account through the edit screen counts as
// approval and clears the request flag.
if ($client->account_requested && $validated['active']) {
$client->account_requested = false;
}
if ($passwordChanged) {
$client->password = $validated['password'];
}
$client->save();
$this->saveCustomFieldValues($client, $validated['custom_field_values'] ?? []);
$this->activity->log(Action::UserUpdated, subject: $client);
if ($wasActive && ! $client->active) {
$this->activity->log(Action::UserDeactivated, subject: $client);
} elseif (! $wasActive && $client->active) {
$this->activity->log(Action::UserActivated, subject: $client);
}
// Skip a no-op resubmit (same name/email/active, no new password).
if (($client->wasChanged(['name', 'email', 'active']) || $passwordChanged)
&& $this->settings->get(Setting::EmailNotificationsEnabled) === true) {
$client->notify(new ClientAccountEditedNotification);
}
return back()->with('success', __('Client updated.'));
}
/**
* Remove this account's second factor, for the client who has lost
* their authenticator and their recovery codes.
*/
public function destroyTwoFactor(User $client, TwoFactorAdministration $twoFactor): RedirectResponse
{
abort_unless($client->isClient(), 404);
$twoFactor->reset($client);
return back()->with('success', __('Two-factor authentication removed.'));
}
public function destroy(Request $request, User $client): RedirectResponse
{
abort_unless($client->isClient(), 404);
$validated = $this->accountDeletion->validate($request, $client);
$name = $client->name;
$client->delete();
$this->activity->log(Action::UserDeleted, context: ['name' => $name]);
$this->accountDeletion->apply($validated, $client, $name);
return redirect()->route('clients.index')->with('success', __('Client deleted.'));
}
/**
* @return list<array<string, mixed>>
*/
private function customFieldDefinitions(): array
{
return array_values(ClientCustomField::query()
->orderBy('sort_order')
->orderBy('id')
->get()
->map(fn (ClientCustomField $field): array => [
'id' => $field->id,
'label' => $field->label,
'type' => $field->type->value,
'options' => $field->options,
'required' => $field->required,
])
->all());
}
/**
* @return array<string, array<int, mixed>>
*/
private function customFieldRules(): array
{
$rules = [];
foreach (ClientCustomField::query()->get() as $field) {
$key = "custom_field_values.{$field->id}";
// Checkboxes are never hard-required here — "required" only
// drives the asterisk shown on the form, not a forced check.
if ($field->type === ClientCustomFieldType::Checkbox) {
$rules[$key] = ['nullable', 'boolean'];
continue;
}
$rules[$key] = [$field->required ? 'required' : 'nullable', 'string', 'max:2000'];
if ($field->type === ClientCustomFieldType::Select && is_array($field->options)) {
$rules[$key][] = Rule::in($field->options);
}
}
return $rules;
}
/**
* @param array<int, mixed> $values field id => submitted value
*/
private function saveCustomFieldValues(User $client, array $values): void
{
foreach (ClientCustomField::query()->get() as $field) {
$submitted = $values[$field->id] ?? null;
$value = $field->type === ClientCustomFieldType::Checkbox
? ($submitted ? '1' : '0')
: (is_string($submitted) ? $submitted : null);
ClientCustomFieldValue::query()->updateOrCreate(
['client_custom_field_id' => $field->id, 'user_id' => $client->id],
['value' => $value === '' ? null : $value],
);
}
}
}
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientFieldContext;
use App\Modules\Clients\ClientPortalCustomFields;
use App\Modules\Clients\ClientProvisioning;
use App\Modules\Groups\Models\Group;
use App\Modules\Groups\Models\MembershipRequest;
use App\Modules\Platform\Captcha\CaptchaForm;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Support\Rules;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Notification;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Inertia\Response;
/**
* Client self-registration (v1's register.php) — client-only by
* construction, gated by the clients_can_register setting. When
* clients_auto_approve is off, the account is created inactive and
* lands in the account-requests queue.
*/
class RegistrationController extends Controller
{
public function __construct(
private readonly Settings $settings,
private readonly ActivityLogger $activity,
private readonly ClientPortalCustomFields $customFields,
private readonly ClientProvisioning $provisioning,
) {}
public function create(): Response
{
abort_unless($this->settings->get(Setting::ClientsCanRegister) === true, 404);
return Inertia::render('auth/register', [
'auto_approve' => $this->settings->get(Setting::ClientsAutoApprove) === true,
'selectable_groups' => $this->selectableGroups()
->map(fn (Group $group): array => ['id' => $group->id, 'name' => $group->name])
->values()
->all(),
'custom_fields' => $this->customFields->rows(ClientFieldContext::Registration, null),
]);
}
/**
* Groups a registrant may request membership to, per the
* clients_can_select_group setting.
*
* @return Collection<int, Group>
*/
private function selectableGroups(): Collection
{
return match ($this->settings->get(Setting::ClientsCanSelectGroup)) {
'public' => Group::query()->where('public', true)->orderBy('name')->get(),
default => Group::query()->whereRaw('1 = 0')->get(),
};
}
public function store(Request $request): RedirectResponse
{
abort_unless($this->settings->get(Setting::ClientsCanRegister) === true, 404);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'confirmed', Password::defaults()],
'groups' => ['array'],
'groups.*' => ['integer', Rule::in($this->selectableGroups()->pluck('id')->all())],
...$this->customFields->rules(ClientFieldContext::Registration, null),
// Exactly one verification per request. v1's registration path
// verified twice for reCAPTCHA v2 — the second time on a token
// its own first call had already consumed — which made
// self-registration impossible to complete.
...Rules::captcha(CaptchaForm::Register),
]);
$autoApprove = $this->provisioning->autoApproves();
// Creation, approval state, the auto-join group and the
// administrator notification are shared with LDAP provisioning —
// see ClientProvisioning. What stays here is what only a
// registration form has: custom fields and requested groups.
$client = $this->provisioning->provision(
$validated['name'],
$validated['email'],
$validated['password'],
Action::ClientSelfRegistered,
);
$this->customFields->save($client, ClientFieldContext::Registration, $validated['custom_field_values'] ?? []);
$autoGroupId = (int) $this->settings->get(Setting::ClientsAutoGroup);
// Requested groups wait for staff approval.
foreach (array_unique($validated['groups'] ?? []) as $groupId) {
if ((int) $groupId === $autoGroupId) {
continue;
}
$membershipRequest = MembershipRequest::query()->create([
'group_id' => $groupId,
'user_id' => $client->id,
]);
$this->activity->log(Action::GroupMembershipRequested, $client, $membershipRequest->group);
}
return redirect()->route('login')->with(
'status',
$autoApprove
? __('Your account has been created. You can log in now.')
: __('Your account request has been received. You will be able to log in once it is approved.'),
);
}
}
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients\Http\Resources\Api;
use App\Models\User;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Clients\Models\ClientCustomField;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin User
*
* A client is a row in `users`, which is the most sensitive table in the
* application — so this enumerates its fields rather than serialising the
* model, and the enumeration is the point.
*
* Never present, regardless of what the model gains later: `password`,
* `two_factor_secret`, `two_factor_recovery_codes`, `remember_token`.
* The first three are credentials; the fourth is a bearer credential in
* its own right, and publishing it would let an API reader impersonate the
* client in a browser.
*
* Custom field *values* are included, because they are ordinary client
* data that any staff member with `edit_clients` already reads on the edit
* screen — the API must not be a second, quieter privacy boundary. They
* are attached only on `show`, so a bulk listing does not hand out every
* client's field data in one call.
*/
class ClientResource extends JsonResource
{
/** @var array<int, string>|null field id => value */
private ?array $customFieldValues = null;
private ?ClientStorageUsage $storage = null;
/** @var array{files: int, folders: int}|null */
private ?array $content = null;
/**
* The richer single-client shape.
*
* A named constructor rather than extra __construct parameters:
* JsonResource::collection() maps the collection through
* `new static($item, $key)`, so widening the constructor silently
* breaks every listing with a TypeError on the second argument.
*
* @param array<int, string> $customFieldValues field id => value
* @param array{files: int, folders: int} $content
*/
public static function detailed(
User $client,
array $customFieldValues,
ClientStorageUsage $storage,
array $content,
): self {
$resource = new self($client);
$resource->customFieldValues = $customFieldValues;
$resource->storage = $storage;
$resource->content = $content;
return $resource;
}
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$data = [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'active' => $this->active,
'account_requested' => $this->account_requested,
// Whether, not what: the state of the second factor is what a
// caller needs to see before removing it. The secret and the
// recovery codes stay where they are.
'two_factor_enabled' => $this->hasTwoFactorEnabled(),
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
];
if ($this->storage !== null) {
$quotaMb = $this->storage->quotaMb($this->resource);
$data['storage'] = [
// The client's own column, where 0 means "inherit the site
// default" rather than "unlimited" — both are reported so a
// caller need not know that rule to display it correctly.
'quota_mb' => $this->storage_quota_mb,
'effective_quota_mb' => $quotaMb,
'unlimited' => $quotaMb === 0,
'used_mb' => (int) ceil($this->storage->usedBytes($this->resource) / 1024 / 1024),
];
}
if ($this->customFieldValues !== null) {
$data['custom_fields'] = ClientCustomField::query()
->orderBy('sort_order')
->orderBy('id')
->get()
->map(fn (ClientCustomField $field): array => [
'id' => $field->id,
'name' => $field->name,
'label' => $field->label,
'type' => $field->type->value,
'value' => $this->customFieldValues[$field->id] ?? null,
])
->all();
}
if ($this->content !== null) {
// What a caller needs in order to answer DELETE's mandatory
// content-disposition question before asking.
$data['content'] = $this->content;
}
return $data;
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients\Models;
use App\Modules\Clients\ClientCustomFieldType;
use App\Modules\Clients\ClientFieldEditability;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* A field definition shown on the staff-facing client create/edit
* screens, and optionally also on the client-facing registration and/or
* account pages (see `client_editability`/`client_contexts`). Configuration
* data — hard-deleted; deleting one cascades its per-client values.
*
* @property int $id
* @property string $name
* @property string $label
* @property ClientCustomFieldType $type
* @property list<string>|null $options
* @property bool $required
* @property int $sort_order
* @property ClientFieldEditability $client_editability
* @property list<string>|null $client_contexts
*/
class ClientCustomField extends Model
{
protected $guarded = [];
protected function casts(): array
{
return [
'type' => ClientCustomFieldType::class,
'options' => 'array',
'required' => 'boolean',
'client_editability' => ClientFieldEditability::class,
'client_contexts' => 'array',
];
}
/**
* @return HasMany<ClientCustomFieldValue, $this>
*/
public function values(): HasMany
{
return $this->hasMany(ClientCustomFieldValue::class);
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* One client's answer for one custom field. Cascades away with either
* side (the field definition or the client).
*
* @property int $id
* @property int $client_custom_field_id
* @property int $user_id
* @property string|null $value
*/
class ClientCustomFieldValue extends Model
{
protected $guarded = [];
/**
* @return BelongsTo<ClientCustomField, $this>
*/
public function field(): BelongsTo
{
return $this->belongsTo(ClientCustomField::class, 'client_custom_field_id');
}
/**
* @return BelongsTo<User, $this>
*/
public function client(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients\Notifications;
use App\Modules\Platform\Notifications\Concerns\RendersOverridableMail;
use App\Modules\Platform\Notifications\EmailTemplateSlot;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
/**
* Sent to every configured admin recipient (Setting::AdminNotificationEmails)
* when a client self-registers. Sent on-demand, one per address, so admin
* addresses aren't exposed to each other in a shared To: header.
*/
class AdminClientRegisteredNotification extends Notification implements ShouldQueue
{
use Queueable, RendersOverridableMail;
public function __construct(
private readonly string $name,
private readonly string $email,
private readonly bool $pendingApproval,
) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$override = $this->overrideOrNull(EmailTemplateSlot::AdminClientRegistered);
$message = $override !== null
? $this->mailFromOverride($override, [':name' => $this->name, ':email' => $this->email])
: (new MailMessage)
->subject(__('A new client has registered'))
->line(__('A new client account was created: :name (:email).', ['name' => $this->name, 'email' => $this->email]));
// The pending-approval notice and its action stay code-controlled
// regardless of a customized template, same as every other action.
if ($this->pendingApproval) {
$message->line(__('Their account is waiting for approval.'))
->action(__('Review pending requests'), route('account-requests.index'));
}
return $message;
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients\Notifications;
use App\Modules\Platform\Notifications\Concerns\RendersOverridableMail;
use App\Modules\Platform\Notifications\EmailTemplateSlot;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class ClientAccountApprovedNotification extends Notification implements ShouldQueue
{
use Queueable, RendersOverridableMail;
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
if (($override = $this->overrideOrNull(EmailTemplateSlot::ClientAccountApproved)) !== null) {
return $this->mailFromOverride($override, [])->action(__('Log in'), route('login'));
}
return (new MailMessage)
->subject(__('Your account has been approved'))
->line(__('Your account request has been approved. You can now log in.'))
->action(__('Log in'), route('login'));
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients\Notifications;
use App\Modules\Platform\Notifications\Concerns\RendersOverridableMail;
use App\Modules\Platform\Notifications\EmailTemplateSlot;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
/**
* Sent on-demand (Notification::route('mail', ...)), never via
* $client->notify() — the account row is force-deleted in the same
* request, and a queued job re-fetching a deleted model would fail.
*/
class ClientAccountDeniedNotification extends Notification implements ShouldQueue
{
use Queueable, RendersOverridableMail;
public function __construct(
private readonly string $name,
) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
if (($override = $this->overrideOrNull(EmailTemplateSlot::ClientAccountDenied)) !== null) {
return $this->mailFromOverride($override, [':name' => $this->name]);
}
return (new MailMessage)
->subject(__('Your account request was denied'))
->greeting(__('Hello :name,', ['name' => $this->name]))
->line(__('Your account request has been denied.'));
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients\Notifications;
use App\Modules\Platform\Notifications\Concerns\RendersOverridableMail;
use App\Modules\Platform\Notifications\EmailTemplateSlot;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
/**
* A generic security-style notice — no diff of what changed (avoids
* exposing e.g. a password-change signal in cleartext) — sent whenever
* staff edit a client's name, email, active status, or password.
*/
class ClientAccountEditedNotification extends Notification implements ShouldQueue
{
use Queueable, RendersOverridableMail;
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
if (($override = $this->overrideOrNull(EmailTemplateSlot::ClientAccountEdited)) !== null) {
return $this->mailFromOverride($override, []);
}
return (new MailMessage)
->subject(__('Your account was updated'))
->line(__('Your account details were recently changed by an administrator. Contact your administrator if this was not expected.'));
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients\Notifications;
use App\Modules\Platform\Notifications\Concerns\RendersOverridableMail;
use App\Modules\Platform\Notifications\EmailTemplateSlot;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
/**
* Sent when staff create a client account directly (ClientsController::store).
* No set-password link — staff already chose the password in that flow,
* unlike self-registration approval which reuses the same login-link shape.
*/
class ClientWelcomeNotification extends Notification implements ShouldQueue
{
use Queueable, RendersOverridableMail;
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
if (($override = $this->overrideOrNull(EmailTemplateSlot::ClientWelcome)) !== null) {
return $this->mailFromOverride($override, [])->action(__('Log in'), route('login'));
}
return (new MailMessage)
->subject(__('Welcome'))
->line(__('An account has been created for you. You can log in now.'))
->action(__('Log in'), route('login'));
}
}
@@ -0,0 +1,464 @@
<?php
declare(strict_types=1);
namespace App\Modules\Comments\Access;
use App\Models\User;
use App\Modules\Comments\CommentVisibility;
use App\Modules\Comments\GuestCommentIdentity;
use App\Modules\Comments\Models\FileComment;
use App\Modules\Files\Access\ShareTargets;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Modules\Identity\UserType;
use App\Modules\Notifications\InAppNotification;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Gate;
/**
* The single place that decides which comments on a file a viewer may
* read, and — its mirror image — which people a new comment may be
* announced to.
*
* Follows the same convention as Files' FilePolicy/ViewableFileScope pair:
* one question, expressed once, in SQL, so no caller has to re-derive it.
* Unlike that pair there is no per-model policy twin doing the same work
* in PHP; FileCommentPolicy::view() defers to this class rather than
* restating it, because two statements of a rule this sharp will drift.
*
* **Callers must have already established that the viewer may see the
* file itself** (Gate::authorize('view', $file), or for guests, that the
* file is publicly reachable). This class narrows an accessible file's
* comments; it is not a substitute for the file's own gate.
*
* The rule that everything else hangs off:
*
* A Clients comment carrying client_context_id = C is never returned to
* any non-staff viewer other than C.
*
* That is what stops one customer learning that another exists — a worse
* failure than leaking a comment's text. A Clients comment with a *null*
* context is a staff message to everyone on the file, and every client
* with access reads it: it is written by staff and names no client, so
* seeing it tells a reader nothing about who else is there.
*
* Nothing outside this class may query file_comments by file_id alone.
*/
class VisibleCommentScope
{
public function __construct(
private readonly StaffLibraryScope $scope,
private readonly ShareTargets $shareTargets,
private readonly GuestCommentIdentity $guests,
) {}
/**
* @param User|null $viewer Null means an anonymous visitor.
* @return Builder<FileComment>
*/
public function for(?User $viewer, File $file): Builder
{
return $this->applyVisibility(
FileComment::query()->where('file_id', $file->id),
$viewer,
$file->isEffectivelyPublic(),
);
}
/**
* Every comment this staff member may read, across their whole library
* — the management screen's query, rather than one file's thread.
*
* Same predicate as for(), so the screen that lists everything is still
* governed by the rule at the top of this class: another person's "only
* me" note is not in it, and neither is a conversation belonging to a
* client this viewer is not assigned to. **A moderation screen is not a
* way around the visibility model** — moderating means deciding about
* comments you can already see.
*
* Staff only. A client has no cross-file view of comments and asking
* for one is a mistake rather than an empty result, but returning
* nothing is the safe way to be wrong.
*
* @return Builder<FileComment>
*/
public function across(User $viewer): Builder
{
if (! $viewer->isStaff()) {
return FileComment::query()->whereRaw('1 = 0');
}
return $this->applyVisibility(
FileComment::query()->whereIn('file_id', $this->scope->files($viewer)->select('files.id')),
$viewer,
// Publicness is a property of each file, so it cannot be one
// value for a query spanning many. It does not have to be: the
// staff branch of applyVisibility never reads this argument
// (staff keep seeing Everyone comments whatever the file's
// current state), and this method is staff-only.
isPublic: false,
);
}
/**
* How many comments are waiting for a decision anywhere in this
* viewer's library — the sidebar badge.
*
* Deliberately not derived from across(): a held comment is invisible
* to everyone but a moderator, so the number is about the queue rather
* than about what this viewer may read, and a moderator who cannot see
* a particular client's thread must still be told the file has
* something waiting.
*/
public function pendingTotal(User $viewer): int
{
if (! $viewer->isStaff() || ! $viewer->can('moderate_comments')) {
return 0;
}
return FileComment::query()
->whereNull('approved_at')
->whereIn('file_id', $this->scope->files($viewer)->select('files.id'))
->count();
}
/**
* How many comments each of these files has, from this viewer's point
* of view — the number on a file row.
*
* Runs the same predicate as for(), because it calls the same private
* method: this is the bulk shape of one rule, not a second statement
* of it. The only thing it cannot batch is whether a file is public,
* which is a property of each row rather than of the query, so the
* files are split into two groups and the predicate applied to each.
*
* @param iterable<File> $files
* @return array<int, int> file id => count
*/
public function countsFor(?User $viewer, iterable $files): array
{
$public = [];
$private = [];
foreach ($files as $file) {
if ($file->isEffectivelyPublic()) {
$public[] = $file->id;
} else {
$private[] = $file->id;
}
}
$counts = [];
foreach ([[$public, true], [$private, false]] as [$ids, $isPublic]) {
if ($ids === []) {
continue;
}
$rows = $this->applyVisibility(FileComment::query()->whereIn('file_id', $ids), $viewer, $isPublic)
->selectRaw('file_id, count(*) as aggregate')
->groupBy('file_id')
->pluck('aggregate', 'file_id');
foreach ($rows as $fileId => $count) {
$counts[(int) $fileId] = (int) $count;
}
}
return $counts;
}
/**
* @param Builder<FileComment> $query
* @return Builder<FileComment>
*/
private function applyVisibility(Builder $query, ?User $viewer, bool $isPublic): Builder
{
// A comment awaiting moderation exists only for those who can act
// on it — and for whoever wrote it, who would otherwise watch their
// own comment vanish on posting and conclude it had failed. A
// visitor is recognised by their session (see GuestCommentIdentity);
// that is weak on purpose, and only ever widens what somebody sees
// of their own writing.
if ($viewer === null || ! $viewer->can('moderate_comments')) {
$ownPending = $viewer === null ? $this->guests->ownCommentIds() : [];
$query->where(fn (Builder $visible) => $visible
->whereNotNull('approved_at')
->when($ownPending !== [], fn (Builder $mine) => $mine->orWhereIn('id', $ownPending)));
}
if ($viewer === null) {
// Publicness is re-derived here on every read rather than
// frozen onto the comment at write time, so making a file
// private later retracts its public comments too.
return $isPublic
? $query->where('visibility', CommentVisibility::Everyone)
: $query->whereRaw('1 = 0');
}
return $query->where(function (Builder $outer) use ($viewer, $isPublic): void {
// Your own comments, whatever audience you gave them. This is
// the only branch that can return a CommentVisibility::OnlyMe
// row, which is what makes "only me" mean it.
$outer->where('author_id', $viewer->id);
if ($viewer->isStaff()) {
// Staff keep seeing Everyone comments after a file stops
// being public — they can see the file, and a history with
// holes in it is worse than one that is merely stale.
$outer->orWhere('visibility', CommentVisibility::Everyone);
$outer->orWhere('visibility', CommentVisibility::StaffOnly);
$this->applyStaffThreads($outer, $viewer);
return;
}
if ($isPublic) {
$outer->orWhere('visibility', CommentVisibility::Everyone);
}
// A client reads two things: what staff addressed to every
// client on this file, and their own conversation. Never a
// staff-only note, and never another client's conversation —
// they are not told the others are there.
$outer->orWhere(fn (Builder $broadcast) => $broadcast
->where('visibility', CommentVisibility::Clients)
->whereNull('client_context_id'));
$outer->orWhere(fn (Builder $thread) => $thread
->where('visibility', CommentVisibility::Clients)
->where('client_context_id', $viewer->id));
});
}
/**
* How many comments on each file are waiting for a decision.
*
* Distinct from unreadCountsFor: unread is "you have not looked at
* this yet", pending is "nobody can see this until somebody acts".
* Only a moderator gets a count at all, which is why the empty array
* for everyone else is the whole answer rather than a filter applied
* afterwards — a staff member who cannot approve should not be shown
* a badge asking them to.
*
* @param list<int> $fileIds
* @return array<int, int> file id => count
*/
public function pendingCountsFor(User $viewer, array $fileIds): array
{
if ($fileIds === [] || ! $viewer->can('moderate_comments')) {
return [];
}
$rows = FileComment::query()
->whereIn('file_id', $fileIds)
->whereNull('approved_at')
->selectRaw('file_id, count(*) as aggregate')
->groupBy('file_id')
->pluck('aggregate', 'file_id');
$counts = [];
foreach ($rows as $fileId => $count) {
$counts[(int) $fileId] = (int) $count;
}
return $counts;
}
/**
* How many comments on each file are new to this viewer.
*
* Read state rides on the notification system's own `read_at` rather
* than a file_comment_reads table of its own: a viewer has already
* been told about every comment they may see (that is what
* recipientsFor guarantees), so "unread notification about this file"
* and "unread comment on this file" are the same set. A second
* mechanism would only be a second thing to keep in step.
*
* @param list<int> $fileIds
* @return array<int, int> file id => count
*/
public function unreadCountsFor(User $viewer, array $fileIds): array
{
if ($fileIds === []) {
return [];
}
$rows = InAppNotification::query()
->where('user_id', $viewer->id)
->where('type', 'file_comment.posted')
->where('subject_type', (new File)->getMorphClass())
->whereIn('subject_id', $fileIds)
->whereNull('read_at')
->selectRaw('subject_id, count(*) as aggregate')
->groupBy('subject_id')
->pluck('aggregate', 'subject_id');
$counts = [];
foreach ($rows as $fileId => $count) {
$counts[(int) $fileId] = (int) $count;
}
return $counts;
}
/**
* Who should be told about $comment, excluding its own author.
*
* This is the set-of-people mirror of for()'s set-of-rows, and exists
* so Notifier's security contract can be honoured without the caller
* re-deriving visibility: a notification must never reach somebody the
* comment itself would not.
*
* Deliberately narrower than "everyone who could read it" in one case:
* an Everyone comment notifies staff only. Every client on a public
* file receiving a notification for every public remark would be noise,
* and staff are the ones who would act on it.
*
* @return Collection<int, User>
*/
public function recipientsFor(FileComment $comment): Collection
{
$file = $comment->file;
/** @var Collection<int, User> $none */
$none = new Collection;
if ($comment->visibility === CommentVisibility::OnlyMe) {
return $none;
}
$recipients = $this->staffWhoCanSee($file);
$client = $comment->clientContext;
if ($comment->visibility === CommentVisibility::Clients) {
if ($client !== null) {
// Staff whose library scope excludes this conversation's
// client must not hear about it — the same boundary for()
// applies to rows.
$recipients = $recipients
->filter(fn (User $staff): bool => $this->scope->canAssignClient($staff, $client))
->values();
$recipients->push($client);
} else {
// Addressed to every client on the file, so every client on
// the file is told. This is the one case that fans out.
foreach ($this->clientsWhoCanSee($comment->file) as $recipient) {
$recipients->push($recipient);
}
}
}
return $recipients
->reject(fn (User $user): bool => $user->id === $comment->author_id)
->values();
}
/**
* Staff who hold moderation rights — the audience for a comment held
* for approval, which is not the same audience as the comment itself
* (it has none yet).
*
* @return Collection<int, User>
*/
public function moderators(File $file): Collection
{
return $this->staffWhoCanSee($file)
->filter(fn (User $staff): bool => $staff->can('moderate_comments'))
->values();
}
/**
* Clients this file is in front of: assigned to it, to a group it is
* shared with, or to the folder it sits in or any folder above that.
*
* Expressed through ShareTargets rather than as an inverted
* File::scopeVisibleToClient — that scope is the authority on "can this
* client see this file", and writing its mirror image here would be a
* third statement of a rule this feature depends on not drifting. The
* cost is that a client who reaches the file only by having uploaded it
* themselves is not notified of a message to all clients; they still
* read it when they open the file.
*
* @return Collection<int, User>
*/
private function clientsWhoCanSee(File $file): Collection
{
$shares = $this->shareTargets->assigned($file);
$groupIds = array_column($shares['groups'], 'id');
$clientIds = array_column($shares['clients'], 'id');
$folder = $file->folder;
if ($folder !== null) {
foreach (Folder::query()->whereIn('id', [$folder->id, ...$folder->ancestorIds()])->get() as $ancestor) {
$inherited = $this->shareTargets->assigned($ancestor);
$groupIds = [...$groupIds, ...array_column($inherited['groups'], 'id')];
$clientIds = [...$clientIds, ...array_column($inherited['clients'], 'id')];
}
}
/** @var Collection<int, User> $clients */
$clients = User::query()
->where('type', UserType::Client)
->where(fn (Builder $who) => $who
->whereIn('id', $clientIds)
->orWhereHas('memberOfGroups', fn (Builder $groups) => $groups->whereIn('groups.id', $groupIds)))
->get();
return $clients;
}
/**
* Resolved per user through the file's own policy rather than as one
* query: FilePolicy::view mixes a permission check (a property of the
* viewer) with a scope check (a property of the row), and staff counts
* are small by design in this product — the v2 model puts limited
* staff on client scoping, not on large teams.
*
* @return Collection<int, User>
*/
private function staffWhoCanSee(File $file): Collection
{
/** @var Collection<int, User> $staff */
$staff = User::query()->where('type', UserType::Staff)->get();
return $staff->filter(fn (User $user): bool => Gate::forUser($user)->allows('view', $file))->values();
}
/**
* @param Builder<FileComment> $outer
*/
private function applyStaffThreads(Builder $outer, User $viewer): void
{
// Null means unrestricted: this staff member sees every client's
// conversation on a file they can already open.
$clientIds = $this->scope->assignableClientIds($viewer);
$outer->orWhere(function (Builder $thread) use ($clientIds): void {
$thread->where('visibility', CommentVisibility::Clients);
if ($clientIds === null) {
return;
}
// A client-scoped staff member sees their own clients'
// conversations, plus the messages addressed to every client on
// the file (no conversation of their own, so no client to be
// out of scope for), and nothing of the clients they are not
// assigned to.
$thread->where(fn (Builder $context) => $context
->whereNull('client_context_id')
->orWhereIn('client_context_id', $clientIds));
});
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Modules\Comments;
use App\Modules\Identity\UserType;
/**
* Who may write a comment (Setting::CommentsAuthors).
*
* This is a setting rather than a permission on purpose. Roles are only
* editable in the community edition — the cloud edition gates the whole
* roles screen behind Capability::UsersManage — so a permission key would
* be unconfigurable for half our installs. It also expresses something a
* permission structurally cannot: `Everyone` includes anonymous visitors,
* who have no account and therefore no role to hold a key.
*/
enum CommentAuthors: string
{
case Staff = 'staff';
case Clients = 'clients';
case StaffAndClients = 'staff_and_clients';
case Everyone = 'everyone';
/**
* @param UserType|null $type Null means an anonymous visitor.
*/
public function allows(?UserType $type): bool
{
return match ($this) {
self::Staff => $type === UserType::Staff,
self::Clients => $type === UserType::Client,
self::StaffAndClients => $type !== null,
self::Everyone => true,
};
}
/**
* English label — also the translation key.
*/
public function label(): string
{
return match ($this) {
self::Staff => 'Staff only',
self::Clients => 'Clients only',
self::StaffAndClients => 'Staff and clients',
self::Everyone => 'Anyone, including visitors who are not logged in',
};
}
}
+144
View File
@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace App\Modules\Comments;
use App\Models\User;
use App\Modules\Comments\Access\VisibleCommentScope;
use App\Modules\Comments\Models\FileComment;
use App\Modules\Files\Models\File;
use Illuminate\Support\Facades\Gate;
/**
* One shape for a comment thread, whoever is asking.
*
* The staff panel, the client portal, all four public themes and the API
* render the same payload, so a field added here reaches every surface at
* once — the same reason the theme pages consume controller props rather
* than fetching their own (see docs/theming-files-checklist.md).
*
* A client's payload never says that a comment belongs to one client's
* conversation rather than to all of them — `conversation` below is
* staff-only. A UI flag can be got around by reading the network tab;
* missing data cannot.
*/
class CommentPresenter
{
public function __construct(
private readonly VisibleCommentScope $scope,
private readonly CommentingRules $rules,
) {}
/**
* @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
{
$forStaff = $viewer?->isStaff() === true;
$comments = $this->scope->for($viewer, $file)
->with(['author', 'clientContext'])
->orderBy('created_at')
->orderBy('id')
->get();
/** @var list<array<string, mixed>> $presented */
$presented = $comments->map(fn (FileComment $comment): array => $this->present($viewer, $comment))->values()->all();
return [
'comments' => $presented,
'can_comment' => $this->rules->canPost($viewer, $file),
// …and why not, when not. Without it the composer disappears
// silently and the empty thread says "No comments yet", which
// reads as nobody having written one rather than as the box
// being closed — the two are indistinguishable to the person
// looking for somewhere to type.
'cannot_comment_reason' => $this->rules->postingBlockedReason($viewer, $file),
// The composer asks an anonymous author for a name and warns
// that their comment is held; a signed-in one gets neither.
// Decided here because the public page serves both, and the
// page cannot tell them apart — it has no viewer.
'is_guest' => $viewer === null,
// Whether an anonymous comment actually waits for a moderator.
// The composer promises that it does, and the promise has to be
// true: with moderation off the comment appears immediately,
// and saying otherwise is simply a lie to the person writing it.
'guest_moderated' => $this->rules->moderatesGuests(),
// Whether this author will be asked for a security check. Sent
// per thread rather than read from the shared props, because
// only the server knows whether this viewer counts as a
// visitor — the composer is the same component either way.
'captcha_required' => $this->rules->captchaRequiredFor($viewer),
// Labels depend on which end of the conversation is reading:
// `clients` is one channel, and staff call it "Clients" while a
// client calls it "Staff". Unavailable audiences are sent too,
// so the composer can show them disabled with the reason rather
// than leave a hole where an option used to be.
'visibilities' => array_map(
fn (array $option): array => [
'value' => $option['visibility']->value,
'label' => $option['visibility']->label($forStaff),
'description' => $option['visibility']->description($forStaff),
'available' => $option['available'],
'reason' => $option['reason'],
],
$this->rules->visibilityOptions($viewer, $file),
),
'default_visibility' => $this->rules->defaultVisibility($viewer, $file)?->value,
'edit_window_minutes' => $this->rules->editWindowMinutes(),
];
}
/**
* @return array<string, mixed>
*/
public function present(?User $viewer, FileComment $comment): array
{
return [
'id' => $comment->id,
'body' => $comment->body,
'author_name' => $comment->authorName(),
'author_type' => $this->authorType($comment),
'is_mine' => $viewer !== null && $comment->author_id === $viewer->id,
'visibility' => $comment->visibility->value,
'visibility_label' => $comment->visibility->label($viewer?->isStaff() === true),
// Whose conversation this is, when it is one client's rather
// than every client's. Staff only: a client must not be able to
// tell that their comment sits among others.
'conversation' => $viewer?->isStaff() === true && $comment->client_context_id !== null
? $comment->clientContext?->name
: null,
// Replying is how staff address one client — the audience is
// inherited from here, never chosen. Only worth offering on a
// comment that has a client behind it to answer.
'can_reply' => $viewer?->isStaff() === true
&& $comment->visibility === CommentVisibility::Clients
&& $comment->client_context_id !== null
&& $comment->author_id !== $viewer->id,
'pending' => $comment->isPending(),
'created_at' => $comment->created_at?->toIso8601String(),
'edited_at' => $comment->edited_at?->toIso8601String(),
'can_update' => $viewer !== null && Gate::forUser($viewer)->allows('update', $comment),
'can_delete' => $viewer !== null && Gate::forUser($viewer)->allows('delete', $comment),
// A held comment is only actionable where it is seen. The
// moderation queue is for working through a backlog; somebody
// who followed the alert on a file row is already looking at
// the one comment they came to decide about.
'can_approve' => $comment->isPending()
&& $viewer !== null
&& Gate::forUser($viewer)->allows('moderate', FileComment::class),
];
}
private function authorType(FileComment $comment): string
{
$author = $comment->author;
if ($author === null) {
return 'guest';
}
return $author->isStaff() ? 'staff' : 'client';
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Modules\Comments;
use App\Modules\Files\Models\File;
/**
* Which files accept comments at all (Setting::CommentsScope).
*
* The `*_files` suffixes exist to keep this vocabulary apart from
* CommentVisibility's, which also has a "public" and a "with access" idea
* and means something entirely different — this enum is about *files*,
* that one is about *comments*.
*
* `SelectedFiles` is the only value that consults the per-file
* `files.commentable` flag; under every other value that column is
* ignored, which is why the file editor only offers the toggle when this
* setting is `selected`.
*/
enum CommentScope: string
{
case None = 'none';
case AllFiles = 'all';
case PublicFiles = 'public_files';
case SharedFiles = 'shared_files';
case SelectedFiles = 'selected';
public function allows(File $file): bool
{
return match ($this) {
self::None => false,
self::AllFiles => true,
self::PublicFiles => $file->isEffectivelyPublic(),
self::SharedFiles => ! $file->isEffectivelyPublic(),
self::SelectedFiles => (bool) $file->commentable,
};
}
/**
* English label — also the translation key.
*/
public function label(): string
{
return match ($this) {
self::None => 'No files — commenting is off',
self::AllFiles => 'All files',
self::PublicFiles => 'Public files only',
self::SharedFiles => 'Shared files only',
self::SelectedFiles => 'Only files marked as commentable',
};
}
}

Some files were not shown because too many files have changed in this diff Show More