150 Commits

Author SHA1 Message Date
ignacionelson 01f41860e6 Finish the list -- it named 25 of the 39 entries
Written by reading the top of the section and stopping, which is exactly
the failure the list exists to prevent. The fourteen it missed were the
tail: several boundary fixes, the 502 behind a proxy, the recovery code
spent twice.

The near-identical limited-role entries are one line naming the surfaces
rather than six lines saying the same thing, so the list stays scannable.
2026-08-27 14:35:18 -03:00
ignacionelson 0999779864 Drop the platform-operator upgrade note too
Same reason as the entry above it. Someone running one installation of
their own has no seats to cap and no provisioning to seed, so three
environment variables they will never set read as noise in the one section
of the file that is supposed to be nothing but things they must do.

The variables and projectsend:status still exist and still work. They are
documented where the people who need them will look.
2026-08-27 14:27:42 -03:00
ignacionelson 34c34b2fd4 Drop the hosted user-management entry from the changelog
CHANGELOG.md ships inside the zip and renders in the app, so it is read
mostly by people running ProjectSend themselves. An entry about seats a
host sells you, and screens that were absent on an edition the reader is
not running, is not news to them -- it is somebody else's product notes in
their release notes.

The feature is unchanged; only its account of itself moves.
2026-08-27 12:58:44 -03:00
ignacionelson ddde779aea Put a plain list of what changed at the top of 2.2.0
Twenty-five entries of two or three sentences each is a reference, not a
summary. Somebody deciding whether to upgrade reads the first screen and
stops. The paragraph that was there named four things out of twenty-five
and buried the rest.

One line each, grouped, no jargon. The detail below is unchanged --
it is what you read once the list has told you which entry you want.
2026-08-27 12:44:50 -03:00
ignacionelson 7cbffefb01 Repair the migrated passwords the hasher will not read
An installation brought over from v1 before the migration tool learned to
relabel carries $2a$ or $2b$ digests in users.password. All three bcrypt
labels name the same algorithm and password_verify() reads any of them,
but Laravel's hasher asks password_get_info() first, gets "unknown", and
throws before it looks at the password -- so the login form answers 500
for every migrated account while accounts created in v2 sign in fine.

Relabelling the stored digest is the whole fix. Four bytes change; salt
and digest are the same, so nobody resets anything and there is no mail to
send. Guarded on password_get_info() reading bcrypt afterwards, so a
truncated row is left visibly broken rather than quietly rewritten to no
effect.

$2x$ is left alone on purpose -- it asks for the pre-2011 handling of
bytes above 127, so relabelling it would lock out anybody whose password
is not plain ASCII.

The 500 itself is asserted, not just the repair, so nobody removes the
migration later on the grounds that bcrypt is bcrypt.

Reported by @pabloalvarez44 in #1706.
2026-08-27 12:10:01 -03:00
ignacionelson d2901e8304 Translate the seat-cap refusals, and say in the changelog what 2.2.0 now carries 2026-08-27 02:44:19 -03:00
ignacionelson 787e9ec189 Report version, edition, capabilities and seat usage as one probe
Asked for by the platform side, and the reason is better than
convenience. Their reconciler's rule is that it observes an end state and
never sends an instruction. `docker exec … php -r '…'` to reach a public
method is an instruction with the caller's argv in it, however harmless
the argv, and it would have been the first crack in that rule. A named
command is an observation, the same kind of thing as reading a directory
size.

`--json` for a machine, plain lines for a person. Nothing here is a
secret or a credential: every field is already visible to any signed-in
administrator, which is what makes it safe to read from outside the
container.

The counts come from SeatAllowance — the code that refuses the account
past the limit — rather than from a second query that agrees with it
today. Two counts that merely agree diverge eventually, over an inactive
account or a soft-deleted one, and the divergence reads as a billing
fault rather than a counting one.

Unlimited is emitted as null, with a test saying so, because the failure
if a reader takes it for zero is a customer on the most expensive plan
whose instance refuses to create a single client. The platform side
independently landed the same care on the emitting end, omitting the
variable rather than sending it empty.

It also answers the question that started all of this. Diagnosing why a
tenant ignored its bucket meant reaching into a container and calling
app() by hand; `projectsend:status` now says which capabilities the
edition grants, which is where that hunt began.
2026-08-27 02:40:28 -03:00
ignacionelson ac691387e8 Seed two-factor enforcement at provision, before the first account exists
The last of the three. Enforcement is a database setting defaulting to
'none', and on a managed installation the only writers are whoever
administers it and the boot that creates them — so a policy meant to be
on from the start had nowhere to be written. A control plane calling in
afterwards leaves a window between the first account existing and the
policy covering it, and the first account is the one with every
permission.

The entrypoint already seeds an account from the environment. This seeds
the policy one line above it, so the administrator is born under the rule
rather than ahead of it. There is a test for exactly that ordering,
because the ordering is the whole point.

Seeded, never overridden. A value that won on every boot would take the
setting away from the person it belongs to — somebody who tightened it
would find it loosened again by a restart. So it writes only when nothing
has ever been stored, the same shape as `projectsend:admin --if-none`.

Two things that would have been easy to get wrong, both pinned:

'none' is the enum's own default, so Settings::get() cannot tell "stored
as none" from "never stored". Asking the accessor would have overwritten
an administrator who deliberately chose it. The command asks the table.

And it reads config rather than env() directly. `config:cache` stops .env
being read at all, which is how TRUSTED_PROXIES came to have no effect on
any web request while looking correct in the file.

Deliberately not a general PROJECTSEND_SETTING_<KEY> mechanism. Every
setting reachable from outside is one whose value depends on where you
look, and the blast radius of getting that wrong is the settings table.
One named key per setting that needs it.

The three new variables are documented in config/projectsend.php and not
in .env.example or the Docker Hub overview. Those two are written for
somebody running one installation for themselves, and a seat cap is not
a thing they have — FILES_WEB_SERVER_READABLE is in .env.example because
a self-hoster on cPanel genuinely meets that problem.
2026-08-27 02:38:39 -03:00
ignacionelson 463e86f82b Refuse an account past the seat count an operator sold
Opening user management on cloud (623ad68) left a managed tenant able to
create staff accounts without limit. This is the other half, and the two
belong in the same release.

max_clients and max_staff_users are numbers the platform sells and does
not enforce — grep finds them only being passed to screens. The
application is the only process that can count against them, so it
accepts the number from the environment and refuses to exceed it. That is
not the same as inventing a plan tier, which is what config/api.php
declines to do when it will not key a rate limit off billing: nothing
here knows what a plan is.

## One definition

staffUsed() and clientUsed() are public and are what the guards read. A
control plane showing "2 of 3 used" from its own query, beside an
application refusing the fourth from a different one, disagrees
eventually — over an inactive account, or a deleted one — and the
disagreement reads as a billing fault rather than a counting one.

## What counts, and the consequences somebody has to explain

An inactive staff account occupies its seat. Excluding it would make
deactivation a way around the cap rather than a way to revoke access,
since reactivating is one click. The cost is an awkward incentive —
deactivating is the safe removal and keeps paying, deleting frees the
seat and asks what happens to the files — and it is better explained than
hidden.

A client awaiting approval does not. Self-registration is open to
strangers, and counting a pending request would let anybody exhaust a
paid limit from the outside, turning a pricing tier into an availability
control. The seat is spent at approval, which is where the guard sits.

A soft-deleted account frees its seat, though not its address —
AvailableEmailRule holds that until erasure. So a seat can be free while
re-adding the same person is still refused, which is the address rule
rather than this one.

## Eight doors, eight tests

There is no single User::create() to guard. StaffAccounts::create()
covers both staff controllers, but a promotion takes a staff seat without
creating anything, a demotion takes a client seat, ClientProvisioning
serves registration and LDAP and social sign-in alike, and approval turns
an uncounted request into a counted client.

A cap is only a cap if every door asks, so there is a test per door and
each was verified to fail without its guard — eight red, with the two
"must not change" cases green either way. DownloadAllowance's shape for
DownloadAllowance's reason: the failure mode is one of them quietly not
asking, invisible from everywhere except the door that forgot.

projectsend:admin is deliberately uncapped and has a test saying so. It
is the recovery path, and anyone who can run it can also edit the
environment the cap comes from.
2026-08-27 02:31:12 -03:00
ignacionelson 623ad686da Open user management on the cloud edition
A managed installation's staff accounts were expected to arrive from
outside it, so users.manage was Community-only and /users, /roles and
their API twins answered 404 there. The platform side spent a long
document designing its way around that gate; opening it is cheaper than
routing around it, and more honest about where the knowledge sits.

The division that settles it is the one managed storage already uses. We
do not manage a tenant's files from outside — a bucket is provisioned, a
scoped credential handed over, and what goes in it is the tenant's
business. Seats are the same kind of thing. A platform knows how many
staff accounts it sold; it does not know whether Alice should be an
Account Manager, and it certainly does not know where her files go when
she leaves. Capacity is the platform's, occupancy is the tenant's, and
the cap belongs in an environment variable rather than in a closed
screen.

The capability stays in front of the routes rather than being deleted.
It is currently true in both editions, but it is the seam an edition
difference has to travel through, and removing it would mean inventing
one again later.

Seven test files asserted the old rule, which is the tests doing their
job. Most flip. Two needed a different example instead: EnsureCapability
and AbilityCapability were both using users.manage to stand for
"Community-only", so they now use storage.configure and manage_updates —
keys that still are.

Two rationales half-expired and say so rather than being quietly
rewritten. CommentAuthors gave two reasons for being a setting rather
than a permission; the first was that roles are uneditable on cloud,
which stopped being true here, and the second — that `Everyone` includes
anonymous visitors, who have no role to hold a key — was always the
stronger and is now the whole of it.

The seat cap this makes necessary is the next commit, not this one. On
its own this change lets a managed tenant create staff accounts without
limit, which is why the two belong in the same release.
2026-08-27 02:18:25 -03:00
ignacionelson c05927c190 Let a newer push cancel the test run it supersedes
The linter has had this since it was written; the suite, which is the
expensive one, never got it. Two pushes landing together ran two full
suites to the end, and the earlier one was checking a subset of what the
later one checks.

Keyed on the ref, so main and a branch never cancel each other, and a
branch with an open pull request does not fight itself — push and
pull_request arrive under two different refs.

One consequence worth stating rather than discovering: on main an
intermediate commit can end up with no run of its own when two pushes
land close together. That is the right trade when what is being verified
is the state of the branch, but it is not free — a bisect or a release
audit that needs a particular commit's own green tick needs that commit
pushed on its own.
2026-08-27 01:50:09 -03:00
ignacionelson 3a7800cc52 Skip the CLA job instead of starting a runner to skip a step
The condition was on the step. A skipped step has still had a machine
allocated for it, and Actions bills per job that runs — so `issue_comment`
firing on every comment in the repository meant every "merged, thank you"
on a pull request, and every comment on an ordinary issue, started a
runner to decide it had nothing to do.

Of the last forty runs, twelve were exactly that. Yesterday's twenty
merges each drew a comment, and each comment drew a runner.

Moved up to the job, where a false condition means no runner at all, and
narrowed with `issue.pull_request` so comments on plain issues stop
qualifying too. GitHub cannot filter `issue_comment` by body at the `on:`
level, so the job is the only place this decision can be made — which is
worth the comment beside it, because the obvious tidy-up is to push it
back down to the step it guards.

Behaviour is unchanged: the same two comment bodies still trigger a
check, and every pull_request_target still does.
2026-08-27 01:48:58 -03:00
ignacionelson ffbde4bea2 Bring the Docker Hub overview in line with 2.2.0
Three things went stale, all of them describing the image rather than
selling it, which is the half of that page people act on.

The tag table's worked example was 2.1.0/2.1. The "what is in the image"
paragraph said one queue worker; there are two now, and the second is
there so that building a large zip cannot hold up every notification
email behind it — worth a sentence, since somebody counting processes in
`docker top` would otherwise wonder. And the storage line said local disk
or S3, which stopped being the whole list when Google Cloud Storage
arrived.

FILES_WEB_SERVER_READABLE is new in 2.2.0 and deliberately not in the
environment table. It exists for hosts where nginx and PHP run as
different users, which cPanel and Plesk do; in this image they are the
same user in the same container, so listing it would invite people to set
something that buys them nothing.
2026-08-27 01:31:16 -03:00
ignacionelson 4c5c956a26 Release 2.2.0 2026-08-27 00:54:55 -03:00
ignacionelson 553f5fd2bf Declare the capability a managed installation's staff seats hang off
Cloud instances are sold seats rather than administering them, so the
tenant's own /users screens stay closed — capability:users.manage is
already Community-only — and a control plane creates, deactivates and
password-resets staff from outside. This is the key that plane gates on.

Only the declaration lives here, the same division StorageManaged and
Branding already use. Everything behind it is a module in the private
cloud-modules package.

Declared before that module exists, deliberately. A capability added
after a release is invisible to every image built from one, and that is
not hypothetical: StorageManaged landed 36 commits after v2.1.0 and has
never shipped, so a fleet with buckets provisioned, credentials scoped
and eight environment variables in place still writes every upload to
local disk — because the gate is here and the gate never left. Declaring
this one now is refusing to make the same mistake twice.

The seat *number* deliberately does not live here. There are no billing
or plan tiers in this application to key off, which is the reason
config/api.php gives for not inventing an installation-level rate limit,
and it holds for the same reason: the number lives where the plans do.
This capability says only who is in charge.

ModuleBoundaryTest grows the other half of its own rule. It filtered on
`api/v1/`, so a package claiming a route anywhere else passed — not
because that was sanctioned, but because nothing was looking, and
/platform/v1 is about to be somewhere else. What it polices now is
machine surfaces, the roots something other than a browser authenticates
to, with api/v1/modules and platform/v1 as the two sanctioned prefixes.

Written twice, because the first version was wrong in a useful way: it
policed every route and immediately caught community-modules' Custom
Assets screens. Those are a module doing exactly what a module is for,
through the host's session and capability middleware in plain sight, and
listing them would be the hardcoded URI list the test above it explains
it is avoiding. Web screens are not the boundary; trusted perimeters are.

Verified by making it fail: a package controller on platform/v2 is caught
and named.
2026-08-27 00:28:08 -03:00
ignacionelson 5d99ab94fd Say on screen when nothing is building zip downloads
Zip building moved onto its own queue, which a manual install's worker
has to be told about. update.sh repairs the service file and Docker is
unaffected, so the population left is somebody upgrading by hand who
skipped the release note — and for them the failure is the worst shape
available. Email keeps going out perfectly. Zip downloads never finish.
Nothing in any log says why, because nothing went wrong: the jobs sit on
a queue nobody is reading. The person who missed it has no reason to
suspect anything, so the notice has to go looking for them.

The application cannot see its own worker processes, only whether work
gets done, so the question is asked from the other end: was a build
requested that no worker ever picked up? That needs a record of when a
build *started*, which is what the new zip_downloads.started_at column
is — stamped before any of the work, so it says a worker had the row,
not that the row succeeded.

Two conditions, because either alone cries wolf. A build has waited past
five minutes and was never started, *and* no other build is in hand. The
second matters because one worker builds one archive at a time: a queue
behind a large build is a healthy queue, and its waiting rows look
exactly like abandoned ones until you notice something running. "In
hand" is bounded by the job's own timeout, so a worker that died holding
a build stops counting as alive an hour later.

The banner sits beside the stale-code one, on every staff page rather
than the dashboard alone, gated on view_system_info for the reason that
one already argues: a background worker not picking work up is a fact
about the machine, not a feature of an edition. It names the fix rather
than the symptom — "your worker command needs --queue=default,zips" —
because somebody reading that downloads are not being processed still
has to work out what to do about it.

Eight tests, covering both halves of the discrimination rather than just
the happy one: a queue waiting behind a live build stays quiet, and a
build held by a worker that died does not.

Translated into all sixteen locales in the same commit, since a release
is close and a banner nobody can read is worse than none.

Checked on screen as well as in assertions, with a real stalled row on
the dev stack: the banner renders, wraps, and reads correctly.
2026-08-27 00:12:42 -03:00
ignacionelson 3b51c5308c Translate the fourteen strings today's work added, into all sixteen locales
Everything merged today landed in English, which is the deliberate trade:
a feature never waits on a language nobody in the room speaks. This is
the pass that settles up.

Fourteen keys, sixteen locales, 224 entries. Appended rather than sorted
in, matching how the previous passes left these files, so the diff is
additions and one trailing comma per catalogue and nothing else.

One of the fourteen was a bug rather than a gap. The scoped
expired-files note was written with a `’` escape in the TSX, so the
scanner read the raw source and the runtime read the interpreted string:
two different keys for one sentence, and a catalogue entry for either one
would never have matched the other. The apostrophe is now a literal
character, which is what every other string in these files does.

Checked mechanically — every :placeholder survives, no plural pipe count
moved, `projectsend:erase-account` is intact in the two messages that
name it — and then read on screen, because a file that parses is not
evidence that a sentence fits its button. Settings -> Descargas renders
its label, its paragraph and its help text in Spanish with no overflow.

Orphans left alone at 250. The ten that touch today's subjects were
checked one by one and every one is a false positive of the kind the
skill warns about: `Expired files` is WIDGET_LABELS data, `Uploader` is a
role name from the database, `Page Expired` is laravel-lang's.
2026-08-26 22:55:18 -03:00
ignacionelson 0f81b74f9e Tell people about today's twelve merged fixes 2026-08-26 22:39:50 -03:00
Ignacio Nelson 1760dc70f8 Merge pull request #1700 from denkfabrik-li/fix/role-scope-authority
Nobody lifts a limit they are standing inside
2026-08-26 22:38:31 -03:00
ignacionelson ef822f2103 Merge pull request #1697 from denkfabrik-li/fix/assigned-clients-authority
Nobody hands out reach they do not hold either

Two resolutions against branches that landed first. #1678 and this one
each add a constructor property and an import to StaffAccounts, so both
are kept. And #1702's merge note called this one exactly: its
"converting an account to staff cannot hand out clients either" case
promoted a stranger client, which #1702 now refuses at 404 before
validation runs. Pointed at a client the actor holds, as that note
proposed, so the request reaches the assigned_clients rule the case is
actually about.
2026-08-26 22:36:47 -03:00
Ignacio Nelson 4cb46c954f Merge pull request #1695 from denkfabrik-li/fix/public-comment-thread-scope
Serve the public comment thread to the public, whoever happens to be logged in
2026-08-26 22:35:13 -03:00
Ignacio Nelson f12692520a Merge pull request #1694 from denkfabrik-li/fix/upload-folder-library-scope
Hold the folder an upload names to the same library boundary as everything else
2026-08-26 22:34:14 -03:00
Ignacio Nelson dacf2b3eda Merge pull request #1693 from denkfabrik-li/fix/public-download-external-disk
Hand over a public download from the disk the file is on
2026-08-26 22:32:54 -03:00
Ignacio Nelson e4cd56f5d6 Merge pull request #1692 from denkfabrik-li/fix/zip-download-limit-at-delivery
Enforce the download limit when a zip is delivered
2026-08-26 22:31:57 -03:00
Ignacio Nelson 9b3f7023d0 Merge pull request #1691 from denkfabrik-li/fix/file-bytes-after-commit
Delete a file's bytes when its transaction commits, not before
2026-08-26 22:30:32 -03:00
Ignacio Nelson 09efad2d8c Merge pull request #1690 from denkfabrik-li/fix/client-portal-subfolder-names
Don't name a subfolder to a client who cannot open it
2026-08-26 22:29:36 -03:00
ignacionelson 9fc5042f4e Merge pull request #1688 from denkfabrik-li/fix/atomic-account-deletion
Delete an account and dispose of its content in one transaction

Resolved the conflict with #1678 the way that PR's merge note predicted:
the erasure stamp goes inside the new transaction, so a deletion that
rolls back cannot leave a live account carrying a date on which it would
be erased.
2026-08-26 22:28:32 -03:00
ignacionelson 835943e1b6 Merge pull request #1686 from denkfabrik-li/fix/chunked-upload-complete-lock
Finalise each chunked upload once, under a per-session lock

Resolved a trivial conflict in ChunkedUploadsTest: this branch and
d7e639b both append tests to the end of the file, so both are kept.
2026-08-26 22:26:28 -03:00
Ignacio Nelson c5d32c06f6 Merge pull request #1684 from denkfabrik-li/fix/create-only-redirect-403
Land a successful create where a create-only role can actually go
2026-08-26 22:24:48 -03:00
Ignacio Nelson d36abd73ba Merge pull request #1682 from denkfabrik-li/fix/chunked-upload-max-size
Enforce the max file size against the bytes a chunked upload assembles
2026-08-26 22:23:50 -03:00
Ignacio Nelson e815ac8be5 Merge pull request #1681 from denkfabrik-li/fix/file-update-folder-scope
Scope a file's destination folder on update(), as move() already does
2026-08-26 22:21:52 -03:00
ignacionelson ebe4550fa6 Tell people the reserved-address fix happened 2026-08-26 22:20:19 -03:00
ignacionelson ad4d75d8fe Merge pull request #1678 from denkfabrik-li/fix/deleted-account-email-reserved
Let a deleted account's email address come back into use

Closes #1648, and with it the last open item of #1647's audit of unique
indexes on soft-deleting tables.

Resolved a trivial conflict in both ClientsControllers: this branch and
today's e7b5b6a each add a constructor property at the same line, so both
are kept. Nothing else overlapped.
2026-08-26 22:20:02 -03:00
ignacionelson 12a8ebe380 Rank top clients by roster, not by library, and factor the client guard
Two things found by checking #1696 and #1699 -- open branches carrying
the same fixes I wrote this morning -- against what I actually shipped.

**topClientsByStorage was scoped with the wrong question.** 4b8220a
narrowed it with StaffLibraryScope::files(), which is right for the two
widgets that name files and wrong for the one that names clients: a
stranger client's upload can sit legitimately inside a scoped viewer's
library, shared with a group one of their own clients belongs to. So the
file was theirs to read and the uploader's name was not theirs to see.
Measured: "Stranger Client Ltd", on nobody's roster, ranked on a scoped
dashboard. assignableClientIds is what the widget is actually asking, and
it is what #1699 used. Their version was right and mine was not.

**The client guard is one method now, not eight copies.** #1696 wrote it
as a private guardTarget() rather than repeating viewer-resolve plus
abort at each site, which is better, and this is a change whose whole
argument is that a rule stated in many places drifts. Behaviour is
identical; the eight sites now read as one rule.

The published document reorders a 404 below a 422 on one path. Scramble
reads abort_unless out of a method body but not out of a helper it calls,
so the 404 now comes from route model binding instead of from the inline
abort -- same response, different position. #1701's body names this trap;
worth knowing it costs ordering and not content.

Credit where it is due: both come from denkfabrik-li's #1696 and #1699,
which were open while I was writing the same fixes. Those two are closed
against this and against e7b5b6a, 4b8220a and 67e9204.
2026-08-26 18:24:13 -03:00
Ignacio Nelson 6a5c9e55aa Merge pull request #1702 from denkfabrik-li/fix/convert-client-account-scope
Promoting a client is still binding a client account
2026-08-26 18:20:25 -03:00
ignacionelson c8078f65c5 Say whose expired files the dashboard is listing
Closing the one thing 4b8220a left open, and the reason it was left: the
expired-files widget reads StaffLibraryScope::files(), and
File::scopeVisibleToClient ends in notExpired(), so a client-scoped
viewer sees only their own expired uploads and never a client's.

Widening that would mean a library query that keeps expired rows, and
scopeVisibleToClient is the single source of truth for client file
access -- the highest-stakes function to go changing for a dashboard
widget. So the boundary stays where it is and the widget stops
overstating itself.

That matters more here than on the two widgets beside it. "Largest
files" showing the largest files somebody can see is still true from
where they stand; a warning about what is due to be deleted, quietly
narrower than it looks, reads as "nothing to worry about" on behalf of
files it never looked at. So this one gets a `scoped` flag from the
server, a title of "Your expired files", a line saying clients' files
are not listed, and an empty state that says none of *your* uploads have
expired rather than that nothing has.

Retitled at the call site rather than in WIDGET_LABELS, because the same
widget means two different things to two viewers and only the server
knows which one is looking.

Checked in a browser for both, not just in the assertions: the scoped
dashboard renders "Your expired files / Files you uploaded. Your
clients' files are not listed here. / None of your uploads have
expired.", with no console errors, and an unscoped administrator's is
unchanged.
2026-08-26 18:12:57 -03:00
ignacionelson 7c5af8570a Have the updater repair a worker that predates the zips queue
Splitting zip builds onto their own queue (92a132d) left manual installs
carrying the one job the release note has to do, and the failure it
produces is the worst shape available: a worker still watching only
`default` sends every email cheerfully and finishes no zip downloads,
with nothing in any log to say why. An upgrade note is a poor place to
put that, because it is read on a laptop and needed on a server.

update.sh already finds projectsend-worker.service, so it now reads the
unit's ExecStart and offers to add --queue=default,zips, keeping a copy
of the original beside it. Before the restart, so the worker comes back
on the command it is going to keep.

Only the unambiguous case is rewritten: a queue:work line with no
--queue at all, which consumes `default` and nothing else. A unit that
already names its queues is somebody's deliberate arrangement, possibly
with a second worker for zips, so that one is described rather than
edited — and one that already includes zips is silently left alone.

Exercised against five unit shapes rather than reasoned about: the plain
command is rewritten and backed up, a declined prompt leaves it untouched
with a warning, a unit already naming zips is a no-op, a custom queue list
without zips warns instead of editing, and a unit that is not queue:work
at all is ignored. The sed itself would double-append if it ran twice;
it cannot, because the --queue= guard above it returns first, and both
read the same first ExecStart line.
2026-08-26 18:09:23 -03:00
ignacionelson 92a132d74f Give zip builds their own queue, so one archive cannot hold up the mail
The last piece of the #1687 follow-up. BuildZipDownloadJob allows itself
an hour, every shipped topology runs exactly one worker, and everything
shares the default queue -- so one large archive delayed every
notification email queued behind it. The size cap and the
one-build-per-person rule bounded that in July; they did not remove it.

onQueue('zips') in the constructor rather than at the dispatch site, so a
second caller cannot forget it. Both images grow a worker for it:
compose.yaml gains worker-zips, supervisord gains [program:queue-zips],
and the existing worker in each narrows to --queue=default. --tries=1
there matches the job, which records its own failure rather than being
retried.

The part that needs care is the manual install. A worker whose command
still says plain `queue:work` consumes `default` only, so it would send
email happily and never finish a single zip, with nothing in any log
saying why. INSTALL.md's unit now reads --queue=default,zips -- one
worker watching both, which is right for most installations -- and says
what happens if you leave it off, with the two-worker split offered for
anyone who would rather keep the two kinds of work apart. CHANGELOG
carries it as an upgrade note, since it is something to do rather than
something that was done.

Verified in the dev stack rather than only in a test: dispatched a build
and watched worker-zips take it while the default worker stayed idle.
2026-08-26 18:06:40 -03:00
ignacionelson eb2917f5ff Hold a group object to the same boundary its membership already has
This overturns something #1701 decided, so it should say so. That PR
closed the membership hole and left GroupsController::update and
destroy installation-wide on purpose, on the grounds that managing the
group object is a different question from managing who is in it.

What decides it is a measurement that was not in front of that decision.
An assignment to a group is how its members reach a file, so deleting a
group revokes that access for every member. Measured before this guard,
with a client-scoped role holding the group permissions:

  stranger client can read the shared file   true
  PATCH /groups/{stranger group}             302, renamed
  DELETE /groups/{stranger group}            302, group gone
  stranger client can read the shared file   false

So a staff member who may not add somebody to a group out of their reach
could delete it out from under the people already in it. That is not a
gentler version of the membership rule, it is a harder one, and the two
sitting on opposite sides of the same boundary was the odd part.

StaffLibraryScope::allowsGroupChange is the reach half of
allowsGroupMembership on its own, since no client appears in this
question -- one predicate, two callers, rather than a second statement of
it. Both surfaces take it, at 404, matching the membership guards.

A group that shares nothing beyond the actor's library still passes, so a
group they created or one holding their own clients stays theirs, and
unscoped staff are unaffected by construction.

The API document moves a 404 above a 422 on two paths. Both already
documented the 404 -- route model binding produced one -- and Scramble
orders responses by where they appear in the method, so the guard landing
before the validate() call is the whole of the change.
2026-08-26 18:04:41 -03:00
ignacionelson 41b4e477b5 Give each parallel test worker its own directory for upload parts
A full parallel run failed once and passed on retry while I was doing the
#1703 follow-up. A flake is worse than a steady failure: it trains you to
re-run rather than look, and it quietly weakens every green run reported
beside it.

Upload parts are real files under storage_path('app/uploads-tmp/{session_id}'),
not a faked disk. Every parallel worker gets its own database, so session
ids restart at 1 in each of them, and two workers writing parts land in
the same directory. On top of that ChunkedUploadsTest's afterEach deleted
the whole tree rather than its own share, for everybody. Six test files
write parts, so this was reachable without anything I added.

The same collision exists inside one worker: RefreshDatabase rolls back,
so ids restart at 1 for every test, and a run that died before its
cleanup leaves parts sitting under the id the next test is about to
claim.

LocalPartStore now reads its root from config, defaulting to exactly
where it always was -- an installation with UPLOAD_PARTS_PATH unset
behaves identically. Tests\TestCase points it at a per-worker directory
and empties that directory per test, which closes the cross-worker, the
cross-run and the intra-worker versions together. ChunkedUploadsTest's
cleanup and its two directory assertions read the configured root rather
than the hardcoded path, so they can no longer reach into a neighbour.

Verified with eight consecutive parallel runs, green, and by watching the
per-worker directories appear separately (w1, w2, w4 … w14) rather than
one shared tree. The isolation itself cannot be asserted from inside a
single test; what a test can pin is the mechanism it rests on, so one
does: parts go where the configured root says.
2026-08-26 18:00:22 -03:00
ignacionelson d7e639b7af Close the two-request version of the deleted-folder target, and say why it failed
Follow-up to #1703, which made `exists:folders,id` mean what its ten
readers already assumed. Two things it named and deliberately left.

**The chunked upload is two requests.** store()'s rule only ever sees the
first: POST /uploads records the resolved folder on the UploadSession and
complete() reads it back from the session rather than from the caller, so
deleting the folder while the bytes are in flight still files the
assembled file into it -- the same orphan state #1703 removes, reached by
a door a validation rule cannot watch. complete() now re-resolves through
Folder::query() and files at the root when the folder has gone.

Root rather than a refusal, because the two moments cost different
things. At store() nothing has been sent, so refusing is free and honest,
which is the call #1703 made. Here the bytes are already uploaded, and
discarding somebody's finished transfer over a folder that vanished
underneath them is the harsher of the two surprises. The file lands
somewhere they can see it and move it.

**The refusal now explains itself.** "The selected folder id is invalid"
says nothing when the answer is that the folder has been deleted -- and
that is the usual way to meet this rule, since a live id picked from a
list is how anybody gets here. It matters most on the chunked path, the
one place #1703 makes a previously-working request fail. A small
ValidationRule object carries the message, which keeps the single
definition Rules::folderId() exists for: a messages() array would have to
be repeated at all ten call sites, and rules meaning different things in
ten places is what went wrong in the first place.

One note for whoever writes the next test here. Upload parts live in
storage_path('app/uploads-tmp/{session_id}'), which is a real shared
directory rather than a faked disk, and each parallel worker's database
restarts session ids at 1 -- so two files writing parts on two workers
collide, and ChunkedUploadsTest's afterEach deletes the whole tree for
everybody. Six test files write parts today. These two cases live in
ChunkedUploadsTest rather than beside the rest of their subject so this
change does not add a seventh racer; the underlying isolation problem
predates it and is worth its own fix.
2026-08-26 17:41:07 -03:00
Ignacio Nelson 8d896191e4 Merge pull request #1703 from denkfabrik-li/fix/deleted-folder-upload-target
Say what `exists:folders,id` was already being read as
2026-08-26 17:35:34 -03:00
ignacionelson 93d5b21c6a Tell people the recovery-code fix happened 2026-08-26 17:26:04 -03:00
Ignacio Nelson 187d599d5f Merge pull request #1704 from denkfabrik-li/fix/recovery-code-single-use
Spend a recovery code once, the way the docblock says
2026-08-26 17:25:24 -03:00
ignacionelson 99b92a7e28 Tell people the notification-preferences fix happened 2026-08-26 16:33:18 -03:00
Ignacio Nelson a78f01f989 Merge pull request #1689 from denkfabrik-li/fix/notification-preference-types
Accept only notification types that can actually notify
2026-08-26 16:32:37 -03:00
ignacionelson e7b5b6a757 Hold client records to the same boundary the rest of the library uses
The other half of the sweep. ClientsController and its API twin checked
`abort_unless($client->isClient(), 404)` and nothing else -- a type
check, not a boundary, which is the phrase #1701 used about the group
membership routes for exactly the same reason.

Measured before the fix, with a client-scoped role holding the client
permissions:

  GET    /clients            every client on the installation, name + email
  GET    /clients/{stranger} 200
  PATCH  /clients/{stranger} 302, name actually changed
  DELETE /clients/{stranger} 302, client gone

The tell was one route over. ClientFilesController::index already draws
this line with StaffLibraryScope::canAssignClient and calls it "the same
boundary StaffLibraryScope enforces everywhere else in the library". Its
neighbours in the same family did not.

So the predicate is not new here. What is new is StaffLibraryScope::clients(),
the listing half of canAssignClient, so a screen narrows by the rule its
own buttons are guarded with instead of restating it -- restating it is
how this went wrong, and how the last four of these went wrong.

Eight actions take it: edit, update, destroy and the two-factor reset on
both surfaces, plus both listings. Answering 404 rather than 403, since a
client outside the roster should not be distinguishable from one that is
not there -- matching the isClient() guard already above it.

Account requests stay installation-wide on purpose: a self-registered
client who has not been approved belongs to nobody yet, so there is no
roster to narrow by and narrowing would empty the screen.

The published API document is unchanged -- both routes already documented
the 404 that the type check produced.
2026-08-26 16:01:27 -03:00
ignacionelson 4b8220a250 Narrow the dashboard's file widgets to the viewer's own library
The sweep after #1685 turned up the same leak two widgets further down
the same controller. largestFiles() and expiredFiles() already take the
viewer -- to decide whether their rows get links -- but queried with a
bare File::query(), so a client-scoped staff member's dashboard named
files belonging to clients they hold nothing of.

The note above largestFiles() says a link that 403s is accepted rather
than adding per-row scope checks. That reasoning is about the link. A row
that should not be there at all is a different problem, and the name is
the part that leaks: "Q3 delinquent accounts" says plenty without ever
being downloadable. Scoping the query is also cheaper than the per-row
check that note declined -- StaffLibraryScope builds a scoped user's
query once per request.

Reachable in the default configuration, unlike the last few of these: the
Client Manager role ships client-scoped and holds view_statistics.

topClientsByStorage() goes with them; it names clients rather than files,
which is the thing MembershipRequest::approvableBy and ActivityLogScope
already exist to keep inside a roster.

counters() and transferSeries() stay installation-wide, and now say so.
A total carries no names -- "417 files" tells a scoped viewer nothing
about whose they are -- and if that ever stops being the line, both move
together.

One consequence worth stating rather than discovering: scopeVisibleToClient
ends in notExpired(), so a scoped viewer's expired-files widget now lists
only their own expired uploads, not a client's. Safe, and under-inclusive
-- telling them about a file auto-delete is about to take needs a library
query that keeps expired rows, which is a boundary to decide rather than
to invent inside a leak fix.
2026-08-26 15:57:51 -03:00
ignacionelson bc33933432 Tell people the repeated-denial bug happened 2026-08-26 15:44:11 -03:00
Ignacio Nelson 350a7b3073 Merge pull request #1705 from denkfabrik-li/fix/deny-membership-request-once
Deny a membership request once, as approve() already does
2026-08-26 15:43:29 -03:00
ignacionelson f1b35cc9f6 Stop a deleted file locking a scoped staff member out of a group for good
#1701 closed a real hole: group membership decides what a client reaches,
and through File::scopeVisibleToClient it decides what the staff member
holding that client reaches, so `edit_groups` alone was never a boundary.
The predicate it added asks whether everything shared with a group is
already inside the actor's library.

It asked by counting: pluck the group's assignment rows, count how many
of those ids the library query returns, and require the two to match. An
assignment row outlives the thing it points at — nothing clears them when
a file or folder is deleted — while files() and folders() exclude trashed
rows by construction. So one deleted file left a count that could never
balance again, and the group closed permanently: the scoped staff member
could no longer add their own client to it, or remove anybody from it,
with a 403 and nothing to explain it. Every group accumulates dead
assignments over time, so groups would have gone quiet one at a time.

Asked the other way round — is there anything live, shared with this
group, that is outside my library — the dead rows drop out by
construction, because the query starts from File/Folder rather than from
the assignment. That is also the truer question: a deleted file is not
reach, since nobody can reach it.

Three tests. A group stays usable after a file shared with it is deleted,
including removing a member; the same for a deleted folder assignment;
and the half that must not soften — a live file still out of reach is
still refused, deleted siblings or not.
2026-08-26 15:21:30 -03:00
Ignacio Nelson 93d22378c4 Merge pull request #1701 from denkfabrik-li/fix/group-membership-library-scope
Group membership is a library boundary, not just a list
2026-08-26 15:19:20 -03:00
ignacionelson 2d83f139c8 Tell people the preview cleanup bug happened 2026-08-26 14:05:59 -03:00
Ignacio Nelson 18e4e014e6 Merge pull request #1683 from denkfabrik-li/fix/orphan-scanner-preview-renditions
Keep preview renditions out of the orphan-file scan
2026-08-26 14:05:12 -03:00
ignacionelson 67e9204654 Narrow the dashboard's recent activity to what its viewer may actually read
#1685 fixed the dashboard rebuilding a log row by hand and dropping
`origin` from it. One layer down, the same method was skipping something
larger: it ran a bare ActivityLog::query(), so ActivityLogScope never
applied.

That scope exists for this exact case, and says so in its own docblock —
`view_actions_log` is not the whole answer for a client-scoped staff
member, because a log entry carries the subject's *name*. An unscoped log
reads out the name of every file in the installation, and who touched it,
to somebody who gets a 403 on the files themselves.

Measured before the fix, one client-scoped viewer with the permission:

  /activity   →  []
  /dashboard  →  Uploaded the file "Q3 delinquent accounts"

Same person, same permission, opposite answers. The activity page and the
download history both apply the scope; the dashboard was the one caller
that did not, which is the same shape of gap #1685 was about.

More reachable than it looks: the Client Manager system role ships with
`view_actions_log`, so this is the default configuration rather than
something an administrator has to build.

Two tests: a scoped viewer sees only the entry about a file in their
library, and an unscoped one still sees everything.

transferSeries() is left alone on purpose. It is unscoped too, but it
returns per-day counts with no names or subjects attached, which is a
different exposure and arguably not one at all.
2026-08-26 13:51:02 -03:00
Ignacio Nelson 5fb98f4785 Merge pull request #1685 from denkfabrik-li/fix/dashboard-activity-origin
Show the dashboard's actorless activity as "Anonymous", not "System"
2026-08-26 13:49:24 -03:00
ignacionelson 0a8b609e8b Build a scoped staff member's library query once per request, not once per row
#1698 moved the library boundary into FileCommentPolicy, where it
belongs, and said plainly what that cost: the moderation screen went
from 65 queries to 465 for a client-scoped moderator with five assigned
clients. Measured here, those numbers are exactly right.

The cost is not in asking. It is that StaffLibraryScope::files() rebuilds
its query every time, and building one runs four immediate lookups per
assigned client — the client's group ids, the same ids again inside
Folder::sharedFolderIds(), that method's own assignment lookup, and the
shared-folder get() in Folder::scopeVisibleToClient(). None of them
depend on the query being built. Gate resolves a fresh policy for every
check, so a listing paid for all of it once per row.

The built query is now memoised per user and handed back as a clone,
since every caller adds to it, and the scope is registered as `scoped`
rather than transient so the memo survives a request. Scoped rather than
a singleton on purpose: a long-lived queue worker keeps singletons
between jobs, and a library query built from one job's data has no
business answering the next one's question.

That is 465 queries down to 60 on the same page — below the 65 it cost
before #1698, because the memo also helps the callers that were already
asking repeatedly. FileVersions::sharedAudience(), which runs the same
helper twice per candidate while resolving notification recipients, gets
it for free.

So the answer to the question #1698 left open is neither of the two it
offered. can_delete stays a real question asked of the policy; nothing
restates the boundary; and the page is faster than it was before the
fix. Three tests: one user's query never answers another's, one caller's
constraints never follow the next, and the moderation screen does not
ask once per row.
2026-08-26 13:30:50 -03:00
Ignacio Nelson 98c01aed3f Merge pull request #1698 from denkfabrik-li/fix/comment-moderation-library-scope
Keep comment moderation inside the moderator's own library
2026-08-26 13:28:25 -03:00
denkfabrik-li 706ebf6166 Nobody lifts a limit they are standing inside
StaffAccounts opens with the rule: "Nobody hands out authority they do
not hold ... that turns one permission into every permission and makes
the rest of the matrix decorative." mayGrant() enforces it for a role's
permissions, guardTarget() applies the same test to an existing account,
and RolesController::guardGrantablePermissions() names the attack in
full -- a non-administrator holding manage_users minting a role that
carries more than they do, and then holding it.

A role carries one more thing, and it is the larger one. `client_scoped`
decides whether the role reaches the clients assigned to its holder or
the whole library, which is the boundary StaffLibraryScope,
ActivityLogScope and every listing in the application are built around.
Nothing weighed it. store() and update() wrote the flag straight from
the request, and mayGrant() looked only at permissions -- so
`manage_users` on a client-scoped role was enough to take the limit off
that role and keep working, or to mint a role without one and move into
it. Either way the next request read the whole library, and the
`assigned_clients` roster that #1697 protects stopped meaning anything
for that account.

Both halves of the existing pair get the missing clause:

  - guardScopeRemoval() in RolesController refuses a client-scoped actor
    who creates a role without the limit, or takes the limit off one
    that has it. Phrased as "removes the limit" rather than "is not
    limited", so only what this request changes is weighed -- the same
    reasoning guardGrantablePermissions() gives for looking at the diff.
    Editing an already-unlimited role's permissions is not this actor
    lifting a limit. Both writers resolve the flag with
    Request::boolean() and hand that same value to the guard and to the
    write: the `boolean` validation rule accepts "0" and 0 as well as
    false and validates without casting, so reading the validated array
    and comparing it strictly would leave this guard and the model's own
    `boolean` cast disagreeing about one value -- which is the shape the
    guard exists to prevent.

  - mayGrant() refuses a client-scoped actor granting a role that is not
    client-scoped, which closes assigning an existing one. It reaches
    both surfaces at once: assignableRoleIds() validates role_id on the
    web and API staff forms and on the account converter,
    assignableRoles() fills the pickers, and guardTarget() covers the
    account itself.

Administrators are unaffected -- mayGrant() returns early for them, and
an administrator role is never client-scoped. Unscoped staff are
unaffected: the clause is conditioned on the actor's own scope, so a
non-administrator with manage_users and no limit creates, edits and
grants exactly as before. The seeded roles are untouched; update()
already refused to move the flag on a system role, which is why the
stock Client Manager was never the way in.

Two changes a client-scoped holder of manage_users will notice, both
following from mayGrant():

  - the role picker on the staff form and the account converter now
    offers only client-scoped roles, rather than offering one the
    request behind it would refuse;
  - editing or deleting a staff account whose role is not client-scoped
    now answers 403, through guardTarget(), on the same "if you could
    not grant their role you have no business editing that account"
    rule that already applied to permissions.

The roles API is read-only (GET /roles is the whole surface), so this
half has no API twin to mirror; the account half is covered above.
2026-08-26 10:35:40 +02:00
denkfabrik-li 8a6543073b Group membership is a library boundary, not just a list
The four routes that edit a group's membership -- add and remove, web
and API -- contain no authorization call of any kind. `can:edit_groups`
in front of them is the whole of it, and a permission is not a boundary.

The authorization sweep looked at these and let them stand, on the
grounds that groups are installation-wide by design: GroupsController
::index lists every group unfiltered, so list and single-object access
agree, and there is no listing/direct-access mismatch to fix. That is
true, and it is the answer to the question of who may *see* a group.
This is a different question: what a write to one *does*.

Joining a group hands the new member everything shared with it. When
that member is one of a client-scoped staff member's own clients,
File::scopeVisibleToClient hands the same content straight back to them
-- that scope is what StaffLibraryScope::files() is built out of. So the
one write turns a file they get a 403 on into a file in their library,
and the download that follows is a 200. ResolvesShareTargets draws that
line on the sharing path through canAssignGroup(); nobody drew it on the
membership path, and canAssignGroup() is *derived from membership*, so
whoever may edit the list also decides what the list entitles them to.

StaffLibraryScope::allowsGroupMembership answers it directly instead of
through the derived predicate, which is the wrong tool here twice over.
Membership asks about reach, so it checks reach: the client must be one
this staff member holds, and the group must not already reach past their
library -- no file assigned to it, and no folder shared with it, outside
StaffLibraryScope. A group nothing has been shared with passes trivially,
which matters, because canAssignGroup() would have said no to a group
that has no members yet and left a scoped staff member unable to put the
first client into one they had just created.

The same write has a second door. MembershipRequestsController::approve
joins a client to a group with identical consequences, under
`approve_groups_memberships_requests`, and deny() decides about somebody
else's client and emails them about it. Both go through the same
boundary, answering 404 to match the guard already above approve().

The queue and its sidebar badge are narrowed to the clients the viewer
holds, through one scope on the model that both read -- the rule the
comment badge in HandleInertiaRequests already states two branches down
("a client-scoped staff member is not shown a number they cannot act
on"), and the reason VisibleCommentScope owns its own pendingTotal()
rather than leaving the middleware to count for itself. Each row carries
the client's name and email, so an unnarrowed queue was also handing
those over for clients outside the roster. Unscoped staff still see every
pending request.

That narrowing is on the client, not on the group: whether a group is
reachable depends on what is shared with it, which is not a question to
ask row by row in a listing. A scoped viewer may therefore still be
shown a request they would be refused on -- one of their own clients
asking to join a group out of their reach. The names were the part that
leaked.

Unscoped staff are unaffected throughout -- both halves of the predicate
are true for them by construction. No seeded role reaches this: Client
Manager is the only client-scoped role that ships, and it holds no group
permissions, so a custom role is needed to get here at all.

The published API document gains a 403 on both member routes.
Regenerated with php artisan scramble:export; Scramble reads abort_unless
out of the method body but not out of a private helper, which is why the
guard is written out at each of the four call sites rather than shared.
2026-08-26 08:49:48 +02:00
denkfabrik-li 5242169bb0 Deny a membership request once, as approve() already does
approve() refuses a request that is not pending:

    abort_unless($group !== null && $client !== null
        && $membershipRequest->status === MembershipRequest::STATUS_PENDING, 404);

deny(), one method below, checks nothing. Denying is not idempotent, so
repeating it is not a no-op:

  - denied_at is stamped again, and that is what the client's re-request
    cooldown counts from (MyGroupsController::inDenyCooldown). Repeating
    the request keeps one client out of one group for as long as somebody
    cares to keep asking, without a single new decision being made.
  - a second GroupMembershipDenied entry goes into the activity log, for
    a denial that did not happen.
  - a second "your request was declined" mail goes to the client.

The queue lists only pending requests, so nothing on the screen offers
this; it takes asking for the route directly. It needs
approve_groups_memberships_requests, so it is not a stranger's move.

The guard is the same one, answering the same 404, placed where deny()
can reach it. deny() keeps tolerating a vanished group or client -- that
tolerance is deliberate and separate: the denied row persists for the
cooldown even when the group it named is gone, and index() already
filters those rows out with whereHas.

Not in this change: deny() writes the status, the log entry and the
notification without a shared transaction. approve() has exactly the same
shape, so fixing one alone would replace a symmetry with a difference,
and doing both means also deciding where the mail sits relative to the
commit -- which is the question #1691 answers for file bytes, and worth
answering on its own rather than inside a state-machine fix.
2026-08-26 06:09:49 +02:00
denkfabrik-li 7ebc9b0905 Spend a recovery code once, the way the docblock says
consumeRecoveryCode() reads the whole list, filters the used code out,
and writes the whole list back. Two requests that both read before
either writes each store their own copy, and the second write puts back
the code the first removed. So a spent code comes back, and the same
code offered twice is accepted twice -- while the method's first line
says "each code works exactly once".

Nobody gets in through this who was not already holding a valid code, so
it is a promise not being kept rather than a door standing open. The
promise is worth keeping anyway: it is the whole reason a printed sheet
of recovery codes can be crossed off, and it is what makes a code that
somebody watched being typed in stop working.

The decision now comes from the row as it stands, re-read under a lock
inside the transaction that writes it -- the shape SendNotificationDigest
already uses to claim the rows it is about to delete. A conditional
update, as in PublicShareController's downloads_count and the delivered_at
claim in #1692, is the other precedent in the tree, but the column is
`encrypted:array`: there is nothing in it a database can compare, so the
comparison has to happen after decryption, under something that holds the
row while it does.

The lock is what makes it atomic against a request arriving at the same
moment. The re-read is what makes the decision right, and it is the half
a test can show: SQLite ignores lockForUpdate, so the accompanying tests
pin the re-read and say so rather than claiming to prove the locking.

config/database.php runs MySQL or Postgres in production, and both honour
it. Saving through the caller's own instance keeps that instance in step
with the row, so a caller cannot go on to decide from a list the database
no longer has.
2026-08-26 06:05:29 +02:00
denkfabrik-li 7727ad7616 Say what exists:folders,id was already being read as
Folder uses SoftDeletes. The `exists` rule runs against the table, so a
folder in the trash passes it -- while every resolution that follows goes
through Folder::query(), which honours the soft delete and finds nothing.
Ten rules across five controllers rely on that check, and each one reads
it as "this folder exists".

Two of them then wrote the id anyway. Api\FilesController::store()
resolves the folder, hands the null to Folder::uploadableBy(), is told
yes -- correctly, that is the rule for a root upload -- and passes
$validated['folder_id'] to the write. FilesController::store() is the
same shape once #1694 gives it the guard. FilesController::update() and
its API twin write it straight through with nothing in between.

The result is a live file inside a deleted folder, which is a state
nothing else in the application produces: FolderService::delete() deletes
every file in the subtree along with it. The row is reachable by id, in
search and over the API, and missing from the listing its uploader would
look in.

Rules::folderId() makes the check mean what its readers assume, once,
where the reasoning can be written down -- the same argument slug() makes
for itself one method above. Every site takes it, so the file cannot end
up with two spellings of the same rule and no way to tell which is the
safe one.

What changes, path by path:

  - POST /files, POST /api/v1/files, PATCH /files/{file} and
    PATCH /api/v1/files/{file} refuse a folder in the trash instead of
    writing its id. This is the fix.
  - POST /uploads used to accept it and quietly file the upload at the
    root -- its guard and its write already agreed, on null. It now says
    so instead, which is what the other upload paths do.
  - files/{file}/move, files/bulk-edit, folders, folders/{folder}/move
    and the portal's my-folders already refused, through
    StaffLibraryScope::folders() or Folder::scopeVisibleToClient(), both
    of which drop trashed rows. They still refuse; the answer is now 422
    naming folder_id rather than a bare 404. Those two guards are asking
    a different question -- "is this folder yours" -- and they keep
    asking it.

No live folder id behaves differently anywhere, and the root (a null
folder_id) is untouched.

The published API document is unchanged: `exists` renders the same either
way. Regenerated with php artisan scramble:export and byte-identical.
2026-08-26 06:01:06 +02:00
denkfabrik-li b9f826282a Promoting a client is still binding a client account
Every route that binds one client — the edit screen, the update, the
delete, the second-factor reset, the file browser — asks whether this
staff member may manage that client. POST users/convert/{user} binds one
too, and asks nothing about it.

AccountConversion::guardToStaff() says why it skips
StaffAccounts::guardTarget, and the reason is sound as far as it goes:
guardTarget asks "could the actor have granted the target's role", which
is meaningless of a client, and what limits a promotion is the role being
*granted* — enforced by the controller validating role_id against
assignableRoleIds(). That answers the question about the role. Nothing
answers the one about the target.

So a client-scoped staff member holding manage_users, edit_users and
edit_clients could promote any client on the installation. It is the
most far-reaching thing that can be done to a client account: the portal
access goes, the assignments that made them somebody's client go inert,
and they come out holding whatever staff role the actor picked from
their own list. The client is never told.

guardToStaff() now asks StaffLibraryScope::canAssignClient — the same
predicate ResolvesShareTargets uses to decide who a file may be shared
with, and true by construction for unscoped staff, so the ordinary
administrator path is untouched. 404 rather than 403, matching both the
isClient() check the controller makes on the way in and the answer the
clients routes give: a client this staff member may not manage should
not be distinguishable from one that is not there.

guardToClient() is unchanged. Its target is a staff account, guardTarget
is the right question to ask about one, and it was already being asked.

The account list on the converter screen is deliberately left as it is.
It runs under can:edit_users and shows every account of the chosen
direction, the same way every other staff surface that lists clients
shows all of them; narrowing a listing is a product decision, not this
fix. What changes is that the button on the row now refuses rather than
going through.
2026-08-26 05:51:51 +02:00
denkfabrik-li 4806b81dc3 Let a deleted account's email address come back into use
An account deleted by an administrator was soft-deleted with erase_after
null, so projectsend:purge-erasures — which filters on
whereNotNull('erase_after') — never reached it, and the unique index on
users.email kept the address reserved forever. Anyone re-creating the
account got "The email has already been taken", naming a conflict nothing
on any screen could show or clear (#1648).

Both halves of the issue's option 3:

Every deletion path now schedules the erasure. The stamp lives in
ErasureSchedule — self-deletion switched to it, and StaffAccounts::delete
(shared by the web screen and the API) and both client controllers call
it right before delete(). Same grace period, same purge, whoever deleted
the account. Deliberately no backfill for rows deleted before this
change: stamping them during an update would start a countdown to data
erasure that nobody chose at deletion time; the message below covers
them instead.

The staff creation paths swap unique:users,email for AvailableEmailRule,
which refuses exactly the same things but can explain the one refusal
the stock message can't: an address held by a deleted account now names
the date it becomes available, and one deleted before scheduling existed
points at projectsend:erase-account. A living account keeps the stock
message, and public registration keeps the stock rule — telling an
anonymous visitor the address belongs to a deleted account would confirm
it had an account here.
2026-08-26 04:04:48 +02:00
denkfabrik-li 2c2b86ffa1 Hold the folder an upload names to the same library boundary as everything else
Folder::uploadableBy() returned true for any staff member without looking
at the folder, on the strength of a comment saying staff had already
validated folder_id through FilesController's own flow. No upload path
did. FilesController::store() did not check the folder at all; the two
that called uploadableBy() — the API upload and the chunked upload the
browser actually posts to — called a guard that could only ever say yes.

A client-scoped staff member could therefore name any folder id and put
the file inside a subtree shared with somebody else's client, where
File::scopeVisibleToClient hands it over without an assignment row ever
being written. That is the boundary StaffLibraryScope's own docblock
claims to hold everywhere.

The staff branch now asks StaffLibraryScope::allowsFolder, which returns
true for unscoped staff, so nothing changes for them. The client branch
is untouched: a client is never client-scoped, and ownership or a public
folder opting into client uploads remains the whole of their rule.

The two folder pickers that fed those ids are narrowed the same way the
listings around them already are.
2026-08-26 04:01:25 +02:00
denkfabrik-li e3554bdd39 Serve the public comment thread to the public, whoever happens to be logged in
VisibleCommentScope says at the top of the class that its callers must
already have established that the viewer may see the file. The public
listing's comment endpoint establishes only the guest half of that — the
file is reachable without an account — and then hands $request->user()
straight to the authenticated reading.

For anyone the file's own gate would refuse, that reading is far too
wide. A staff account outside its library, or one holding no file
permission at all, read the file's staff-only notes; a client the file
was never shared with read the messages staff addressed to that file's
clients. Both get a 403 from GET /files/{file}/comments and needed only
to ask the public URL instead.

The endpoint now asks the file's own gate which reading applies. A reader
it admits sees no less than before. A reader it refuses gets what a
visitor gets, widened by their own comments — which is what this
controller has always promised them, and all it promised. The
held-comment rule moves into a method both readings share rather than
being restated.

The same root reaches PATCH and DELETE /comments/{comment}, which bind a
comment rather than a file and so never authorized `view` on it either.
They answer with the thread, and now with the one the file's gate allows.
Refusing them outright would be wrong: somebody who commented through
the public page is exactly the person entitled to edit their own words.
2026-08-26 03:59:29 +02:00
denkfabrik-li 3af8235729 Nobody hands out reach they do not hold either
StaffAccounts opens with the rule for roles: "Nobody hands out authority
they do not hold … that turns one permission into every permission and
makes the rest of the matrix decorative." mayGrant enforces it, and
guardTarget applies the same test to an existing account.

The client roster never got the same treatment. `assigned_clients` was
validated as `exists:users,id where type = client` and passed straight to
syncAssignedClients, with nothing anywhere asking whether the actor holds
the clients they are handing out. Assigning a client is not a label: it
is that client's whole library, given to whoever is on the other end.

The case that matters is the actor's own account. guardTarget returns
immediately when the target is the actor — editing your own name and
email is not a question of authority — so a client-scoped staff member
with edit_users could PATCH their own id with every client on the
installation and read the whole library from then on.

assignableClientIds() answers the roster question the way
assignableRoleIds() answers the role one, and the five places that accept
`assigned_clients` — users store and update on both surfaces, and the
account conversion — validate against it. It returns the full roster for
an unrestricted actor, so every caller validates against one list instead
of composing a conditional rule; that list is already client-typed, which
is why one rule replaces the exists() and the type filter together.

The two pickers that offer the roster are narrowed to the same list, so a
form no longer offers a client the request behind it will refuse.

syncAssignedClients is untouched: which ids stick to which role is its
decision and it was never the problem.
2026-08-26 03:46:12 +02:00
denkfabrik-li acda732ff4 Enforce the download limit when a zip is delivered
A download limit is checked when an archive is ordered and again while it
is built, but it is only spent when the archive is collected. Nothing
about ordering or building moves the count, so every check along the way
sees an allowance that is still untouched.

That turns a prepared archive into a voucher. Order the same limited file
into ten archives and all ten pass, because at the point each one is
checked nothing has been taken yet. Collect them all and the file has
been downloaded ten times against a limit of one. The three endpoints are
independent of the interface that normally drives them, so this needs
nothing more than calling store() in a loop — and no timing luck at all,
since the archives can be collected minutes apart.

DownloadAllowance says of itself that six routes put a file's bytes on
the wire and that every one of them asks, precisely because there is no
choke point to put the rule in. The zip pair asked in the two places that
do not count and not in the one that does.

So the delivery re-checks what the archive holds, where the count
actually moves. Refusing is 403, matching the single-file download route
for the same situation. It is also the only one of the two candidates
that reaches the person: an archive is fetched by navigating to it, and
there is no error view for 422, so the message would be replaced by the
framework's generic "something is broken" page.

One refused file refuses the whole delivery, because nothing can be taken
out of a finished archive without building it again. Ordering the same
selection afresh is the way through — the build leaves the spent file out
and names it in skipped_files, which the poll already reports. This is
stricter than store(), which drops spent files from a selection and
refuses only when nothing survives: there, a selection can still be
narrowed, and here it cannot.

Checking costs nothing where nothing is limited. An unlimited file is
answered from its own column and never reaches a count.

Claiming the delivery is a conditional update now rather than a read
followed by a write. Two fetches of one archive arriving together both
saw delivered_at unset and both wrote a full set of downloads, counting a
single delivery twice — the same shape as the conditional increment that
guards a share link's max_downloads. Only the fetch that moves the column
logs anything; the other still receives the archive, which is the
existing rule that re-fetching one prepared zip is one delivery.

Two things this deliberately leaves alone. Simultaneous downloads of one
file can still both pass before either is logged: that race is documented
in DownloadAllowance, and closing it needs the counter column it explains
why it does not have. And an archive already delivered stays fetchable
for its 24 hours even once the limit is spent — one delivery, re-fetched,
which is what that rule is for.

An archive built before the job recorded its contents is handed over the
way it always was, without this check. What it holds can only be guessed
at by resolving the selection a second time, and guessing is exactly what
must not decide a refusal: the same reconstruction refuses over files the
archive does not hold and misses files it does. Those rows stop existing
within a day or two of an upgrade, and until then they behave as they did
before this change rather than worse.
2026-08-26 03:37:48 +02:00
denkfabrik-li a8b1987e2c Hand over a public download from the disk the file is on
5754016 moved this controller's thumbnail() and preview() onto
StoredFileResponse and left download(), the last method in the same
class, building its own response:

    'X-Accel-Redirect' => '/protected-files/'.$file->path,

That prefix is nginx's internal location for the local files disk, and
$file->disk is never consulted. On an install with external storage
switched on it names a path nothing ever wrote, so the public download
fails — while the same file downloads correctly from the file manager
and from a share link, and previews correctly from this very page,
because all three go through the object that knows the rule.

That commit's own message names the shape: the knowledge "was sitting in
a private method on one class and inline in another, so the next caller
could not inherit it and did not". It is an object now, and this is the
call site that was not moved onto it. StoredFileResponse is already
injected here as $this->bytes — preview(), two methods above, uses it —
and attachment() is the method FileDownloadController and
PublicShareController already call.

Nothing changes for a local install: attachment() emits the same four
headers this method wrote by hand, through the same ContentDisposition
call. The return type widens to Response|RedirectResponse because a
non-local disk answers with a redirect to a presigned URL, which is the
signature preview() already declares.

The regression test fails against the unfixed controller — checked in
both directions rather than assumed. The existing local-disk case grew
assertions for the other three headers, so "unchanged for local" is
pinned rather than argued: it passes before and after.
2026-08-26 03:20:28 +02:00
denkfabrik-li 16787cf697 Record which files a zip actually contains
A zip download's row stores what was asked for — some file ids, some
folder ids — and the download action resolved that selection a second
time, when the archive was collected, to decide what to log as
downloaded.

The two are not the same thing. Folder contents are resolved against the
scope as it stands at that moment, and an archive is written some time
before it is fetched. Add a file to the folder in between and it was
logged as downloaded without ever having been in the zip. Move one out
of the folder and it was handed over without being logged at all. The
same goes for a file that expired or otherwise left the requester's
scope after the build: its bytes are in the archive either way. Nothing
about this is visible to anyone — the download count on the file is
simply wrong.

The job already walks exactly the set that goes in, and already counted
it for file_count. It now keeps the ids rather than a tally, and the
download action logs those. count() gives back the number it was
keeping before.

Rows written before this column existed fall back to resolving the
selection, which is what they were built for; the purge command clears
them within a day.
2026-08-26 02:54:59 +02:00
ignacionelson 073101d184 Put a ceiling on a zip download, and clean up after the ones that fail
Follow-up to #1687, which made a zip build report failure honestly. Four
things it passed near, none of them regressions it introduced.

A zip has never had a size limit — only a cap of 10,000 files, which
bounds nothing that costs anything. Ten thousand spreadsheets zip in
seconds; two hundred videos is an hour of stream-copying and an archive
that fills the disk. Bytes are what a build actually costs, so the new
Settings → Downloads screen caps the total size instead, at 2 GB out of
the box. It is a setting rather than a constant because the safe figure
depends on free disk, on whether sources live on a remote disk, and on
the plan a hosted tenant is on — the file count stays fixed, since it is
a foot-gun rail and not a knob anybody needs. The controller measures
the selection at request time and names both numbers when it refuses;
the job measures again, because it re-derives the selection at run time
and a folder can grow while the job waits in the queue.

Every shipped topology runs exactly one queue worker, and everything
shares the default queue, so raising the job timeout to an hour handed
any signed-in person an hour of everyone else's notification mail. There
is now one build in progress per requester and a named throttle bucket
on the endpoint, which had neither. A pending row older than an hour is
treated as abandoned rather than in progress, so a worker killed hard
enough to skip failed() cannot lock somebody out for good. Giving zip
builds their own queue is the structural fix and wants its own change:
it touches compose, supervisord and the systemd unit in INSTALL.md, and
an install that upgrades without changing its worker command would stop
building zips silently.

zip_downloads.requested_by cascades on delete, so removing a user takes
their rows with it and strands every archive they built — invisible to a
purge that walks rows, and to OrphanFileScanner, which skips zips/ on
purpose. The purge now also sweeps files in zips/ that no row explains,
after a day's grace so a build in progress is never taken out from under
itself.

Two smaller things while in here. A build that failed because every file
had already hit its download limit said only that nothing was available,
and dropped the skipped list — the same distinction the store guard goes
out of its way to draw at request time. And a failed close() now logs
libzip's reason, which the @ silencing had been discarding: "the disk is
full" and "the source vanished" are different problems for whoever has
to fix one, while the requester still sees a message with no server
paths in it.
2026-08-25 21:44:27 -03:00
denkfabrik-li 65e7f37d36 Delete a file's bytes when its transaction commits, not before
File::booted() removed the bytes the moment a row was deleted. For a
single file that is right. Two paths delete files inside a transaction,
though, and both delete many at once: FolderService::delete() takes a
folder's whole subtree, and DeletedAccountContent::cascadeDelete() takes
everything an account uploaded.

Anything that rolls either transaction back puts every row back while the
bytes are already gone. A transaction exists to make a set of writes
undoable, and removing the bytes was the one write in that set that
nothing can undo. The account path is the sharper one: since content
disposal is nested inside the caller's transaction, the write that fails
need not be in this code at all.

The two failure directions are not equal. Bytes gone with the rows
restored leaves rows pointing at nothing and no way back. Rows gone with
the bytes left leaves orphans on disk, which OrphanFileScanner already
exists to find. Defer to the recoverable one.

Three properties this relies on, all of them checked rather than assumed:
without a pending transaction the callback runs immediately, so a single
delete is unchanged; a savepoint committing inside a larger transaction
does not fire it, which is exactly the account case; and the connection
is the row's own rather than whichever is default.

detachOnDelete stays inside the transaction — it repairs the version
chain's pointers, which is database work that must roll back with
everything else.
2026-08-26 02:15:34 +02:00
denkfabrik-li 862765643b Don't name a subfolder to a client who cannot open it
MyFilesController::index() builds the portal's folder list in two
branches. At the root it narrows to $visibleIds; one level in it listed
every direct child of the folder being browsed, visible or not.

Opening one was still refused — $current is resolved through
visibleToClient() and 404s otherwise — so what escaped was the name, not
the contents. A name is worth protecting here for the same reason
VisibleCommentScope gives about its own boundary: it is what stops one
customer learning that another exists.

$visibleIds is computed once and wanted in three places. The root branch
narrows by it, the breadcrumb narrows by it, and the nested branch did
not. That is a gap rather than a distinction, and the class docblock had
already promised the opposite: "Group and internal folder names never
leak."

Reachable only where a folder is visible for the created_by reason
rather than by sharing. Inside a shared subtree every child matches by
path prefix anyway, so nothing leaks there. A client with
create_own_folders makes such a folder through POST /my-folders, and
staff can file anything inside it — FoldersController::store resolves its
parent through StaffLibraryScope, which is unfiltered for unscoped staff.

Files were never affected: that branch's query already starts from
File::query()->visibleToClient(). The breadcrumb already trims to the
first visible ancestor. Neither is touched.
2026-08-26 02:02:19 +02:00
denkfabrik-li d19ec11970 Accept only notification types that can actually notify
update() validated preferences.*.type as ['required', 'string'], so any
string at all became a row in notification_preferences. Nothing reads it
afterwards: emailEnabledFor() looks preferences up by a key the registry
knows, so a row under an unknown key is invisible for good.

It is not a way into somebody else's settings — user_id comes from the
session, never the payload — which is why this is validation rather than
authorization. The cost is a table that quietly accumulates rows nobody
can see, explain, or remove through the interface.

edit() already knew the answer. It filters the registry down to the types
that can email at all, by either route, and renders exactly those as
toggles. That list is now derived once and used by both halves, so what
the screen offers and what it accepts back cannot drift apart.

Rejecting a registered-but-unmailable key (client_uploaded is the one in
tree) is deliberate rather than incidental: FilesServiceProvider explains
that it has no mail companion on purpose, so a preference row for it
could never change what anybody receives.
2026-08-26 01:16:39 +02:00
Ignacio Nelson b832f6bc3d Merge pull request #1687 from denkfabrik-li/fix/zip-download-job-robustness
Never mark a zip download ready over an archive that was not written
2026-08-25 20:14:18 -03:00
denkfabrik-li 3439537efe Keep comment moderation inside the moderator's own library
FileCommentPolicy::moderate() asked only whether somebody is staff and
holds moderate_comments. It never weighed the file the comment sits on,
and delete() returns true the moment moderate() does — so a client-scoped
moderator could delete any comment on the installation by naming its id.

Three call sites already knew this and wrote the boundary out by hand,
each with its own abort_unless($library->allowsFile(...), 403) after the
gate. The two that did not are FileCommentsController::destroy(), web and
API: both bind a comment directly, so nothing earlier in the request
establishes that the viewer may see its file.

The intent was documented in three places and enforced in none of them by
the policy — StaffLibraryScope says "the policies consult allowsFile() so
direct access respects the same boundary", VisibleCommentScope says "a
moderation screen is not a way around the visibility model". Put the rule
where those docblocks already say it lives.

moderate() now takes the comment when there is one. Named against the
class it still answers the coarser "does this user moderate at all",
which is what the queue's gate and the affordances ask. Membership is
tested by file id, so a file soft-deleted out from under its comments is
not in a scoped moderator's library either.

The author branch of delete() is deliberately untouched: deleting your
own words inside the edit window is not moderation, and a client is not
client-scoped in StaffLibraryScope's sense.

Approving through the API now derives its 403 from Gate::authorize rather
than the removed abort_unless, so the committed OpenAPI document gains
the shared AuthorizationException ref in place of an inline "An error"
schema — the shape nine of the other twelve documented 403s already use.
2026-08-26 01:09:47 +02:00
ignacionelson 5c00d189e4 Deletions can start a Zap after all
I said they could not, in the guide, the Zapier page and the changelog.
That was carried over from the limitation of polling a list, where a
deleted row stops being returned and nothing marks the moment it went. It
was never true of the activity endpoint: file.deleted is recorded like
any other action, so ?action[]=file.deleted works today.

Checked with a test rather than the enum, which turned up the shape a
caller needs: a deletion entry has no subject, because the row is gone by
the time the entry is written, so the name is snapshotted into
context.name instead. Reading subject.name there gets you null.
2026-08-25 18:51:05 -03:00
ignacionelson 7646e99f33 Add an activity endpoint, so an integration can react rather than poll for shape
Every list in /api/v1 answers "what is there now". Nothing answered
"what happened", and for the two events people most want to act on there
was nowhere to look at all.

Sharing a file writes an assignment row and never touches the file, so no
amount of polling /files?updated_since= will ever show a share. A
download is recorded only in the activity log. So the most requested
automations for a file-sharing product — tell me when a client gets a
file, tell me when they open it — were not possible to build.

GET /api/v1/activity is one feed rather than one endpoint per event,
because the log already records every one of them and a caller filtering
by action gets whatever the application grows later without waiting for
us to expose it.

It reuses what already exists: view_actions_log is the permission the
activity screen uses, and ActivityLogScope narrows the rows the same way,
so a staff member limited to their assigned clients cannot read the whole
installation's log through a token when the screen would not show it.

Two deliberate limits. Class names never reach the wire — subject.type is
a stable public string, or moving a model between namespaces would be a
breaking change to a frozen contract. And no ip_address, though the
column exists and the screen shows it: a person looking at a log has
decided to look, where an integration streams every row to somebody
else's servers by default.

PollingQuery grew an optional column so it can walk a table that is
appended to rather than edited. The parameter stays updated_since
everywhere, because on an append-only log the two timestamps are the same
thing and one shape learned once is worth more than a second name.
2026-08-25 18:44:51 -03:00
ignacionelson 3f27c8dbf8 Correct the Zapier guide on token expiry, and say which Zapier plan it needs
Two things the guide got wrong, both of which would waste somebody's
afternoon.

It said tokens max out at a year and left it there. There is a "Never
expires" option on the token screen, and for an unattended integration
that is usually the right choice — the guide now says so, along with why
it is off by default and what to do instead if you keep an expiry.

And everything in it uses Webhooks by Zapier, which Zapier includes only
from its Professional plan up. Somebody on the free plan would follow the
whole page and then find they cannot add the step. That belongs at the
top, not in a support ticket.
2026-08-25 18:35:46 -03:00
denkfabrik-li 61c385e423 Delete an account and dispose of its content in one transaction
Deleting a staff or client account is two writes: soft-delete the account,
then cascade or reassign the files and folders it owns. All four destroy()
paths (Users + Clients, web + API) ran them one after the other with nothing
tying them together.

If the second write throws, the account is already gone but its content is
not handled. The concrete way in is the reassign branch: validate() checks
reassign_to_id with exists(active), but apply() re-resolves it with
findOrFail() a moment later (AccountContentDeletion:108), so a target
deactivated or deleted in between throws — leaving a soft-deleted account
whose files still point at it, and a UserDeleted log for a deletion that did
not finish.

Wrap the delete()+apply() pair in a single DB::transaction() in each of the
four destroy() methods. validate() and the authorization guards stay outside
it: they are read-only and must be able to reject before anything is written.
cascadeDelete()/reassignTo() already open their own transaction, which nests
as a savepoint under this one, so the account soft-delete, its activity log,
and the content work now commit or roll back together.

Tests: a DeletedAccountContent double that reports content to handle and then
throws while handling it (tests/Helpers.php) drives one test per destroy()
endpoint asserting the account survives the failure and no UserDeleted entry
is written; each goes red against the un-wrapped controller.
2026-08-25 23:03:13 +02:00
denkfabrik-li eade83a576 Translate the finalising-refusal message into all sixteen languages 2026-08-25 22:41:43 +02:00
denkfabrik-li ff7758a31a Never mark a zip download ready over an archive that was not written
BuildZipDownloadJob deferred every write to ZipArchive::close() but then
marked the row STATUS_READY regardless of the result:

- close() returns false when a source file was deleted between addFile()
  and close() (a concurrent staff delete runs FileDiskCleanup at once) or
  the disk filled up; the row went ready over an archive libzip never
  wrote, and the download controller X-Accel-served a path that isn't there.
- An archive that ended up with no entries (every selected file removed or
  its allowance spent before the queued job ran) is written as no file at
  all by libzip, yet close() still returns true — again marked ready.

Check both the close() return and the added-entry count, and fail the row
(deleting any partial archive) when either says nothing was written.

The job also had no $tries/$timeout/failed(): a build of up to MAX_FILES
sources runs past the worker's default 60s timeout, and the kill skips the
catch, stranding the row as PENDING while the frontend polls forever. Give
it room, run it once, and add a failed() backstop that fails a row still
pending (leaving an already-resolved one alone).

Finally, purge leftover zips/{id}.zip* by row id: a killed build leaves a
partial archive and libzip temp file with no path recorded, so the path
field alone never cleaned them up.
2026-08-25 22:37:39 +02:00
denkfabrik-li 329aef98e0 Finalise each chunked upload once, under a per-session lock
complete() assembled the received parts into the one target file and
created the File row with no guard against a second complete() for the
same session running at the same time -- an Uppy retry, a double submit,
a resend after a lost connection. Two of them would interleave writes
into the session's single `assembled` file (the stored bytes then no
longer match the checksum computed from the in-memory buffers) and could
each create a File row.

Take a per-session lock around the finalisation and fail a second caller
fast; the lock's TTL releases the claim if a completion dies mid-flight,
so a genuine retry still works. The body moves to a finalise() helper so
complete() reads as auth + lock + finalise.
2026-08-25 22:13:46 +02:00
denkfabrik-li e6dc271f27 Land a successful create where a create-only role can actually go
Four create flows redirected to the new record's edit page on success,
but store is gated by create_* while the edit page is gated by edit_*,
and PermissionChecker has no create-implies-edit rule. A role holding
create_* without edit_* would create the record -- write, activity log
and notifications all run -- and then meet a 403 on the success
redirect, with no way to tell the action worked and every reason to
submit a duplicate. Categories is reachable with plain UI clicks, since
the sidebar shows it from create_categories alone.

Keep landing on the edit page for anyone who may edit, and divert only
those who can't -- to the create form, which shares store's own gate
and is therefore reachable by exactly whoever just created the record;
the success toast shows there. The index would not do: Clients/Groups
lists are gated by manage_*, which store itself does not require.
Implying edit_* from create_* would not do either -- edit has no
own/others split here, so it would silently hand a deliberately narrow
create-only role edit (two-factor reset included) on every existing
record.
2026-08-25 21:45:37 +02:00
denkfabrik-li 0c8518f7b7 Show the dashboard's actorless activity as "Anonymous", not "System"
The Recent activity widget rebuilt each log entry inline instead of going
through ActivityPresenter, and the inline copy dropped `origin`. On the
frontend "System" and "Anonymous" are both actor_name null and only
`origin` tells them apart, so every actorless entry -- public and
share-link downloads, anonymous comments -- rendered as "System ...".

Present the entries through the shared ActivityPresenter, the same
sentence-ready shape the activity page and detail panels already use, so
the dashboard cannot drift from them again.
2026-08-25 21:29:56 +02:00
denkfabrik-li 67f340d23d Keep preview renditions out of the orphan-file scan
The orphan scanner skips derived artifacts by path prefix, but the list
was a hard-coded ['thumbnails/', 'zips/'] that never learned about
'previews/'. ImageRendition::Preview caches under previews/ (and
previews/external/) on the local files disk, so every cached preview was
reported as an orphan: offered for import on the orphans screen, and
deleted by the purge command once past the grace period. An imported
preview also became a File row pointing at a path the rendition cache
owns -- destroyed the moment its source file was deleted or the cache
was flushed.

Derive the rendition prefixes from ImageRendition::cases() rather than
repeating them, so a future rendition can't be forgotten here the way
previews were; 'zips/' (the download-bundle job's) stays as it was.
2026-08-25 20:49:24 +02:00
denkfabrik-li 21fdf98981 Scope a file's destination folder on update(), as move() already does
Setting folder_id through update() is the same privileged reparent as
move() and bulkUpdate(), but only those two verified the target folder
was inside the caller's library (StaffLibraryScope::folders). update()
validated it only for existence, so a client-scoped staff member could
reparent an in-scope file into a folder shared with a client they are
not assigned to -- which File::scopeVisibleToClient then exposes to that
client, sidestepping the boundary the sharing endpoints enforce
(guardAssignable), and likewise into a public folder without
upload_public.

Apply the same scope->folders()->findOrFail() guard on both the web and
API update(), but only when folder_id actually changes, so re-saving a
file that already sits in an out-of-scope folder (reachable via a direct
client share) still works.
2026-08-25 20:35:18 +02:00
denkfabrik-li 71d6b8937e Enforce the max file size against the bytes a chunked upload assembles
store() checks Setting::MaxFileSizeMb against the size the client declares
when it opens the session, and complete() re-checks the storage quota
against the real assembled byte count -- but nothing re-checked the size
limit itself. A client that declared a one-byte upload and then streamed
gigabytes of parts passed store()'s check and was never stopped, so the
configured limit (which store() applies to everyone, staff included) did
not hold for the resumable path that real uploads use.

Re-check the assembled byte count against MaxFileSizeMb in complete(),
cleaning up the assembled bytes and the session exactly as the quota
branch already does.
2026-08-25 20:32:13 +02:00
ignacionelson 6ab90aee79 Say plainly that deleting an account is two calls, not one
StaffAccounts::delete() soft-deletes the row. What happens to the files
and folders that account owns is a separate collaborator, and a caller
that stops at the first one leaves them pointing at an account that no
longer exists.

The docblock mentioned "the content-reassignment step" in passing, as
context for the return value, which is not the same as saying it is
required. Worth stating outright because the mistake hides: validate()
returns an empty array when the account owns nothing, so an account with
no files deletes perfectly through delete() alone, and keeps doing so
until somebody deletes a colleague who had actually done some work.

Found while reviewing a design that was about to call delete() on its
own.
2026-08-25 14:22:25 -03:00
ignacionelson 91d34b204c Let something other than a browser session identify itself to the audit log
An actor with no personal access token has always meant a browser, and
for as long as a session and a Sanctum token were the only two ways to
authenticate, that was true. It stops being true the moment anything else
can, and the failure is silent: the action gets recorded as a person
clicking, in the one table whose whole purpose is answering "did I do
that, or did something acting for me?"

Nothing misreports today — every call site that passes an explicit actor
is a browser request, an API request whose actor carries the token, or a
console command with no actor at all. This closes the trap before the AI
connector in cloud-modules walks into it.

ActivityOrigin is a closed enum, so core has to publish both the case and
the hook before a package can use either. ResolvingActivityOrigin is
asked only in the ambiguous case: a request carrying a token is the API
and a request with nobody signed in is public or system, and neither is
in any doubt, so neither is offered — one package must not be able to
quietly relabel how every integration's actions are attributed.

The person stays the actor. They authorised it, and a log naming the
assistant instead would lose the only fact that matters. What the
connector was called goes in api_token_name, beside a null token id,
because that column means a row in personal_access_tokens and this is not
one.

The new origin is kept out of the activity filter unless the edition can
actually produce it. A filter option that can only ever return nothing is
a feature dangled at an edition that does not have it, which is the one
thing the edition boundary exists not to do.
2026-08-25 14:19:32 -03:00
ignacionelson 73533910b0 Move the API documentation tabs to the top, endpoints last
Having the endpoint table always visible with the tab strip halfway down
the page made the two prose documents look like a footnote to the table,
and it was not obvious there was anything to switch between.

One tab strip, directly under the heading, three views: the guide first
because it is what someone arriving here usually wants, then Zapier, then
the endpoint table. Nothing else changed.
2026-08-25 01:21:26 -03:00
ignacionelson 7059ee293a Add a Zapier guide to the API documentation page
The API has had everything Zapier needs since it shipped — a bearer
token, an auth-test endpoint at /me, and list endpoints that return
newest-first with a stable id, which is exactly the shape a polling
trigger wants. What was missing was anyone saying so.

This is written for somebody wiring up a Zap, not for somebody writing
code, which is why it is a second document rather than a section of the
guide. Same reason it renders in-app rather than linking to GitHub: the
installations most likely to need it are the ones least likely to have
outbound internet access.

It says out loud the two things that will otherwise be discovered the
hard way — a token expires within a year and nothing renews it, and a
deletion cannot start a Zap because polling cannot see one.
2026-08-25 01:08:09 -03:00
ignacionelson 5e3ea5a48b Translate the OAuth mail strings into all sixteen languages
The 21 strings the Microsoft 365 and Gmail transports added in #1679.
Appended, so the diff is only what is new.

The two provider names stay in English on purpose — they are product
nouns, and will read as untranslated in the scanner the same way API and
OK do. The tenant-ID placeholder sits in a fixed-width input, so each
translation of it is kept to roughly the length of the English rather
than the length the sentence wants to be; a fuller Spanish rendering was
already truncating on screen.
2026-08-25 00:32:42 -03:00
ignacionelson eda8091aef Serialise OAuth token refreshes, and don't offer Connect for an unsaved provider
Both fixes are follow-ups to the mail providers @denkfabrik-li added in #1679.

A refresh token is good for exactly one use — Microsoft and Google both
retire it as they issue the next one. Two queue workers finding the same
expired access token would therefore both spend it, and the loser gets
invalid_grant back. That is the same answer a revoked grant gives, so a
healthy connection would be marked broken, painted red on the settings
page and mailed to every admin. Refreshes now hold a per-connection lock
and whoever waits re-reads the row, which normally means finding a token
the winner already stored and not refreshing at all.

The Connect button read the provider dropdown, but the flow it starts
uses the saved provider. On an installation with both vendors registered,
switching without saving would open the wrong consent screen. The
dropdown now counts as an unsaved change like any other field, which also
gives it the right "save first" hint for free.
2026-08-25 00:24:48 -03:00
Ignacio Nelson 6147b2721e Merge pull request #1679 from denkfabrik-li/feature/oauth-mail-providers
Send email through Microsoft 365 or Gmail as an admin-connected mailbox
2026-08-25 00:22:26 -03:00
ignacionelson 4f4fb92b85 Group the Settings menu by subject instead of by permission
The conditional entries were pushed onto the end of the list after the
unconditional ones, so where an item appeared depended on whether it
needed a capability rather than on what it was about: Storage sat under
Languages, Email templates sat nowhere near Email, and Scheduler landed
between Branding and About.

Now there is one ordered list and each entry carries its own condition,
so the order survives whatever the edition and permissions turn on.
2026-08-25 00:12:34 -03:00
ignacionelson 1b3abd28b7 Merge branch 'main' into feature/oauth-mail-providers
Both sides added a .gitignore rule in the same place: this branch's
exception for docs/email-oauth.md, and main's block for the local dev
TLS material. Keep both.
2026-08-25 00:07:40 -03:00
ignacionelson a14ff0c837 Tell people the proxy fix happened
#1674 changed the code, the test and both install guides, and stopped
there — so the release notes would have shipped without the one fix that
closed #1672, and the person who reported it would have read them and
found nothing.

Written from the symptom rather than the cause: nobody searching the
changelog is looking for "reads TRUSTED_PROXIES at the wrong point in
the boot sequence", they are looking for why signing in behind Traefik
gave them an error. The upgrade note names config:cache, because a
proxy install that runs it puts the bug straight back.
2026-08-24 20:48:12 -03:00
ignacionelson 6086821d6c Close the other three doors that answered a write with a 302
#1680 fixed the redirect rendered from an exception and said plainly
what it did not cover: EnsureSetupIsComplete, EnsureAccountIsActive and
EnforceTwoFactor answer before HandleInertiaRequests is ever entered, so
a response they return never unwinds through Inertia's 302 to 303
upgrade either. Same 405, reached a different way — an account
deactivated while its owner was part-way through a form, or one being
made to enrol in two-factor.

The rule now lives in one place rather than four. Three copies of "if
the method is PUT, PATCH or DELETE" is how the fourth caller gets it
wrong, and WriteSafeRedirect can carry the explanation of why 303 —
which is worth more than the three lines it replaces, because nothing
about a bare setStatusCode call says what a browser does with a 302.

PUT /timezone is the route the setup test uses: it is one of only two
writes a guest can reach and the only one that middleware does not
exempt, so the case is real rather than defensive. All three new tests
were run against the unfixed middleware and fail there.

Extends the work of @denkfabrik-li, who found the gap and wrote it down.
2026-08-24 20:31:50 -03:00
Ignacio Nelson 30f08fdd3b A write that meets an expired session lands on the login page
A write that meets an expired session lands on the login page

A browser follows a 302 by replaying the request method, POST aside, so
a widget save whose session had expired replayed as PUT /login — and
/login takes GET and POST only. The 405 that came back hid the one
thing the person needed to be told, which was to sign in again.

Inertia already upgrades 302 to 303 for writes, but a redirect rendered
during exception handling never travels back through the middleware
stack, so the guest redirect after AuthenticationException was never
reached. bootstrap/app.php now does it for those.

Fixes #1673, reported by @mstewart14. Found, diagnosed and fixed by
@denkfabrik-li, who also wrote down the part this does not cover: the
direct redirects from EnsureSetupIsComplete, EnsureAccountIsActive and
EnforceTwoFactor sit outside HandleInertiaRequests and can still produce
the same 405 on a write.
2026-08-24 20:23:49 -03:00
ignacionelson a8f5fa5e10 Update .gitignore 2026-08-24 20:21:31 -03:00
ignacionelson ce487da19a Keep other people's warnings out of our users' consoles
React strips its own development warnings from a production build;
several of our dependencies do not. Radix emits an accessibility warning
for every dialog it considers underdescribed, and it was reaching
anyone who opened devtools on a real installation — advice aimed at
whoever builds the software, shown to whoever uses it.

console.error is deliberately left in. Something has genuinely gone
wrong when it fires, and a support conversation that opens with a real
stack trace is worth more than a tidy console. Only the advisory levels
are dropped, and only from production: npm run dev still shows
everything, which is where those warnings are useful.

The bundle goes from 12 console.log and 18 console.warn to none and two
— the survivors being a Recharts truthiness guard and Uppy's logger
object, neither of which speaks unless asked. What this does not do is
fix what Radix was complaining about: sixteen of twenty-three dialogs
have no description, which is a real accessibility gap and its own
piece of work.
2026-08-24 20:11:48 -03:00
ignacionelson a86017f2c2 Merge: Google Cloud Storage as a storage backend
External storage stops meaning S3 and nothing else. The Storage settings
screen asks which provider first, and Google Cloud Storage sits beside
the S3-compatible option for every self-hosted installation; the hosted
edition is handed a bucket by its environment instead, through a
capability declared here and behaviour that lives in cloud-modules.

Three of the bugs fixed here predate the feature and affect S3 users
today: share links and public group thumbnails both assumed the local
disk, and an upload the storage backend refused was recorded as if it
had been stored.

Verified against a live bucket, not only against tests — which is how
the last two were found.
2026-08-24 20:06:02 -03:00
ignacionelson 55e17498a2 Log which bucket an upload could not be written to
The failure message names the disk, which reads as a credentials problem
even when the real cause is a bucket name that was never changed — the
exact confusion produced by switching an existing S3 configuration over
to Google and leaving the old bucket in the field.

Logged rather than shown, because 'throw' => false means the reason is
already gone by the time this code runs, and because the message goes to
whoever was uploading. That can be a client, and a bucket name is not
theirs to see.
2026-08-24 19:56:10 -03:00
ignacionelson a459d45c87 Store the bytes, or say you did not
Two bugs a green suite could not find, both from pointing the
application at a real Google Cloud Storage bucket.

The adapter attaches a legacy per-object ACL to every write, and a
bucket with uniform bucket-level access — which our own setup
instructions require, and which Google recommends — refuses it:
"Cannot insert legacy ACL for an object when uniform bucket-level access
is enabled". So the default configuration could not write to the
recommended bucket. The library ships
UniformBucketLevelAccessVisibility for exactly this, and nothing is
lost by never setting an ACL: every object here is private and every
read is a signed URL.

The second is worse and was never about Google. Both file disks are
configured 'throw' => false, so a refused write returns false rather
than raising, and LocalPartStore ignored the return. The upload reported
success, the File row was written, and the bytes were nowhere — the
listing showed a file whose download could never work. An expired S3
credential did the same thing. It now checks, and the controller already
turns that into a validation error rather than a 500, so the person
uploading is told.

Verified against a live bucket with a key scoped to
roles/storage.objectAdmin: the probe lists, writes land, reads
round-trip byte for byte, and a signed URL comes back 200 carrying
"Informe año.pdf" intact through both the ASCII and RFC 8187 forms of
Content-Disposition.
2026-08-24 19:52:05 -03:00
ignacionelson b22d3cf33c Translate the storage provider strings into all sixteen locales
Eight new strings from the Google Cloud Storage work, and nothing else:
the scan reported the same eight missing everywhere, so this is a
translation pass rather than a backlog.

Each locale keeps the word for a bucket it was already using — kova in
Turkish, бакет in Russian, 存储桶 in Chinese — and its own level of
formality, Sie in German and vous in French against tú in Spanish and
Italian. "Google Cloud Storage" is a product name and stays as it is in
all sixteen, the way API and OK already do. The :field placeholder
survives verbatim, which is asserted rather than assumed.

Entries are inserted in place rather than appended, so each file shows
eight added lines and nothing else moved. Verified by re-running the
scan to zero missing, the Locale suite, and reading the settings screen
in Spanish in a browser — a file that parses is not evidence that a
sentence fits its button.
2026-08-24 19:40:58 -03:00
ignacionelson f7db586c7e Say that files can live in Google Cloud Storage too
Three lines still told readers S3 was the only option, which stopped
being true and is the sort of thing somebody chooses a different product
over. The install guide's storage section now says what each backend is
for, that Test connection exists and is worth using before switching
uploads over, and — the part people actually get wrong — that choosing a
backend applies to new uploads and moves nothing that is already stored.
2026-08-24 19:40:58 -03:00
ignacionelson 23b7dc0d11 Declare the capability a managed installation's storage hangs off
Cloud instances are given a bucket rather than configuring one, which is
the counterpart of StorageConfigure above it rather than a contradiction
of it: one edition points itself at storage, the other is pointed.

Only the declaration lives here. The behaviour is in the private
cloud-modules package, the same division Branding already uses, and
without that package the capability is inert and files stay on local
disk — so a self-hosted installation that somehow holds it is unchanged.
2026-08-24 18:35:44 -03:00
denkfabrik-li 4737849ec5 Answer redirected writes with 303 so browsers follow with GET
A redirect born in exception handling - the guest redirect after an
expired login, above all - never travels back through the middleware
stack, so Inertia's usual 302-to-303 upgrade cannot reach it. Browsers
follow a 302 by replaying the request method on the redirect target
(only POST is downgraded to GET), so a widget save whose session just
died replays as PUT /login and fails with a 405 that hides the real
"please sign in again" (#1673).

Repeat the upgrade in the exception pipeline: any 302 answered to a
PUT, PATCH or DELETE becomes a 303. Reads keep their 302, POST needs
nothing - browsers already downgrade it.
2026-08-24 23:33:55 +02:00
ignacionelson daec0a877e Offer Google Cloud Storage as a storage backend
External storage meant S3 and nothing else, which is an odd hole for a
product whose users are as likely to be standing on Google Cloud as on
AWS — and paying to move bytes between two clouds to use this. The
Storage screen now asks which provider first, and the answer decides
which fields it shows, which it validates, and which driver the
files_external disk resolves to.

One disk, not two. files.disk is a stored column, so a third disk name
would fragment the data model and make every $file->disk consumer know
three names instead of two; the driver is swapped instead. A service
account key gets its own encrypted column rather than sharing `secret`,
because the two are validated, labelled and displayed differently and
one column meaning two things is how that goes wrong later.

Three things do not work by simply adding the adapter, and all three
fail quietly:

Laravel's temporaryUrl() looks for getTemporaryUrl() on the adapter,
while League's GCS adapter names it temporaryUrl(), so without the
registered callback every download and preview is a 500.

The two SDKs spell the signing options differently, and an unrecognised
one is dropped in silence — the symptom is a download named after the
storage key, not an exception. GoogleCloudStorageDriver translates, so
callers keep speaking one vocabulary, and the test asserts on the URL's
contents rather than on "a redirect happened", which is what would let
it regress.

That callback is also re-bound to the FilesystemAdapter before it runs,
so the translation is captured before registering rather than called as
$this->

`provider` is validated with 'sometimes', not 'required': absent means
S3, which is what every payload written before this choice meant, and
stops a browser holding a stale bundle from failing to save on a field
it cannot see.

Verified in a browser as well as in tests — which is how the null
provider on an unmigrated row was found, since the suite migrates and
never sees that state.
2026-08-24 16:38:13 -03:00
ignacionelson 57540164fa Read a file from the disk it is actually on, everywhere
Two routes still assumed every file sits on local disk, which stopped
being true the moment external storage was switched on. A share link
answered with X-Accel-Redirect whatever the file's disk said, pointing
nginx at a path it has nothing behind; a public listing built a
thumbnail from Storage::disk('files')->path(), which for an externally
stored file is a path nobody ever wrote. Both fail only for installs
using S3, and only on those two routes, so the same file downloading
correctly from the file manager made the share link look like the
broken thing rather than where the file lives.

Neither is a new rule. FileDownloadController and
FileThumbnailController already did it right, which is the actual
finding: the knowledge was sitting in a private method on one class and
inline in another, so the next caller could not inherit it and did not.
Both are now objects with one job.

StoredFileResponse replaces InlineFileResponse and grows an
attachment() alongside inline(), since the two differ only by
disposition. LocalSourceFile takes a closure rather than returning a
path: the version that returned one also left the caller to unlink it,
and both of those are exactly the mistakes made here.

The regression tests fail against the previous controllers — checked in
both directions rather than assumed.
2026-08-24 16:24:39 -03:00
denkfabrik-li 8b59bb2a5a Move fakeIdToken() to the shared test helpers
It is used by both OAuth mail test files, which --parallel runs in
separate processes — exactly the situation tests/Helpers.php exists
for, as its own header explains. CI caught what a whole-suite serial
run hides.
2026-08-24 09:56:04 +02:00
denkfabrik-li 6a8a287984 Name the tab Outgoing email, not Sending
Discussion feedback: in ProjectSend "sending" can just as well mean
files. Outgoing email is unambiguous and the established name for this
screen elsewhere (GitLab, Jira, Moodle all call it that).
2026-08-24 06:58:28 +02:00
ignacionelson 457fed0c86 Stop spending CI time on checks that check nothing
The tests job spent 191 of its 270 seconds running the suite one process
at a time; --parallel runs the same 1763 tests across the runner's cores
with nothing skipped. paratest is already a dev dependency.

The linter job was worse: 150 of its 195 seconds went to `pint` with no
--test and its auto-commit step commented out, so it reformatted the
runner's checkout, exited 0 and threw the result away. `npm run format`
is `prettier --write` and did the same. Both are gone, with a note on
what reinstating them as real gates would take -- a formatting sweep
first, then the flag. What remains is eslint, now read-only so it can
actually fail, and the job no longer needs PHP at all.

Both workflows now cancel superseded runs, and neither runs for a change
that only touches prose nobody's code reads. CHANGELOG.md and docs/ are
deliberately absent from that list: ReleaseNotes parses one and two
controllers serve the other.
2026-08-23 23:59:53 -03:00
ignacionelson 4f38c9adee Show one confirmation toast, not two
Every page wraps itself in AppLayout, so a flashed redirect that lands on
a different page component tears the layout down and builds it again --
Toaster with it. The fresh Toaster then reads the flash at mount *and*
catches the router success event for the same visit, and every "Client
created." arrived twice. Saves that stay on the same component never
remount, which is why this survived unnoticed.

Deduping on the flash object's identity rather than its text is what
keeps the success listener doing its job: two genuine identical messages
in a row are separate objects and still both toast.

Verified in a real browser rather than by types: create a client, two
toasts before, one after, and two consecutive creates over SPA
navigation still toast once each.

Reported and diagnosed by @denkfabrik-li in #1675.
2026-08-23 23:44:07 -03:00
Ignacio Nelson f7d6fe929e Merge pull request #1676 from denkfabrik-li/fix/social-connect-inertia-location
Send the browser to the provider, not the XHR
2026-08-23 23:39:44 -03:00
ignacionelson 1030f719fc Record the connect-a-provider fix in the changelog
The Connect button on Settings → Connected accounts did nothing at all,
which is the kind of thing somebody upgrading needs to see written down.

Found and fixed by @denkfabrik-li in #1676.
2026-08-23 23:28:42 -03:00
denkfabrik-li 19d34ef38b Document the OAuth mail providers, linked from the settings screen
A step-by-step guide (docs/email-oauth.md, same shape as the API
guide, whitelisted alongside it) through both vendor consoles: the
Entra app registration with its three account-type choices and what
each means for the tenant field, and the Google Cloud client with its
consent screen, test users and the testing-status 7-day refresh-token
expiry. The troubleshooting entries are errors actually hit while
building this — including Graph's ErrorQuotaExceeded, whose message
text hides that the mailbox may simply be full.

The Sending tab links to the guide right where an admin picks an
OAuth provider, via the shared links.source origin.
2026-08-23 22:46:25 +02:00
denkfabrik-li 4eb8cf915a Add Google / Gmail as the second OAuth mail provider
Same delegated shape as the Microsoft 365 provider, through the same
broker interface: the admin registers an OAuth client in Google Cloud
Console, connects the Google account the installation should send as,
and outgoing email goes through the Gmail API's messages.send as that
account.

The shared authorization-code machinery (exchange, refresh, token
storage, id_token account detection, RFC 6749 failure telling a dead
grant from a transient one) moves into an abstract OAuthCodeFlowBroker;
the two vendor brokers keep only their endpoints, scopes and consent
URL parameters. Google's quirks live where they belong: offline access
with a forced consent screen (the only way Google issues a refresh
token), and a refresh response that never re-sends one — the store
keeps what it has.

The settings screen needed no changes: the dropdown, the credential
form and the connect flow all derive from the provider enum.
2026-08-23 22:46:24 +02:00
denkfabrik-li 933eaa2ba4 Send mail through Microsoft Graph as an admin-connected mailbox
Adds "Microsoft 365 (OAuth)" to the Email settings provider dropdown.
Selecting it swaps the SMTP form for an app registration (client id,
secret, optional tenant) and a "Connect mailbox" flow: the admin signs
into the mailbox the installation should send as, and outgoing email
goes through Graph sendMail as that mailbox — no password, no app
password, no SMTP AUTH, which Microsoft is winding down.

Delegated flow on purpose: it needs no admin consent and works for
work/school and personal accounts alike. Its one weakness — a grant
can die silently behind a password reset or a Conditional Access
change — is answered by a daily scheduled refresh that keeps the
token alive and, on a dead grant, warns the settings admins once
in-app and on the settings page instead of letting mail stop quietly.

Tokens and the client secret live encrypted in their own row and are
read fresh at send time, never through the boot-config cache. The
stored SMTP transport survives a provider switch untouched.
2026-08-23 22:46:24 +02:00
denkfabrik-li fbd6c3603d Add tests for the connect redirect navigation 2026-08-23 22:15:36 +02:00
denkfabrik-li 5bfc5a0883 Send the browser to the provider, not the XHR 2026-08-23 22:13:36 +02:00
Eliana Bracciaforte 94e4aa36e4 Merge pull request #1674 from projectsend/fix/trusted-proxies-read-from-env
Read TRUSTED_PROXIES late enough for it to be seen
2026-08-22 15:56:31 -03:00
elibrachas 027e8532d2 Name the 419 as a symptom of an untrusted proxy
DOCKER.md said getting TRUSTED_PROXIES wrong gives you wrong client IPs
or wrong links but never affects whether a request succeeds. It does: it
is what makes the create-your-admin form come back as a 419, which is the
first thing a new install behind a proxy hits and gives no hint about the
cause. Said so, and kept the point that a 502 is a different problem.

INSTALL.md told operators never to run config:cache, and the only reason
it gave was that doing so disabled TRUSTED_PROXIES. That read now goes
through the config layer, so the reason is gone and the section claimed
something untrue. Replaced with the caveat that does apply to a cached
config: re-run it after editing .env.

Also a troubleshooting entry under the symptom people search for — 419 on
login, or being returned to the login screen at random — since the
existing proxy entry only covered rate limiting and the download log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:41:49 -03:00
elibrachas 2a82335e07 Move the shared public-listing helpers to tests/Helpers.php
publicListingFile() and publicListingImageFile() were defined in
PublicGroupsTest.php and used from PublicFilePreviewTest.php too. Pest
declares a test file's functions as ordinary globals, so that works only
once the defining file has been loaded — which under --parallel depends
on how the runner happens to distribute files across processes. Adding
any unrelated test file anywhere in the suite reshuffles that and takes
PublicFilePreviewTest.php down with "Call to undefined function", and
running it on its own with --filter never worked at all.

tests/Helpers.php exists for exactly this and its docblock describes this
failure; these two had just been missed. publicPageProps() stays where it
is, since only one file uses it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:41:49 -03:00
elibrachas 1aaab1bf66 Read TRUSTED_PROXIES late enough for it to be seen
The value was read with env() inside the withMiddleware closure in
bootstrap/app.php. That closure runs when the HTTP kernel is resolved,
which is before the dotenv bootstrapper reads .env — so on every web
request env() returned null for anything set in .env, and the proxy was
never trusted. It worked when the value came from a real environment
variable, which is why the Docker compose path was fine and the manual
install described in INSTALL.md, where we tell people to put it in .env,
was not. Artisan bootstraps in the other order, so a check from the
command line reported the setting as working the whole time.

Behind a TLS-terminating proxy the consequence is not subtle. Laravel
falls back to the connecting address and the plain scheme, builds every
link and redirect with http:// while the browser is on https://, and
marks the session cookie non-secure. The browser then declines to send
that cookie to what it reads as a different, less secure origin, the
session arrives empty, and the first write fails with a 419 that reads as
"your session expired" — most often on the create-your-admin form, which
is the first thing a new install submits. Afterwards each redirect leaves
and re-enters over the wrong scheme, which is the random bounce back to
the login screen people report as flakiness.

Moved to config/trustedproxy.php, the key the framework's TrustProxies
middleware already falls back to on its own. Config files load after
dotenv, so the value is there whether it comes from .env or from the
environment.

This was also the only env() read outside config/, which means
config:cache is no longer dangerous on this application.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:41:31 -03:00
ignacionelson 503676647f Let a split-user host serve downloads
A download is not served by PHP. PHP authorizes it and hands the web
server the path with X-Accel-Redirect, so the web server has to open a
file PHP wrote. Where those are different users — cPanel and Plesk
commonly arrange it that way — it cannot: uploads land 0600 inside a 0700
directory, and traversing 0700 means being its owner. Nothing else on the
site shows a symptom. Uploading works, the library lists everything, and
only downloads fail, as ERR_INVALID_RESPONSE in the browser and
`open() ... failed (13: Permission denied)` in the web server's log.

FILES_WEB_SERVER_READABLE writes uploads 0644/0755 instead. Opt-in and
spread into the disk configuration rather than switched by a ternary, so
an install that does not set it keeps byte-for-byte the configuration it
had: the relaxed modes are readable by every account on the machine,
which is the wrong trade wherever the web server and PHP are one user, as
in the image and on most self-administered servers.

The two halves are not enforced alike, which is the part worth knowing.
`visibility` has Flysystem chmod each file after writing it, so 0644
holds under any umask. A directory is created by mkdir(), which masks its
mode argument, so 0755 is a ceiling: a pool at umask 0077 still produces
0700 and still cannot be traversed. That cannot be fixed from config, so
INSTALL.md carries it — how to tell the two users apart, the one-time
chmod for files already on disk, and the pool setting for the umask.
FilePermissionsTest asserts all three modes, umask cases included, since
the asymmetry is invisible from the configuration.

Reported by @denkfabrik-li (#1668), who diagnosed it and verified the
remedy on the affected host.
2026-08-21 16:34:14 -03:00
ignacionelson 5a7c9938dd Work properly behind a reverse proxy
Three findings from one report of intermittent 502s behind Nginx Proxy
Manager, all of them ours.

Stop sending the Link: preload header. AddLinkHeadersForPreloadedAssets
copied every Vite preload into a response header, duplicating tags the
document already carried in its head — twenty on the login page. nginx
buffers a response's headers into a single block defaulting to 4 KB, so
/files, at 6060 bytes of headers, was refused with "upstream sent too big
header" and the proxy answered 502. Which pages went over depended on how
many assets they loaded, which is why it read as intermittent rather than
as a header that is always too big: the login screen fitted, the
application did not. Removing it takes /files to 1247 bytes and
/dashboard from 4544 to 1247. Nothing is lost — the browser reads the
tags in the document, and we send no 103 Early Hints.

Send nginx's logs to the container's streams. supervisord captures what
each program writes to its own stdout, but nginx opens the files named in
the package's nginx.conf as soon as it reads its config, so access and
error logs went to /var/log/nginx/ inside the container. That is where
the reason for every 502 and every 403 was written, and docker logs never
showed it — so a proxy problem presented as no logs on either side, which
is exactly how it was reported.

Document the thing neither guide covered. DOCKER.md had no reverse-proxy
section at all: no mention of proxies, of 502s, or of TRUSTED_PROXIES,
which until now was explained only in a comment in the compose example.
It gains one, including that TRUSTED_PROXIES cannot cause a 502 and is
the wrong place to dig. INSTALL.md's nginx-in-front-of-Apache path gains
the proxy_* buffer settings its fastcgi_* equivalents already had.

Reported by @denkfabrik-li (#1664), who traced it to the middleware
independently, and separately by a user running Nginx Proxy Manager who
found the too-big-header line in the proxy's own log.
2026-08-21 15:19:44 -03:00
Ignacio Nelson d1d1999216 Merge pull request #1671 from projectsend/feature/file-downloads-previews-tab
A Downloads & previews tab on the file page
2026-08-21 15:10:13 -03:00
ignacionelson 51eea30dda Answer "did they ever actually get it?" from the file itself
The two things staff most often want to know about a file — who
downloaded it, who looked at it — were answerable only by reading the
whole activity log past everything else that had happened to it, or by
going back to the library list for the details panel.

The file's own page now has a Downloads & previews tab: the twenty most
recent times it was taken or looked at, each with who did it and the
address it went to, over a running count of both. Below them, two
buttons open the file's full history already filtered — one to every
download, one to every preview — so the narrow question is one click
and the whole log is still one click further.

Which filter value stands for "every download" is decided server-side
and travels with the payload, because it is a fact about the log's
vocabulary: downloads are three actions and share a group, previews are
one action and are filtered by name. The history page now also keeps
whatever filter it was sent with visible in its dropdown even at a
count of zero, so a button cannot land somebody on an empty table above
a select that has gone blank.
2026-08-21 15:04:11 -03:00
Ignacio Nelson c18f2f0f73 Merge pull request #1670 from projectsend/fix/1661-update-instructions-for-source-builds
Tell a clone-and-build install to rebuild, not to pull
2026-08-21 14:55:19 -03:00
ignacionelson 3d6089a501 Docs: clear up two contradictions in the migration and Docker guides
Step 2 of the v1 migration guide said Direct hardlinks your files instead of
copying them. It does not: copy is the default in both the command and the
screen, and hardlink is one of the four strategies you choose in step 3a. Say
that where the choice is first mentioned.

DOCKER.md's "Move the data you already have" reads like it is about the data in
a Legacy install. It is about relocating an already-running install's named
volumes onto the host paths chosen a step earlier, which is why it opens by
telling a new installation to skip it. Retitle it and spell out that a new
install waiting for a v1 migration skips it too — that data arrives later,
through the migration tool, and the install has to be empty when it does.
2026-08-21 14:39:24 -03:00
ignacionelson 8f12c83d21 Tell a clone-and-build install to rebuild, not to pull
ProjectSend prints the update instructions for the way this server was
installed, and it knew two answers where it needed three: anything inside
a container was handed `docker compose pull && docker compose up -d`. On
the Compose stack that builds from a checkout there is no image behind
those containers, so `pull` skips every ProjectSend service and `up -d`
then finds them all current — the update reports success, changes
nothing, and the dashboard goes on offering the same release. Reported by
@mueller7382, who stayed on 2.0.0 that way while 2.1.0 was out (#1661).

Those installations are now their own kind, told to `git pull` and
rebuild, with the two steps a checkout needs that an image does not: its
dependencies and its compiled frontend live outside git, so a release
that moved either leaves them stale.

Two signals decide it, in that order. The published image now declares
itself with PROJECTSEND_IMAGE, which is the only evidence an operator
bind-mounting over /var/www/html can neither hide nor forge; failing that
— images published before this — a working tree in the install directory,
which the image never has and the repository's own stack always does.
getenv() rather than env(), because a cached configuration makes env()
outside a config file return null, and the answer would flip silently on
exactly the installs most likely to have cached it.

The stale-code banner keeps treating both container kinds alike: what
clears it is recreating the container, whichever way its image was built.

The changelog also credits the reporter of #1663, which was missed when
that entry was written.
2026-08-21 14:35:49 -03:00
Ignacio Nelson 11e6876826 Merge pull request #1669 from projectsend/feature/preview-video-audio-pdf
Preview video, audio and PDF, not only images
2026-08-21 14:21:31 -03:00
ignacionelson 88c182cf3b Preview video, audio and PDF, not only images
v1 could preview four kinds of file in a modal — images, video, audio and
PDF. v2 previewed only images, and not by decision: preview shipped as part
of the image *thumbnail* work (1c68aa1), so "previewable" quietly became a
synonym for "GD can decode it". FileThumbnailController::preview() gated on
ThumbnailGenerator::SUPPORTED_MIME_TYPES, the frontend mirrored the same
four types, and the dialog was a hardcoded <img>.

Rather than widen that list — it drives pathFor(), extensionFor(),
generate() and FileDiskCleanup, and a video reaching getimagesize() is a
500 — this separates the two questions. PreviewKind now answers "may these
bytes be served inline, and what element renders them?", while
ThumbnailGenerator keeps answering the narrower "can this app decode it
itself?", which is what renditions, the cache and the watermark hook
actually depend on. Image delegates to it so the two cannot drift.

The allowlist stays a security boundary: mime_type is sniffed from the
bytes, so text/html and image/svg+xml remain excluded, and PreviewKind is
deliberately narrower than "formats a browser might cope with" — no
quicktime, avi or matroska, because an embedded player for those shows a
black rectangle. Those still download exactly as before.

docs/security-audit-2026-08-05.md finding 1 recorded that adding
application/pdf "should be a conscious decision". This is that decision,
and three things were measured rather than assumed:

- An <iframe sandbox> cannot be used. Chrome refuses to run its PDF viewer
  in a sandboxed frame at all (ERR_BLOCKED_BY_CLIENT, with or without
  allow-same-origin) — the attribute removes the feature, it does not
  harden it.
- nginx's `Content-Security-Policy: sandbox; default-src 'none'` on
  /protected-files/ does work (a <video> frame lands in an opaque origin),
  but Chrome exempts its PDF viewer from it, so it is not what protects
  the PDF case.
- What does is the allowlist plus the browser's own PDF sandbox, where PDF
  JavaScript has no DOM and no cookies.

Range requests were verified end to end: 206 with a correct Content-Range,
a byte-perfect file reassembled from three ranges, and a real browser
seeking to 10s of a 20s clip. nginx drops the upstream Content-Length on
the X-Accel path, so there is no collision.

Two settings, both defaulting on so no installation loses what it has:
clients_can_preview_files and public_listing_preview_enabled. Staff are
never gated. The anonymous side needed a route of its own — there was no
public preview endpoint — with its own throttle bucket, since a bare
throttle: shares one counter across that whole block.

A preview now logs at most one FilePreviewed per viewer per file per five
minutes: a <video> turns one deliberate act into a long tail of Range
requests, and a row each would bury the log.

Also fixes a layout bug the tests could never catch. A portal file row was
flex justify-between with three children — name, comment trigger, download
— so the middle one settled wherever the name happened to end and the
comment icon sat at a different place on every row. The name block now
takes the slack and every action lives in one trailing group, with the
comment trigger in a fixed-width slot so the icons form a column. And
because half the previewable files have no thumbnail to click — a PDF, an
mp3 and an mp4 all render as a generic icon — every row gains an explicit
PreviewAction beside DownloadAction, matching whatever style that theme
gives its download control.
2026-08-21 14:14:23 -03:00
Ignacio Nelson 30f66cff2b Merge pull request #1667 from projectsend/feature/file-activity-tab-and-download-filters
Activity tab on a file's page, filters on both history screens
2026-08-21 13:33:44 -03:00
ignacionelson cca3d9c314 Group only the downloads, which are the actions that need it
The grouped filter had a second member, "All previews", built on a
public-preview action that does not exist: previewing is recorded one
way today, so its own option already answers "who previewed this?" in
full. Static analysis caught the reference; the group would have been
unreachable even if it had compiled, since a group with a single
present member is deliberately not offered.

Previews get a group here the day a second way to preview a file is
recorded separately, and the test now pins the single-member case on a
file whose log holds one flavour of download.
2026-08-21 13:12:26 -03:00
ignacionelson 7d1903f9db Let the download history be searched
The installation-wide download history listed every download newest
first and offered nothing else, so "did that client ever actually
download the contract?" meant paging through everything that had
happened since.

It now filters by file name, by who downloaded it, and by date range,
in the same toolbar every other list uses: the query string carries the
filters, so a narrowed view is a link somebody can be sent.

Both names are matched against what the entry snapshotted rather than
through a join, so a file or an account deleted since is still findable
by the name it went out under — often exactly what this page is being
asked. The filters narrow the viewer's already-scoped query rather than
replacing it, so a client-scoped staffer cannot search their way to a
download of a file outside their library.
2026-08-21 12:39:22 -03:00
ignacionelson 6b76c11192 Answer "what happened to this file?" on the file's own page
A file's history was only reachable from the library list, through the
details panel's Activity tab — so anyone who arrived at the file from a
link, a search or a notification had to go back and find the row they
came from to ask what had happened to it.

The file's own page now carries an Activity tab of its own, next to
General and Sharing: the twenty most recent entries, fetched only if the
tab is opened, and a link to the full history. It is behind the same
view_actions_log permission as everywhere else.

That full history is now filterable, which is the point of sending
somebody to it. The action list is built from the file's own log rather
than from the eighty-odd actions the software can record — all but a
handful of which can never apply to a file — and each option carries its
count. Downloads are three separate actions on purpose (a signed-in
recipient, a public link, the public group listing), so "All downloads"
asks that question once instead of three times; the group only appears
when the file's log actually holds more than one of its members.
Narrowing by who acted and by date range works the same as it does on
the main activity log, the reader's own calendar day included.
2026-08-21 12:39:16 -03:00
Ignacio Nelson 283c79bcd6 Merge pull request #1666 from projectsend/fix/1663-open-basedir-container-probe
Stop container detection from taking the dashboard down on shared hosting
2026-08-21 12:09:49 -03:00
ignacionelson 1b6513f0fb Stop container detection from taking the dashboard down on shared hosting
Deciding which update instructions to print starts with asking whether we
are running in a container, and that question is asked by looking for the
file a container runtime leaves in the root of the filesystem. Shared
hosting confines PHP to the webspace with open_basedir, where looking
outside it is a warning rather than a false — and the framework's error
handler turns warnings into exceptions, so the probe threw instead of
answering. The dashboard is the one page that asks, so it returned a 500
while everything else worked (#1663).

Suppress both probes. A host that keeps PHP inside a single directory is
not our published image, so false is the right answer as well as the
surviving one, and it lands on the manual instructions that shared
hosting wants anyway. Checking ini_get('open_basedir') instead would get
a hardened container wrong in the other direction, handing the manual
sequence to someone whose files are inside an image.

The dashboard was only the first symptom. updateNotice() reaches the same
call on every Inertia response once a newer release exists, and
RunningCodeState reaches it whenever the applied and running versions
disagree — so the next release, or the host's next update attempt, would
have taken every page rather than one.
2026-08-21 12:03:23 -03:00
Ignacio Nelson cb67a15e81 Merge pull request #1660 from projectsend/docs/quickstart-setup-screen-is-the-default
Let the setup screen be what the quickstart actually shows
2026-08-19 21:54:57 -03:00
ignacionelson 1c62036ed2 Let the setup screen be what the quickstart actually shows
The example compose file shipped with ADMIN_NAME/ADMIN_EMAIL/ADMIN_PASSWORD
filled in, so the entrypoint created the first administrator and nobody ever
reached the setup screen the README, the Docker Hub page and the website all
promise. Someone who followed the instructions literally — edit APP_URL and
the passwords — also ended up with a publicly reachable administrator on
admin@example.com with a password printed in a public file.

Comment the three variables out. Unattended provisioning still works for
anyone who wants it, it is just opt-in now, and the first thing a new install
shows is the setup screen again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:50:00 -03:00
Ignacio Nelson 5e23474e8b Merge pull request #1659 from projectsend/docs/docker-guide-follows-the-image
Point the Docker guide at the image people actually run
2026-08-19 15:39:27 -03:00
ignacionelson 202a1d7ad5 Point the Docker guide at the image people actually run
#1658 reports that app, web, db and redis have no restart policy, so the
stack does not come back after a reboot. True, and fixed here — but the
file it is about is the development stack, and the production example has
had the policy all along. The reporter got there by following DOCKER.md,
which is #1627 again: 745f24c fixed the README's pointer and left this
page's body describing a stack no user should be running.

Against an image install almost every procedure on it was wrong. It said
uploads live in `storage/app/files/` "in the project directory" and `.env`
beside it — both are on the storage volume, and the entrypoint generates
that `.env` itself. Its compose.override.yaml recipe bind-mounted into
app, web, worker and scheduler, which are one container under supervisord
in the image, at a path one level too deep to carry APP_KEY. It told
people to chown a directory the entrypoint already chowns, to rsync from a
host path that does not exist, and to `git pull` to upgrade. Its mysqldump
read ${DB_ROOT_PASSWORD} from a .env an image install does not have, so
the documented backup silently fell back to `root` and failed. Docker Hub
links this page as "where your data lives, backups, moving to another
server".

So it is now about the image, and shorter for it: two volumes instead of
three loose things, the key explained where people actually lose it, no
override file because the compose file is the operator's own, and a
reboot section — the answer to the issue for anyone who wrote their own
compose. The clone-and-build stack keeps one pointer to CONTRIBUTING.md,
which has been the correct place for it since #1627.

The Docker Hub page keeps the two facts a reader who never leaves it
needs and hands off the procedures, so the drift that caused this has one
copy to go wrong instead of two.

Adminer and mailpit stay without a restart policy on purpose: those come
up for a session, not for the life of the machine.

Refs #1658

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:21:12 -03:00
Ignacio Nelson 8b5d480670 Merge pull request #1657 from projectsend/docs/dockerhub-screenshot
Show the dashboard on the Docker Hub page
2026-08-19 12:06:43 -03:00
ignacionelson e279e83fd0 Show the dashboard on the Docker Hub page
The page described the application in three paragraphs and then went
straight to a compose file. Somebody deciding whether to pull it had no
idea what it looks like — and for a thing whose whole job is a screen your
clients use, that is the question they are actually asking.

The dashboard, after the paragraphs that say what this is and before the
quick start, which is the point in the page where a reader has decided they
are interested and not yet decided to spend ten minutes.

The same image the README uses, and the same alt text, which was written to
describe the screen rather than to name the file. One screenshot, not
three: the README has the other two and the caption says so, and a registry
description that scrolls past its own install instructions has stopped
being an install page.

Absolute raw.githubusercontent URL, because a repository-relative path
resolves to nothing on hub.docker.com — the same reason the badges point
there. .github/ is stripped from the release artifact, which does not
matter here: this file is pasted into a description, not shipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:47:40 -03:00
Ignacio Nelson 318550f866 Merge pull request #1656 from projectsend/docs/dockerhub-badges
Give the Docker Hub page a masthead
2026-08-18 22:40:39 -03:00
ignacionelson 9c991f495d Give the Docker Hub page a masthead
The page opened on a bare H1, which on a registry listing reads as an
unfinished description rather than a product. Docker Hub's own search
results, and every well-kept image beside ours, lead with a mark and a row
of badges — and the badges are not decoration there: version, size and
where to get help are the questions somebody has before they decide to
pull.

Six of them, each answering one of those: the current release, pull count,
compressed image size, stars, Discord, and the licence. Four are live
values rather than static text, so the page stops being something anybody
has to remember to update — the release badge already reads v2.1.0, and
image size already reads 78.4 MiB.

Pure markdown, no HTML. Docker Hub sanitises HTML out of descriptions, so
the centred layouts people write for GitHub silently collapse there; the
badges are consecutive markdown links, which is what actually renders as a
row. Each link carries a title, so hovering says what it is for.

The mark is apple-touch-icon.png and not the wordmark or the favicon, for a
reason worth writing down: favicon.svg has a viewBox and no width, so it
has no intrinsic size and renders at whatever the container offers — which
on a wide column is enormous. The PNG is 180x180 and renders as a mark.
That also matches README.md, which puts a small icon above the title
rather than a banner.

Colours are README.md's, not the ones on the page this was modelled after:
3b5bdb for the project, 0b7285 for the Docker facts, and Discord's own
brand colour where the badge is a Discord badge. The point is that the two
front doors look like the same project.

Every URL checked: twelve, all 200, and the four dynamic badges verified to
render real values rather than shields.io's "invalid".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:08:26 -03:00
260 changed files with 18099 additions and 1199 deletions
+9
View File
@@ -61,6 +61,15 @@ SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
# Set this only if your web server and PHP-FPM run as different system
# users — common on cPanel/Plesk shared hosting. Uploaded files are
# written 0600 in 0700 directories, which nginx cannot read, and since
# nginx is what actually streams a download (PHP authorizes, then hands
# it the path) every download fails while the rest of the site works.
# Relaxes those to 0644/0755, which every account on the machine can
# read — leave it off if your web server and PHP are the same user.
# FILES_WEB_SERVER_READABLE=true
QUEUE_CONNECTION=redis
CACHE_STORE=redis
+19 -1
View File
@@ -27,10 +27,28 @@ permissions:
jobs:
cla:
# This condition belongs to the job, not to the step below it, and moving
# it back down would quietly cost money. A step that is skipped has still
# had a runner allocated for it; a job that is skipped never gets one, and
# Actions bills per job that runs. `issue_comment` fires on every comment
# in the repository, so with the check one level lower every "thanks,
# merged" on a pull request — and every comment on a plain issue — spun up
# a machine to decide it had nothing to do.
#
# GitHub cannot filter `issue_comment` by body at the `on:` level, so this
# is the only place the decision can be made.
#
# `issue.pull_request` is present only when the comment is on a pull
# request; comments on ordinary issues have nothing for this action to
# check.
if: >-
github.event_name == 'pull_request_target'
|| (github.event.issue.pull_request
&& (github.event.comment.body == 'recheck'
|| github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA'))
runs-on: ubuntu-latest
steps:
- name: CLA check
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
uses: contributor-assistant/github-action@v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+45 -25
View File
@@ -5,13 +5,33 @@ on:
branches:
- develop
- main
paths:
# Only the frontend is actually checked here, so only the frontend
# needs to trigger it.
- 'resources/**'
- 'package.json'
- 'package-lock.json'
- 'eslint.config.js'
- '.prettierrc*'
- 'tsconfig.json'
- '.github/workflows/lint.yml'
pull_request:
branches:
- develop
- main
paths:
- 'resources/**'
- 'package.json'
- 'package-lock.json'
- 'eslint.config.js'
- '.prettierrc*'
- 'tsconfig.json'
- '.github/workflows/lint.yml'
permissions:
contents: write
# A second push supersedes the first.
concurrency:
group: linter-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
quality:
@@ -19,32 +39,32 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
- uses: actions/setup-node@v4
with:
php-version: '8.4'
node-version: '22'
cache: 'npm'
# community-modules resolves from its public GitHub repository (the vcs
# entry in composer.json); cloud-modules is not required. COMPOSER_AUTH
# just lifts the anonymous GitHub API rate limit for the fetch.
- name: Install Dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GITHUB_TOKEN }}"}}'
run: |
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
npm install
- name: Run Pint
run: vendor/bin/pint
- name: Format Frontend
run: npm run format
run: npm ci
# `eslint .` rather than `npm run lint`, which is `eslint . --fix`:
# a formatter that rewrites the checkout and throws the result away
# cannot fail a build, so it was never a gate. This one is.
- name: Lint Frontend
run: npm run lint
run: npx eslint .
# - name: Commit Changes
# uses: stefanzweifel/git-auto-commit-action@v5
# with:
# commit_message: fix code style
# commit_options: '--no-verify'
# Two steps used to live here and were removed on 2026-08-23, because
# neither could ever fail:
#
# - `vendor/bin/pint`, without `--test` and with the auto-commit step
# commented out. It reformatted the runner's checkout, exited 0 and
# threw the result away — 150 seconds of this job's 195, gating
# nothing. Reinstating it as a real gate means `pint --test`, which
# today reports around a hundred pre-existing failures; the honest
# order is a formatting sweep first, then the flag.
#
# - `npm run format`, which is `prettier --write`, for the same reason.
# `prettier --check` currently reports 44 files, so the same applies:
# sweep, then switch. `npm run format:check` is the command.
#
# Dropping them also let the PHP toolchain go: nothing left here needs it.
+70 -1
View File
@@ -5,10 +5,67 @@ on:
branches:
- develop
- main
paths-ignore:
# Files no code reads and no test covers. Deliberately NOT listed:
# CHANGELOG.md, which ReleaseNotes parses and ReleaseNotesTest
# covers, and docs/, whose only two tracked files are served by
# ApiDocsController and OpenApiController. A malformed edit to
# either is exactly the thing that must not skip the suite.
#
# Repeated verbatim under pull_request: GitHub Actions does not
# support YAML anchors.
- 'README.md'
- 'CONTRIBUTING.md'
- 'SECURITY.md'
- 'LICENSING.md'
- 'CLA-ENTITY.md'
- 'CLA-INDIVIDUAL.md'
- 'INSTALL.md'
- 'UPDATE.md'
- 'DOCKER.md'
- 'MIGRATING-FROM-V1.md'
- 'docker/production/dockerhub-overview.md'
- '.github/screenshots/**'
pull_request:
branches:
- develop
- main
paths-ignore:
- 'README.md'
- 'CONTRIBUTING.md'
- 'SECURITY.md'
- 'LICENSING.md'
- 'CLA-ENTITY.md'
- 'CLA-INDIVIDUAL.md'
- 'INSTALL.md'
- 'UPDATE.md'
- 'DOCKER.md'
- 'MIGRATING-FROM-V1.md'
- 'docker/production/dockerhub-overview.md'
- '.github/screenshots/**'
# A second push supersedes the first: there is no value in finishing a run
# for a commit nobody will look at again.
concurrency:
group: tests-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# A second push supersedes the first — the later run covers a superset of
# what the earlier one was checking, so finishing both buys nothing and
# costs a runner.
#
# Keyed on the ref, so `main` and a branch never cancel each other. A push
# to a branch that also has a pull request open produces two events with
# two different refs, which is why they do not fight either.
#
# The tradeoff worth naming: on `main` this means an intermediate commit
# can end up with no run of its own when two pushes land together. That is
# accepted here — what is being verified is the state of the branch, and
# the run that survives is the one that includes both commits. If a commit
# ever needs its own green tick (a bisect, a release audit), push it alone.
concurrency:
group: tests-${{ github.ref }}
cancel-in-progress: true
jobs:
ci:
@@ -87,5 +144,17 @@ jobs:
- name: Static Analysis
run: ./vendor/bin/phpstan analyse --no-progress
# `--parallel` rather than a shorter suite. One process took 191s of
# this job's 4m30s; the same 1763 tests across the runner's cores
# take about a third of that, with nothing skipped. paratest is
# already a dev dependency (via Pest), so this needs no new install.
#
# `:memory:` explicitly: parallel testing gives each process its own
# database, and an in-memory one per process is what the suite is
# verified against locally. The job-level DB_DATABASE above is a file
# path, which parallel workers would have to create and migrate
# individually — a difference in behaviour with nothing to gain.
- name: Tests
run: ./vendor/bin/pest
run: ./vendor/bin/pest --parallel
env:
DB_DATABASE: ':memory:'
+7
View File
@@ -39,3 +39,10 @@ yarn-error.log
/database/seeders/DevDataSeeder.php
/docs/*.md
!/docs/api-guide.md
!/docs/email-oauth.md
!/docs/api-zapier.md
# ── Local-only dev TLS ──
# mkcert certificates and the nginx config that terminates HTTPS on the
# dev `web` container. Machine-specific, and one of them is a private key.
/docker/web/local/
+460
View File
@@ -13,6 +13,466 @@ Anything under **Upgrade notes** is something you have to do, not something we d
This section collects changes as they land; the release process turns it into a numbered entry when
a version is cut.
## 2.2.0 — 27 August 2026
A big release. Most of it closes holes in who can see what. The rest is a handful of new things.
**New**
- Google Cloud Storage can hold your files, alongside S3.
- You can set a maximum size for a zip download.
- Zip downloads no longer hold up your email.
- ProjectSend warns you if nothing is building your zip downloads.
- A deleted account's email address can be used again.
**Closed holes in who can see what**
- A staff member limited to their own clients now stays limited everywhere: client records, file
names, groups, comment moderation, and the dashboard's activity and expired-file lists.
- Private notes on a publicly shared file stay private.
- Clients no longer see the names of folders they cannot open.
- The maximum file size now applies to large uploads too.
- A download limit now holds when a zip is collected.
- A large upload cannot be finished twice at once.
- A two-factor recovery code can only be used once.
- Your notification settings accept only the switches the screen offers.
**Fixed**
- People whose accounts came from ProjectSend v1 can sign in again.
- Sessions no longer break behind a reverse proxy.
- No more 502 Bad Gateway behind a reverse proxy.
- Saving after your session expires takes you to the login page, not an error.
- An upload that cannot be stored now fails instead of vanishing.
- Downloads, thumbnails and share links work when files are kept in cloud storage.
- A zip download is never offered when the archive was not actually written.
- Deleting an account either finishes completely or does nothing at all.
- A file can no longer be put into a folder that has been deleted.
- Declining a group membership request now happens once, not twice.
- Creating something with a create-only role no longer ends in an error page.
- Connecting Google or Microsoft to an account you already have now works.
- Downloads work on cPanel and Plesk, where the web server is not PHP's user.
- The dashboard no longer fails on shared hosting.
- An installation built from source is no longer told to pull an image.
- `docker logs` now shows the web server's log.
- The repair tool no longer mistakes cached previews for stray files.
- One confirmation message instead of two.
**Before you upgrade, read the two notes below.** One of them needs you to do something if you
installed ProjectSend by hand.
### Upgrade notes
- **Manual installs: your background worker needs one more queue.** Building a zip download now runs
on its own queue, so a worker started before this version watches the wrong one — it will keep
sending email perfectly while no zip download ever finishes, and nothing in any log will say why.
`update.sh` spots this and offers to fix the service file for you, keeping a copy of the old one,
so for most people there is nothing to do but say yes. If you upgrade by hand, change the
`ExecStart` line in `/etc/systemd/system/projectsend-worker.service` to read
`queue:work --queue=default,zips …`, then `sudo systemctl daemon-reload && sudo systemctl restart
projectsend-worker`. INSTALL.md has the full file, and the two-worker setup if you would rather
keep the two kinds of work apart. Docker installations need no change.
If it is ever missed, ProjectSend now says so on screen: staff who can see system information get
a banner naming the problem and the fix.
- **If you run behind a reverse proxy, check `TRUSTED_PROXIES`.** It is now read correctly, which it
was not before — see the fix below. Set it in `.env`, and do not run `config:cache`, which stops
`.env` being read at all.
### Added
- **Google Cloud Storage as a storage backend.** External storage used to mean S3 and nothing else.
The Storage settings screen now asks which provider you are using first, and offers Google Cloud
Storage alongside the S3-compatible option: choose it, paste a service account key with read and
write access to your bucket, and new uploads go there. The key is stored encrypted and never shown
again, and **Test connection** checks it can actually reach the bucket before you switch anything
over — using a probe that works with a least-privilege key, rather than one that needs permission
to read the bucket's own settings. Downloads and previews are handed to the visitor as a
short-lived signed link, exactly as they already were for S3.
Nothing changes for an existing installation. Configurations saved before this release are S3, are
still S3, and are not asked to say so. Files already stored stay where they are — the setting
applies to new uploads, and there is still no migration between backends.
- **A maximum size for zip downloads.** A new Settings → Downloads screen sets the largest selection
anyone can ask for as a single zip — 2 GB out of the box, any figure you like, or 0 for no limit.
Building an archive costs disk space and occupies the background worker for as long as it takes to
write, so one person asking for a whole library at once used to hold up every notification email
behind it. Ask for more than the limit and you are told how large your selection is and what the
ceiling is, rather than simply refused; each person can have one archive being prepared at a time,
for the same reason.
- **ProjectSend tells you if nothing is building your zip downloads.** The change below gives zip
building its own queue, which a manual install's background worker has to be told about. Miss that
and the failure is silent: email keeps going out, zip downloads simply never finish, and nothing
in any log says why. Staff who can see system information now get a banner naming the problem and
the one-line fix, so nobody has to work it out from a spinner that never stops.
- **Zip downloads no longer hold up your email.** Preparing a large archive can take a while, and it
used to run on the same queue as everything else — so one big zip could delay every notification
email behind it. Zip building now has a queue of its own, and the Docker images run a second
background worker for it.
**Manual installs:** your background worker has to be told about the new queue, or zips will never
finish and nothing will say why. `update.sh` spots this and offers to fix the worker service for
you, keeping a copy of the old one — so for most people there is nothing to do but say yes. If you
update by hand, or your worker already names its own queues (the updater will say so rather than
edit a deliberate arrangement), add `zips` to its `--queue` list and reload systemd. Docker
installations need no change. See INSTALL.md for the two-worker setup if you would rather keep the
two kinds of work apart.
- **A deleted account's email address can be used again.** Deleting an account keeps its record for
a grace period before erasing it for good, and the address stays reserved until that happens — but
only accounts that deleted *themselves* were ever scheduled for erasure. An account an
administrator deleted sat in that state permanently, and its address could never be reused, with
nothing on screen to explain why. Every deletion now schedules the erasure the same way, whoever
performed it, and the staff screens explain a reserved address rather than saying only that it is
taken: which date it frees up, or which command frees it sooner. Public registration deliberately
keeps the plain "already taken" message, since telling a stranger the address once had an account
here is the disclosure that message exists to avoid.
Accounts deleted before this change keep their old state on purpose — stamping them during an
update would quietly start a countdown to erasure that nobody chose. The console command named in
the new message handles those.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1678](https://github.com/projectsend/projectsend/pull/1678), closing
[#1648](https://github.com/projectsend/projectsend/issues/1648))
- **A staff role limited to its own clients now stays limited.** Several ways around that limit are
closed together, because any one of them made the rest decorative. A role holding the "manage
users" permission could edit its own role and simply switch the limit off; it could hand itself
clients it was never assigned; it could promote any client on the installation to a staff account,
which is the most far-reaching thing that can be done to a client record. Uploading into, or
moving a file into, a folder belonging to somebody else's clients is refused too, as is browsing
the folder pickers past your own tree. None of this was reachable with any role that ships with
ProjectSend — each needed a custom role built on the roles screen — but the combinations are ones
the screen offers, so anyone who built one should update.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1681](https://github.com/projectsend/projectsend/pull/1681),
[#1694](https://github.com/projectsend/projectsend/pull/1694),
[#1697](https://github.com/projectsend/projectsend/pull/1697),
[#1700](https://github.com/projectsend/projectsend/pull/1700) and
[#1702](https://github.com/projectsend/projectsend/pull/1702))
- **A public file's private notes stay private.** The comment thread on a publicly listed file is
meant to show what any visitor sees. It was instead answering signed-in visitors as themselves, so
simply having an account — any account — showed staff-only notes on that file, or the messages
addressed to that file's clients. Being signed in now shows you what a visitor sees, plus your own
comments, unless you were entitled to see the file anyway.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1695](https://github.com/projectsend/projectsend/pull/1695))
- **A client is no longer shown the names of folders they cannot open.** Browsing into a folder in
the client portal listed every subfolder inside it, including ones shared with somebody else.
Opening one was always refused, so what escaped was the name — which can be enough, when folders
are named after the people they belong to.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1690](https://github.com/projectsend/projectsend/pull/1690))
- **The maximum file size now applies to large uploads.** Big files are sent in pieces, and the size
limit was only checked against the size the sender *claimed* before sending anything. Declaring a
tiny upload and then sending gigabytes passed every check. The assembled file is now measured
against the limit before it is accepted.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1682](https://github.com/projectsend/projectsend/pull/1682))
- **A download limit now holds when a zip is collected.** Preparing an archive never spent anybody's
download allowance, and only collecting one did — so an archive prepared while a file was still
available stayed collectable after its limit was spent, and several could be held that way at
once. The limit is now checked at the moment the archive is handed over, which is also the moment
it is spent. Archives also record exactly which files went into them, so the download history
counts what was actually delivered rather than re-guessing it afterwards.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1692](https://github.com/projectsend/projectsend/pull/1692))
- **Public downloads work on installations using external storage.** The public listing's download
link always answered as though the file were on the server's own disk, so on an installation
keeping files in object storage it pointed at a path that had never been written. Its neighbours
on the same page — thumbnails and previews — already handled both. Now it does too.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1693](https://github.com/projectsend/projectsend/pull/1693))
- **A large upload cannot be finished twice at once.** A retry or a double submit arriving while the
first was still assembling could interleave with it, storing bytes that no longer matched the
file's own checksum, or recording the same upload twice. Finishing an upload now takes a lock for
that upload, and a second attempt is turned away rather than joining in.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1686](https://github.com/projectsend/projectsend/pull/1686))
- **Deleting an account either finishes or does nothing.** Removing an account and dealing with the
files it owns were two separate steps with nothing holding them together, so a failure in the
second left the account gone and its files still pointing at it — most easily when the person
chosen to inherit them was deleted in between. Both now happen together or not at all. Relatedly,
a file's stored bytes are now removed once the deletion is committed rather than as it happens, so
a cancelled bulk deletion no longer restores records whose files are already gone.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1688](https://github.com/projectsend/projectsend/pull/1688) and
[#1691](https://github.com/projectsend/projectsend/pull/1691))
- **Creating something with a create-only role no longer ends in an error page.** Roles can grant
permission to create clients, staff accounts, groups or categories without permission to edit
them. Creating one worked, but the page it sent you to afterwards was the edit page, which such a
role may not open — so the record was created and you were shown a permission error, with no way
to tell whether it had worked. You now land back on the create form with the confirmation message.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1684](https://github.com/projectsend/projectsend/pull/1684))
### Fixed
- **Accounts migrated from v1 can sign in again.** On some installations brought over from
ProjectSend Legacy, every migrated person got an error page instead of a login screen — while
anybody whose account was created in v2 signed in perfectly. The cause was the label on the stored
password. Older versions of PHP wrote `$2a$` or `$2b$` where newer ones write `$2y$`; all three are
the same algorithm, but ProjectSend only recognised the last one and gave up before it had even
looked at the password. Upgrading relabels the affected accounts in place. Nothing about anybody's
password changes, so there is no reset mail to send and nothing for you to do — the password they
already had simply starts working again. The migration tool no longer creates the problem in the
first place, from version 1.0.3 onwards.
([#1706](https://github.com/projectsend/projectsend/issues/1706), reported by
[@pabloalvarez44](https://github.com/pabloalvarez44))
- **Sessions no longer break behind a reverse proxy.** Signing in, or submitting the first-run setup
form, could answer with a page-filling error instead — most visibly for anyone running behind
Traefik, Nginx Proxy Manager or Caddy. `TRUSTED_PROXIES` was being read too early in the boot
sequence to be seen at all, so the setting had never had any effect on a web request. Without it
ProjectSend believed every visitor was arriving from the proxy over plain HTTP, built its links and
cookies accordingly, and rejected the form that came back as though it had come from somewhere
else. Docker installations that set the value as an environment variable were unaffected the whole
time; manual installs, where the guide tells you to put it in `.env`, were not — which is why this
looked so inconsistent. **Upgrade note:** if you run behind a proxy, set `TRUSTED_PROXIES` and do
not run `config:cache`, which stops `.env` being read at all. Both are covered in INSTALL.md.
([#1672](https://github.com/projectsend/projectsend/issues/1672), reported by
[@mstewart14](https://github.com/mstewart14); fixed by
[@elibrachas](https://github.com/elibrachas) in
[#1674](https://github.com/projectsend/projectsend/pull/1674))
- **Saving something after your session has expired now takes you to the login page.** Instead of
being told to sign in again, you got an unexplained error — the dashboard's widget settings and
several settings screens were the usual places to meet it. The cause was a detail of how browsers
follow redirects: they repeat the original request at the new address, so "save this" became "save
this to the login page", which the login page has no idea what to do with. It now answers in a way
that sends the browser to read the page rather than repeat the save. The same thing could happen to
an account that was deactivated while someone was working in it, or one being asked to set up
two-factor authentication, and both are fixed with it.
([#1673](https://github.com/projectsend/projectsend/issues/1673), reported by
[@mstewart14](https://github.com/mstewart14); found, diagnosed and fixed by
[@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1680](https://github.com/projectsend/projectsend/pull/1680))
- **An upload that cannot be stored now fails instead of disappearing.** When files are kept in
object storage and the storage backend refuses a write — an expired key, a bucket that has been
renamed or removed, a permission that changed underneath you — the upload used to report success
and record the file anyway. The entry appeared in the file list, and the download it promised was
never going to work, because the bytes had gone nowhere. The upload now stops and says so, and no
file is recorded. Installations keeping files on local disk were never affected.
- **Downloads and thumbnails for installations using external storage.** Two places assumed every
file sat on the server's own disk, which stopped being true the moment S3-compatible storage was
switched on. A share link to a file held in a bucket produced a broken download, and a public
listing could not draw a thumbnail for one at all — while the same file downloaded and previewed
correctly everywhere else, which made it look like the share link or the listing was at fault
rather than where the file lived. Both now read the file from wherever it actually is. Nothing
changes for installations keeping files on local disk, which is most of them.
- **One confirmation message instead of two.** Saving a new client, system user or role showed the
same green "Client created." twice, stacked. So did deleting one. It was only ever cosmetic —
nothing happened twice — but it read as though something had, which is the last thing a
confirmation should do. Saves that stay on the same screen, such as the email settings, were never
affected.
([#1675](https://github.com/projectsend/projectsend/issues/1675), reported and diagnosed by
[@denkfabrik-li](https://github.com/denkfabrik-li))
- **Connecting a provider to an account that already has one.** Signing in with Google, Microsoft or
a custom provider worked, but attaching one to an existing account did not: the **Connect** button
on Settings → Connected accounts appeared to do nothing at all. The button asks the server in the
background, and the server answered by redirecting to the provider — a redirect a browser will not
follow out of a background request to another site. The page sat there with no consent screen and
no error to explain it, so the only reading available was that the button was dead. The server now
tells the browser to go to the provider itself, and the flow starts as it should. Signing in from
the login page was never affected, and neither is it now.
([#1676](https://github.com/projectsend/projectsend/pull/1676), found and fixed by
[@denkfabrik-li](https://github.com/denkfabrik-li))
- **Downloads on a host where the web server is not PHP's user.** A download is not served by PHP:
PHP checks permissions and then hands the web server the path to stream. Where the two run as
different users — cPanel and Plesk commonly arrange it that way — the web server could not open
the file, because uploads are written readable only by the account that wrote them. The rest of
the site gave no sign of it: uploading worked, the library listed everything, and only downloads
failed, in the browser as `ERR_INVALID_RESPONSE`. Setting `FILES_WEB_SERVER_READABLE=true` now
writes uploads so the web server can read them. It is opt-in, and deliberately so — the modes it
uses are readable by every account on the machine, which is the wrong trade on a server where the
web server and PHP are the same user, as they are in the Docker image and on most servers people
set up themselves. The install guide has the full procedure, including the one thing no
application setting can fix: a PHP-FPM pool with a restrictive umask, which caps new directories
no matter what ProjectSend asks for.
([#1668](https://github.com/projectsend/projectsend/issues/1668), reported by
[@denkfabrik-li](https://github.com/denkfabrik-li))
- **An installation that builds its own containers is no longer told to pull.** ProjectSend prints
the update instructions for the way you installed it, and it had two answers where it needed
three: anything running in a container was handed `docker compose pull && docker compose up -d`,
including the Compose stack that builds from a checkout of the repository. There is no image
behind those containers to pull, so both commands ran, reported success and changed nothing — and
the dashboard went on offering the same release. Those installations are now recognised and given
`git pull && docker compose up -d --build` instead, with the two extra steps a checkout needs when
a release moves its dependencies or its frontend.
([#1661](https://github.com/projectsend/projectsend/issues/1661), reported by
[@mueller7382](https://github.com/mueller7382))
- **The dashboard no longer fails on shared hosting.** To decide which update instructions to print,
ProjectSend asks whether it is running inside a container by looking for a file in the root of the
filesystem. On shared hosting PHP is usually confined to your own directory, and looking outside it
is treated as an error rather than as a "no" — so the one page that asks the question, the
dashboard, returned a 500 while every other page worked. It now takes the restriction as the answer
it always was: a server that keeps PHP inside a single directory is not our container image, and
gets the manual update instructions, which is correct for shared hosting anyway. Nothing to change
on your side, and no setting you would have been able to change if there were.
([#1663](https://github.com/projectsend/projectsend/issues/1663), reported by
[@denkfabrik-li](https://github.com/denkfabrik-li))
- **502 Bad Gateway behind a reverse proxy.** Every page carried a `Link:` header listing its
frontend assets, duplicating tags the page already had in its `<head>` — twenty of them on the
login screen, more on a heavier page. nginx buffers a response's headers into a single block that
defaults to 4 KB, so the file list, at over 6 KB of headers, was refused with `upstream sent too
big header` and the proxy answered 502. Which pages went over depended on how many assets they
loaded, so it looked like an intermittent fault: the login screen appeared, and then the
application did not. The duplicate header is gone — the same pages now send under 1.3 KB — and no
browser loses anything, because the tags it actually reads were always in the document. The
install guide gained the proxy buffer settings for anyone on an older version or behind a proxy
holding a tighter default.
([#1664](https://github.com/projectsend/projectsend/issues/1664), reported by
[@denkfabrik-li](https://github.com/denkfabrik-li))
- **`docker logs` now shows the web server's log.** The container runs nginx, PHP-FPM, the queue
worker and the scheduler, and all of them reported to Docker except the one you need when a
request fails: nginx opened the log files named in its own configuration and wrote to them inside
the container, where nothing looks. The effect was that a proxy problem produced no logs on either
side — the reason for every 502 and every 403 existed, in a file nobody knew to open. Both its
access and error logs now go to the container's output, and the Docker guide has a section on
running behind a reverse proxy that says which side a given message points at.
- **A zip download is never offered over an archive that was not written.** Archives are built in the
background, and the writing all happens at the very end — so a source file deleted while the build
waited its turn, or a disk that filled up, produced no archive at all while the download was still
marked ready. Clicking it then failed with an unexplained error. The same went for a selection
whose files had all become unavailable: an archive with nothing in it is not written to disk
either. Both now fail the build and say why. Large archives were affected differently: a build
taking longer than a minute was killed by the queue worker and the download simply spun forever,
waiting for something that had already stopped. Builds now get the time they need, a build the
queue gives up on reports itself as failed, and the partial files an interrupted build leaves
behind are cleaned up rather than sitting on disk unnoticed.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1687](https://github.com/projectsend/projectsend/pull/1687))
- **Comment moderation now stops at the same boundary everything else does.** A staff role can be
limited to its own assigned clients, and everything in the library respects that — listings,
downloads, file details, and the moderation queue itself. Deleting or approving a single comment
did not. Someone with a client-limited role who also held the comment moderation permission could
remove any comment on the installation by its id, including conversations belonging to clients
they were not assigned to, on files they could not open. No role that ships with ProjectSend
combines those two things, so this needed a custom role to reach; if you have built one, it is
worth updating for. The boundary now lives in the rule itself rather than being restated by each
screen, which is how the gap opened in the first place.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1698](https://github.com/projectsend/projectsend/pull/1698))
- **The dashboard's recent activity now respects a limited role's boundary.** A staff role can be
limited to its own assigned clients, and the activity page has always honoured that — showing only
entries about files, folders and clients in that person's scope. The dashboard's Recent activity
widget did not: it listed the eight most recent entries from the whole installation, file names
and all, to someone who would be refused the files themselves. The Client Manager role ships with
the permission this widget needs, so any installation using it was affected. Both screens now
answer the same way. Nothing changes for an administrator or any unrestricted role.
- **Cached previews are no longer mistaken for stray files.** The tool that finds files sitting on
disk with no database record knew to ignore cached thumbnails, but had never been told about the
larger previews added alongside them. So every cached preview was listed as an unclaimed file:
offered for import on the orphan-files screen, and deleted by the daily cleanup once past its
grace period. Importing one also created a file entry pointing at a path the preview cache owns,
which then vanished the next time that cache was cleared. The list of what counts as a generated
copy is now derived from the copies themselves, so a new kind cannot be left off it again.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1683](https://github.com/projectsend/projectsend/pull/1683))
- **Group membership now respects a limited role's boundary.** A staff role can be limited to its
own assigned clients. Adding somebody to a group, or taking them out, checked only that the person
held the "edit groups" permission — not that the group was any of their business. Because joining a
group hands the new member everything shared with it, someone with a limited role could put one of
their own clients into any group on the installation and, through that client, reach files they
had been refused a moment earlier. Approving or denying a membership request was the same write
through a second door, and the requests screen listed every pending request by name and email,
including clients outside the viewer's roster. All of it is now held to the same boundary the rest
of the library uses, and the sidebar count agrees with the screen behind it. No role that ships
with ProjectSend combines the two permissions this needed, so reaching it took a custom role.
Nothing changes for an administrator or any unrestricted role.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1701](https://github.com/projectsend/projectsend/pull/1701))
- **Declining a group membership request now happens once.** Approving a request that had already
been decided was refused; declining one was not, and declining is not a repeatable act. Each
repeat re-dated the decision — which is what the client's waiting period before asking again
counts from — so the same stale request, sent again, could keep somebody out of a group
indefinitely without anyone deciding anything. It also wrote a second entry in the activity log
and sent the client a second "your request was declined" email for one decision. The queue only
ever lists requests still waiting, so nothing on screen offered this. Both actions now behave the
same way.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1705](https://github.com/projectsend/projectsend/pull/1705))
- **The dashboard's expired-files list says whose files it is showing.** For a staff role limited to
its own clients it lists that person's own uploads, since an expired file is already out of reach
of the clients it was shared with. It now says so — "Your expired files", and a line explaining
what is not in the list — rather than presenting a short list as though it were the whole picture.
A warning about what is due to be deleted is worth nothing if it is quietly narrower than it looks.
- **A limited staff role no longer reaches every client record, or every file name on the
dashboard.** Two more places where holding a permission was treated as holding a boundary. The
clients screen listed every client on the installation by name and email, and a role limited to
its own assigned clients could open, rename, or delete any of them — the same through the API.
Separately, the dashboard's largest-files, expired-files and top-clients widgets named files and
clients from across the whole installation, which mattered more because the Client Manager role
that ships with ProjectSend holds the permission those widgets need. Both now use the same rule
the rest of the library already did. Installation-wide totals stay installation-wide: a count
carries no names. Nothing changes for an administrator or any unrestricted role.
- **Notification settings accept only the switches they offer.** Saving your notification
preferences would store a row for any name a request happened to carry, including ones nothing in
ProjectSend can send. Such a row was never read again and could not be seen or removed from the
screen, so the table quietly collected entries nobody could reach. The form now checks what comes
back against the same list it offered, so the two cannot drift apart. Nothing reachable from the
screen changes — it only ever sends back switches it was given.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1689](https://github.com/projectsend/projectsend/pull/1689))
- **A two-factor recovery code is now spent exactly once.** Using a code removed it from your list
by rewriting the whole list, so two sign-in attempts arriving at the same moment could each save
their own copy and put back the code the other had just spent. Nobody could get in who was not
already holding a valid code, but a code you had crossed off a printed sheet — or watched somebody
type — could quietly start working again, which is the one thing recovery codes promise not to do.
The code is now removed from the record as it stands at that moment, under a lock, so a second
attempt cannot undo the first.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1704](https://github.com/projectsend/projectsend/pull/1704))
- **A file can no longer be filed into a folder that has been deleted.** Deleting a folder deletes
everything inside it, so a file that lands in one afterwards sits somewhere that was already
emptied — reachable by link and in search, but missing from the folder listing its uploader would
look in. Uploading or moving a file into a deleted folder now says so instead, and picks up the
case where a folder is deleted while a large upload is still transferring: the finished file lands
at the top level rather than being thrown away, since the transfer had already happened. The
message says the folder no longer exists rather than that the value was invalid.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1703](https://github.com/projectsend/projectsend/pull/1703))
- **A limited staff role can no longer rename or delete a group it has no part in.** Group
membership was already held to that boundary; the group itself was not, which was the sharper half
— sharing a file with a group is how its members reach that file, so deleting the group takes the
access away from every one of them, including clients outside the person's own list. A role
limited to its own clients can still manage any group that shares nothing beyond what it can
already see, so a group it created, or one holding its own clients, stays fully editable. Nothing
changes for an administrator or any unrestricted role.
## 2.1.0 — 18 August 2026
Updating, mostly. ProjectSend now tells you when there is a new version, ends an update somewhere
+232 -92
View File
@@ -8,20 +8,37 @@ data with it.
Read this before you put real files in ProjectSend, not after.
> Getting started with Docker in the first place is covered in [README](README.md#getting-started).
> **This page is about the official image**, `projectsend/projectsend`, started from the
> `compose.example.yaml` in [Getting started](README.md#getting-started). That is the supported way
> to run it.
>
> A **clone of this repository is a development copy, not an installation** — it builds from source,
> bind-mounts the working tree, and ships nothing pre-built. If that is what you are running, its
> setup and its data layout are [CONTRIBUTING.md](CONTRIBUTING.md), not this page.
>
> Installing without Docker, on a plain PHP server, is [INSTALL.md](INSTALL.md).
---
## The three things that matter
## The two things that matter
Everything ProjectSend cannot regenerate lives in exactly three places:
Everything ProjectSend cannot regenerate lives in exactly two Docker volumes:
| What | Where it is by default | Losing it means |
|---|---|---|
| **The database** | A Docker *named volume*, `projectsend_db-data` | Everything except the files themselves: accounts, groups, permissions, share links, comments, the activity log |
| **Uploaded files** | `storage/app/files/` in the project directory | The files your clients downloaded — gone |
| **`.env`** | The project directory | `APP_KEY`, without which saved SMTP and LDAP passwords cannot be decrypted |
| **The database** | The volume mounted at `/var/lib/mysql` — `projectsend_db-data` | Everything except the files themselves: accounts, groups, permissions, share links, comments, the activity log |
| **Uploaded files, and `APP_KEY`** | The volume mounted at `/var/www/html/storage` — `projectsend_storage` | The files your clients downloaded, and the key that decrypts saved SMTP and LDAP passwords |
The second one is the one people get wrong, because it is two things in one place. The container
generates `.env` on first boot and keeps it *on the storage volume*, at `storage/.env`, symlinked
into place — precisely so `APP_KEY` survives the container being replaced. A key that changes
between restarts signs everybody out and makes every encrypted column permanently unreadable, and
nothing errors when it happens. Back up the volume and you have both halves; back up only
`storage/app/files/` and you have the files without the key.
(If you set `APP_KEY` in the environment instead, Laravel reads it from there and it wins. That is
the right move when you already manage secrets somewhere else — but then it is *that* system's
backup you are relying on.)
You do not have to work out which of these you have from memory. **The dashboard's System panel
reports where your uploaded files actually live** — a host directory, a Docker volume (named), or
@@ -36,14 +53,14 @@ Two things you may be surprised to find you do **not** need to protect:
everyone out and drops any not-yet-sent emails or half-built zips. Annoying; not data loss.
- **Parts of `storage/app/files/`** are derived, not precious: `zips/` (built downloads, deleted
automatically after a day), `thumbnails/` and `previews/` (rebuilt on demand the next time
somebody looks at a file). They sit in the same directory as the real uploads, so the simplest
thing is to back up all of it and not think about which is which.
somebody looks at a file). They sit inside the volume you are backing up anyway, so the simplest
thing is to take all of it and not think about which is which.
## The good news, and the one command to fear
Named volumes are already outside the container lifecycle. `docker compose down`,
`docker compose up --build`, deleting and recreating every container — none of those touch
`projectsend_db-data`. Upgrading does not lose your database, and never did.
Named volumes are already outside the container lifecycle. `docker compose pull`,
`docker compose down`, deleting and recreating every container — none of those touch
`projectsend_db-data` or `projectsend_storage`. Upgrading does not lose your data, and never did.
The command that *does* destroy it is:
@@ -52,73 +69,185 @@ docker compose down -v # ← the -v deletes the named volumes
```
That flag exists to clean up a development machine. On a real installation it deletes your entire
database in about a second, with no confirmation. The same goes for `docker volume prune` and
`docker system prune --volumes` when the stack happens to be down.
database and every uploaded file in about a second, with no confirmation. The same goes for
`docker volume prune` and `docker system prune --volumes` when the stack happens to be down.
So the actual problem with the default setup is not fragility, it is **invisibility**: your
database is somewhere under `/var/lib/docker/volumes/`, which means most people never back it up
and would not know where to look. The rest of this page fixes that.
So the actual problem with the default setup is not fragility, it is **invisibility**: your data is
somewhere under `/var/lib/docker/volumes/`, which means most people never back it up and would not
know where to look. The rest of this page fixes that.
---
## Surviving a reboot
Every service needs a restart policy, or the Docker daemon will not start it again when the host
comes back:
```yaml
services:
app:
restart: unless-stopped
db:
restart: unless-stopped
redis:
restart: unless-stopped
```
`compose.example.yaml` already has this on all three. It is worth checking if you wrote your own
compose file, because the failure is silent and delayed: the stack works perfectly until the first
reboot or power cut, and then the site is simply down with no error anywhere. `depends_on` does not
cover this — it applies to `docker compose up`, not to containers the daemon brings back at boot.
```sh
docker compose ps -a # after a reboot, everything should be Up, not Exited (0)
```
---
## Behind a reverse proxy
Almost nobody exposes the container directly: there is a proxy in front terminating TLS — Nginx
Proxy Manager, Traefik, Caddy, or an nginx vhost you wrote. Two things are worth setting before you
go looking for a bug that isn't there.
### Tell ProjectSend the proxy is there
```yaml
environment:
TRUSTED_PROXIES: "*"
```
Without it every visitor appears to come from the proxy. The login rate limiter then treats all of
your users as one attacker, and the download log records the proxy's address instead of the
person's. `compose.example.yaml` already sets this.
Leaving it unset does not cause a `502` — that means your proxy could not get a usable response out
of the container at all, which is a different problem with a different fix. It does cause a **419
"page expired"**. Without it the application never learns the proxy terminated TLS, so it builds
its links and redirects with `http://` while the browser is on `https://`, and marks the session
cookie as non-secure. The browser declines to send that cookie back to what it now reads as a
different, less secure origin, the session arrives empty, and the first thing you submit — usually
the create-your-admin-account form — is rejected as a stale token. After that you get returned to
the login screen at random, because each redirect leaves and re-enters over the wrong scheme.
Your proxy also has to pass the original `Host` header through, or the links come out naming the
container instead of your domain. Most do by default: `passHostHeader=true` in Traefik,
`proxy_set_header Host $host;` in nginx.
### Give the proxy header headroom
If you are running a version before this one, some pages — the dashboard and the file list first —
can send a response header block larger than the 4 KB single page nginx buffers headers into by
default, and the proxy answers `502 Bad Gateway`. Because it depends on the page, it looks like an
intermittent fault rather than a setting: the login screen loads, and then the application does not.
The proxy's own error log names it exactly:
```
upstream sent too big header while reading response header from upstream
```
ProjectSend no longer sends headers that large. On an older version, or behind any proxy holding a
default that tight, raise them:
```nginx
proxy_buffer_size 32k;
proxy_buffers 8 32k;
proxy_busy_buffers_size 64k;
```
In Nginx Proxy Manager that goes in the **Advanced** tab of the proxy host. Traefik and Caddy have
their own spellings; the idea is the same.
### When something does go wrong, read the container's log
The app container logs everything — nginx, PHP-FPM, the queue worker and the scheduler — to Docker:
```sh
docker compose logs -f app
docker compose logs --since 30m app | grep -iE "error|upstream|502"
```
nginx's line is the one that matters for a proxy problem, because it says which side failed.
`connect() failed` or `upstream timed out` means the request reached the container and PHP was the
problem. **Nothing at all**, while your proxy reports a 502, means the request never arrived — look
at the proxy, the network between them, and the published port, not at ProjectSend.
The container also answers a cheap health endpoint that touches neither the database nor Redis, which
is the quickest way to separate "the app is down" from "the proxy cannot reach the app". Run both
during an outage, from the same machine:
```sh
curl -s -o /dev/null -w '%{http_code}\n' http://<host-ip>:8080/up # straight at the container
curl -s -o /dev/null -w '%{http_code}\n' https://files.example.com/up
```
Docker records the same check every 30 seconds, so there is a history to read after the fact:
```sh
docker inspect --format 'restarts={{.RestartCount}} oom={{.State.OOMKilled}} health={{.State.Health.Status}}' $(docker compose ps -q app)
```
A non-zero `restarts`, or `oom=true`, means the container is dying and coming back rather than
misbehaving — check memory. `compose.example.yaml` sets no limits, and MySQL, Redis and up to ten
PHP-FPM workers add up on a small VPS.
---
## Putting the data where you chose
Bind-mount both to real paths on the host, so your data sits somewhere you picked, somewhere you
can see in `ls`, and somewhere your existing backup tool already knows about.
Bind-mount both volumes to real paths on the host, so your data sits somewhere you picked, somewhere
you can see in `ls`, and somewhere your existing backup tool already knows about.
### 1. Make the directories
```sh
sudo mkdir -p /srv/projectsend/files /srv/projectsend/mysql
# The app containers run as uid 1000 by default (the WWWUSER build argument).
# If you set WWWUSER to something else in .env, use that instead.
sudo chown -R 1000:1000 /srv/projectsend/files
sudo mkdir -p /srv/projectsend/storage /srv/projectsend/mysql
```
Leave `/srv/projectsend/mysql` owned by root — the MySQL image sets its own ownership the first
time it starts.
No `chown` needed for either. The ProjectSend container recreates the directory tree it needs on
every boot and sets its own ownership (uid 1000), precisely because a bind-mounted host directory
arrives empty where a named volume arrives seeded from the image. The MySQL image does the same for
its own directory the first time it starts.
### 2. Create `compose.override.yaml`
### 2. Point the compose file at them
Next to `compose.yaml`. Docker Compose reads this file automatically and merges it on top, so you
never edit the tracked `compose.yaml` and nothing you write here is lost on the next update.
`compose.example.yaml` is yours — you downloaded and edited it — so change the volumes in place
rather than layering an override on top:
```yaml
services:
# All four app containers must see the same files directory. Missing one of
# them is the classic mistake: uploads land in one place and downloads are
# served from another, so every download 404s. `web` is the one people
# forget — nginx serves the bytes itself, from
# /var/www/html/storage/app/files/, so it needs the mount just as much as
# the container that wrote them.
app:
volumes:
- /srv/projectsend/files:/var/www/html/storage/app/files
web:
volumes:
- /srv/projectsend/files:/var/www/html/storage/app/files
worker:
volumes:
- /srv/projectsend/files:/var/www/html/storage/app/files
scheduler:
volumes:
- /srv/projectsend/files:/var/www/html/storage/app/files
# Was: storage:/var/www/html/storage
- /srv/projectsend/storage:/var/www/html/storage
db:
volumes:
# Was: db-data:/var/lib/mysql
- /srv/projectsend/mysql:/var/lib/mysql
```
Check the result before applying it — this prints the fully merged configuration:
Mount the whole `storage` directory, not `storage/app/files` inside it. Uploads are only half of
what lives there — `storage/.env` holds `APP_KEY`, and mounting one level too deep leaves the key
back inside the container where the next `docker compose down` takes it.
Then drop `storage:` and `db-data:` from the `volumes:` block at the bottom, if nothing else uses
them, and check the result before applying it — this prints the fully merged configuration:
```sh
docker compose config
```
### 3. Move the data you already have
### 3. Move an existing install's data onto the new paths
**Skip this on a brand-new installation.** There is nothing to move; go straight to step 4.
This step is only for an install that has **already been running** on the named volumes and is now
moving to the host paths you just chose. It moves ProjectSend's own storage and database, nothing
else.
**Skip it on a brand-new installation** — there is nothing to move; go straight to step 4. That
includes an install you are about to migrate ProjectSend Legacy (v1) into: those files and that
database come across later, through the migration tool, and the new install has to be empty when
they do. See [MIGRATING-FROM-V1.md](MIGRATING-FROM-V1.md).
Stop everything first. Copying a database out from under a running MySQL is how you get a backup
that restores into a corrupt table.
@@ -127,25 +256,22 @@ that restores into a corrupt table.
docker compose down # no -v
```
Files, which are already on the host inside the project directory:
A throwaway container is the tidy way to reach inside a named volume:
```sh
sudo rsync -a storage/app/files/ /srv/projectsend/files/
sudo chown -R 1000:1000 /srv/projectsend/files
```
docker run --rm \
-v projectsend_storage:/from \
-v /srv/projectsend/storage:/to \
alpine sh -c 'cd /from && cp -a . /to'
The database, which is in the named volume. A throwaway container is the tidy way to reach inside
one:
```sh
docker run --rm \
-v projectsend_db-data:/from \
-v /srv/projectsend/mysql:/to \
alpine sh -c 'cd /from && cp -a . /to'
```
(`projectsend_db-data` is the volume's real name — the `db-data` from `compose.yaml` prefixed with
the project name. `docker volume ls` will confirm it.)
(Those are the volumes' real names — the `storage` and `db-data` from your compose file, prefixed
with the project name. `docker volume ls` will confirm them.)
### 4. Start, and check
@@ -155,14 +281,22 @@ docker compose up -d
Then prove it worked rather than assuming: log in and check the dashboard's System panel — **Files
stored on** should now read *Host directory*, and the Docker-volume warning should be gone. Then
open a file, **download it**, and upload a new one; confirm the new upload appears in
`/srv/projectsend/files/` on the host. A download that returns nothing means one of the four
containers is missing the mount from step 2.
open a file, **download it**, and upload a new one; confirm the new upload appears under
`/srv/projectsend/storage/app/files/` on the host.
Once you are satisfied, and not before, you can reclaim the old volume:
Confirm the key came across too, since that is the half nothing on screen will tell you about:
```sh
docker volume rm projectsend_db-data
grep '^APP_KEY=' /srv/projectsend/storage/.env
```
If that is empty or missing while your database has saved SMTP or LDAP credentials, stop and go
back — the container will generate a *new* key and those passwords become unreadable.
Once you are satisfied, and not before, you can reclaim the old volumes:
```sh
docker volume rm projectsend_storage projectsend_db-data
```
---
@@ -178,37 +312,41 @@ copy of a live data directory is not a snapshot — it is a set of files capture
different moments, and it may restore into something subtly broken. Use a dump:
```sh
docker compose exec -T db \
mysqldump -u root -p"${DB_ROOT_PASSWORD:-root}" \
docker compose exec -T db sh -c \
'mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" \
--single-transaction --routines --triggers \
projectsend > projectsend-$(date +%F).sql
projectsend' > projectsend-$(date +%F).sql
```
`--single-transaction` is what makes this safe on a running database: the dump sees one consistent
moment in time without locking anybody out.
moment in time without locking anybody out. Reading the password from the container's own
environment keeps it off your shell history and off the process list on the host.
### The files
### The files, and the key
```sh
rsync -a /srv/projectsend/files/ /your/backup/location/files/
rsync -a /srv/projectsend/storage/ /your/backup/location/storage/
```
Ordinary files, no special handling. Restoring means copying them back and fixing ownership
(`chown -R 1000:1000`).
Ordinary files, no special handling — and taking the whole directory is what picks up `.env` with
`APP_KEY` in it. That file is a few hundred bytes and it is the difference between a perfect backup
and one where the SMTP and LDAP passwords in your database are undecryptable.
### `.env`
If you kept the named volume instead of bind-mounting, the same content comes out through a
throwaway container:
Copy it somewhere safe, once, and again whenever you change it. It is a few hundred bytes and it
holds `APP_KEY` — lose that and the SMTP and LDAP passwords stored in your database become
undecryptable, even though the rest of the backup is perfect.
```sh
docker run --rm -v projectsend_storage:/from -v "$PWD":/to \
alpine tar czf /to/projectsend-storage-$(date +%F).tar.gz -C /from .
```
### Restoring
```sh
docker compose up -d db
docker compose exec -T db mysql -u root -p"${DB_ROOT_PASSWORD:-root}" projectsend < projectsend-2026-08-08.sql
sudo rsync -a /your/backup/location/files/ /srv/projectsend/files/
sudo chown -R 1000:1000 /srv/projectsend/files
docker compose exec -T db sh -c \
'mysql -u root -p"$MYSQL_ROOT_PASSWORD" projectsend' < projectsend-2026-08-08.sql
sudo rsync -a /your/backup/location/storage/ /srv/projectsend/storage/
docker compose up -d
```
@@ -222,19 +360,17 @@ restored is a hypothesis, not a backup.
With the data outside the containers, an upgrade touches only the containers:
```sh
docker compose down # again: no -v
git pull # or unpack the new release over the directory
docker compose up -d --build
docker compose pull
docker compose up -d
```
The app container runs `php artisan projectsend:update` itself on boot — the same command a
manual install runs — so it migrates the database and verifies its reference data with no separate
step. Take a database dump first anyway — migrations move forwards, not
backwards, and the one time you skip it will be the time you want it.
That is the whole procedure. The container runs `php artisan projectsend:update` itself on boot —
the same command a manual install runs — so it migrates the database and verifies its reference data
with no separate step. Take a database dump first anyway: migrations move forwards, not backwards,
and the one time you skip it will be the time you want it.
If you run the published image rather than building your own, it is `docker compose pull` followed
by `docker compose up -d`. Either way, **[UPDATE.md](UPDATE.md)** has the whole procedure: what the
container does on its way up, how to tell it worked, and what to do when it does not.
**[UPDATE.md](UPDATE.md)** has the rest: what the container does on its way up, how to tell it
worked, and what to do when it does not.
---
@@ -243,10 +379,14 @@ container does on its way up, how to tell it worked, and what to do when it does
This is the payoff for everything above, and it is worth doing once deliberately so you know it
works:
1. Dump the database and copy `/srv/projectsend/`, `.env` and the dump to the new machine.
2. Install Docker, put the project directory in place, restore both as described under
1. Dump the database, and copy `/srv/projectsend/` (or the storage tarball) and the dump to the new
machine.
2. Install Docker, put your `compose.yaml` in place, restore both as described under
[Restoring](#restoring).
3. Point DNS at the new machine, and update `APP_URL` in `.env` if the address changed.
3. Point DNS at the new machine, and update `APP_URL` in your compose file if the address changed.
No export tool, no vendor involvement, nothing that only works while the old machine is alive.
That is the property worth protecting, and the reason this page exists.
Bring `APP_KEY` across with the storage directory — a fresh key on the new machine leaves the site
working and the saved mail and LDAP passwords silently broken.
No export tool, no vendor involvement, nothing that only works while the old machine is alive. That
is the property worth protecting, and the reason this page exists.
+121 -28
View File
@@ -68,8 +68,23 @@ instruction PHP just gave. There is no setting to change; the header names simpl
Two ways out, if nginx really is impossible on your hosting:
- Put nginx in front of Apache as a reverse proxy, serving `/protected-files/` itself. This works
but is more moving parts than just using nginx.
- Store your files in S3-compatible object storage instead (see
but is more moving parts than just using nginx. Give the proxy some header headroom while you are
there — the same headroom the reference configuration in Step 6 gives PHP-FPM, in the directives a
proxy uses instead:
```nginx
proxy_buffer_size 32k;
proxy_buffers 8 32k;
proxy_busy_buffers_size 64k;
```
nginx buffers a response's headers into a single block that defaults to one memory page — 4 KB on
most systems — and answers `502 Bad Gateway` with `upstream sent too big header` when they do not
fit. The page that goes over is not always the same one, so it presents as an intermittent fault
rather than as a misconfiguration. This applies to any proxy in front of ProjectSend, not just
this one: Nginx Proxy Manager, Traefik and a hand-written nginx vhost all ship the same default.
([#1664](https://github.com/projectsend/projectsend/issues/1664))
- Store your files in object storage instead — S3-compatible or Google Cloud Storage (see
[Storing files somewhere other than this server](#storing-files-somewhere-other-than-this-server)).
Files kept there are never on your server's disk, so downloads become a signed, expiring redirect
to the storage provider and the web server is not involved at all. This is a genuine, supported
@@ -182,6 +197,66 @@ sudo chown -R www-data:www-data /var/www/projectsend
sudo chmod -R 775 /var/www/projectsend/storage /var/www/projectsend/bootstrap/cache
```
### If your web server and PHP-FPM are different users
Check before you go further, because the symptom is misleading:
```sh
ps -o user= -C nginx | sort -u # the web server's user
ps -o user= -C php-fpm | sort -u # PHP's user
```
Most servers you set up yourself run both as `www-data` and there is nothing to do here. Managed
panels often do not — cPanel and Plesk commonly give each site its own PHP user while nginx runs as
its own. If the two differ, add this to your `.env`:
```dotenv
FILES_WEB_SERVER_READABLE=true
```
Uploaded files are written `0600` inside `0700` directories, readable only by the user that wrote
them. That is deliberate, and on a same-user server it is the safer setting. But a download is not
served by PHP: PHP checks permissions and then hands the web server the path with `X-Accel-Redirect`
(see [Why nginx](#why-nginx)), so the web server has to open a file PHP owns. When it cannot, **the
whole site works and only downloads fail** — the browser reports `ERR_INVALID_RESPONSE` and the
nginx error log says:
```
open() ".../storage/app/files/..." failed (13: Permission denied)
```
The setting relaxes new uploads to `0644`/`0755`. Be aware of what that means on a shared machine:
those modes are readable by *every* account on the server, not only by the web server. The files stay
off the web — the `internal` directive in Step 6 sees to that — but they are no longer private from
your neighbours, so leave this off unless you need it.
Files already on disk keep the permissions they were written with, so fix those once:
```sh
sudo find /var/www/projectsend/storage/app/files -type d -exec chmod 755 {} +
sudo find /var/www/projectsend/storage/app/files -type f -exec chmod 644 {} +
```
**Then check that new uploads keep it.** Upload a file and look at the directory it landed in:
```sh
ls -ld /var/www/projectsend/storage/app/files/*/*
```
If it is `drwxr-xr-x` you are done. If it is still `drwx------`, your PHP-FPM pool runs with a
restrictive umask, and no application setting can beat it: ProjectSend asks for `0755`, but the
directory is created by `mkdir()`, and `mkdir()` masks whatever mode it is given with the umask of
the process. (Files are unaffected — they are set explicitly after being written, so they are `0644`
either way.) Fix it in the pool configuration, not here:
```ini
; /etc/php/8.4/fpm/pool.d/your-pool.conf — the path varies by panel
php_admin_value[umask] = 0022
```
Some panels expose this as a "umask" field instead. Restart PHP-FPM afterwards, then re-run the
`chmod` above for anything uploaded in the meantime.
## Step 5 — Prepare the application
Three commands. Run them from the install directory, as the web server's user, so that everything
@@ -309,7 +384,7 @@ User=www-data
Group=www-data
Restart=always
WorkingDirectory=/var/www/projectsend
ExecStart=/usr/bin/php artisan queue:work --tries=3 --backoff=3
ExecStart=/usr/bin/php artisan queue:work --queue=default,zips --tries=3 --backoff=3
[Install]
WantedBy=multi-user.target
@@ -321,6 +396,15 @@ Then:
sudo systemctl enable --now projectsend-worker
```
`--queue=default,zips` matters. Building a zip runs on its own queue, so a worker that is not told
to watch `zips` will send email happily and never finish a single zip download — with nothing in any
log to say why. One worker watching both is fine for most installations; ordinary work is taken
first, and a large zip simply holds the worker while it runs.
If zip downloads are heavily used and you would rather they never delayed email, run a second unit
with `--queue=zips` and narrow the first one to `--queue=default`. That is what the Docker images
do.
**Without this, no email is ever sent** and zip downloads never finish. `Restart=always` matters
too: saving your email settings restarts the worker so it picks up the new values, and it needs to
come back on its own.
@@ -372,8 +456,20 @@ the worker afterwards.
### Storing files somewhere other than this server
Out of the box, uploads live in `storage/app/files/` on this machine. You can point ProjectSend at
S3-compatible object storage instead from **System → Settings → Storage** — useful when the files
outgrow the server's disk.
object storage instead from **System → Settings → Storage** — useful when the files outgrow the
server's disk.
Two backends are offered. **S3-compatible** covers AWS S3 and everything speaking that API: MinIO,
Backblaze B2, Wasabi, DigitalOcean Spaces. Leave the endpoint blank for AWS itself, or set it to the
service's own address and turn on path-style addressing, which most of them need. **Google Cloud
Storage** takes a service account key with read and write access to the bucket, pasted in as the JSON
file Google issues; it is stored encrypted and never shown again.
Whichever you choose, use **Test connection** before switching uploads over — it checks the
credentials actually reach the bucket, rather than leaving you to find out at the first upload.
The setting applies to new uploads. Files already on local disk stay there and keep working, and
there is no migration between backends.
### Making it faster
@@ -388,28 +484,16 @@ sudo -u www-data php artisan event:cache
You only run these once: `projectsend:update` notices they are in place and rebuilds them for you
after every update. If you change your mind, `php artisan optimize:clear` undoes all three.
#### One command to skip: `config:cache`
#### `config:cache` and your `.env`
Every Laravel deployment guide on the internet lists `php artisan config:cache` alongside those
three, and `php artisan optimize` runs it for you. **Don't** — not on this application.
Every Laravel deployment guide also lists `php artisan config:cache`, and `php artisan optimize`
runs it for you. It is safe here, with one thing to remember.
Here is why. Caching the configuration writes every resolved setting into one PHP file, and from
then on the framework stops reading your `.env` at all, on the entirely reasonable grounds that
everything in it has already been baked in. That holds for settings read the normal way, through
`config()`. ProjectSend reads one value earlier than that — `TRUSTED_PROXIES`, which has to be
known before the middleware stack is assembled, so it is read straight from the environment. Cache
the config and that read returns nothing.
Nothing breaks loudly. The site comes up, you log in, everything looks fine. But if there is a
proxy or CDN in front of the server, ProjectSend goes back to believing every visitor is the proxy:
the login rate limiter now counts all of your users as one attacker and locks the whole site out
after five wrong passwords, and every row in the download log records the proxy's address instead
of the person who actually downloaded the file. Both are the kind of thing you discover weeks
later, from a complaint.
If you have already run it — or ran `php artisan optimize`, which includes it — `php artisan
config:clear` puts things back immediately, and every update clears it too, saying why. The three commands above are safe and give you nearly
all of the speed anyway; `config:cache` was always the smallest win of the four.
Caching the configuration writes every resolved setting into one PHP file, and from then on the
framework stops reading your `.env` at all — everything in it has already been baked in. So
**re-run `php artisan config:cache` every time you edit `.env`**, or the edit does nothing and you
are left staring at a setting that is plainly there and plainly ignored. `php artisan config:clear`
goes back to reading `.env` directly, and every update clears it too, saying why.
---
@@ -502,13 +586,22 @@ applies to a non-standard port; behind a TLS proxy on 443 you do not need it.
**A change I made in `.env` has no effect.**
Run `php artisan optimize:clear`, then restart PHP-FPM and the worker. Both hold the old values
until they are restarted. If it *still* has no effect, someone has run `php artisan config:cache`
(or `optimize`) on this install — see [One command to skip](#one-command-to-skip-configcache).
(or `optimize`) on this install — re-run it to pick the new value up, or `php artisan config:clear`
to go back to reading `.env` directly.
**Everyone is locked out of the login form at once, or the download log shows the same IP for
every download.**
ProjectSend is seeing your proxy or CDN instead of your visitors. Set `TRUSTED_PROXIES` in `.env`
(step 3) — and make sure `config:cache` has not been run, which stops that value from being read
at all. Same section as above.
(step 3) and restart PHP-FPM.
**Behind a reverse proxy: 419 "page expired" when you log in or save a form, or you land back on
the login screen at random.**
`TRUSTED_PROXIES` again (step 3). Without it ProjectSend never learns the proxy terminated TLS, so
it builds its links and redirects with `http://` while the browser is on `https://`, and marks the
session cookie as non-secure. The browser then declines to send that cookie back, the session
arrives empty, and the write fails with a 419 that reads as an expired session. Make sure your
proxy passes the original `Host` header through as well — `proxy_set_header Host $host;` in nginx,
`passHostHeader=true` in Traefik (its default).
Still stuck? Ask in the [community forum](https://www.projectsend.org/) or open an issue on
[GitHub](https://github.com/projectsend/projectsend/issues), and include the last few lines of
+5 -3
View File
@@ -178,9 +178,11 @@ are listed at each step.
| Legacy and ProjectSend are on the **same machine** | [**Direct**](#step-3a--direct-same-machine) |
| Legacy is on **another server**, or on hosting you cannot reach from the new box | [**Bundle**](#step-3b--bundle-different-machines) |
Direct is faster and simpler, and on a single filesystem it does not copy your files at all — it
hardlinks them, so 400 GB migrates in seconds and both installs point at the same bytes until you
decide otherwise. Use it if you can.
Direct is faster and simpler. It copies your files by default, and it can also *hardlink* them
instead when you ask it to — on a single filesystem that writes no bytes at all, so 400 GB migrates
in seconds and both installs point at the same bytes until you decide otherwise. Either way your
Legacy install is left intact. Use Direct if you can; [Step 3a](#step-3a--direct-same-machine) has
the strategies.
---
+3 -2
View File
@@ -47,7 +47,7 @@ per-seat pricing. It runs on your server, and the files stay there.
- 16 languages
- A REST API with scoped tokens and generated OpenAPI docs
- Privacy controls, including GDPR-grade account erasure with a grace period
- Local disk or S3-compatible storage
- Local disk, S3-compatible storage, or Google Cloud Storage
## Screenshots
@@ -78,7 +78,8 @@ docker compose -f compose.example.yaml up -d
```
Open `APP_URL` and the first thing you see is a setup screen that creates your administrator
account — or set `ADMIN_EMAIL` and `ADMIN_PASSWORD` in the file first and it is created for you.
account — or uncomment `ADMIN_EMAIL` and `ADMIN_PASSWORD` in the file first, with a password of
your own, and it is created for you.
Before you put real files in it, read **[DOCKER.md](DOCKER.md)** — where your database and uploads
actually live, how to move them onto paths you chose, and how to back them up so an upgrade can't
+3 -3
View File
@@ -1,8 +1,8 @@
# Updating ProjectSend
How to move an existing installation to a newer version, for both ways of running it. If you are
installing for the first time, you want [INSTALL.md](INSTALL.md) (or [DOCKER.md](DOCKER.md))
instead.
installing for the first time, you want [Getting started](README.md#getting-started) for Docker or
[INSTALL.md](INSTALL.md) for your own server instead.
Two rules hold everywhere in this document:
@@ -17,7 +17,7 @@ Which path you are on decides the rest:
| How you installed | What updating means | Manual steps |
|---|---|---|
| The official Docker image (`projectsend/projectsend`) | Pull a new image, recreate the container | None — the container migrates itself |
| Docker Compose built from source (DOCKER.md) | New code, rebuild the image | None — same entrypoint |
| Docker Compose built from a clone (CONTRIBUTING.md) | New code, rebuild the image | None — same entrypoint |
| A release zip on your own server (INSTALL.md) | Download the zip, run one script | `sudo ./update.sh`, and answer three questions |
ProjectSend also tells you which of these you are on: the **System** card on the dashboard prints
@@ -8,6 +8,7 @@ use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientFieldContext;
use App\Modules\Clients\ClientPortalCustomFields;
use App\Modules\Identity\Erasure\ErasureSchedule;
use App\Modules\Platform\Localization\TimezoneRegistry;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
@@ -107,8 +108,7 @@ class ProfileController extends Controller
// Self-deletion: soft delete now, permanent GDPR erasure after
// the disclosed grace period (Setting::AccountErasureGraceDays).
$graceDays = (int) app(Settings::class)->get(Setting::AccountErasureGraceDays);
$user->forceFill(['erase_after' => now()->addDays($graceDays)])->save();
app(ErasureSchedule::class)->apply($user);
$user->delete();
app(ActivityLogger::class)->log(Action::UserDeleted, $user, context: ['name' => $user->name]);
@@ -15,6 +15,7 @@ use App\Modules\Platform\Attribution\Attribution;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use App\Modules\Platform\Captcha\Captcha;
use App\Modules\Platform\Installation\Installation;
use App\Modules\Files\Queue\StalledZipBuilds;
use App\Modules\Platform\Localization\LocaleRegistry;
use App\Modules\Platform\Localization\TimezoneRegistry;
use App\Modules\Platform\OfficialLinks;
@@ -102,6 +103,7 @@ class HandleInertiaRequests extends Middleware
'pending' => $this->pendingCounts($request),
'update_notice' => $this->updateNotice($request),
'code_notice' => $this->codeNotice($request),
'worker_notice' => $this->workerNotice($request),
'locale' => app()->getLocale(),
// The clock this viewer reads dates by, and whether it is a
// choice or a fallback. The frontend needs both: the first to
@@ -145,10 +147,14 @@ class HandleInertiaRequests extends Middleware
}
if ($checker->allows($user, Permission::ApproveGroupsMembershipsRequests)) {
// Narrowed like the queue it badges, and by the same scope —
// a client-scoped staff member is not shown a number they
// cannot act on. Same rule the comments badge below states.
$counts['membership_requests'] = MembershipRequest::query()
->pending()
->whereHas('user')
->whereHas('group')
->approvableBy($user)
->count();
}
@@ -245,6 +251,29 @@ class HandleInertiaRequests extends Middleware
return app(RunningCodeState::class)->current();
}
/**
* Whether anything is serving the queue zip builds run on.
*
* Gated on view_system_info for the reason codeNotice() gives above:
* a background worker that is not picking work up is a fact about the
* machine rather than a feature of an edition. Same audience, same
* banner slot, one question further along.
*
* @return array{waiting_since: string}|null
*/
protected function workerNotice(Request $request): ?array
{
$user = $request->user();
if ($user === null || ! app(PermissionChecker::class)->allows($user, Permission::ViewSystemInfo)) {
return null;
}
$waitingSince = app(StalledZipBuilds::class)->oldestUnstarted();
return $waitingSince === null ? null : ['waiting_since' => $waitingSince->toIso8601String()];
}
/**
* App strings use English text as the translation key, so "en" ships no
* messages — the key itself is the fallback.
@@ -16,12 +16,12 @@ use League\CommonMark\MarkdownConverter;
/**
* The API reference, inside the admin UI.
*
* Rendered from the two files that are already the source of truth — the
* committed OpenAPI document and docs/api-guide.md — rather than embedding
* a third-party documentation UI. An iframe or a CDN-hosted renderer would
* mean a page that ignores the app's theme, breaks its links, and goes
* blank on an install with no outbound internet access, which self-hosted
* installations regularly are.
* Rendered from the files that are already the source of truth — the
* committed OpenAPI document, docs/api-guide.md and docs/api-zapier.md —
* rather than embedding a third-party documentation UI. An iframe or a
* CDN-hosted renderer would mean a page that ignores the app's theme,
* breaks its links, and goes blank on an install with no outbound internet
* access, which self-hosted installations regularly are.
*
* The markdown is converted server-side with league/commonmark, already a
* framework dependency, so no JavaScript renderer joins the bundle.
@@ -31,7 +31,11 @@ class ApiDocsController extends Controller
public function __invoke(Request $request): Response
{
return Inertia::render('api/docs', [
'guide_html' => $this->guideHtml(),
'guide_html' => $this->markdown('docs/api-guide.md'),
// A second page rather than a section of the guide: the guide
// is written for someone building against the API, this is
// written for someone wiring up a Zap and reading nothing else.
'zapier_html' => $this->markdown('docs/api-zapier.md'),
'endpoints' => $this->endpoints(),
'spec_url' => route('api.openapi'),
'version' => $this->spec()['info']['version'] ?? null,
@@ -96,16 +100,20 @@ class ApiDocsController extends Controller
return $position === false ? '' : substr($description, $position);
}
private function guideHtml(): string
/**
* @param string $file repository-relative path to a markdown file
* shipped with the application
*/
private function markdown(string $file): string
{
$path = base_path('docs/api-guide.md');
$path = base_path($file);
if (! is_file($path)) {
return '';
}
// A deliberately small extension set. The guide is a file shipped
// with the application, not user input — but rendering it with the
// A deliberately small extension set. These are files shipped with
// the application, not user input — but rendering them with the
// narrowest converter that does the job keeps it that way even if
// someone later points this at something less trustworthy.
$environment = new Environment([
+13 -4
View File
@@ -29,6 +29,13 @@ use Illuminate\Support\Carbon;
* one forever. The cost is re-seeing the boundary row, which a client
* de-duplicates by id — the safe direction of the trade.
*
* Some tables have no `updated_at` because their rows are never edited —
* the activity log is one. They pass their own column instead. The
* *parameter* stays `updated_since` for every endpoint even so: the
* shape being learned once is worth more than a second name that would
* behave identically, since on an append-only table the two timestamps
* are the same thing.
*
* Known limitation, documented rather than papered over: polling cannot
* observe deletions. A soft-deleted row simply stops appearing. Webhooks
* are the fix, and are deliberately a later phase.
@@ -39,9 +46,11 @@ class PollingQuery
* @template TModel of Model
*
* @param Builder<TModel> $query
* @param string $column the timestamp to walk, for a table whose
* rows are appended rather than edited
* @return CursorPaginator<int, TModel>
*/
public function paginate(Request $request, Builder $query, string $table): CursorPaginator
public function paginate(Request $request, Builder $query, string $table, string $column = 'updated_at'): CursorPaginator
{
$since = $request->query('updated_since');
@@ -53,11 +62,11 @@ class PollingQuery
// a polling client would see an empty result forever instead of
// an error. Carbon also normalises the offset into the app's
// timezone, so a caller in any timezone gets the same rows.
$query->where("{$table}.updated_at", '>=', Carbon::parse($since)->timezone(config('app.timezone')))
->orderBy("{$table}.updated_at")
$query->where("{$table}.{$column}", '>=', Carbon::parse($since)->timezone(config('app.timezone')))
->orderBy("{$table}.{$column}")
->orderBy("{$table}.id");
} else {
$query->orderByDesc("{$table}.updated_at")
$query->orderByDesc("{$table}.{$column}")
->orderByDesc("{$table}.id");
}
+3
View File
@@ -54,6 +54,7 @@ enum Action: string
case ShareLinkRevoked = 'share_link.revoked';
case ShareLinkDownloaded = 'share_link.downloaded';
case PublicFileDownloaded = 'public_file.downloaded';
case PublicFilePreviewed = 'public_file.previewed';
case FolderCreated = 'folder.created';
case FolderRenamed = 'folder.renamed';
case FolderMoved = 'folder.moved';
@@ -180,6 +181,7 @@ enum Action: string
self::ShareLinkRevoked => 'Revoked a public link for the file ":subject"',
self::ShareLinkDownloaded => 'Downloaded the file ":subject" via a public link',
self::PublicFileDownloaded => 'Downloaded the file ":subject" via the public group listing',
self::PublicFilePreviewed => 'Previewed the file ":subject" via the public group listing',
self::FolderCreated => 'Created the folder ":subject"',
self::FolderRenamed => 'Renamed the folder ":subject"',
self::FolderMoved => 'Moved the folder ":subject"',
@@ -278,6 +280,7 @@ enum Action: string
self::ShareLinkRevoked => 'A public link was revoked',
self::ShareLinkDownloaded => 'A file was downloaded via a public link',
self::PublicFileDownloaded => 'A file was downloaded via the public group listing',
self::PublicFilePreviewed => 'A file was previewed via the public group listing',
self::FolderCreated => 'A folder was created',
self::FolderRenamed => 'A folder was renamed',
self::FolderMoved => 'A folder was moved',
+30 -8
View File
@@ -5,10 +5,12 @@ declare(strict_types=1);
namespace App\Modules\Audit;
use App\Models\User;
use App\Modules\Audit\Events\ResolvingActivityOrigin;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Event;
class ActivityLogger
{
@@ -35,16 +37,20 @@ class ActivityLogger
// gaps. Reading the current request's credential is the same kind of
// implicit lookup this class already does for the actor and the IP.
$token = $user?->currentAccessToken();
[$origin, $credentialName] = $this->originFor($user, $token);
ActivityLog::query()->create([
'actor_id' => $user?->getKey(),
'actor_name' => $user?->name,
'actor_type' => $user?->type->value,
'origin' => $this->originFor($user, $token),
'origin' => $origin,
// Only ever a personal access token's id — the column means a
// row in that table, and a credential that is not one leaves
// it null and identifies itself by name alone.
'api_token_id' => $token?->getKey(),
// Snapshotted beside the id for the same reason actor_name is:
// a revoked token must not leave its entries pointing at nothing.
'api_token_name' => $token?->getAttribute('name'),
'api_token_name' => $credentialName,
'action' => $action,
'subject_type' => $subject?->getMorphClass(),
'subject_id' => $subject?->getKey(),
@@ -77,23 +83,39 @@ class ActivityLogger
* untestable — the failure mode being that it looks right in
* production and nothing proves it. A console command and a queued job
* have no route; a request does.
*
* @return array{ActivityOrigin, ?string} the origin, and what to
* record the credential as —
* null when there is no
* credential to name
*/
private function originFor(?User $actor, mixed $token): ActivityOrigin
private function originFor(?User $actor, mixed $token): array
{
if ($token !== null) {
return ActivityOrigin::Api;
$name = $token->getAttribute('name');
return [ActivityOrigin::Api, is_string($name) ? $name : null];
}
if ($actor !== null) {
return ActivityOrigin::Ui;
if ($actor === null) {
return [request()->route() === null ? ActivityOrigin::System : ActivityOrigin::Public, null];
}
return request()->route() === null ? ActivityOrigin::System : ActivityOrigin::Public;
// An actor and no personal access token has always meant a browser
// session, and for a long time nothing else could authenticate a
// request. Ask before assuming it: a credential core does not know
// about would otherwise be recorded as a person clicking, which is
// the one thing this column exists not to get wrong. Nothing
// listens on a stock installation, so the answer stays Ui.
$asking = new ResolvingActivityOrigin($actor);
Event::dispatch($asking);
return [$asking->origin ?? ActivityOrigin::Ui, $asking->credentialName];
}
private function shouldRecordIp(Action $action, ?User $actor): bool
{
if (! in_array($action, [Action::FileDownloaded, Action::FilePreviewed, Action::ShareLinkDownloaded, Action::PublicFileDownloaded], true)) {
if (! in_array($action, [Action::FileDownloaded, Action::FilePreviewed, Action::ShareLinkDownloaded, Action::PublicFileDownloaded, Action::PublicFilePreviewed], true)) {
return true;
}
+13
View File
@@ -38,6 +38,18 @@ enum ActivityOrigin: string
/** Scheduled tasks and console commands. */
case System = 'system';
/**
* An AI assistant acting for a signed-in person, through a connector
* they authorised — the code that can produce this ships in
* projectsend/cloud-modules and nowhere else.
*
* The person stays the actor: they authorised it, and an audit trail
* that named the assistant instead would lose the only fact that
* matters when something unexpected shows up. What the connector was
* called goes beside the entry, the way an API token's name does.
*/
case Mcp = 'mcp';
/**
* English label — also the translation key.
*/
@@ -48,6 +60,7 @@ enum ActivityOrigin: string
self::Api => 'API',
self::Public => 'Not signed in',
self::System => 'System',
self::Mcp => 'AI assistant',
};
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Modules\Audit\Events;
use App\Models\User;
use App\Modules\Audit\ActivityOrigin;
/**
* "This request has a signed-in actor and no personal access token — was
* it really a browser?"
*
* Asked only in that one ambiguous case. A request carrying a Sanctum
* token is the API, a request with nobody signed in is public or system,
* and neither is in any doubt — so neither is offered here.
*
* The doubt exists because "no token" has always meant "a session", and
* that stops being true the moment anything else can authenticate a
* request. `ActivityOrigin` is a closed enum a package cannot extend, so
* core has to publish both the case and this hook before a package can
* say "that was mine". Without it a new credential would be recorded as
* a person clicking in a browser — silently, and in the one table whose
* whole purpose is answering "did I do that, or did something acting for
* me?"
*
* Set `$origin` only if you recognise the credential on the current
* request. Leaving it null means "not mine", which is the honest answer
* for every listener that is not looking at its own guard.
*
* Listened to by *string* class name from a package, same as every other
* hook here — see docs/extension-points-architecture.md.
*/
final class ResolvingActivityOrigin
{
/**
* What actually authenticated this request. Null until a listener
* claims it, after which core stops assuming a browser session.
*/
public ?ActivityOrigin $origin = null;
/**
* What to show beside the entry — the name of the connector or
* application acting, not the person. Snapshotted into the same
* column an API token's name goes in, for the same reason: revoking
* the credential must not leave the entry pointing at nothing.
*/
public ?string $credentialName = null;
public function __construct(
public readonly User $actor,
) {}
}
@@ -76,10 +76,19 @@ class ActivityLogController extends Controller
'key' => $action->value,
'description' => $action->description(),
], Action::cases()),
'origins' => array_map(fn (ActivityOrigin $origin): array => [
'key' => $origin->value,
'label' => $origin->label(),
], ActivityOrigin::cases()),
// Every origin this installation could actually produce.
// Offering a filter that can only ever return nothing would
// be dangling a feature this edition does not have, which is
// the one thing the edition boundary is meant not to do.
'origins' => collect(ActivityOrigin::cases())
->reject(fn (ActivityOrigin $origin): bool => $origin === ActivityOrigin::Mcp
&& ! $this->capabilities->has(Capability::AiConnector))
->map(fn (ActivityOrigin $origin): array => [
'key' => $origin->value,
'label' => $origin->label(),
])
->values()
->all(),
]);
}
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Modules\Audit\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Modules\Api\Support\PollingQuery;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Audit\ActivityLogScope;
use App\Modules\Audit\Http\Resources\Api\ActivityResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Validation\Rule;
/**
* What has happened in this installation.
*
* The endpoint automation tools actually need. Every other list here
* answers "what is there now"; a caller that wants to *react* — post to
* Slack when a file is shared, add a row when a client downloads one —
* needs to know that something happened, and the shape of the thing
* afterwards does not say. Sharing a file writes an assignment row and
* never touches the file, so polling the file list cannot see it at all.
*
* One feed rather than one endpoint per event, because the log already
* records every one of them and a caller filtering by `action` gets any
* event the application ever grows without waiting for an endpoint.
*/
class ActivityController extends Controller
{
public function __construct(
private readonly PollingQuery $polling,
private readonly ActivityLogScope $scope,
) {}
/**
* List activity, newest first.
*
* Filter by `action` — repeat the parameter for more than one, as
* `?action[]=file.assigned&action[]=file.downloaded`. `subject_type`
* narrows to one kind of thing (`file`, `user`, `group`, …).
*
* Entries are never edited, so `updated_since` walks the moment each
* one was recorded. Everything else about polling is the shape every
* list endpoint here shares.
*
* Scoped to what the caller may read: a staff member limited to their
* assigned clients sees entries about their own library and their own
* actions, never the whole installation's.
*/
public function index(Request $request): AnonymousResourceCollection
{
$filters = $request->validate($this->polling->rules() + [
'action' => ['nullable', 'array'],
'action.*' => [Rule::enum(Action::class)],
'subject_type' => ['nullable', 'string', 'max:64'],
]);
$viewer = $request->user();
assert($viewer !== null);
$query = $this->scope->apply(ActivityLog::query(), $viewer);
if (($filters['action'] ?? []) !== []) {
$query->whereIn('action', $filters['action']);
}
if (($filters['subject_type'] ?? null) !== null) {
$query->where('subject_type', $this->subjectClass($filters['subject_type']));
}
// created_at, not updated_at: the log is appended to and never
// edited, and has no updated_at column to walk.
return ActivityResource::collection(
$this->polling->paginate($request, $query, 'activity_log', 'created_at')
);
}
/**
* The public name for a kind of subject, back to the class the column
* actually holds. An unknown name matches nothing rather than
* everything — a filter that silently ignores what it was given would
* hand back the whole log to a caller who asked for one slice of it.
*/
private function subjectClass(string $type): string
{
return array_search($type, ActivityResource::subjects(), true) ?: '__no_such_subject__';
}
}
@@ -9,8 +9,11 @@ use App\Models\User;
use App\Modules\Api\ApiUsage;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Audit\ActivityLogScope;
use App\Modules\Audit\ActivityPresenter;
use App\Modules\Audit\DashboardWidgetPreferences;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Models\File;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\UserType;
@@ -24,6 +27,7 @@ use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Storage\StorageDurability;
use App\Modules\Platform\System\SystemEnvironment;
use App\Modules\Platform\Updates\LatestReleaseInfo;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
@@ -50,6 +54,9 @@ class DashboardController extends Controller
private readonly Installation $installation,
private readonly TimezoneRegistry $timezones,
private readonly SystemEnvironment $environment,
private readonly ActivityPresenter $presenter,
private readonly ActivityLogScope $scope,
private readonly StaffLibraryScope $library,
) {}
public function __invoke(Request $request): Response
@@ -81,10 +88,10 @@ class DashboardController extends Controller
? ['preset' => $preset, 'from' => $from->toDateString(), 'to' => $to->toDateString()]
: null,
'top_clients_by_storage' => $canStatistics && $prefs->isEnabled($user, 'top_clients_by_storage')
? $this->topClientsByStorage()
? $this->topClientsByStorage($user)
: null,
'largest_files' => $canStatistics && $prefs->isEnabled($user, 'largest_files') ? $this->largestFiles($user) : null,
'recent' => $canActionsLog && $prefs->isEnabled($user, 'recent') ? $this->recentActivity() : null,
'recent' => $canActionsLog && $prefs->isEnabled($user, 'recent') ? $this->recentActivity($user) : null,
'system' => $canSystem && $prefs->isEnabled($user, 'system') ? $this->systemInfo() : null,
// Both editions — informational content, not an update action,
// so no Capability check alongside the permission (unlike
@@ -206,6 +213,12 @@ class DashboardController extends Controller
*/
private function counters(): array
{
// Deliberately installation-wide, unlike the three widgets below.
// A total carries no names — "417 files" tells a scoped viewer
// nothing about whose they are — and the same reasoning leaves
// transferSeries() alone. If that ever stops being the line, both
// move together.
return [
'files' => File::query()->count(),
'files_bytes' => (int) File::query()->sum('size'),
@@ -276,11 +289,21 @@ class DashboardController extends Controller
*
* @return list<array{id: int, name: string, used_bytes: int, quota_mb: int}>
*/
private function topClientsByStorage(): array
private function topClientsByStorage(User $viewer): array
{
// Narrowed by roster, not by library. This widget names *clients*,
// and files() is the wrong lens for that: a stranger client's
// upload can be inside a scoped viewer's library — shared with a
// group one of their own clients is in — which put the stranger's
// name on the widget. Measured: a client called "Stranger Client
// Ltd", on nobody's roster, ranked on a scoped dashboard.
// assignableClientIds is the question actually being asked.
$clientIds = $this->library->assignableClientIds($viewer);
$rows = File::query()
->select('uploaded_by', DB::raw('SUM(size) as total_bytes'))
->whereHas('uploader', fn ($query) => $query->where('type', UserType::Client))
->when($clientIds !== null, fn (Builder $query) => $query->whereIn('uploaded_by', $clientIds))
->groupBy('uploaded_by')
->orderByDesc('total_bytes')
->limit(5)
@@ -338,7 +361,14 @@ class DashboardController extends Controller
$staffModule = $this->capabilities->has(Capability::UsersManage) && $viewer->can('manage_users');
$canStaffUsers = $staffModule && $viewer->can('edit_users');
return array_values(File::query()
// Narrowed to the viewer's library, not just its links. The
// note above is about a link that 403s; a row that should not be
// here at all is a different problem, and the file's *name* is
// the part that leaks — "Q3 delinquent accounts" says plenty
// without being downloadable. Scoping the query costs one call:
// StaffLibraryScope builds a scoped user's query once per
// request, so this is not a per-row check.
return array_values($this->library->files($viewer)
->with('uploader:id,name,type')
->orderByDesc('size')
->limit(10)
@@ -377,8 +407,21 @@ class DashboardController extends Controller
$canFiles = $viewer->can('upload') || $viewer->can('edit_files') || $viewer->can('edit_others_files');
return [
'count' => File::query()->expired()->count(),
'files' => array_values(File::query()->expired()->orderBy('expires_at')->limit(10)
// Both the count and the list read the viewer's library, so
// the number cannot describe files the list is not allowed to
// name. Same reason largestFiles() is scoped.
//
// Narrower than it looks for a client-scoped viewer:
// File::scopeVisibleToClient ends in notExpired(), so an
// expired file belonging to one of their clients is not in
// their library, and only their own expired uploads reach
// this list. Rather than widen the boundary — which would
// mean a library query that keeps expired rows, and
// scopeVisibleToClient is the single source of truth for
// client file access — the widget says what it is showing.
// `scoped` is how it knows to.
'count' => $this->library->files($viewer)->expired()->count(),
'files' => array_values($this->library->files($viewer)->expired()->orderBy('expires_at')->limit(10)
->get(['id', 'name', 'expires_at'])
->map(fn (File $file): array => [
'id' => $file->id,
@@ -386,6 +429,10 @@ class DashboardController extends Controller
'expires_at' => $file->expires_at?->toIso8601String(),
'edit_url' => $canFiles ? route('files.edit', $file->id, false) : null,
])->all()),
// Whether this list is "everything expired" or "everything of
// yours that expired" — a widget whose whole job is warning
// about what is due to be deleted has to say which it means.
'scoped' => $viewer->isClientScoped(),
'auto_delete_enabled' => (bool) $this->settings->get(Setting::ExpiredFilesAutoDeleteEnabled),
// Schedule::command('projectsend:purge-expired-files')->daily()
// runs at 00:00 — always "tonight" from whenever this loads.
@@ -396,28 +443,27 @@ class DashboardController extends Controller
/**
* @return array<int, array<string, mixed>>
*/
private function recentActivity(): array
private function recentActivity(User $viewer): array
{
return ActivityLog::query()
// Narrowed through ActivityLogScope, exactly as the activity page and
// the download history are. `view_actions_log` is not the whole
// answer for a client-scoped viewer: a log entry carries the
// subject's name, so an unscoped one reads out the name of every
// file in the installation and who touched it, to somebody who gets
// a 403 on the files themselves. The Client Manager role ships with
// the permission, so this is the default configuration.
//
// Presented through the shared ActivityPresenter, not rebuilt inline —
// the same sentence-ready shape the activity page and detail panels
// use. Rebuilding it here once dropped `origin`, which is the only
// thing that tells an actorless "Anonymous" entry from a "System" one.
return $this->scope->apply(ActivityLog::query(), $viewer)
->orderByDesc('created_at')
->orderByDesc('id')
->limit(8)
->get()
->map(fn (ActivityLog $entry): array => [
'id' => $entry->id,
'created_at' => $entry->created_at->toIso8601String(),
'actor_name' => $entry->actor_name,
'actor_type' => $entry->actor_type,
'template' => $entry->action->template(),
'replacements' => [
'subject' => $entry->subject_name
?? ($entry->subject_id !== null ? __('(deleted account)') : ''),
...collect($entry->context ?? [])
->filter(fn ($value): bool => is_scalar($value))
->map(fn ($value): string => (string) $value)
->all(),
],
])->all();
->map(fn (ActivityLog $entry): array => $this->presenter->present($entry))
->all();
}
/**
@@ -5,12 +5,17 @@ declare(strict_types=1);
namespace App\Modules\Audit\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Audit\ActivityLogScope;
use App\Modules\Audit\DownloadPresenter;
use App\Modules\Files\Models\File;
use App\Modules\Platform\Localization\LocalDay;
use App\Modules\Platform\Localization\TimezoneRegistry;
use App\Support\Pagination;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
@@ -27,6 +32,7 @@ class DownloadsController extends Controller
public function __construct(
private readonly DownloadPresenter $presenter,
private readonly ActivityLogScope $scope,
private readonly TimezoneRegistry $timezones,
) {}
public function index(Request $request): Response
@@ -34,15 +40,9 @@ class DownloadsController extends Controller
$viewer = $request->user();
assert($viewer !== null);
// A download row names the file and says who fetched it from which
// IP, so it needs the viewer's library scope applied — not just
// `view_actions_log`. See ActivityLogScope for the full reasoning.
$entries = $this->scope
->apply(ActivityLog::query(), $viewer)
->where('subject_type', (new File)->getMorphClass())
->whereIn('action', [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded])
->orderByDesc('created_at')
->orderByDesc('id')
$filters = $this->validatedFilters($request);
$entries = $this->filteredQuery($filters, $viewer)
->paginate(25)
->withQueryString();
@@ -63,6 +63,65 @@ class DownloadsController extends Controller
];
})->all(),
'pagination' => Pagination::meta($entries),
'filters' => $filters,
]);
}
/**
* @return array{file: ?string, user: ?string, from: ?string, to: ?string}
*/
private function validatedFilters(Request $request): array
{
$validated = $request->validate([
'file' => ['nullable', 'string', 'max:255'],
'user' => ['nullable', 'string', 'max:255'],
'from' => ['nullable', 'date'],
'to' => ['nullable', 'date', 'after_or_equal:from'],
]);
return [
'file' => $validated['file'] ?? null,
'user' => $validated['user'] ?? null,
'from' => $validated['from'] ?? null,
'to' => $validated['to'] ?? null,
];
}
/**
* @param array{file: ?string, user: ?string, from: ?string, to: ?string} $filters
* @return Builder<ActivityLog>
*/
private function filteredQuery(array $filters, User $viewer): Builder
{
$timezone = $this->timezones->resolve($viewer);
// A download row names the file and says who fetched it from which
// IP, so it needs the viewer's library scope applied — not just
// `view_actions_log`. See ActivityLogScope for the full reasoning.
return $this->scope
->apply(ActivityLog::query(), $viewer)
->where('subject_type', (new File)->getMorphClass())
->whereIn('action', [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded])
// Both names are matched on what the entry snapshotted, not on
// a join: a file or an account deleted since is still findable
// by the name it went out under, which is often exactly what
// this page is being asked.
->when($filters['file'], fn (Builder $query, string $file) => $query->where('subject_name', 'like', "%{$file}%"))
// Only rows with a real account can match a name. The two
// anonymous flavours ("Public link", "Public listing") are
// labels this page prints, not stored values, so a search for
// them finds nothing rather than something arbitrary.
->when($filters['user'], fn (Builder $query, string $user) => $query->where('actor_name', 'like', "%{$user}%"))
// The viewer's own calendar day, not the UTC one — see LocalDay.
->when(
$filters['from'] !== null ? LocalDay::start($filters['from'], $timezone) : null,
fn (Builder $query, Carbon $from) => $query->where('created_at', '>=', $from),
)
->when(
$filters['to'] !== null ? LocalDay::end($filters['to'], $timezone) : null,
fn (Builder $query, Carbon $to) => $query->where('created_at', '<=', $to),
)
->orderByDesc('created_at')
->orderByDesc('id');
}
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Modules\Audit\Http\Resources\Api;
use App\Modules\Audit\ActivityLog;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* One entry in the activity log, as an integration reads it.
*
* Deliberately not ActivityPresenter's shape. That one exists to render a
* sentence, so it hands back a template and the words to slot into it —
* right for a screen, useless to a caller that wants to branch on what
* happened. Here the action is a key, the subject is an object, and the
* specifics stay in `context`.
*
* @mixin ActivityLog
*/
class ActivityResource extends JsonResource
{
/**
* Class names are internal structure and must never reach the wire:
* moving a model between namespaces would otherwise be a breaking API
* change, and `/api/v1` is a frozen contract. These strings are the
* contract instead — add to this map when a new kind of thing becomes
* a subject, and never rename an entry in it.
*
* @var array<class-string, string>
*/
private const SUBJECTS = [
\App\Models\User::class => 'user',
\App\Modules\Files\Models\File::class => 'file',
\App\Modules\Files\Models\Folder::class => 'folder',
\App\Modules\Files\Models\Category::class => 'category',
\App\Modules\Groups\Models\Group::class => 'group',
\App\Modules\Identity\Models\Role::class => 'role',
\App\Modules\Clients\Models\ClientCustomField::class => 'client_custom_field',
];
/**
* The map, for the controller's reverse lookup.
*
* @return array<class-string, string>
*/
public static function subjects(): array
{
return self::SUBJECTS;
}
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'action' => $this->action->value,
'created_at' => $this->created_at->toIso8601String(),
// Snapshots, not joins. The actor may since have been deleted,
// and the entry still has to say who it was.
'actor' => $this->actor_id === null && $this->actor_name === null ? null : [
'id' => $this->actor_id,
'name' => $this->actor_name,
'type' => $this->actor_type,
],
// How it arrived: a person in the browser, an integration, a
// visitor with no account, or the installation itself.
'origin' => $this->origin->value,
'subject' => $this->subject_type === null ? null : [
'type' => self::SUBJECTS[$this->subject_type] ?? 'other',
'id' => $this->subject_id,
'name' => $this->subject_name,
],
// Whatever the action recorded beyond its subject — who a file
// was shared with, how many files a cascade removed. Shape
// varies by action and is documented per action rather than
// here.
'context' => $this->context ?? [],
// ip_address is deliberately absent. It is stored for some
// actions and shown on the activity screen, but handing a
// client's IP to an automation tool is a privacy expansion
// with no matching use — see docs/api-todo.md.
];
}
}
@@ -14,6 +14,7 @@ use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Seats\SeatAllowance;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Support\Facades\Notification;
@@ -36,6 +37,7 @@ class ClientProvisioning
public function __construct(
private readonly Settings $settings,
private readonly ActivityLogger $activity,
private readonly SeatAllowance $seats,
) {}
/**
@@ -70,6 +72,15 @@ class ClientProvisioning
): User {
$autoApprove ??= $this->autoApproves();
// Only when the account arrives already approved. A request that
// still needs a decision is not yet a client this installation has
// taken on, and counting one would let a stranger exhaust a paid
// limit from the registration form — see SeatAllowance. The guard
// for those sits on approval instead.
if ($autoApprove) {
$this->seats->guardClient();
}
$client = User::create([
'type' => UserType::Client,
'active' => $autoApprove,
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Modules\Clients\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Platform\Seats\SeatAllowance;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
@@ -28,6 +29,7 @@ use Inertia\Response;
class AccountRequestsController extends Controller
{
public function __construct(
private readonly SeatAllowance $seats,
private readonly ActivityLogger $activity,
private readonly Settings $settings,
) {}
@@ -67,6 +69,12 @@ class AccountRequestsController extends Controller
{
abort_unless($client->isClient() && $client->account_requested, 404);
// The moment a request becomes a client this installation has taken
// on, which is where the seat is spent — provisioning deliberately
// does not count a pending one, so that a stranger at the
// registration form cannot exhaust a paid limit. See SeatAllowance.
$this->seats->guardClient();
$client->forceFill([
'active' => true,
'account_requested' => false,
@@ -11,6 +11,8 @@ use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientCustomFieldType;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Platform\Seats\SeatAllowance;
use App\Modules\Clients\Http\Resources\Api\ClientResource;
use App\Modules\Clients\Models\ClientCustomField;
use App\Modules\Clients\Models\ClientCustomFieldValue;
@@ -18,6 +20,8 @@ use App\Modules\Clients\Notifications\ClientAccountEditedNotification;
use App\Modules\Clients\Notifications\ClientWelcomeNotification;
use App\Modules\Files\DeletedAccountContent;
use App\Modules\Identity\AccountContentDeletion;
use App\Modules\Identity\Erasure\AvailableEmailRule;
use App\Modules\Identity\Erasure\ErasureSchedule;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
@@ -28,6 +32,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
@@ -54,6 +59,9 @@ class ClientsController extends Controller
private readonly ClientStorageUsage $storageUsage,
private readonly DeletedAccountContent $accountContent,
private readonly AccountContentDeletion $accountDeletion,
private readonly StaffLibraryScope $scope,
private readonly SeatAllowance $seats,
private readonly ErasureSchedule $erasure,
) {}
public function index(Request $request): AnonymousResourceCollection
@@ -63,7 +71,12 @@ class ClientsController extends Controller
'status' => ['nullable', Rule::in(['active', 'inactive'])],
]);
$query = User::query()->where('type', UserType::Client);
// Narrowed the same way the web listing is, and by the same
// rule the object routes below are guarded with.
$viewer = $request->user();
assert($viewer !== null);
$query = $this->scope->clients($viewer);
if (($filters['search'] ?? null) !== null) {
$search = $filters['search'];
@@ -79,18 +92,36 @@ class ClientsController extends Controller
return ClientResource::collection($this->polling->paginate($request, $query, 'users'));
}
public function show(User $client): ClientResource
/**
* Mirrors the web controller's guard, as every API twin here does:
* the token's `edit_clients` says its owner manages clients, not
* that they manage *this* one.
*/
private function guardTarget(Request $request, User $client): void
{
abort_unless($client->isClient(), 404);
$viewer = $request->user();
assert($viewer !== null);
abort_unless($this->scope->canAssignClient($viewer, $client), 404);
}
public function show(Request $request, User $client): ClientResource
{
$this->guardTarget($request, $client);
return $this->resourceFor($client);
}
public function store(Request $request): JsonResponse
{
$this->seats->guardClient();
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users,email'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
// No `confirmed`: repeating a password is a defence against a
// human mistyping into a form, and an API caller has no second
// field to mistype. This installation's password policy still
@@ -133,7 +164,8 @@ class ClientsController extends Controller
public function update(Request $request, User $client): ClientResource
{
abort_unless($client->isClient(), 404);
$this->guardTarget($request, $client);
$validated = $request->validate([
'name' => ['sometimes', 'string', 'max:255'],
@@ -203,9 +235,10 @@ class ClientsController extends Controller
* in the activity log against the caller. Answers 204 whether or not a
* second factor was actually in force.
*/
public function destroyTwoFactor(User $client, TwoFactorAdministration $twoFactor): JsonResponse
public function destroyTwoFactor(Request $request, User $client, TwoFactorAdministration $twoFactor): JsonResponse
{
abort_unless($client->isClient(), 404);
$this->guardTarget($request, $client);
$twoFactor->reset($client);
@@ -230,16 +263,29 @@ class ClientsController extends Controller
*/
public function destroy(Request $request, User $client): JsonResponse
{
abort_unless($client->isClient(), 404);
$this->guardTarget($request, $client);
$validated = $this->accountDeletion->validate($request, $client);
$name = $client->name;
$client->delete();
// Soft-deleting the account and disposing of its files are two
// separate writes; keep them in one transaction so a failure in the
// second (e.g. the reassignment target deleted between validation
// and apply()'s findOrFail) cannot leave the account deleted with
// its content still pointing at it.
//
// The erasure stamp goes inside for the same reason: a deletion
// that rolls back must not leave a live account carrying a date
// on which it would be erased.
DB::transaction(function () use ($validated, $client): void {
$name = $client->name;
$this->erasure->apply($client);
$client->delete();
$this->activity->log(Action::UserDeleted, context: ['name' => $name]);
$this->activity->log(Action::UserDeleted, context: ['name' => $name]);
$this->accountDeletion->apply($validated, $client, $name);
$this->accountDeletion->apply($validated, $client, $name);
});
return response()->json(status: 204);
}
@@ -32,6 +32,7 @@ class ClientSettingsController extends Controller
'clients_can_select_group' => $this->settings->get(Setting::ClientsCanSelectGroup),
'clients_membership_deny_cooldown_days' => $this->settings->get(Setting::ClientsMembershipDenyCooldownDays),
'default_client_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
'clients_can_preview_files' => $this->settings->get(Setting::ClientsCanPreviewFiles),
'groups' => Group::query()->orderBy('name')->get()
->map(fn (Group $group): array => ['id' => $group->id, 'name' => $group->name])
->all(),
@@ -47,6 +48,7 @@ class ClientSettingsController extends Controller
'clients_can_select_group' => ['required', Rule::in(['none', 'public'])],
'clients_membership_deny_cooldown_days' => ['required', 'integer', 'min:0', 'max:365'],
'default_client_storage_quota_mb' => ['required', 'integer', 'min:0'],
'clients_can_preview_files' => ['required', 'boolean'],
]);
$this->settings->set(Setting::ClientsCanRegister, $validated['clients_can_register']);
@@ -55,6 +57,7 @@ class ClientSettingsController extends Controller
$this->settings->set(Setting::ClientsCanSelectGroup, $validated['clients_can_select_group']);
$this->settings->set(Setting::ClientsMembershipDenyCooldownDays, (int) $validated['clients_membership_deny_cooldown_days']);
$this->settings->set(Setting::DefaultClientStorageQuotaMb, (int) $validated['default_client_storage_quota_mb']);
$this->settings->set(Setting::ClientsCanPreviewFiles, $validated['clients_can_preview_files']);
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'clients']);
@@ -10,12 +10,16 @@ use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientCustomFieldType;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Platform\Seats\SeatAllowance;
use App\Modules\Clients\Models\ClientCustomField;
use App\Modules\Clients\Models\ClientCustomFieldValue;
use App\Modules\Clients\Notifications\ClientAccountEditedNotification;
use App\Modules\Clients\Notifications\ClientWelcomeNotification;
use App\Modules\Files\DeletedAccountContent;
use App\Modules\Identity\AccountContentDeletion;
use App\Modules\Identity\Erasure\AvailableEmailRule;
use App\Modules\Identity\Erasure\ErasureSchedule;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
@@ -26,6 +30,7 @@ use App\Support\Pagination;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
@@ -44,6 +49,9 @@ class ClientsController extends Controller
private readonly ClientStorageUsage $storageUsage,
private readonly DeletedAccountContent $accountContent,
private readonly AccountContentDeletion $accountDeletion,
private readonly StaffLibraryScope $scope,
private readonly SeatAllowance $seats,
private readonly ErasureSchedule $erasure,
) {}
public function index(Request $request): Response
@@ -58,8 +66,14 @@ class ClientsController extends Controller
'status' => $validated['status'] ?? null,
];
$clients = User::query()
->where('type', UserType::Client)
// Narrowed by the same rule the buttons on each row are guarded
// with. A client-scoped staff member is not shown the name and
// email of somebody they can reach nothing of — the same thing
// MembershipRequest::approvableBy does for its queue.
$viewer = $request->user();
assert($viewer !== null);
$clients = $this->scope->clients($viewer)
->when($filters['search'], fn (Builder $query, string $search) => $query->where(fn (Builder $q) => $q
->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")))
@@ -98,9 +112,13 @@ class ClientsController extends Controller
public function store(Request $request): RedirectResponse
{
// A client created here is approved by construction, so it counts
// immediately — unlike a self-registration awaiting a decision.
$this->seats->guardClient();
$validated = $request->validate(array_merge([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users,email'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
'password' => ['required', 'confirmed', Password::defaults()],
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
], $this->customFieldRules()));
@@ -130,13 +148,43 @@ class ClientsController extends Controller
$client->notify(new ClientWelcomeNotification);
}
return redirect()->route('clients.edit', $client)->with('success', __('Client created.'));
// A role can hold create_clients without edit_clients, and the edit
// page this used to land on unconditionally answers such a role
// with a 403 — after the client was created, logged and welcomed.
// Fall back to the create form: it shares this route's own gate, so
// it is reachable by exactly whoever just created the record, and
// the success toast shows there.
$target = $request->user()?->can('edit_clients')
? redirect()->route('clients.edit', $client)
: redirect()->route('clients.create');
return $target->with('success', __('Client created.'));
}
public function edit(User $client): Response
/**
* The one question every route binding a client has to ask.
*
* A permission is not a boundary: `edit_clients` says this staff
* member manages clients, not that they manage *this* one — the same
* rule ClientFilesController::index applies one route over. 404
* rather than 403, so a client outside the roster is not
* distinguishable from one that is not there.
*/
private function guardTarget(Request $request, User $client): void
{
abort_unless($client->isClient(), 404);
$viewer = $request->user();
assert($viewer !== null);
abort_unless($this->scope->canAssignClient($viewer, $client), 404);
}
public function edit(Request $request, User $client): Response
{
$this->guardTarget($request, $client);
return Inertia::render('clients/edit', [
'client' => [
'id' => $client->id,
@@ -160,7 +208,8 @@ class ClientsController extends Controller
public function update(Request $request, User $client): RedirectResponse
{
abort_unless($client->isClient(), 404);
$this->guardTarget($request, $client);
$validated = $request->validate(array_merge([
'name' => ['required', 'string', 'max:255'],
@@ -219,9 +268,10 @@ class ClientsController extends Controller
* Remove this account's second factor, for the client who has lost
* their authenticator and their recovery codes.
*/
public function destroyTwoFactor(User $client, TwoFactorAdministration $twoFactor): RedirectResponse
public function destroyTwoFactor(Request $request, User $client, TwoFactorAdministration $twoFactor): RedirectResponse
{
abort_unless($client->isClient(), 404);
$this->guardTarget($request, $client);
$twoFactor->reset($client);
@@ -230,16 +280,29 @@ class ClientsController extends Controller
public function destroy(Request $request, User $client): RedirectResponse
{
abort_unless($client->isClient(), 404);
$this->guardTarget($request, $client);
$validated = $this->accountDeletion->validate($request, $client);
$name = $client->name;
$client->delete();
// Soft-deleting the account and disposing of its files are two
// separate writes; keep them in one transaction so a failure in the
// second (e.g. the reassignment target deleted between validation
// and apply()'s findOrFail) cannot leave the account deleted with
// its content still pointing at it.
//
// The erasure stamp goes inside for the same reason: a deletion
// that rolls back must not leave a live account carrying a date
// on which it would be erased.
DB::transaction(function () use ($validated, $client): void {
$name = $client->name;
$this->erasure->apply($client);
$client->delete();
$this->activity->log(Action::UserDeleted, context: ['name' => $name]);
$this->activity->log(Action::UserDeleted, context: ['name' => $name]);
$this->accountDeletion->apply($validated, $client, $name);
$this->accountDeletion->apply($validated, $client, $name);
});
return redirect()->route('clients.index')->with('success', __('Client deleted.'));
}
@@ -68,6 +68,50 @@ class VisibleCommentScope
);
}
/**
* The thread as somebody reading the public listing sees it: what a
* visitor is shown, plus their own comments if they happen to be
* signed in.
*
* for() above is the authenticated reading, and it assumes what the
* top of this class demands — that the caller established the viewer
* may see the *file*. The public listing establishes only the other
* half of that, namely that the file is reachable without logging in,
* which is the whole of it for a visitor and not nearly enough for an
* account: for() hands any staff member the file's StaffOnly notes and
* any client the messages addressed to all clients on it.
*
* So a signed-in reader is answered as a visitor is, widened by their
* own writing — which is what "being logged in should not show you
* less than a stranger sees, and their own comments should be theirs
* to edit" asks for and all it asks for. A reader the file's own gate
* would admit is not narrowed at all; the caller sends them through
* for() instead.
*
* The held-comment rule is not restated here — hideUnapproved() is the
* same one for()'s readings get, so a comment waiting for a moderator
* stays exactly as visible, or invisible, as it was.
*
* @return Builder<FileComment>
*/
public function forPublicReader(?User $viewer, File $file): Builder
{
$query = FileComment::query()->where('file_id', $file->id);
if ($viewer === null) {
return $this->applyVisibility($query, null, $file->isEffectivelyPublic());
}
$this->hideUnapproved($query, $viewer);
return $query->where(fn (Builder $outer) => $outer
->where('author_id', $viewer->id)
->when(
$file->isEffectivelyPublic(),
fn (Builder $stranger) => $stranger->orWhere('visibility', CommentVisibility::Everyone),
));
}
/**
* Every comment this staff member may read, across their whole library
* — the management screen's query, rather than one file's thread.
@@ -171,25 +215,40 @@ class VisibleCommentScope
return $counts;
}
/**
* A comment awaiting moderation exists only for those who can act on
* it — and for whoever wrote it, who would otherwise watch their own
* comment vanish on posting and conclude it had failed. A visitor is
* recognised by their session (see GuestCommentIdentity); that is weak
* on purpose, and only ever widens what somebody sees of their own
* writing.
*
* Its own method because forPublicReader() answers a different
* audience question and the same held-comment one, and a rule this
* sharp stated twice is a rule that drifts.
*
* @param Builder<FileComment> $query
*/
private function hideUnapproved(Builder $query, ?User $viewer): void
{
if ($viewer !== null && $viewer->can('moderate_comments')) {
return;
}
$ownPending = $viewer === null ? $this->guests->ownCommentIds() : [];
$query->where(fn (Builder $visible) => $visible
->whereNotNull('approved_at')
->when($ownPending !== [], fn (Builder $mine) => $mine->orWhereIn('id', $ownPending)));
}
/**
* @param Builder<FileComment> $query
* @return Builder<FileComment>
*/
private function applyVisibility(Builder $query, ?User $viewer, bool $isPublic): Builder
{
// A comment awaiting moderation exists only for those who can act
// on it — and for whoever wrote it, who would otherwise watch their
// own comment vanish on posting and conclude it had failed. A
// visitor is recognised by their session (see GuestCommentIdentity);
// that is weak on purpose, and only ever widens what somebody sees
// of their own writing.
if ($viewer === null || ! $viewer->can('moderate_comments')) {
$ownPending = $viewer === null ? $this->guests->ownCommentIds() : [];
$query->where(fn (Builder $visible) => $visible
->whereNotNull('approved_at')
->when($ownPending !== [], fn (Builder $mine) => $mine->orWhereIn('id', $ownPending)));
}
$this->hideUnapproved($query, $viewer);
if ($viewer === null) {
// Publicness is re-derived here on every read rather than
+11 -6
View File
@@ -9,12 +9,17 @@ use App\Modules\Identity\UserType;
/**
* Who may write a comment (Setting::CommentsAuthors).
*
* This is a setting rather than a permission on purpose. Roles are only
* editable in the community edition — the cloud edition gates the whole
* roles screen behind Capability::UsersManage — so a permission key would
* be unconfigurable for half our installs. It also expresses something a
* permission structurally cannot: `Everyone` includes anonymous visitors,
* who have no account and therefore no role to hold a key.
* This is a setting rather than a permission on purpose, and one of the
* two reasons has since expired. It used to be that roles were editable
* only in the community edition — the cloud edition gated the whole roles
* screen behind Capability::UsersManage — so a permission key would have
* been unconfigurable for half our installs. That stopped being true in
* 2.2.0, when users.manage opened on both editions.
*
* The reason that carries it now is the one a permission structurally
* cannot express: `Everyone` includes anonymous visitors, who have no
* account and therefore no role to hold a key. That was always the
* stronger half; it is now the whole of it.
*/
enum CommentAuthors: string
{
+12 -2
View File
@@ -31,13 +31,23 @@ class CommentPresenter
) {}
/**
* The whole payload for one file's thread.
*
* $viewerMaySeeFile is the precondition VisibleCommentScope states at
* the top of its class: the caller must already have established that
* this viewer may see the file. A caller that has not says so, and the
* thread is narrowed to the public reading instead of the
* authenticated one.
*
* @return array{comments: list<array<string, mixed>>, can_comment: bool, cannot_comment_reason: string|null, is_guest: bool, guest_moderated: bool, captcha_required: bool, visibilities: list<array<string, mixed>>, default_visibility: string|null, edit_window_minutes: int}
*/
public function thread(?User $viewer, File $file): array
public function thread(?User $viewer, File $file, bool $viewerMaySeeFile = true): array
{
$forStaff = $viewer?->isStaff() === true;
$comments = $this->scope->for($viewer, $file)
$comments = ($viewerMaySeeFile
? $this->scope->for($viewer, $file)
: $this->scope->forPublicReader($viewer, $file))
->with(['author', 'clientContext'])
->orderBy('created_at')
->orderBy('id')
+27 -3
View File
@@ -7,6 +7,7 @@ namespace App\Modules\Comments;
use App\Models\User;
use App\Modules\Comments\Access\VisibleCommentScope;
use App\Modules\Comments\Models\FileComment;
use App\Modules\Files\Access\StaffLibraryScope;
use Illuminate\Support\Facades\Gate;
/**
@@ -20,6 +21,7 @@ class FileCommentPolicy
public function __construct(
private readonly VisibleCommentScope $scope,
private readonly CommentingRules $rules,
private readonly StaffLibraryScope $library,
) {}
public function view(User $user, FileComment $comment): bool
@@ -44,16 +46,38 @@ class FileCommentPolicy
public function delete(User $user, FileComment $comment): bool
{
if ($this->moderate($user)) {
if ($this->moderate($user, $comment)) {
return true;
}
return $comment->author_id === $user->id && $this->withinEditWindow($comment);
}
public function moderate(User $user): bool
/**
* Called both ways: with a comment, to decide about that one, and
* against the class, to ask whether this user moderates at all (the
* queue's own gate, and the affordances that offer it).
*
* The library boundary belongs here rather than in each caller. Named
* against the class it cannot be applied — there is no file to weigh —
* so that form answers the coarser question and every caller holding a
* comment should pass it.
*/
public function moderate(User $user, ?FileComment $comment = null): bool
{
return $user->isStaff() && $user->can('moderate_comments');
if (! $user->isStaff() || ! $user->can('moderate_comments')) {
return false;
}
if ($comment === null || ! $user->isClientScoped()) {
return true;
}
// By file id rather than through the relation: a file soft-deleted
// out from under its comments resolves to null there, and the
// answer for a scoped moderator is the same either way — it is not
// in their library. Unscoped staff never reach this line.
return $this->library->files($user)->whereKey($comment->file_id)->exists();
}
private function withinEditWindow(FileComment $comment): bool
@@ -70,9 +70,9 @@ class CommentModerationController extends Controller
{
$viewer = $request->user();
assert($viewer !== null);
Gate::forUser($viewer)->authorize('moderate', FileComment::class);
// Moderation rights are not a way around the library boundary.
abort_unless($this->library->allowsFile($viewer, $comment->file), 403);
// Moderation rights are not a way around the library boundary; the
// policy weighs the comment's file, so name the comment.
Gate::authorize('moderate', $comment);
$this->comments->approve($comment, $viewer);
@@ -11,7 +11,6 @@ use App\Modules\Comments\CommentPresenter;
use App\Modules\Comments\CommentVisibility;
use App\Modules\Comments\FileComments;
use App\Modules\Comments\Models\FileComment;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Platform\Localization\LocalDay;
use App\Modules\Platform\Localization\TimezoneRegistry;
use Carbon\Carbon;
@@ -44,7 +43,6 @@ class CommentsController extends Controller
public function __construct(
private readonly FileComments $comments,
private readonly StaffLibraryScope $library,
private readonly CommentPresenter $presenter,
private readonly VisibleCommentScope $scope,
private readonly TimezoneRegistry $timezones,
@@ -95,9 +93,9 @@ class CommentsController extends Controller
{
$viewer = $request->user();
assert($viewer !== null);
Gate::forUser($viewer)->authorize('moderate', FileComment::class);
// Moderation rights are not a way around the library boundary.
abort_unless($this->library->allowsFile($viewer, $comment->file), 403);
// Moderation rights are not a way around the library boundary; the
// policy weighs the comment's file, so name the comment.
Gate::forUser($viewer)->authorize('moderate', $comment);
$this->comments->approve($comment, $viewer);
@@ -113,7 +111,6 @@ class CommentsController extends Controller
$viewer = $request->user();
assert($viewer !== null);
Gate::forUser($viewer)->authorize('delete', $comment);
abort_unless($this->library->allowsFile($viewer, $comment->file), 403);
$this->comments->remove($comment);
@@ -77,7 +77,7 @@ class FileCommentsController extends Controller
$this->comments->edit($comment, $validated['body']);
return response()->json($this->payload($viewer, $comment->file));
return response()->json($this->payloadAfterChange($viewer, $comment->file));
}
public function destroy(Request $request, FileComment $comment): JsonResponse
@@ -90,10 +90,13 @@ class FileCommentsController extends Controller
$this->comments->remove($comment);
return response()->json($this->payload($viewer, $file));
return response()->json($this->payloadAfterChange($viewer, $file));
}
/**
* The thread for the two routes that bind a file, after their own
* `view` authorization has passed.
*
* @return array<string, mixed>
*/
private function payload(User $viewer, File $file): array
@@ -101,6 +104,28 @@ class FileCommentsController extends Controller
return $this->presenter->thread($viewer, $file);
}
/**
* The thread that goes back with a change to one comment.
*
* update() and destroy() bind a comment rather than a file, so nothing
* in the request has established that this viewer may read the file's
* conversation — only that this one comment is theirs to change.
* Somebody who commented through the public listing is exactly that
* person, and refusing them on their own edit would be wrong, so the
* reading they get back is the one the file's own gate allows them:
* the public page's, if that is how they arrived.
*
* @return array<string, mixed>
*/
private function payloadAfterChange(User $viewer, File $file): array
{
return $this->presenter->thread(
$viewer,
$file,
viewerMaySeeFile: Gate::forUser($viewer)->allows('view', $file),
);
}
/**
* The comment being answered, resolved through the same scope that
* decided what this viewer may read. A reply can therefore only ever
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Modules\Comments\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Comments\CommentingRules;
use App\Modules\Comments\CommentPresenter;
use App\Modules\Comments\CommentVisibility;
@@ -17,6 +18,7 @@ use App\Modules\Platform\Settings\Settings;
use App\Support\Rules;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
/**
* Comments on a publicly-listed file, for visitors who are not logged in.
@@ -46,7 +48,7 @@ class PublicFileCommentsController extends Controller
{
$this->guard($publicSlug, $file);
return response()->json($this->presenter->thread($request->user(), $file));
return response()->json($this->thread($request->user(), $file));
}
public function store(Request $request, string $publicSlug, File $file): JsonResponse
@@ -86,7 +88,31 @@ class PublicFileCommentsController extends Controller
$this->guests->remember($comment->id);
}
return response()->json($this->presenter->thread($viewer, $file), 201);
return response()->json($this->thread($viewer, $file), 201);
}
/**
* The thread as this endpoint may serve it.
*
* guard() establishes the guest half of VisibleCommentScope's
* precondition — the file is reachable without logging in — and that
* is the whole of it for a visitor. It says nothing about an account,
* and handing a signed-in viewer to the authenticated reading anyway
* is what let any staff account read a public file's StaffOnly notes
* and any client account read the messages addressed to that file's
* clients. The file's own gate decides which reading applies; the one
* it does not admit still reads what a visitor reads plus their own
* comments, which is what this endpoint has always promised them.
*
* @return array<string, mixed>
*/
private function thread(?User $viewer, File $file): array
{
return $this->presenter->thread(
$viewer,
$file,
viewerMaySeeFile: $viewer !== null && Gate::forUser($viewer)->allows('view', $file),
);
}
/**
@@ -6,8 +6,11 @@ namespace App\Modules\Files\Access;
use App\Models\User;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\FileAssignment;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Models\FolderAssignment;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\UserType;
use Illuminate\Database\Eloquent\Builder;
/**
@@ -26,10 +29,37 @@ use Illuminate\Database\Eloquent\Builder;
*/
class StaffLibraryScope
{
/**
* Built queries, by user id. Building one is not free: it walks the
* assigned clients and File::scopeVisibleToClient runs four immediate
* lookups for each of them, none of which depend on the query being
* built. Callers ask over and over — the policies ask once per row on
* a listing, and Gate resolves a fresh policy for every check — so the
* same handful of lookups were being repeated per row.
*
* A clone goes back rather than the query itself, since every caller
* adds to it. Registered with the container as `scoped`, so the memo
* lasts a request and is dropped between queue jobs.
*
* @var array<int, Builder<File>>
*/
private array $files = [];
/** @var array<int, Builder<Folder>> */
private array $folders = [];
/**
* @return Builder<File>
*/
public function files(User $user): Builder
{
return clone ($this->files[$user->id] ??= $this->buildFiles($user));
}
/**
* @return Builder<File>
*/
private function buildFiles(User $user): Builder
{
$query = File::query();
@@ -53,6 +83,14 @@ class StaffLibraryScope
* @return Builder<Folder>
*/
public function folders(User $user): Builder
{
return clone ($this->folders[$user->id] ??= $this->buildFolders($user));
}
/**
* @return Builder<Folder>
*/
private function buildFolders(User $user): Builder
{
$query = Folder::query();
@@ -139,10 +177,124 @@ class StaffLibraryScope
return $ids === null || in_array($client->id, $ids, true);
}
/**
* Every client this staff member may act on, as a query.
*
* The listing half of canAssignClient(), so a screen narrows by the
* same rule its buttons are guarded with rather than restating it —
* which is how ClientsController came to list every client on the
* installation, name and email, to a viewer who could reach nothing
* of theirs. An unscoped user gets the whole roster, unchanged.
*
* @return Builder<User>
*/
public function clients(User $user): Builder
{
$query = User::query()->where('type', UserType::Client);
$ids = $this->assignableClientIds($user);
return $ids === null ? $query : $query->whereIn('id', $ids);
}
public function canAssignGroup(User $user, Group $group): bool
{
$ids = $this->assignableGroupIds($user);
return $ids === null || in_array($group->id, $ids, true);
}
/**
* Whether a staff member may put a client into a group, or take one
* out again.
*
* Not canAssignGroup(): that answers "may I share with this group",
* and it answers it *from* the membership — a group counts as the
* user's because one of their clients is in it. Deciding membership
* with a predicate derived from membership means whoever may edit
* the list also decides what the list entitles them to, which is not
* a boundary at all. It is also the wrong answer here in the other
* direction: a group nobody has joined yet belongs to nobody, so a
* scoped staff member could never put the first member into a group
* they had just created.
*
* The question membership actually asks is about reach. Joining a
* group hands the new member everything shared with it, and — when
* that member is one of the actor's own clients — hands the actor
* the same content back through File::scopeVisibleToClient, which is
* what StaffLibraryScope::files() is built on. So both sides have to
* hold: the client must be one this staff member holds, and the
* group must not already reach beyond their library. A group with
* nothing shared with it passes trivially, which is what keeps a
* newly created one usable.
*
* Unscoped staff are unaffected — both halves are true for them by
* construction.
*/
public function allowsGroupMembership(User $user, Group $group, User $client): bool
{
return $this->canAssignClient($user, $client) && $this->groupReachesNoFurther($user, $group);
}
/**
* Whether this staff member may change a group itself — rename it,
* make it public, delete it.
*
* The reach half of allowsGroupMembership, on its own because there
* is no client in the question. Deleting a group is the destructive
* end of it: an assignment to a group is how its members reach a
* file, so removing the group takes that access away from every one
* of them. A staff member who may not add somebody to a group out of
* their reach should not be able to delete it out from under the
* people already in it.
*/
public function allowsGroupChange(User $user, Group $group): bool
{
return $this->groupReachesNoFurther($user, $group);
}
/**
* Whether everything shared with this group is already inside the
* user's library — files assigned to it, and the folders whose
* subtrees it can browse.
*
* Asked as "is anything shared with this group outside my library",
* rather than by counting assignment rows against library rows. An
* assignment outlives the thing it points at: nothing clears these
* rows when a file or folder is deleted, and a deleted one can never
* appear in files()/folders(), which exclude trashed rows. Counting
* therefore never balanced again, and the group became permanently
* unmanageable for a scoped staff member — including for their own
* clients, and including removing somebody. Starting from the live
* row rather than from the assignment ignores the dead ones by
* construction, which is also the right answer: a deleted file is
* not reach, because nobody can reach it.
*/
private function groupReachesNoFurther(User $user, Group $group): bool
{
if (! $user->isClientScoped()) {
return true;
}
$morph = $group->getMorphClass();
$assignedFiles = FileAssignment::query()->select('file_id')
->where('assignable_type', $morph)->where('assignable_id', $group->id);
$outside = File::query()
->whereIn('id', $assignedFiles)
->whereNotIn('id', $this->files($user)->select('id'))
->exists();
if ($outside) {
return false;
}
$assignedFolders = FolderAssignment::query()->select('folder_id')
->where('assignable_type', $morph)->where('assignable_id', $group->id);
return ! Folder::query()
->whereIn('id', $assignedFolders)
->whereNotIn('id', $this->folders($user)->select('id'))
->exists();
}
}
@@ -7,6 +7,7 @@ namespace App\Modules\Files\Console;
use App\Modules\Files\Models\ZipDownload;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use League\Flysystem\UnableToRetrieveMetadata;
class PurgeZipDownloadsCommand extends Command
{
@@ -18,16 +19,80 @@ class PurgeZipDownloadsCommand extends Command
{
$stale = ZipDownload::query()->where('created_at', '<', now()->subDay())->get();
// Listed once up front: the loop only deletes, so nothing it does
// changes what a later row would match.
$builtZips = collect(Storage::disk('files')->files('zips'));
foreach ($stale as $zipDownload) {
// Every artifact tied to this row's id, not just the recorded
// path: a build killed before it finished (worker timeout, disk
// full) leaves a partial archive — and libzip's temp file
// alongside it — with no path ever written back to the row.
$artifacts = $builtZips
->filter(fn (string $path): bool => str_starts_with(basename($path), $zipDownload->id.'.zip'))
->all();
if ($zipDownload->path !== null) {
Storage::disk('files')->delete($zipDownload->path);
$artifacts[] = $zipDownload->path;
}
Storage::disk('files')->delete(array_values(array_unique($artifacts)));
$zipDownload->delete();
}
$this->info("Purged {$stale->count()} stale zip download(s).");
$swept = $this->sweepUnreferenced();
$this->info("Purged {$stale->count()} stale zip download(s) and {$swept} unreferenced file(s).");
return self::SUCCESS;
}
/**
* Rows are what the loop above cleans by, so a file whose row is gone
* is invisible to it — and a row can vanish without its files:
* zip_downloads.requested_by cascades on delete, so removing a user
* takes their rows with it and leaves every archive they built behind.
* Anything already stranded that way before this command learned to
* look is in the same position.
*
* OrphanFileScanner skips zips/ on purpose — this command owns that
* directory, so closing the gap belongs here.
*/
private function sweepUnreferenced(): int
{
$disk = Storage::disk('files');
$cutoff = now()->subDay()->getTimestamp();
$live = array_flip(ZipDownload::query()->pluck('id')->all());
$unreferenced = [];
foreach ($disk->files('zips') as $path) {
// Both an archive (12.zip) and libzip's temp beside it
// (12.zip.aB3xY9) lead with the row id they belong to.
$id = explode('.', basename($path))[0];
if (ctype_digit($id) && isset($live[(int) $id])) {
continue;
}
try {
// A day's grace before deleting something no row explains.
// Nothing here should outlive its row by design, so the
// wait costs nothing — and it means a file another process
// has only just put there is never taken out from under it.
if ($disk->lastModified($path) >= $cutoff) {
continue;
}
} catch (UnableToRetrieveMetadata) {
// Gone between listing the directory and asking about it.
continue;
}
$unreferenced[] = $path;
}
$disk->delete($unreferenced);
return count($unreferenced);
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Delivery;
use App\Modules\Files\Models\File;
use App\Support\ContentDisposition;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
/**
* A stored file's own bytes, put on the wire for whichever disk it lives
* on.
*
* Every route that hands over a file reaches this after authorizing in
* its own way — a policy, a share token, a public-listing check. It
* authorizes nothing itself, and deliberately knows nothing about who is
* asking. The one thing it knows is the thing each caller kept getting
* wrong on its own: that `$file->disk` decides how the bytes travel.
*
* Local disk: X-Accel-Redirect, so nginx streams the file and PHP never
* touches the bytes. Anything else — S3, GCS and friends — gets a
* short-lived presigned URL carrying the disposition, which an object
* store ranges just as well.
*
* That distinction matters most for inline(): a <video> seeking through
* an hour of footage issues a long tail of Range requests, and nginx's
* static handler answers those with 206s on its own, dropping the
* Content-Length below in favour of the range it actually served.
*
* Callers of inline() must have established that the mime type is
* inline-safe first; PreviewKind is the allowlist, and the reason there
* is one.
*/
class StoredFileResponse
{
/** Shown in place — a preview. */
public function inline(File $file): Response|RedirectResponse
{
return $this->make($file, ContentDisposition::inline($file->original_name));
}
/** Handed over — a download. */
public function attachment(File $file): Response|RedirectResponse
{
return $this->make($file, ContentDisposition::attachment($file->original_name));
}
private function make(File $file, string $disposition): Response|RedirectResponse
{
if ($file->disk !== 'files') {
$url = Storage::disk($file->disk)->temporaryUrl(
$file->path,
now()->addHour(),
['ResponseContentDisposition' => $disposition],
);
return redirect()->away($url);
}
return response('', 200, [
'X-Accel-Redirect' => '/protected-files/'.$file->path,
'Content-Type' => $file->mime_type,
'Content-Disposition' => $disposition,
'Content-Length' => (string) $file->size,
]);
}
}
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Modules\Files;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Notifications\FileShareDigestNotification;
@@ -20,6 +21,17 @@ use Illuminate\Support\ServiceProvider;
class FilesServiceProvider extends ServiceProvider
{
public function register(): void
{
// Scoped rather than transient: the library query it builds costs
// several lookups per assigned client, and the policies ask for it
// once per row on a listing — Gate resolves a fresh policy for
// every check, so without this the instance memo would never be
// reached twice. Scoped rather than a singleton so a long-lived
// queue worker starts each job with an empty memo.
$this->app->scoped(StaffLibraryScope::class);
}
public function boot(): void
{
Gate::policy(File::class, FilePolicy::class);
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Folders;
use App\Modules\Files\Models\Folder;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* An id naming a folder that is actually there.
*
* A rule object rather than `Rule::exists(...)->whereNull('deleted_at')`
* for one reason: the message. The generic form says "The selected folder
* id is invalid", which tells somebody nothing when the real answer is
* that the folder they picked has since been deleted — and that is the
* usual way to meet this rule, since a live id they chose from a list is
* how they got here. It matters most on the chunked upload path, which is
* the one place a request that used to succeed now fails.
*
* Carrying the message on the rule keeps the single definition
* Rules::folderId() exists for: a `messages()` array would have to be
* repeated at every call site, which is how the plain `exists:folders,id`
* it replaced came to mean two different things in ten places.
*/
class FolderExistsRule implements ValidationRule
{
/**
* @param Closure(string, string|null=): \Illuminate\Translation\PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if ($value === null || $value === '') {
return;
}
if (! is_numeric($value)) {
// Reached only when a caller drops `integer`; the message
// still has to make sense to whoever sees it.
$fail(__('That folder could not be found.'));
return;
}
// Folder::query() honours the soft delete, which is the whole
// point — the table-level `exists` rule this replaces does not.
if (Folder::query()->whereKey((int) $value)->exists()) {
return;
}
$fail(__('That folder no longer exists. Pick another one and try again.'));
}
}
@@ -12,6 +12,7 @@ use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Comments\CommentingRules;
use App\Modules\Comments\CommentScope;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Access\ViewableFileScope;
use App\Modules\Files\DownloadLimitScope;
use App\Modules\Files\Http\Resources\Api\FileResource;
@@ -57,6 +58,7 @@ class FilesController extends Controller
private readonly ClientStorageUsage $storageUsage,
private readonly ActivityLogger $activity,
private readonly CommentingRules $commenting,
private readonly StaffLibraryScope $scope,
) {}
/**
@@ -174,7 +176,7 @@ class FilesController extends Controller
'file' => ['required', 'file'],
'name' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'folder_id' => ['nullable', 'integer', 'exists:folders,id'],
'folder_id' => Rules::folderId(),
]);
/** @var UploadedFile $upload */
@@ -275,7 +277,7 @@ class FilesController extends Controller
$validated = $request->validate([
'name' => ['sometimes', 'string', 'max:255'],
'description' => ['sometimes', 'nullable', 'string', 'max:2000'],
'folder_id' => ['sometimes', 'nullable', 'integer', 'exists:folders,id'],
'folder_id' => ['sometimes', ...Rules::folderId()],
'public' => ['sometimes', 'boolean'],
'commentable' => ['sometimes', 'boolean'],
'slug' => Rules::slug('files', $file->id),
@@ -286,6 +288,21 @@ class FilesController extends Controller
'download_limit_scope' => ['sometimes', Rule::enum(DownloadLimitScope::class)],
]);
// Reparenting through update() must respect the same library scope as
// the web move()/bulkUpdate() paths: the destination folder must be
// one this user can see. Only enforced when folder_id actually
// changes, so re-saving a file that already sits in an out-of-scope
// folder (reachable via a direct client share) still works. The
// integer rule admits numeric strings, so cast before the strict
// change comparison.
if (array_key_exists('folder_id', $validated) && $validated['folder_id'] !== null) {
$validated['folder_id'] = (int) $validated['folder_id'];
if ($validated['folder_id'] !== $file->folder_id) {
$this->scope->folders($user)->findOrFail($validated['folder_id']);
}
}
$attributes = array_intersect_key($validated, array_flip(['name', 'description', 'folder_id']));
if (array_key_exists('expires_at', $validated) && $user->can('set_file_expiration_date')) {
@@ -81,7 +81,14 @@ class CategoriesController extends Controller
$this->activity->log(Action::CategoryCreated, subject: $category);
return redirect()->route('categories.edit', $category)->with('success', __('Category created.'));
// Same create-without-edit rule as ClientsController::store() — and
// the most reachable case of it: the sidebar shows Categories from
// create_categories alone, with no manage tier in between.
$target = $request->user()?->can('edit_categories')
? redirect()->route('categories.edit', $category)
: redirect()->route('categories.create');
return $target->with('success', __('Category created.'));
}
public function edit(Category $category): Response
@@ -24,11 +24,13 @@ use App\Modules\Identity\UserType;
use App\Modules\Notifications\Notifier;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Support\Rules;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
@@ -71,7 +73,7 @@ class ChunkedUploadsController extends Controller
'size' => ['required', 'integer', 'min:1'],
'type' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'folder_id' => ['nullable', 'integer', 'exists:folders,id'],
'folder_id' => Rules::folderId(),
'previous_file_id' => ['nullable', 'integer'],
]);
@@ -240,6 +242,33 @@ class ChunkedUploadsController extends Controller
$user = $request->user();
assert($user !== null);
// Serialise completion per session: two concurrent completes (an Uppy
// retry, a double submit, a lost-connection resend) would otherwise
// both assemble into the one target file and create two File rows.
// The lock's TTL releases the claim if a completion dies mid-flight,
// so a later retry still works.
$lock = Cache::lock('upload-complete:'.$session->id, 120);
if (! $lock->get()) {
throw ValidationException::withMessages([
'parts' => __('This upload is already being finalised.'),
]);
}
try {
return $this->finalise($session, $user);
} finally {
$lock->release();
}
}
/**
* Assemble a session's received parts into a stored File. Runs under
* complete()'s per-session lock, so it is the single writer to the
* session's target path and the only creator of its File row.
*/
private function finalise(UploadSession $session, User $user): JsonResponse
{
$extension = strtolower(pathinfo($session->original_name, PATHINFO_EXTENSION));
$targetPath = now()->format('Y/m').'/'.Str::uuid()->toString().($extension !== '' ? '.'.$extension : '');
@@ -249,6 +278,22 @@ class ChunkedUploadsController extends Controller
throw ValidationException::withMessages(['parts' => $exception->getMessage()]);
}
// store()'s size check ran against the client-declared, unverified
// size, so a small declared size would otherwise let an upload of any
// size through here. Re-check the real assembled byte count against
// the same limit store() applies to everyone. No File row exists yet
// at this point, so cleanup only needs to undo what assemble() wrote.
$maxMb = (int) $this->settings->get(Setting::MaxFileSizeMb);
if ($maxMb > 0 && $assembled['size'] > $maxMb * 1024 * 1024) {
Storage::disk($assembled['disk'])->delete($assembled['path']);
$session->delete();
throw ValidationException::withMessages([
'size' => __('This file exceeds the maximum allowed size of :max MB.', ['max' => (string) $maxMb]),
]);
}
// store()'s quota check used a client-declared, unverified size —
// re-check against the real assembled byte count before this
// becomes a File row. No File row exists yet at this point, so
@@ -272,6 +317,23 @@ class ChunkedUploadsController extends Controller
// the previewer's browser. Detect the real mime type from the assembled bytes.
$mimeType = Storage::disk($assembled['disk'])->mimeType($assembled['path']) ?: 'application/octet-stream';
// Re-resolved rather than taken from the session. A chunked
// upload is two requests, and store()'s rule only ever sees the
// first: delete the folder while the bytes are in flight and the
// recorded id names a folder whose own deletion already removed
// every file in it. Filing into it would recreate exactly the
// state Rules::folderId() exists to prevent.
//
// The root, rather than a refusal, because the two moments cost
// different things. At store() nothing has been sent, so refusing
// is free and honest. Here the bytes are already uploaded, and
// throwing away somebody's finished transfer over a folder that
// vanished underneath them is the harsher of the two surprises —
// the file lands somewhere they can see it and move it.
$folderId = $session->folder_id !== null && Folder::query()->whereKey($session->folder_id)->exists()
? $session->folder_id
: null;
$file = $this->storeFile->create(
uploader: $user,
originalName: $session->original_name,
@@ -280,7 +342,7 @@ class ChunkedUploadsController extends Controller
size: $assembled['size'],
checksum: $assembled['checksum'],
description: $session->description,
folderId: $session->folder_id,
folderId: $folderId,
disk: $assembled['disk'],
);
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
/**
* Limits that apply when files leave the installation rather than when
* they arrive. Only the zip cap for now — consumed by
* ZipDownloadsController and BuildZipDownloadJob.
*/
class DownloadSettingsController extends Controller
{
public function __construct(
private readonly Settings $settings,
private readonly ActivityLogger $activity,
) {}
public function edit(): Response
{
return Inertia::render('system/settings/downloads', [
'max_zip_download_size_mb' => $this->settings->get(Setting::MaxZipDownloadSizeMb),
]);
}
public function update(Request $request): RedirectResponse
{
$validated = $request->validate([
// Same ceiling as the upload size field: a megabyte figure
// large enough to be meaningless is the same as unlimited,
// which 0 already says more clearly.
'max_zip_download_size_mb' => ['required', 'integer', 'min:0', 'max:1048576'],
]);
$this->settings->set(Setting::MaxZipDownloadSizeMb, (int) $validated['max_zip_download_size_mb']);
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'downloads']);
return back();
}
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Modules\Files\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Audit\ActivityPresenter;
@@ -18,10 +19,15 @@ use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Models\ShareLink;
use App\Modules\Files\Versions\FileVersionLinks;
use App\Modules\Platform\Localization\LocalDay;
use App\Modules\Platform\Localization\TimezoneRegistry;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
@@ -34,6 +40,41 @@ class FileDetailsController extends Controller
/** Raw rows considered when grouping downloads() by actor — see that method's docblock. */
private const DOWNLOADS_SUMMARY_LIMIT = 500;
/**
* How a file leaves: three actions, because *how* it left matters —
* a signed-in recipient, somebody following a public link, and a
* visitor to a public group listing are all recorded separately.
*
* @var non-empty-list<Action>
*/
private const DOWNLOAD_ACTIONS = [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded];
/**
* Looking at a file without taking it. One action today; if a second
* way to preview is ever recorded separately, add it here and give
* previews an ACTION_GROUPS entry the way downloads has one.
*
* @var non-empty-list<Action>
*/
private const PREVIEW_ACTIONS = [Action::FilePreviewed];
/**
* Filters that stand for a question rather than for one logged action.
*
* Nobody reading a file's history wants to ask "who downloaded this?"
* three times, so this offers it once — and only when the file's own
* log holds more than one of the members, since otherwise it would
* filter to exactly what its single member already offers.
*
* @var array<string, array{label: string, actions: non-empty-list<Action>}>
*/
private const ACTION_GROUPS = [
'downloads' => [
'label' => 'All downloads',
'actions' => self::DOWNLOAD_ACTIONS,
],
];
public function __construct(
private readonly ActivityPresenter $presenter,
private readonly DownloadPresenter $downloadPresenter,
@@ -41,6 +82,7 @@ class FileDetailsController extends Controller
private readonly CommentingRules $commenting,
private readonly FileVersionLinks $versionLinks,
private readonly DownloadAllowance $allowance,
private readonly TimezoneRegistry $timezones,
) {}
public function show(Request $request, File $file): JsonResponse
@@ -136,6 +178,65 @@ class FileDetailsController extends Controller
return response()->json(['entries' => $entries, 'total' => $total]);
}
/**
* Who has actually had this file: its downloads and previews, newest
* first, with a count of each.
*
* A narrower question than activity() and a much more frequent one —
* "did they ever actually get it?" — which the full log answers only
* by being read past everything else that has happened to the file.
*/
public function access(Request $request, File $file): JsonResponse
{
$viewer = $request->user();
assert($viewer !== null);
Gate::forUser($viewer)->authorize('view', $file);
abort_unless($viewer->can('view_actions_log'), 403);
$base = fn (): Builder => ActivityLog::query()
->where('subject_type', $file->getMorphClass())
->where('subject_id', $file->id);
$entries = $base()
->whereIn('action', [...self::DOWNLOAD_ACTIONS, ...self::PREVIEW_ACTIONS])
->orderByDesc('created_at')->orderByDesc('id')
->limit(20)->get()
->map(fn (ActivityLog $entry): array => [
// The sentence the presenter builds already says which of
// the two this was ("Downloaded the file …"), so nothing
// here has to label the row a second time.
...$this->presenter->present($entry),
// Subject to the privacy setting that decides whether an
// address is recorded at all, so it is often null.
'ip_address' => $entry->ip_address,
]);
return response()->json([
'entries' => $entries,
'downloads_total' => $base()->whereIn('action', self::DOWNLOAD_ACTIONS)->count(),
'previews_total' => $base()->whereIn('action', self::PREVIEW_ACTIONS)->count(),
// Built here rather than in the page: which filter value stands
// for "every download" is a fact about the log's vocabulary,
// and a group key only exists while the group does.
'downloads_url' => $this->historyUrl($file, 'downloads', self::DOWNLOAD_ACTIONS),
'previews_url' => $this->historyUrl($file, 'previews', self::PREVIEW_ACTIONS),
]);
}
/**
* The file's history, pre-filtered to one question: by the group when
* one covers these actions, and by the action itself when the group
* would have a single member and therefore does not exist.
*
* @param non-empty-list<Action> $actions
*/
private function historyUrl(File $file, string $groupKey, array $actions): string
{
$filter = isset(self::ACTION_GROUPS[$groupKey]) ? $groupKey : $actions[0]->value;
return route('files.activity.history', $file, false).'?action='.$filter;
}
/**
* Full, paginated activity history for a file — the "View full
* history" destination linked from the details panel's Activity tab,
@@ -148,7 +249,15 @@ class FileDetailsController extends Controller
Gate::forUser($viewer)->authorize('view', $file);
abort_unless($viewer->can('view_actions_log'), 403);
return $this->renderHistory($file->getMorphClass(), $file->id, $file->name, route('files.edit', $file, false));
return $this->renderHistory(
$request,
$file->getMorphClass(),
$file->id,
$file->name,
route('files.edit', $file, false).'?tab=activity',
'files.activity.history',
['file' => $file->id],
);
}
/**
@@ -173,7 +282,7 @@ class FileDetailsController extends Controller
$query = ActivityLog::query()
->where('subject_type', $file->getMorphClass())
->where('subject_id', $file->id)
->whereIn('action', [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded]);
->whereIn('action', self::DOWNLOAD_ACTIONS);
$total = (clone $query)->count();
@@ -304,15 +413,35 @@ class FileDetailsController extends Controller
Gate::forUser($viewer)->authorize('view', $folder);
abort_unless($viewer->can('view_actions_log'), 403);
return $this->renderHistory($folder->getMorphClass(), $folder->id, $folder->name, route('files.index', ['folder' => $folder->id], false));
return $this->renderHistory(
$request,
$folder->getMorphClass(),
$folder->id,
$folder->name,
route('files.index', ['folder' => $folder->id], false),
'folders.activity.history',
['folder' => $folder->id],
);
}
private function renderHistory(string $morphClass, int $subjectId, string $subjectName, string $backUrl): Response
{
$entries = ActivityLog::query()
->where('subject_type', $morphClass)
->where('subject_id', $subjectId)
->orderByDesc('created_at')->orderByDesc('id')
/**
* @param array<string, mixed> $routeParams
*/
private function renderHistory(
Request $request,
string $morphClass,
int $subjectId,
string $subjectName,
string $backUrl,
string $routeName,
array $routeParams,
): Response {
$viewer = $request->user();
assert($viewer !== null);
$filters = $this->validatedHistoryFilters($request);
$entries = $this->historyQuery($morphClass, $subjectId, $filters, $viewer)
->paginate(25)
->withQueryString();
@@ -327,8 +456,135 @@ class FileDetailsController extends Controller
'next' => $entries->nextPageUrl(),
'total' => $entries->total(),
],
'filters' => $filters,
'action_options' => $this->actionOptions($morphClass, $subjectId, $filters['action']),
'subject_name' => $subjectName,
'back_url' => $backUrl,
'route_name' => $routeName,
'route_params' => $routeParams,
]);
}
/**
* The actions this subject's history actually contains, with how many
* times each happened.
*
* Built from the log rather than from `Action::cases()`: the enum has
* over eighty members and all but a handful can never appear against a
* file, so offering them all would be a dropdown you scroll past the
* answer in. What is here is what happened.
*
* @return list<array{key: string, label: string, count: int}>
*/
private function actionOptions(string $morphClass, int $subjectId, ?string $active): array
{
/** @var array<string, int> $counts */
$counts = ActivityLog::query()
->where('subject_type', $morphClass)
->where('subject_id', $subjectId)
->selectRaw('action, count(*) as total')
->groupBy('action')
->pluck('total', 'action')
->map(fn ($total): int => (int) $total)
->all();
$options = [];
foreach (self::ACTION_GROUPS as $key => $group) {
$present = array_filter($group['actions'], fn (Action $action): bool => isset($counts[$action->value]));
// One member present means the group would filter to exactly
// what its member already offers, under a vaguer name — unless
// this *is* what is currently being filtered on (the file
// page's "View all downloads" button links straight to it), in
// which case the dropdown has to be able to show its own value.
if (count($present) < 2 && $active !== $key) {
continue;
}
$options[] = [
'key' => $key,
'label' => $group['label'],
'count' => array_sum(array_map(fn (Action $action): int => $counts[$action->value], $present)),
];
}
// Enum order, not count order, so the list does not rearrange
// itself under the reader every time the file is downloaded.
foreach (Action::cases() as $action) {
// Same reason as the group above: a filter arrived at from a
// link stays visible in the dropdown even at a count of zero,
// rather than leaving it blank over an empty table.
if (! isset($counts[$action->value]) && $active !== $action->value) {
continue;
}
$options[] = [
'key' => $action->value,
'label' => $action->description(),
'count' => $counts[$action->value] ?? 0,
];
}
return $options;
}
/**
* @return array{action: ?string, actor: ?string, from: ?string, to: ?string}
*/
private function validatedHistoryFilters(Request $request): array
{
$validated = $request->validate([
'action' => ['nullable', Rule::in([
...array_keys(self::ACTION_GROUPS),
...array_column(Action::cases(), 'value'),
])],
'actor' => ['nullable', 'string', 'max:255'],
'from' => ['nullable', 'date'],
'to' => ['nullable', 'date', 'after_or_equal:from'],
]);
return [
'action' => $validated['action'] ?? null,
'actor' => $validated['actor'] ?? null,
'from' => $validated['from'] ?? null,
'to' => $validated['to'] ?? null,
];
}
/**
* @param array{action: ?string, actor: ?string, from: ?string, to: ?string} $filters
* @return Builder<ActivityLog>
*/
private function historyQuery(string $morphClass, int $subjectId, array $filters, User $viewer): Builder
{
$timezone = $this->timezones->resolve($viewer);
return ActivityLog::query()
->where('subject_type', $morphClass)
->where('subject_id', $subjectId)
->when($filters['action'], function (Builder $query, string $action): void {
$group = self::ACTION_GROUPS[$action] ?? null;
$group === null
? $query->where('action', $action)
: $query->whereIn('action', array_map(fn (Action $member): string => $member->value, $group['actions']));
})
// Matched on the name snapshotted onto the entry, the same as
// the main log: an account deleted since is still findable by
// the name it acted under, which is the whole point of the
// snapshot.
->when($filters['actor'], fn (Builder $query, string $actor) => $query->where('actor_name', 'like', "%{$actor}%"))
// The reader's own calendar day, not the UTC one — see LocalDay.
->when(
$filters['from'] !== null ? LocalDay::start($filters['from'], $timezone) : null,
fn (Builder $query, Carbon $from) => $query->where('created_at', '>=', $from),
)
->when(
$filters['to'] !== null ? LocalDay::end($filters['to'], $timezone) : null,
fn (Builder $query, Carbon $to) => $query->where('created_at', '<=', $to),
)
->orderByDesc('created_at')
->orderByDesc('id');
}
}
@@ -8,28 +8,26 @@ use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Access\DownloadAllowance;
use App\Modules\Files\Delivery\StoredFileResponse;
use App\Modules\Files\Models\File;
use App\Support\ContentDisposition;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
/**
* Authorized downloads without the bytes ever traversing PHP: for a file
* on the local disk, the app checks the policy and answers with
* X-Accel-Redirect; nginx streams the file from the protected location
* (brief §3). The cloud edition swaps this for presigned URLs behind the
* same route. A file on the community-only external storage disk already
* gets exactly that — a presigned URL redirect — since nginx has no way
* to serve bytes it doesn't have on disk.
* Authorized downloads without the bytes ever traversing PHP: the app
* checks the policy, and StoredFileResponse answers with either an
* X-Accel-Redirect for nginx to stream from the protected location
* (brief §3) or a presigned URL when the file lives on external storage,
* since nginx has no way to serve bytes it doesn't have on disk.
*/
class FileDownloadController extends Controller
{
public function __construct(
private readonly ActivityLogger $activity,
private readonly DownloadAllowance $allowance,
private readonly StoredFileResponse $bytes,
) {}
public function __invoke(Request $request, File $file): Response|RedirectResponse
@@ -44,21 +42,6 @@ class FileDownloadController extends Controller
$this->activity->log(Action::FileDownloaded, subject: $file);
if ($file->disk !== 'files') {
$url = Storage::disk($file->disk)->temporaryUrl(
$file->path,
now()->addHour(),
['ResponseContentDisposition' => ContentDisposition::attachment($file->original_name)],
);
return redirect()->away($url);
}
return response('', 200, [
'X-Accel-Redirect' => '/protected-files/'.$file->path,
'Content-Type' => $file->mime_type,
'Content-Disposition' => ContentDisposition::attachment($file->original_name),
'Content-Length' => (string) $file->size,
]);
return $this->bytes->attachment($file);
}
}
@@ -8,15 +8,21 @@ use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Access\DownloadAllowance;
use App\Modules\Files\Delivery\StoredFileResponse;
use App\Modules\Files\Models\File;
use App\Modules\Files\Preview\PreviewKind;
use App\Modules\Files\Thumbnails\Events\ResolvingImageRendering;
use App\Modules\Files\Thumbnails\ImageAudience;
use App\Modules\Files\Thumbnails\ImageRendition;
use App\Modules\Files\Thumbnails\LocalSourceFile;
use App\Modules\Files\Thumbnails\ThumbnailGenerator;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Support\ContentDisposition;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
@@ -32,17 +38,25 @@ use Illuminate\Support\Facades\Storage;
* file's contents — a real, audit-worthy action, just not a "download."
*
* SECURITY: both methods serve bytes inline, from this app's own origin,
* with the File's stored mime type — so both are restricted to
* with the File's stored mime type, so both are restricted to an
* allowlist — but not the same one, because they are asking different
* questions. `thumbnail()` is bounded by
* ThumbnailGenerator::SUPPORTED_MIME_TYPES, the raster formats this app
* renders itself. That list is the allowlist; nothing else is ever served
* inline. Do NOT widen it to text/html, image/svg+xml, or anything else a
* browser executes script from, and do not reach for the upload
* allowed-extensions setting as a substitute: that setting matches on the
* *extension*, while mime_type is detected from the *bytes*
* (ChunkedUploadsController::complete), so a .txt holding HTML is stored
* as text/html and would render as a page here. Serving a file inline as
* a type the browser executes is same-origin script execution with the
* viewer's session.
* decodes and re-encodes itself, since a thumbnail *is* a rendition.
* `preview()` is bounded by PreviewKind, which additionally admits the
* video, audio and PDF types a browser plays natively and this app never
* touches. PreviewKind's docblock carries the rule in full; the short
* version is that neither list may ever grow a type a browser executes
* script from, and neither may be derived from the upload
* allowed-extensions setting, which matches on the *extension* while
* mime_type is detected from the *bytes*
* (ChunkedUploadsController::complete).
*
* Serving media inline is also why `preview()` logs at most one
* Action::FilePreviewed per viewer per file per five minutes: a `<video>`
* seeking through a recording issues a long tail of Range requests
* against this same URL, and one row each would bury the log under a
* single deliberate act.
*
* Renditions always cache on the local "files" disk regardless of where
* the source file lives — they're a derived artifact, not the original,
@@ -60,6 +74,9 @@ class FileThumbnailController extends Controller
private readonly ThumbnailGenerator $thumbnails,
private readonly ActivityLogger $activity,
private readonly DownloadAllowance $allowance,
private readonly StoredFileResponse $bytes,
private readonly LocalSourceFile $source,
private readonly Settings $settings,
) {}
public function thumbnail(Request $request, File $file): Response
@@ -82,66 +99,92 @@ class FileThumbnailController extends Controller
/**
* A file opened to be looked at.
*
* A preview is not the file — it is a rendered view of it, which is
* why it may be decorated at all. But rendering one is expensive
* (decoding and re-encoding a full-size photograph) where serving the
* stored bytes is nearly free, so core only pays that cost when a
* listener says this particular viewer must be served a rendering:
* ResolvingImageRendering asks, and defaults to no. On an
* For an image, a preview is not the file — it is a rendered view of
* it, which is why it may be decorated at all. But rendering one is
* expensive (decoding and re-encoding a full-size photograph) where
* serving the stored bytes is nearly free, so core only pays that
* cost when a listener says this particular viewer must be served a
* rendering: ResolvingImageRendering asks, and defaults to no. On an
* installation that watermarks, a client gets a bounded, watermarked
* render and staff get the original; on one that does not, everyone
* gets exactly what this endpoint has always returned.
*
* For video, audio and PDF there is no rendering to resolve — this
* app cannot decode any of them, so it has no rendition to cache, no
* watermark to stamp, and nothing to ask about. Those go straight to
* the bytes.
*/
public function preview(Request $request, File $file): Response|RedirectResponse
{
Gate::authorize('view', $file);
// Only types this app renders itself may be served inline; anything
// else is a download, not a preview. See the class docblock — the
// stored mime type is sniffed from the bytes, so an allowed
// The inline allowlist. See the class docblock and PreviewKind —
// the stored mime type is sniffed from the bytes, so an allowed
// extension is not evidence of a safe-to-render payload.
abort_unless(ThumbnailGenerator::supports($file->mime_type), 404);
$kind = PreviewKind::forMime($file->mime_type);
abort_if($kind === null, 404);
// Staff are never gated: this switch exists so an installation can
// decide what its *clients* may do with a file short of taking it.
// 404 rather than 403 because with the setting off the endpoint is
// not a thing that exists for this viewer.
abort_if(
$request->user()?->isStaff() !== true && ! $this->settings->get(Setting::ClientsCanPreviewFiles),
404,
);
// A preview is not counted as a download, but it is refused once
// the download limit is spent — because unless a listener asks
// for a rendering (nothing does by default), the branches below
// serve the *original bytes* at full size. Without this a cap
// would be one URL away from meaningless for every image on the
// install. thumbnail() needs no such guard: a 300px rendition is
// not the file.
// for a rendering (nothing does by default, and nothing ever does
// for media), the branches below serve the *original bytes* at
// full size. Without this a cap would be one URL away from
// meaningless for every previewable file on the install.
// thumbnail() needs no such guard: a 300px rendition is not the
// file.
abort_unless($this->allowance->allows($file, $request->user()), 403);
$this->activity->log(Action::FilePreviewed, subject: $file);
$this->logPreview($file, $request);
$audience = ImageAudience::forViewer($request->user());
if ($kind === PreviewKind::Image) {
$audience = ImageAudience::forViewer($request->user());
$decision = new ResolvingImageRendering($audience, ImageRendition::Preview, $file->mime_type);
Event::dispatch($decision);
$decision = new ResolvingImageRendering($audience, ImageRendition::Preview, $file->mime_type);
Event::dispatch($decision);
if ($decision->required) {
$path = $this->render($file, $audience, ImageRendition::Preview);
if ($decision->required) {
$path = $this->render($file, $audience, ImageRendition::Preview);
abort_if($path === null, 404);
abort_if($path === null, 404);
return $this->serve($file, $path);
return $this->serve($file, $path);
}
}
if ($file->disk !== 'files') {
$url = Storage::disk($file->disk)->temporaryUrl(
$file->path,
now()->addHour(),
['ResponseContentDisposition' => ContentDisposition::inline($file->original_name)],
);
return $this->bytes->inline($file);
}
return redirect()->away($url);
/**
* One log row per viewer per file per five minutes.
*
* Watching a video is a single deliberate act that the browser turns
* into dozens of Range requests against this route, and each one
* arrives here indistinguishable from someone clicking preview again.
* Cache::add is the whole mechanism: it writes only if the key is
* absent, so the first request through the window logs and the rest
* are silent, without a read-then-write race between two of them.
*
* Keyed by viewer, so one client's playback never suppresses another
* person's preview of the same file. Anonymous viewers do not reach
* this route at all — see PublicGroupsController::preview.
*/
private function logPreview(File $file, Request $request): void
{
$key = 'file-preview-logged:'.$file->id.':'.($request->user()->id ?? 'guest');
if (Cache::add($key, true, now()->addMinutes(5))) {
$this->activity->log(Action::FilePreviewed, subject: $file);
}
return response('', 200, [
'X-Accel-Redirect' => '/protected-files/'.$file->path,
'Content-Type' => $file->mime_type,
'Content-Disposition' => ContentDisposition::inline($file->original_name),
'Content-Length' => (string) $file->size,
]);
}
/**
@@ -164,15 +207,14 @@ class FileThumbnailController extends Controller
}
$disk->makeDirectory(dirname($path));
$sourcePath = $this->localSourcePathFor($file);
try {
$this->thumbnails->generate($sourcePath, $disk->path($path), $file->mime_type, $audience, $rendition);
} finally {
if ($file->disk !== 'files') {
@unlink($sourcePath);
}
}
$this->source->use($file, fn (string $sourcePath) => $this->thumbnails->generate(
$sourcePath,
$disk->path($path),
$file->mime_type,
$audience,
$rendition,
));
return $path;
}
@@ -185,38 +227,4 @@ class FileThumbnailController extends Controller
'Content-Disposition' => ContentDisposition::inline($file->original_name),
]);
}
/**
* A local-disk file's real path (fast path). Anything else is
* stream-copied to a temp file first — the caller unlinks it once
* rendering is done.
*/
private function localSourcePathFor(File $file): string
{
if ($file->disk === 'files') {
return Storage::disk('files')->path($file->path);
}
$tempPath = tempnam(sys_get_temp_dir(), 'thumb-src-');
if ($tempPath === false) {
throw new \RuntimeException('Could not create a temp file for '.$file->original_name);
}
$stream = Storage::disk($file->disk)->readStream($file->path);
$out = fopen($tempPath, 'wb');
if ($stream === null || $out === false) {
throw new \RuntimeException('Could not read '.$file->original_name.' from its storage disk.');
}
stream_copy_to_stream($stream, $out);
fclose($out);
if (is_resource($stream)) {
fclose($stream);
}
return $tempPath;
}
}
@@ -76,7 +76,7 @@ class FilesController extends Controller
'file' => ['required', 'file', 'max:102400'],
'name' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'folder_id' => ['nullable', 'integer', 'exists:folders,id'],
'folder_id' => Rules::folderId(),
]);
/** @var UploadedFile $upload */
@@ -93,6 +93,17 @@ class FilesController extends Controller
$user = $request->user();
assert($user !== null);
// The check the other two upload paths make and this one did not:
// a folder outside the uploader's library is not a place to put a
// file. Without it this route reached any folder on the
// installation, and File::scopeVisibleToClient then hands the file
// to whoever that folder's subtree is shared with.
$folder = isset($validated['folder_id'])
? Folder::query()->whereKey($validated['folder_id'])->first()
: null;
abort_unless(Folder::uploadableBy($user, $folder), 403);
if (! app(UploadExtensionPolicy::class)->isAllowed($user, $upload->getClientOriginalName())) {
throw ValidationException::withMessages([
'file' => __('This file type is not allowed for upload.'),
@@ -197,7 +208,11 @@ class FilesController extends Controller
'url' => route('files.edit', $member, false),
'is_current' => $member->id === $file->id,
])->values(),
'folder_options' => Folder::query()->orderBy('path')->orderBy('name')->get()
// Narrowed like every other folder listing: an unscoped staff
// member gets the whole tree, a client-scoped one only their
// own. Unfiltered this handed a scoped staffer every folder
// name and id on the installation.
'folder_options' => $this->scope->folders($viewer)->orderBy('path')->orderBy('name')->get()
->map(fn (Folder $folder): array => ['id' => $folder->id, 'name' => $folder->name])->all(),
'categories' => Category::query()->orderBy('name')->get(['id', 'name', 'color'])
->map(fn (Category $category): array => ['id' => $category->id, 'name' => $category->name, 'color' => $category->color])->all(),
@@ -206,6 +221,11 @@ class FilesController extends Controller
'can_update' => Gate::forUser($viewer)->allows('update', $file),
'can_delete' => Gate::forUser($viewer)->allows('delete', $file),
'can_manage_public' => $viewer->can('upload_public'),
// Whether this page offers its Activity tab. The file's own
// page is where somebody lands from a link, a search or a
// notification, so "what happened to this file" has to be
// answerable here and not only from the library's list.
'can_view_activity' => $viewer->can('view_actions_log'),
// The per-file switch only does anything while the comment
// scope is `selected`; under every other value the page hides
// it rather than offer a control with no current effect.
@@ -237,7 +257,7 @@ class FilesController extends Controller
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'folder_id' => ['nullable', 'integer', 'exists:folders,id'],
'folder_id' => Rules::folderId(),
'public' => ['sometimes', 'boolean'],
'commentable' => ['sometimes', 'boolean'],
// The slug only matters (and is only shown) once a file is
@@ -250,10 +270,25 @@ class FilesController extends Controller
'download_limit_scope' => ['nullable', Rule::enum(DownloadLimitScope::class)],
]);
// The edit form posts folder_id as a string; cast so the strict
// change comparison below matches the model's int.
$folderId = isset($validated['folder_id']) ? (int) $validated['folder_id'] : null;
$user = $request->user();
// Reparenting through update() is the same privileged write as
// move()/bulkUpdate(), so it needs the same guard: the destination
// must be a folder this user can actually see. Only checked when the
// folder actually changes, so re-saving a file that already sits in
// an out-of-scope folder (reachable via a direct client share) still
// works.
if ($folderId !== null && $folderId !== $file->folder_id && $user !== null) {
$this->scope->folders($user)->findOrFail($folderId);
}
$attributes = [
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'folder_id' => $validated['folder_id'] ?? null,
'folder_id' => $folderId,
];
// Only meaningful while the comment scope is `selected`, and only
@@ -322,7 +357,7 @@ class FilesController extends Controller
Gate::authorize('update', $file);
$validated = $request->validate([
'folder_id' => ['nullable', 'integer', 'exists:folders,id'],
'folder_id' => Rules::folderId(),
]);
$folderId = $validated['folder_id'] ?? null;
@@ -358,7 +393,7 @@ class FilesController extends Controller
'file_ids.*' => ['integer', 'distinct'],
'folder_action' => ['required', Rule::in(['no_change', 'move'])],
'folder_id' => ['nullable', 'integer', 'exists:folders,id'],
'folder_id' => Rules::folderId(),
'description_action' => ['required', Rule::in(['no_change', 'set'])],
'description' => ['nullable', 'string', 'max:2000'],
@@ -186,7 +186,11 @@ class FoldersController extends Controller
'expired' => $expired,
'categories' => Category::query()->orderBy('name')->get(['id', 'name', 'color'])
->map(fn (Category $category): array => ['id' => $category->id, 'name' => $category->name, 'color' => $category->color])->all(),
'folder_options' => Folder::query()->orderBy('path')->orderBy('name')->get()
// Narrowed like every other folder listing on this screen: an
// unscoped staff member gets the whole tree, a client-scoped
// one only their own. Unfiltered this handed a scoped staffer
// every folder name and id on the installation.
'folder_options' => $this->scope->folders($user)->orderBy('path')->orderBy('name')->get()
->map(fn (Folder $folder): array => ['id' => $folder->id, 'name' => $folder->name])->all(),
'can_create_folders' => $user->can('create_own_folders'),
'can_upload' => $user->can('upload'),
@@ -305,7 +309,7 @@ class FoldersController extends Controller
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'parent_id' => ['nullable', 'integer', 'exists:folders,id'],
'parent_id' => Rules::folderId(),
'public' => ['sometimes', 'boolean'],
'slug' => Rules::slug('folders'),
'allow_client_uploads' => ['sometimes', 'boolean'],
@@ -389,7 +393,7 @@ class FoldersController extends Controller
Gate::authorize('update', $folder);
$validated = $request->validate([
'parent_id' => ['nullable', 'integer', 'exists:folders,id'],
'parent_id' => Rules::folderId(),
]);
$newParent = $this->resolveParent($request->user(), $validated['parent_id'] ?? null);
@@ -122,15 +122,23 @@ class MyFilesController extends Controller
// Subfolders: at root, every visible folder whose parent isn't
// itself visible (top of each shared subtree, or a client-owned
// folder with no visible parent); inside a folder, its direct
// children.
// folder with no visible parent); inside a folder, the visible
// ones among its direct children.
//
// Both branches narrow to $visibleIds. Being handed a folder is
// not permission to read the names of everything filed inside
// it: a client-created folder is visible through created_by,
// which says nothing about a subfolder staff later put there.
if ($current === null) {
$folders = Folder::query()
->whereIn('id', $visibleIds)
->where(fn ($q) => $q->whereNull('parent_id')->orWhereNotIn('parent_id', $visibleIds))
->orderBy('name');
} else {
$folders = Folder::query()->where('parent_id', $current->id)->orderBy('name');
$folders = Folder::query()
->whereIn('id', $visibleIds)
->where('parent_id', $current->id)
->orderBy('name');
}
// Files: inside a folder, that folder's files; at root, only
@@ -260,6 +268,13 @@ class MyFilesController extends Controller
'can_upload' => $client->can('upload'),
'can_upload_here' => Folder::uploadableBy($client, $current),
'can_create_folders' => $client->can('create_own_folders'),
// Whether a row is clickable to look at rather than only to
// take. Per page rather than per file: the mime type decides
// which files can be previewed and every theme already knows
// how to read one, so all this has to carry is whether the
// installation offers it here at all. See
// FileThumbnailController::preview, which re-checks it.
'preview_enabled' => $this->settings->get(Setting::ClientsCanPreviewFiles),
]);
}
@@ -10,6 +10,7 @@ use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Folders\FolderService;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Support\Rules;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
@@ -43,7 +44,7 @@ class MyFoldersController extends Controller
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'parent_id' => ['nullable', 'integer', 'exists:folders,id'],
'parent_id' => Rules::folderId(),
]);
$parent = null;
@@ -8,10 +8,10 @@ use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Access\DownloadAllowance;
use App\Modules\Files\Delivery\StoredFileResponse;
use App\Modules\Files\Models\Category;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\ShareLink;
use App\Support\ContentDisposition;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response;
use Inertia\Inertia;
@@ -28,6 +28,7 @@ class PublicShareController extends Controller
public function __construct(
private readonly ActivityLogger $activity,
private readonly DownloadAllowance $allowance,
private readonly StoredFileResponse $bytes,
) {}
public function show(string $token): InertiaResponse
@@ -101,11 +102,6 @@ class PublicShareController extends Controller
$this->activity->log(Action::ShareLinkDownloaded, subject: $file);
return response('', 200, [
'X-Accel-Redirect' => '/protected-files/'.$file->path,
'Content-Type' => $file->mime_type,
'Content-Disposition' => ContentDisposition::attachment($file->original_name),
'Content-Length' => (string) $file->size,
]);
return $this->bytes->attachment($file);
}
}
@@ -15,12 +15,16 @@ use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Models\ZipDownload;
use App\Modules\Files\Uploads\StoreUploadedFile;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Support\ContentDisposition;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Number;
/**
* A folder's "Download as zip" button and the file listing's multi-select
@@ -40,6 +44,7 @@ class ZipDownloadsController extends Controller
private readonly ActivityLogger $activity,
private readonly ViewableFileScope $viewable,
private readonly DownloadAllowance $allowance,
private readonly Settings $settings,
) {}
public function store(Request $request): JsonResponse
@@ -47,6 +52,22 @@ class ZipDownloadsController extends Controller
$user = $request->user();
assert($user !== null);
// One build at a time per requester. A zip holds the queue worker
// for as long as it takes to write, and everything else — every
// notification email — waits behind it, so a queue of them from
// one person is everyone else's outage. An hour old is treated as
// abandoned rather than in progress: BuildZipDownloadJob::failed()
// resolves a row the worker gave up on, but a worker killed hard
// enough never runs it, and nobody should be locked out forever by
// a row nothing will ever finish.
$inFlight = ZipDownload::query()
->where('requested_by', $user->id)
->where('status', ZipDownload::STATUS_PENDING)
->where('created_at', '>', now()->subHour())
->exists();
abort_if($inFlight, 429, __('A zip download is already being prepared. Wait for that one to finish before starting another.'));
$validated = $request->validate([
'file_ids' => ['array'],
'file_ids.*' => ['integer'],
@@ -98,9 +119,32 @@ class ZipDownloadsController extends Controller
fn (Folder $folder): int => (clone $visible)->whereIn('folder_id', $folder->subtreeFolderIds())->count(),
);
// Measured the same way, and deliberately without the allowance
// filter the loose-file branch applies: a folder's total can only
// come out at or above what the archive will really weigh, and an
// over-estimate is the safe direction for a cap.
$totalSize = (int) $files->sum('size') + (int) $folders->sum(
fn (Folder $folder): int => (int) (clone $visible)->whereIn('folder_id', $folder->subtreeFolderIds())->sum('size'),
);
abort_if($fileCount === 0, 422, __('The selected folders are empty.'));
abort_if($fileCount > self::MAX_FILES, 422, __('Too many files selected. Choose a smaller selection and try again.'));
// Bytes, not file count, are what a build costs — worker time, the
// temp copies a remote disk needs, and the archive on disk. The
// message names both numbers because "too big" without them leaves
// someone guessing how much to deselect.
$maxBytes = (int) $this->settings->get(Setting::MaxZipDownloadSizeMb) * 1024 * 1024;
abort_if(
$maxBytes > 0 && $totalSize > $maxBytes,
422,
__('That selection is :size. Zip downloads are limited to :limit — select fewer files and try again.', [
'size' => Number::fileSize($totalSize, precision: 1),
'limit' => Number::fileSize($maxBytes),
]),
);
$zipDownload = ZipDownload::query()->create([
'requested_by' => $user->id,
'status' => ZipDownload::STATUS_PENDING,
@@ -137,8 +181,7 @@ class ZipDownloadsController extends Controller
// Only the first time. Re-fetching one prepared archive is the
// same delivery, not a fresh download of everything inside it.
if ($zipDownload->delivered_at === null) {
$this->logContainedDownloads($zipDownload, $user);
$zipDownload->forceFill(['delivered_at' => now()])->save();
$this->deliverOnce($zipDownload, $user);
}
$size = Storage::disk('files')->size($path);
@@ -152,14 +195,81 @@ class ZipDownloadsController extends Controller
}
/**
* Every file actually bundled gets a FileDownloaded entry — otherwise
* a file's download history/count would silently miss zip downloads.
* Hand the archive over, once: refuse it if anything inside is out of
* allowance, otherwise count everything it holds as downloaded.
*
* This is the only point that spends a download limit, which is why
* it also has to be the point that enforces it. Building an archive
* takes nothing, so ordering the same limited file into any number of
* archives passes every check on the way — store() and the job both
* look at an allowance nothing has drawn on yet — and collecting them
* all afterwards would hand over more copies than the limit allows.
*
* One refused file refuses the whole delivery, because nothing can be
* taken out of a finished archive without building it again. Ordering
* the same selection afresh is the way through: the build leaves the
* spent file out and names it in skipped_files.
*
* An archive from before the job recorded its contents is handed over
* the way it always was, without this check. Its contents can only be
* guessed at by resolving the selection again, and guessing is exactly
* what must not decide a refusal: the same reconstruction both refuses
* over files the archive does not hold and misses files it does. Those
* rows stop existing within a day or two of an upgrade, and until then
* they behave as they did before this change rather than worse.
*/
private function logContainedDownloads(ZipDownload $zipDownload, User $requester): void
private function deliverOnce(ZipDownload $zipDownload, User $requester): void
{
$recorded = $zipDownload->contained_file_ids;
// What the job wrote down, read back as it stands — deliberately
// not filtered by what the requester may see today. The bytes are
// in the archive already, so a file that has since expired or left
// their scope is still being given to them, and a count that
// quietly dropped it would understate what was taken.
$contained = $recorded === null
? $this->resolveSelection($zipDownload, $requester)
: File::query()->whereIn('id', $recorded)->get();
abort_if(
$recorded !== null
&& $contained->contains(fn (File $file): bool => ! $this->allowance->allows($file, $requester)),
403,
__('Those files have reached their download limit.'),
);
// Atomic, so two fetches arriving together are still one delivery:
// only the request that actually moves delivered_at logs anything.
// Same reasoning as the conditional increment guarding a share
// link's max_downloads in PublicShareController. The other request
// still receives the archive — that is the re-fetch rule above.
$claimed = ZipDownload::query()
->whereKey($zipDownload->id)
->whereNull('delivered_at')
->update(['delivered_at' => now()]);
if ($claimed === 0) {
return;
}
// Every file actually bundled gets a FileDownloaded entry —
// otherwise a file's download history/count would silently miss
// zip downloads.
foreach ($contained as $file) {
$this->activity->log(Action::FileDownloaded, subject: $file);
}
}
/**
* What an archive built before the job recorded its contents is taken
* to hold: the selection, resolved again, which is how this worked
* throughout. Only reachable for rows written by an older release,
* and PurgeZipDownloadsCommand removes those within a day.
*
* @return Collection<int, File>
*/
private function resolveSelection(ZipDownload $zipDownload, User $requester): Collection
{
// Same per-file filter the job used to decide what actually went
// into the archive, so the log records what was really downloaded
// rather than everything that happened to sit in the folder.
$visible = $this->viewable->for($requester);
$fileIds = collect($zipDownload->file_ids);
@@ -177,9 +287,7 @@ class ZipDownloadsController extends Controller
// further past it.
$skipped = collect($zipDownload->skipped_files ?? [])->pluck('id')->all();
foreach ((clone $visible)->whereIn('id', $fileIds->unique())->whereNotIn('id', $skipped)->get() as $file) {
$this->activity->log(Action::FileDownloaded, subject: $file);
}
return (clone $visible)->whereIn('id', $fileIds->unique())->whereNotIn('id', $skipped)->get();
}
private function filenameFor(ZipDownload $zipDownload): string
+158 -10
View File
@@ -10,6 +10,8 @@ use App\Modules\Files\Access\ViewableFileScope;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Models\ZipDownload;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Builder;
@@ -17,6 +19,7 @@ use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Throwable;
use ZipArchive;
@@ -40,9 +43,40 @@ class BuildZipDownloadJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* A zip build is not usefully retryable — a source file that went
* missing mid-build, or an allowance spent while the job waited, makes
* a second attempt no likelier to succeed — so a failure is recorded
* once and surfaced to the requester rather than silently retried.
*/
public int $tries = 1;
/**
* Building the archive is the whole job, and a large selection (up to
* ZipDownloadsController::MAX_FILES sources, some stream-copied from a
* remote disk) runs well past the queue worker's default 60s timeout.
* Without room the worker kills the process mid-build before the catch
* can run, stranding the row as PENDING forever; failed() is the
* backstop for when the kill lands anyway.
*/
public int $timeout = 3600;
public function __construct(
private readonly int $zipDownloadId,
) {}
) {
// Its own queue, because $timeout is an hour and every shipped
// topology runs one worker: on the default queue a single large
// build holds up every notification email behind it. Set in the
// constructor rather than at the dispatch site so a second caller
// cannot forget it.
//
// A worker has to be listening. The images run a second one; a
// manual install whose worker command still says plain
// `queue:work` consumes `default` only, so INSTALL.md documents
// `--queue=default,zips` for the single-worker case — see the
// upgrade note in CHANGELOG.md.
$this->onQueue('zips');
}
public function handle(): void
{
@@ -52,6 +86,14 @@ class BuildZipDownloadJob implements ShouldQueue
return;
}
// Stamped before any of the work, because the only thing this is
// for is telling "a worker has this in hand" apart from "nobody
// is listening to the zips queue". A build that waits and never
// starts is the second, which is what a manual install whose
// worker command predates that queue looks like from here. See
// StalledZipBuilds.
$zipDownload->forceFill(['started_at' => now()])->save();
// Authorization is re-derived here, against the requester, rather
// than trusted from what the controller stored: a folder id only
// says "this user may open this folder", never "this user may read
@@ -88,9 +130,12 @@ class BuildZipDownloadJob implements ShouldQueue
$tempFiles = [];
$skipped = [];
// Counted rather than derived from $usedNames, which also
// holds the folder entry names.
$added = 0;
// Collected rather than derived from $usedNames, which also
// holds the folder entry names. Recording the ids, not just a
// count, is what lets the download action log exactly what it
// hands over instead of resolving the selection a second time
// against a scope that may have moved since.
$addedIds = [];
foreach ((clone $visible)->whereIn('id', $zipDownload->file_ids)->get() as $file) {
// Re-checked here for the same reason visibility is: the
@@ -105,24 +150,87 @@ class BuildZipDownloadJob implements ShouldQueue
$entryName = $this->dedupeName($usedNames, $this->entrySegment($file->original_name));
$zip->addFile($this->localPathFor($file, $tempFiles), $entryName);
$totalSize += $file->size;
$added++;
$addedIds[] = $file->id;
}
foreach (Folder::query()->whereIn('id', $zipDownload->folder_ids)->get() as $folder) {
$totalSize += $this->addFolder($zip, $folder, $requester, $usedNames, $tempFiles, $visible, $skipped, $added);
$totalSize += $this->addFolder($zip, $folder, $requester, $usedNames, $tempFiles, $visible, $skipped, $addedIds);
}
$zip->close();
// Re-checked here, not only in ZipDownloadsController: the
// selection is re-derived at build time, so a folder that grew
// while the job sat in the queue could otherwise fill the disk
// with an archive nobody is allowed to ask for. unchangeAll()
// drops every pending entry, so close() writes nothing rather
// than writing an archive we would delete a line later.
$maxBytes = (int) app(Settings::class)->get(Setting::MaxZipDownloadSizeMb) * 1024 * 1024;
if ($maxBytes > 0 && $totalSize > $maxBytes) {
$zip->unchangeAll();
@$zip->close();
foreach ($tempFiles as $tempFile) {
@unlink($tempFile);
}
$this->fail($zipDownload, $relativePath, 'The selection grew past the maximum zip download size before the archive could be built.', $skipped);
return;
}
// ZipArchive defers every write to close(): a source file
// deleted after its addFile() (a concurrent staff delete runs
// FileDiskCleanup at once) or a full disk only surfaces here,
// as a false return. Its low-level warning is silenced (as with
// the @unlink cleanup below) so the return value is the signal
// we act on, deterministically, rather than an exception whose
// firing depends on the error_reporting level. An archive that
// ended up with no entries is the same kind of non-result —
// libzip writes no file for one at all, even though close()
// still returns true. Either way there is nothing to serve, so
// the row must not be marked ready over a missing or empty
// archive: the download controller would X-Accel a file that
// isn't there.
$written = @$zip->close();
foreach ($tempFiles as $tempFile) {
@unlink($tempFile);
}
if ($written !== true || $addedIds === []) {
if ($written !== true) {
// What the requester sees stays generic: a libzip
// string means nothing to them and can name a server
// path. An operator needs the opposite — "disk full"
// and "the source file vanished" are different
// problems — so the reason goes to the log instead.
Log::error('A zip download could not be written.', [
'zip_download_id' => $zipDownload->id,
'reason' => $zip->getStatusString(),
]);
}
// Nothing written is told apart from nothing added, and
// "every file had already been downloaded as often as it
// was meant to be" from "there was nothing left to send".
// They are different problems for the person who asked,
// and fail() carries the skipped list either way, so
// "which files?" stays answerable from the row.
$this->fail($zipDownload, $relativePath, match (true) {
$written !== true => 'The zip archive could not be written.',
$skipped !== [] => 'Every selected file had already reached its download limit.',
default => 'None of the selected files were available to add to the archive.',
}, $skipped);
return;
}
$zipDownload->update([
'status' => ZipDownload::STATUS_READY,
'path' => $relativePath,
'total_size' => $totalSize,
'file_count' => $added,
'file_count' => count($addedIds),
'contained_file_ids' => $addedIds,
'skipped_files' => $skipped === [] ? null : $skipped,
]);
} catch (Throwable $e) {
@@ -137,6 +245,45 @@ class BuildZipDownloadJob implements ShouldQueue
}
}
/**
* One way out for every build that cannot produce an archive: drop
* whatever landed on disk, and leave the row saying what happened and
* what was left out.
*
* @param list<array{id: int, name: string}> $skipped
*/
private function fail(ZipDownload $zipDownload, string $relativePath, string $message, array $skipped): void
{
Storage::disk('files')->delete($relativePath);
$zipDownload->update([
'status' => ZipDownload::STATUS_FAILED,
'error' => $message,
'skipped_files' => $skipped === [] ? null : $skipped,
]);
}
/**
* Runs when the queue gives up on the job — most importantly when the
* worker kills it for exceeding $timeout, which skips handle()'s own
* catch and would otherwise leave the row PENDING forever, polled by
* the frontend with no end. Only a row still pending is touched: a
* build that already resolved itself (ready or failed) is left alone.
*/
public function failed(?Throwable $exception): void
{
$zipDownload = ZipDownload::query()->find($this->zipDownloadId);
if ($zipDownload === null || $zipDownload->status !== ZipDownload::STATUS_PENDING) {
return;
}
$zipDownload->update([
'status' => ZipDownload::STATUS_FAILED,
'error' => 'The zip archive could not be built.',
]);
}
/**
* A local-disk file is added by its real path (fast path). Anything
* else gets stream-copied to a temp file first — ZipArchive::addFile()
@@ -182,8 +329,9 @@ class BuildZipDownloadJob implements ShouldQueue
* @param array<int, string> $tempFiles
* @param Builder<File> $visible every file the requester may read
* @param list<array{id: int, name: string}> $skipped
* @param list<int> $addedIds every file really written into the archive
*/
private function addFolder(ZipArchive $zip, Folder $folder, User $requester, array &$usedNames, array &$tempFiles, Builder $visible, array &$skipped, int &$added): int
private function addFolder(ZipArchive $zip, Folder $folder, User $requester, array &$usedNames, array &$tempFiles, Builder $visible, array &$skipped, array &$addedIds): int
{
$allowance = app(DownloadAllowance::class);
@@ -209,7 +357,7 @@ class BuildZipDownloadJob implements ShouldQueue
$entryPath = $this->dedupeName($usedNames, $entryPath);
$zip->addFile($this->localPathFor($file, $tempFiles), $entryPath);
$totalSize += $file->size;
$added++;
$addedIds[] = $file->id;
}
return $totalSize;
+14 -1
View File
@@ -105,7 +105,20 @@ class File extends Model
// because it needs the row's own pointers intact.
app(FileVersions::class)->detachOnDelete($file);
app(FileDiskCleanup::class)->delete($file);
// The bytes go once the transaction holding this row commits,
// not alongside the row itself. A cascade — a folder subtree,
// an account's content — deletes many rows in one transaction,
// and anything that rolls it back afterwards puts every row
// back while the bytes are already gone: a loss nothing can
// undo. Deferred, the worst case is bytes left on disk with no
// row, which OrphanFileScanner already finds and reports.
//
// Outside a transaction the callback runs immediately, so
// deleting one file is unchanged. Nested transactions only fire
// it at the outermost commit, which is the case this is for.
$file->getConnection()->afterCommit(
fn () => app(FileDiskCleanup::class)->delete($file)
);
});
}
+15 -6
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Modules\Files\Models;
use App\Models\User;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Groups\Models\Group;
use App\Support\Concerns\HasUniqueSlug;
use Illuminate\Database\Eloquent\Builder;
@@ -152,11 +153,19 @@ class Folder extends Model
/**
* Whether $user may upload a new file directly into $folder (null =
* loose at the root, always allowed). Staff already validate folder_id
* through FilesController's own flow — this is the client-facing
* check, used by ChunkedUploadsController: the client owns the
* folder, or it's a public folder that opts into client uploads and
* the client's role permits uploading into public folders at all.
* loose at the root, always allowed).
*
* Staff are held to the library boundary they are held to everywhere
* else: an unscoped staff member may use any folder, a client-scoped
* one only the folders StaffLibraryScope already shows them. This is
* the only place that decides it: every upload path — the web form,
* the API and the chunked flow the browser actually posts to — comes
* through here rather than checking folder_id for itself.
*
* For a client this is unchanged, and is still the whole of the
* check: they own the folder, or it is a public folder that opts into
* client uploads and their role permits uploading into public folders
* at all.
*/
public static function uploadableBy(User $user, ?self $folder): bool
{
@@ -165,7 +174,7 @@ class Folder extends Model
}
if ($user->isStaff()) {
return true;
return app(StaffLibraryScope::class)->allowsFolder($user, $folder);
}
return $folder->isOwnedBy($user)
+4
View File
@@ -22,8 +22,10 @@ use Illuminate\Support\Carbon;
* @property string|null $error
* @property list<int> $file_ids
* @property list<int> $folder_ids
* @property list<int>|null $contained_file_ids
* @property list<array{id: int, name: string}>|null $skipped_files
* @property Carbon|null $delivered_at
* @property Carbon|null $started_at
*/
class ZipDownload extends Model
{
@@ -40,8 +42,10 @@ class ZipDownload extends Model
return [
'file_ids' => 'array',
'folder_ids' => 'array',
'contained_file_ids' => 'array',
'skipped_files' => 'array',
'delivered_at' => 'datetime',
'started_at' => 'datetime',
];
}
+25 -8
View File
@@ -6,6 +6,7 @@ namespace App\Modules\Files;
use App\Models\User;
use App\Modules\Files\Models\File;
use App\Modules\Files\Thumbnails\ImageRendition;
use App\Modules\Files\Uploads\UploadExtensionPolicy;
use App\Modules\Platform\Settings\ExternalStorageConfigApplier;
use App\Modules\Platform\Settings\ExternalStorageSettings;
@@ -20,13 +21,6 @@ use Illuminate\Support\Facades\Storage;
*/
class OrphanFileScanner
{
// Derived artifacts written by FileThumbnailController and
// BuildZipDownloadJob respectively — never orphaned uploads, so
// never candidates regardless of what's in the files table.
// Thumbnails are always local; zips would be too if that job ever
// ran against 'files_external', so the exclusion applies per-disk.
private const EXCLUDED_PREFIXES = ['thumbnails/', 'zips/'];
public function __construct(
private readonly UploadExtensionPolicy $extensionPolicy,
private readonly ExternalStorageConfigApplier $externalStorage,
@@ -160,9 +154,32 @@ class OrphanFileScanner
));
}
/**
* Path prefixes that are derived artifacts, never orphaned uploads, so
* never candidates regardless of what's in the files table: every image
* rendition's cache directory (taken from ImageRendition so a new
* rendition can't be forgotten here — previews used to be) plus the
* download-bundle job's 'zips'. Thumbnails and previews are always local;
* zips would be too if that job ever ran against 'files_external', so the
* exclusion applies per-disk.
*
* @return list<string>
*/
private function excludedPrefixes(): array
{
$prefixes = array_map(
static fn (ImageRendition $rendition): string => $rendition->directory().'/',
ImageRendition::cases(),
);
$prefixes[] = 'zips/';
return $prefixes;
}
private function isExcluded(string $path): bool
{
foreach (self::EXCLUDED_PREFIXES as $prefix) {
foreach ($this->excludedPrefixes() as $prefix) {
if (str_starts_with($path, $prefix)) {
return true;
}
+105
View File
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Preview;
use App\Modules\Files\Thumbnails\ThumbnailGenerator;
/**
* What kind of inline view, if any, a stored file gets — the single
* answer to "may these bytes be served inline, and what element renders
* them?", shared by FileThumbnailController::preview (signed in) and
* PublicGroupsController::preview (anonymous).
*
* SECURITY: this is an allowlist, and it is the boundary. Preview serves
* a file's own bytes inline, from this app's origin, labelled with the
* mime type stored on the row — so anything a browser executes script
* from would be same-origin script execution with the viewer's session.
* Never add text/html, image/svg+xml, or any other document type, and
* never derive this list from Setting::AllowedUploadExtensions: that
* setting matches on the *extension* while mime_type is sniffed from the
* *bytes* (ChunkedUploadsController::complete), so a .txt holding HTML is
* stored as text/html and would arrive here looking allowed.
*
* Deliberately narrower than "files a browser might cope with": every
* type below is one every current browser decodes natively. Formats like
* video/quicktime, video/x-msvideo and video/x-matroska are left out
* because an embedded player for them shows a black rectangle. They
* upload and download exactly as before — only the inline view is
* withheld.
*
* Distinct from ThumbnailGenerator::SUPPORTED_MIME_TYPES, which answers a
* narrower question: which types this app can *decode and re-encode*
* itself, and therefore has renditions, a cache and a watermark hook for.
* Image delegates to it rather than restating it, so the two cannot drift.
*/
enum PreviewKind: string
{
/** Rendered with <img>; the only kind with thumbnails and renditions. */
case Image = 'image';
/** Rendered with <video controls>. */
case Video = 'video';
/** Rendered with <audio controls>. */
case Audio = 'audio';
/** Rendered in a sandboxed <iframe>, by the browser's own viewer. */
case Pdf = 'pdf';
/** @var list<string> */
private const VIDEO_MIME_TYPES = [
'video/mp4',
'video/webm',
'video/ogg',
];
/**
* More spellings than there are formats: the mime type is whatever
* finfo made of the bytes, and it is not consistent across systems —
* a .wav is audio/x-wav on one box and audio/vnd.wave on another, and
* an .m4a can come back as audio/mp4 or audio/x-m4a.
*
* @var list<string>
*/
private const AUDIO_MIME_TYPES = [
'audio/mpeg',
'audio/wav',
'audio/x-wav',
'audio/vnd.wave',
'audio/ogg',
'audio/webm',
'audio/mp4',
'audio/x-m4a',
'audio/aac',
'audio/flac',
'audio/x-flac',
];
public static function forMime(string $mimeType): ?self
{
if (ThumbnailGenerator::supports($mimeType)) {
return self::Image;
}
if (in_array($mimeType, self::VIDEO_MIME_TYPES, true)) {
return self::Video;
}
if (in_array($mimeType, self::AUDIO_MIME_TYPES, true)) {
return self::Audio;
}
if ($mimeType === 'application/pdf') {
return self::Pdf;
}
return null;
}
public static function supports(string $mimeType): bool
{
return self::forMime($mimeType) !== null;
}
}
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Queue;
use App\Modules\Files\Models\ZipDownload;
use Illuminate\Support\Carbon;
/**
* Whether anything is consuming the `zips` queue.
*
* The application cannot see its own worker processes; it can only see
* whether work gets done. So the question is asked from the other end —
* a build that was requested a while ago and that no worker ever picked
* up means nobody is listening to that queue.
*
* Which is a real configuration, not a hypothetical one. Zip building
* moved onto its own queue, and a manual install whose worker command
* still reads a plain `queue:work` consumes `default` and nothing else.
* It goes on sending every email perfectly while no zip download ever
* finishes, and nothing in any log says why — the worst shape a
* misconfiguration can take, and the reason this is worth a banner
* rather than a line in a release note.
*
* Two conditions, because one of them alone cries wolf:
*
* - a build has been waiting past GRACE and was never started; and
* - no other build is in hand right now.
*
* The second matters because one worker builds one archive at a time. A
* queue behind a large build is a healthy queue, and its waiting rows
* look exactly like abandoned ones until you notice something running.
* "In hand" is itself bounded by the job's own timeout: a build that
* started three hours ago is not in progress, it is a worker that died
* holding it.
*/
class StalledZipBuilds
{
/**
* Long enough that an ordinary wait never trips it, short enough to
* be found on the day the install is upgraded rather than the week.
*/
private const GRACE_MINUTES = 5;
/**
* Matches BuildZipDownloadJob::$timeout. Past it, a build that
* started is not running any more — the worker died holding it, and
* the queue is as unattended as if it had never begun.
*/
private const IN_HAND_MINUTES = 60;
/**
* The oldest build nothing ever picked up, or null when the queue is
* being served.
*/
public function oldestUnstarted(): ?Carbon
{
if ($this->buildInHand()) {
return null;
}
$waiting = ZipDownload::query()
->where('status', ZipDownload::STATUS_PENDING)
->whereNull('started_at')
->where('created_at', '<', now()->subMinutes(self::GRACE_MINUTES))
->min('created_at');
return $waiting === null ? null : Carbon::parse($waiting);
}
private function buildInHand(): bool
{
return ZipDownload::query()
->where('status', ZipDownload::STATUS_PENDING)
->whereNotNull('started_at')
->where('started_at', '>', now()->subMinutes(self::IN_HAND_MINUTES))
->exists();
}
}
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Thumbnails;
use App\Modules\Files\Models\File;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
/**
* A real path on this machine for a stored file, so that something which
* can only work on local bytes — image and video rendering, all of which
* shells out or hands a path to a C library — can work on any file
* whatever disk it lives on.
*
* A local file is used where it lies. Anything else is stream-copied to a
* temp file and removed afterwards.
*
* The callback shape is the point. This started as a private method on
* one controller that returned a path and left the caller to unlink it,
* and the second place that needed it did not call it at all — it passed
* the *local* disk's path() for a file on external storage, which is a
* path that does not exist, so every public-listing thumbnail of an
* externally stored file failed. Handing back a path is an invitation to
* both of those mistakes; a closure that owns the lifetime is not.
*/
class LocalSourceFile
{
/**
* @template TReturn
*
* @param callable(string): TReturn $work
* @return TReturn
*/
public function use(File $file, callable $work): mixed
{
if ($file->disk === 'files') {
return $work(Storage::disk('files')->path($file->path));
}
$tempPath = tempnam(sys_get_temp_dir(), 'thumb-src-');
if ($tempPath === false) {
throw new RuntimeException('Could not create a temp file for '.$file->original_name);
}
try {
$this->copyDown($file, $tempPath);
return $work($tempPath);
} finally {
@unlink($tempPath);
}
}
private function copyDown(File $file, string $tempPath): void
{
$stream = Storage::disk($file->disk)->readStream($file->path);
$out = fopen($tempPath, 'wb');
if ($stream === null || $out === false) {
if (is_resource($out)) {
fclose($out);
}
throw new RuntimeException('Could not read '.$file->original_name.' from its storage disk.');
}
try {
stream_copy_to_stream($stream, $out);
} finally {
fclose($out);
if (is_resource($stream)) {
fclose($stream);
}
}
}
}
+50 -2
View File
@@ -7,6 +7,7 @@ namespace App\Modules\Files\Uploads;
use App\Modules\Files\Storage\ResolvingUploadDisk;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\File as FileSystem;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use RuntimeException;
@@ -203,12 +204,40 @@ class LocalPartStore
Event::dispatch($diskEvent);
$disk = $diskEvent->disk;
Storage::disk($disk)->writeStream($targetPath, $readStream);
$written = Storage::disk($disk)->writeStream($targetPath, $readStream);
if (is_resource($readStream)) {
fclose($readStream);
}
// The disks are configured with 'throw' => false, so a refused
// write is a `false` return rather than an exception — and the
// caller goes on to record a File row for bytes that were never
// stored. Losing an upload silently is worse than failing it, and
// this is the only place that can tell the difference: a real
// instance of it was a GCS bucket rejecting the adapter's ACL,
// which looked exactly like a successful upload.
if ($written === false) {
// The reason is lost by the time it gets here — 'throw' => false
// means Flysystem swallowed the exception rather than passing it
// on — so log what was attempted. Which bucket it was is the
// difference between reading this as "my credentials expired"
// and "I typed the wrong bucket name", and only the log can say
// it: the message below is shown to whoever was uploading, which
// includes clients, and a bucket name is not theirs to see.
Log::error('Upload could not be written to storage.', [
'disk' => $disk,
'bucket' => config('filesystems.disks.'.$disk.'.bucket'),
'driver' => config('filesystems.disks.'.$disk.'.driver'),
'path' => $targetPath,
]);
throw new RuntimeException(
'Could not write the assembled upload to the "'.$disk.'" disk. '
.'Check the storage backend is reachable and its credentials are still valid.'
);
}
$this->abort($session);
return [
@@ -226,7 +255,26 @@ class LocalPartStore
private function directory(UploadSession $session): string
{
return storage_path('app/uploads-tmp/'.$session->id);
return $this->root().'/'.$session->id;
}
/**
* Where part files live while a transfer is in progress.
*
* Configurable only so the test suite can hold it apart per parallel
* worker. This is a real directory rather than a faked disk, and each
* worker's database restarts session ids at 1, so two workers writing
* parts land in the same place — and ChunkedUploadsTest's afterEach
* deletes the whole tree, for everybody. Unset, which is every
* installation, the path is what it has always been.
*/
private function root(): string
{
$configured = config('projectsend.uploads.parts_path');
return is_string($configured) && $configured !== ''
? rtrim($configured, '/')
: storage_path('app/uploads-tmp');
}
private function partPath(UploadSession $session, int $partNumber): string
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Groups\Http\Resources\Api\GroupResource;
use App\Modules\Groups\Models\Group;
use Illuminate\Http\Request;
@@ -17,6 +18,7 @@ class GroupMembersController extends Controller
{
public function __construct(
private readonly ActivityLogger $activity,
private readonly StaffLibraryScope $scope,
) {}
public function store(Request $request, Group $group): GroupResource
@@ -37,6 +39,17 @@ class GroupMembersController extends Controller
]);
}
$actor = $request->user();
assert($actor instanceof User);
// Membership is a library boundary, not just a list: joining a
// group hands the new member everything shared with it, and if
// that member is one of the actor's own clients,
// File::scopeVisibleToClient hands the same content back to the
// actor. `edit_groups` in front of the route is a permission,
// not a boundary. See StaffLibraryScope::allowsGroupMembership.
abort_unless($this->scope->allowsGroupMembership($actor, $group, $client), 403);
// syncWithoutDetaching, so adding an existing member is a no-op and
// a retried request is safe.
$group->members()->syncWithoutDetaching([$client->id]);
@@ -46,8 +59,15 @@ class GroupMembersController extends Controller
return new GroupResource($group->loadCount('members')->load('members'));
}
public function destroy(Group $group, User $member): GroupResource
public function destroy(Request $request, Group $group, User $member): GroupResource
{
$actor = $request->user();
assert($actor instanceof User);
// The same boundary as store(): taking somebody out of a group
// is a decision about their access, and about a group.
abort_unless($this->scope->allowsGroupMembership($actor, $group, $member), 403);
$group->members()->detach($member->id);
$this->activity->log(Action::GroupMemberRemoved, subject: $group, context: ['member' => $member->name]);
@@ -9,6 +9,7 @@ use App\Modules\Api\Support\PollingQuery;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Groups\Http\Resources\Api\GroupResource;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Groups\Models\Group;
use App\Support\Rules;
use Illuminate\Database\Eloquent\Builder;
@@ -29,6 +30,7 @@ class GroupsController extends Controller
public function __construct(
private readonly PollingQuery $polling,
private readonly ActivityLogger $activity,
private readonly StaffLibraryScope $scope,
) {}
public function index(Request $request): AnonymousResourceCollection
@@ -83,6 +85,14 @@ class GroupsController extends Controller
public function update(Request $request, Group $group): GroupResource
{
$viewer = $request->user();
assert($viewer !== null);
// Mirrors the web controller: a group reaching past this token
// owner's library is not theirs to change, and deleting one
// revokes its members' access to everything assigned to it.
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
$validated = $request->validate([
'name' => ['sometimes', 'string', 'max:255'],
'slug' => Rules::slug('groups', $group->id),
@@ -108,8 +118,16 @@ class GroupsController extends Controller
return new GroupResource($group->refresh()->loadCount('members'));
}
public function destroy(Group $group): JsonResponse
public function destroy(Request $request, Group $group): JsonResponse
{
$viewer = $request->user();
assert($viewer !== null);
// Mirrors the web controller: a group reaching past this token
// owner's library is not theirs to change, and deleting one
// revokes its members' access to everything assigned to it.
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
$name = $group->name;
$group->delete();
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Groups\Models\Group;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -17,6 +18,7 @@ class GroupMembersController extends Controller
{
public function __construct(
private readonly ActivityLogger $activity,
private readonly StaffLibraryScope $scope,
) {}
public function store(Request $request, Group $group): RedirectResponse
@@ -35,6 +37,17 @@ class GroupMembersController extends Controller
]);
}
$actor = $request->user();
assert($actor instanceof User);
// Membership is a library boundary, not just a list: joining a
// group hands the new member everything shared with it, and if
// that member is one of the actor's own clients,
// File::scopeVisibleToClient hands the same content back to the
// actor. `edit_groups` in front of the route is a permission,
// not a boundary. See StaffLibraryScope::allowsGroupMembership.
abort_unless($this->scope->allowsGroupMembership($actor, $group, $client), 403);
$group->members()->syncWithoutDetaching([$client->id]);
$this->activity->log(Action::GroupMemberAdded, subject: $group, context: ['member' => $client->name]);
@@ -42,8 +55,15 @@ class GroupMembersController extends Controller
return back();
}
public function destroy(Group $group, User $member): RedirectResponse
public function destroy(Request $request, Group $group, User $member): RedirectResponse
{
$actor = $request->user();
assert($actor instanceof User);
// The same boundary as store(): taking somebody out of a group
// is a decision about their access, and about a group.
abort_unless($this->scope->allowsGroupMembership($actor, $group, $member), 403);
$group->members()->detach($member->id);
$this->activity->log(Action::GroupMemberRemoved, subject: $group, context: ['member' => $member->name]);
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\UserType;
use App\Support\Pagination;
@@ -25,6 +26,7 @@ class GroupsController extends Controller
public function __construct(
private readonly ActivityLogger $activity,
private readonly PublicUrl $publicUrl,
private readonly StaffLibraryScope $scope,
) {}
public function index(Request $request): Response
@@ -92,7 +94,12 @@ class GroupsController extends Controller
$this->activity->log(Action::GroupMadePublic, subject: $group, context: ['slug' => $group->slug]);
}
return redirect()->route('groups.edit', $group)->with('success', __('Group created.'));
// Same create-without-edit rule as ClientsController::store().
$target = $request->user()?->can('edit_groups')
? redirect()->route('groups.edit', $group)
: redirect()->route('groups.create');
return $target->with('success', __('Group created.'));
}
public function edit(Group $group): Response
@@ -126,6 +133,19 @@ class GroupsController extends Controller
public function update(Request $request, Group $group): RedirectResponse
{
$viewer = $request->user();
assert($viewer !== null);
// A group whose reach extends past this staff member's library is
// not theirs to change. #1701 drew this line for membership; the
// object itself needs it for the same reason and more sharply —
// an assignment to a group is how its members reach a file, so
// deleting one revokes that access for every member, including
// clients outside this person's roster. Measured before this
// guard: a scoped role deleted a stranger's group and the
// stranger's client stopped seeing the file it carried.
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
// The slug only matters (and is only shown) once a group is
@@ -154,8 +174,21 @@ class GroupsController extends Controller
return back()->with('success', __('Group updated.'));
}
public function destroy(Group $group): RedirectResponse
public function destroy(Request $request, Group $group): RedirectResponse
{
$viewer = $request->user();
assert($viewer !== null);
// A group whose reach extends past this staff member's library is
// not theirs to change. #1701 drew this line for membership; the
// object itself needs it for the same reason and more sharply —
// an assignment to a group is how its members reach a file, so
// deleting one revokes that access for every member, including
// clients outside this person's roster. Measured before this
// guard: a scoped role deleted a stranger's group and the
// stranger's client stopped seeing the file it carried.
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
$name = $group->name;
$group->delete();
@@ -5,8 +5,11 @@ declare(strict_types=1);
namespace App\Modules\Groups\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Groups\Models\Group;
use App\Modules\Groups\Models\MembershipRequest;
use App\Modules\Groups\Notifications\GroupMembershipDeniedNotification;
use App\Modules\Notifications\Notifier;
@@ -30,6 +33,7 @@ class MembershipRequestsController extends Controller
private readonly ActivityLogger $activity,
private readonly Settings $settings,
private readonly Notifier $notifier,
private readonly StaffLibraryScope $scope,
) {}
public function index(Request $request): Response
@@ -40,12 +44,19 @@ class MembershipRequestsController extends Controller
$filters = ['search' => $validated['search'] ?? null];
$viewer = $request->user();
assert($viewer !== null);
$requests = MembershipRequest::query()
->pending()
// A request whose client or group vanished is dead weight; excluding
// it in SQL (not after fetching) keeps pagination counts honest.
->whereHas('user')
->whereHas('group')
// Narrowed the way the buttons on each row now are — see
// MembershipRequest::scopeApprovableBy, which the sidebar badge
// reads too so the number and this screen agree.
->approvableBy($viewer)
->with(['group', 'user'])
->when($filters['search'], fn (Builder $query, string $search) => $query->where(fn (Builder $q) => $q
->whereHas('user', fn (Builder $u) => $u->where('name', 'like', "%{$search}%")->orWhere('email', 'like', "%{$search}%"))
@@ -68,13 +79,15 @@ class MembershipRequestsController extends Controller
]);
}
public function approve(MembershipRequest $membershipRequest): RedirectResponse
public function approve(Request $request, MembershipRequest $membershipRequest): RedirectResponse
{
$group = $membershipRequest->group;
$client = $membershipRequest->user;
abort_unless($group !== null && $client !== null && $membershipRequest->status === MembershipRequest::STATUS_PENDING, 404);
$this->guardRequest($request, $group, $client);
$group->members()->syncWithoutDetaching([$client->id]);
$membershipRequest->delete();
@@ -85,11 +98,26 @@ class MembershipRequestsController extends Controller
return back()->with('success', __('Membership request approved.'));
}
public function deny(MembershipRequest $membershipRequest): RedirectResponse
public function deny(Request $request, MembershipRequest $membershipRequest): RedirectResponse
{
// The half of approve()'s guard that applies here. A request that
// has already been denied is not a decision left to make, and
// taking it again re-stamps denied_at -- which is what the
// client's re-request cooldown counts from, so the same request
// repeated keeps a client out of a group indefinitely -- while
// writing a second log entry and sending a second "your request
// was declined" mail for one decision. The queue only ever lists
// pending requests, so this is not reachable through the screen;
// it is reachable by asking for the route directly.
abort_unless($membershipRequest->status === MembershipRequest::STATUS_PENDING, 404);
$group = $membershipRequest->group;
$client = $membershipRequest->user;
if ($group !== null && $client !== null) {
$this->guardRequest($request, $group, $client);
}
// The denied row persists: the client sees the outcome, and it
// enforces the re-request cooldown.
$membershipRequest->forceFill([
@@ -107,4 +135,25 @@ class MembershipRequestsController extends Controller
return back()->with('success', __('Membership request denied.'));
}
/**
* Approving a request is GroupMembersController::store by another
* door: it joins a client to a group, with the same consequence for
* what that client -- and any staff member holding them -- can reach
* afterwards. Denying one is a decision about somebody's client, and
* emails them about it. Both belong inside the same boundary, and
* `approve_groups_memberships_requests` in front of the route is a
* permission, not one.
*
* 404 rather than 403, matching the guard immediately above it in
* approve(): a request this staff member may not act on should not
* be distinguishable from one that is not there.
*/
private function guardRequest(Request $request, Group $group, User $client): void
{
$viewer = $request->user();
assert($viewer !== null);
abort_unless($this->scope->allowsGroupMembership($viewer, $group, $client), 404);
}
}
@@ -9,11 +9,14 @@ use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Comments\CommentingRules;
use App\Modules\Files\Access\DownloadAllowance;
use App\Modules\Files\Delivery\StoredFileResponse;
use App\Modules\Files\Models\Category;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Preview\PreviewKind;
use App\Modules\Files\Thumbnails\ImageAudience;
use App\Modules\Files\Thumbnails\ImageRendition;
use App\Modules\Files\Thumbnails\LocalSourceFile;
use App\Modules\Files\Thumbnails\ThumbnailGenerator;
use App\Modules\Files\Versions\FileVersionLinks;
use App\Modules\Groups\Http\Controllers\Concerns\InteractsWithPublicListing;
@@ -79,6 +82,8 @@ class PublicGroupsController extends Controller
private readonly PublicThemeRegistry $themes,
private readonly CapabilityRegistry $capabilities,
private readonly CommentingRules $commenting,
private readonly StoredFileResponse $bytes,
private readonly LocalSourceFile $source,
) {}
public function index(Request $request, string $publicSlug): InertiaResponse|RedirectResponse
@@ -208,6 +213,13 @@ class PublicGroupsController extends Controller
'thumbnail_url' => ThumbnailGenerator::supports($file->mime_type)
? route('public.thumbnail', [$publicSlug, $file->slug])
: null,
// Null whenever preview is unavailable, for any of the three
// reasons — switched off, wrong type, or the download limit
// spent — so a theme has one thing to check and the setting
// itself never ships to a visitor's browser. preview() below
// re-checks all three: this decides what to offer, not what
// is allowed.
'preview_url' => $this->previewUrlFor($file, $publicSlug),
'download_url' => route('public.download', [$publicSlug, $file->slug]),
// Same decided shape the listings send, so a theme's single
// file page disables its button for the same reason a row
@@ -241,7 +253,18 @@ class PublicGroupsController extends Controller
if (! $disk->exists($thumbnailPath)) {
$disk->makeDirectory(dirname($thumbnailPath));
$this->thumbnails->generate($disk->path($file->path), $disk->path($thumbnailPath), $file->mime_type, ImageAudience::External, ImageRendition::Thumbnail);
// Never $disk->path($file->path): the rendition is cached on
// the local disk, but the *source* lives on whichever disk the
// file was uploaded to, and a local path for an externally
// stored file is a path that does not exist.
$this->source->use($file, fn (string $sourcePath) => $this->thumbnails->generate(
$sourcePath,
$disk->path($thumbnailPath),
$file->mime_type,
ImageAudience::External,
ImageRendition::Thumbnail,
));
}
return response('', 200, [
@@ -251,7 +274,59 @@ class PublicGroupsController extends Controller
]);
}
public function download(string $publicSlug, File $file): Response
/**
* The anonymous twin of FileThumbnailController::preview: a public
* file shown rather than handed over.
*
* Nothing is rendered or cached here — an anonymous viewer only ever
* previews the stored bytes. The watermark hook that decorates a
* client's image preview has no equivalent on this route, for the
* same reason thumbnail() hardcodes ImageAudience::External: there is
* no viewer to tell apart.
*/
public function preview(string $publicSlug, File $file): Response|RedirectResponse
{
$this->guardSlug($publicSlug);
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 404);
abort_unless($this->settings->get(Setting::PublicListingPreviewEnabled) === true, 404);
abort_if(PreviewKind::forMime($file->mime_type) === null, 404);
// 403 rather than 404 for the same reason download() does it, and
// it is the same allowance being read: preview serves the whole
// file, so a spent cap has to close this door too or it closes
// nothing.
abort_unless($this->allowance->allows($file, null), 403);
$this->activity->log(Action::PublicFilePreviewed, subject: $file);
return $this->bytes->inline($file);
}
/**
* What showFile() offers, which is not the same question as what
* preview() permits — this one also declines to advertise a preview
* whose download limit is already spent, so a visitor is not given a
* button that can only answer 403.
*/
private function previewUrlFor(File $file, string $publicSlug): ?string
{
if ($this->settings->get(Setting::PublicListingPreviewEnabled) !== true) {
return null;
}
if (PreviewKind::forMime($file->mime_type) === null) {
return null;
}
if (! $this->allowance->allows($file, null)) {
return null;
}
return route('public.preview', [$publicSlug, $file->slug]);
}
public function download(string $publicSlug, File $file): Response|RedirectResponse
{
$this->guardSlug($publicSlug);
@@ -265,11 +340,6 @@ class PublicGroupsController extends Controller
$this->activity->log(Action::PublicFileDownloaded, subject: $file);
return response('', 200, [
'X-Accel-Redirect' => '/protected-files/'.$file->path,
'Content-Type' => $file->mime_type,
'Content-Disposition' => ContentDisposition::attachment($file->original_name),
'Content-Length' => (string) $file->size,
]);
return $this->bytes->attachment($file);
}
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Modules\Groups\Models;
use App\Models\User;
use App\Modules\Files\Access\StaffLibraryScope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -44,6 +45,37 @@ class MembershipRequest extends Model
return $query->where('status', self::STATUS_PENDING);
}
/**
* The requests a staff member may actually act on.
*
* MembershipRequestsController guards approve() and deny() with
* StaffLibraryScope::allowsGroupMembership, because joining a client
* to a group decides what that client -- and any staff member
* holding them -- can reach. This is the listing half of the same
* rule, and both the queue and the sidebar badge read it, so the
* number and the screen behind it cannot drift apart. That is why it
* lives here rather than in either caller, the same reasoning
* VisibleCommentScope::pendingTotal() gives for owning the comment
* badge instead of leaving the middleware to count for itself.
*
* Narrowed on the client only. Whether the *group* is reachable is
* the other half of allowsGroupMembership, and it depends on what is
* shared with that group -- not a question to ask row by row in a
* listing. So a scoped viewer may still be shown a request they
* would be refused on; it will be one of their own clients asking to
* join a group out of their reach, rather than a client they were
* never meant to hear about. The names are the part that leaks.
*
* @param Builder<MembershipRequest> $query
* @return Builder<MembershipRequest>
*/
public function scopeApprovableBy(Builder $query, User $viewer): Builder
{
$clientIds = app(StaffLibraryScope::class)->assignableClientIds($viewer);
return $clientIds === null ? $query : $query->whereIn('user_id', $clientIds);
}
/**
* @return BelongsTo<Group, $this>
*/
+33 -5
View File
@@ -7,7 +7,9 @@ namespace App\Modules\Identity;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Identity\Models\Role;
use App\Modules\Platform\Seats\SeatAllowance;
use App\Modules\Identity\Permissions\SystemRole;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
@@ -36,6 +38,8 @@ class AccountConversion
public function __construct(
private readonly StaffAccounts $accounts,
private readonly ActivityLogger $activity,
private readonly StaffLibraryScope $library,
private readonly SeatAllowance $seats,
) {}
/**
@@ -43,6 +47,10 @@ class AccountConversion
*/
public function guardToClient(User $actor, User $target): void
{
// The mirror of the promotion above: a demotion takes a client
// seat and frees a staff one.
$this->seats->guardClient();
$this->guardSelf($actor, $target);
// Only on this direction. "Could the actor have granted the
@@ -84,11 +92,31 @@ class AccountConversion
{
$this->guardSelf($actor, $target);
// No guardTarget here — see guardToClient(). What actually limits
// a promotion is the role being granted, and that is enforced by
// the caller validating role_id against
// StaffAccounts::assignableRoleIds(): nobody hands out authority
// they do not hold.
// A promotion takes a staff seat. It frees a client one at the same
// moment, so the two caps move in opposite directions and only the
// one being filled can refuse. Asked in the guard rather than in
// toStaff() so a refusal happens before the transaction opens.
$this->seats->guardStaff();
// No guardTarget here — see guardToClient(). It asks "could the
// actor have granted the target's role", which is meaningless of
// a client; what limits a promotion is the role being *granted*,
// and the caller enforces that by validating role_id against
// StaffAccounts::assignableRoleIds().
//
// That answers the question about the role. It does not answer
// the one about the target, and the target here is a client
// account: the same object every other route that binds one
// holds to the actor's own roster. A promotion is the most
// far-reaching thing that can be done to a client — it takes
// their portal access away, makes their assignments inert, and
// leaves them holding staff permissions the actor chose — so
// reaching one outside that roster through this door and no
// other is not a rule, it is a gap. 404 rather than 403, like
// the clients routes and like the isClient() check the caller
// makes on the way in: a client this staff member may not manage
// should not be distinguishable from one that is not there.
abort_unless($this->library->canAssignClient($actor, $target), 404);
// An account request is not an account yet. Approving one is a
// deliberate decision with its own screen and its own audit entry;
@@ -7,6 +7,7 @@ namespace App\Modules\Identity\Console;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Identity\Erasure\AvailableEmailRule;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\UserType;
@@ -43,7 +44,7 @@ class CreateAdminCommand extends Command
['name' => $name, 'email' => $email, 'password' => $password],
[
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
'email' => ['required', 'string', 'email', 'max:255', new AvailableEmailRule],
'password' => ['required', Password::defaults()],
],
);
@@ -12,7 +12,7 @@ class PurgeErasuresCommand extends Command
{
protected $signature = 'projectsend:purge-erasures';
protected $description = 'Permanently erase self-deleted accounts whose grace period has passed (runs daily)';
protected $description = 'Permanently erase deleted accounts whose grace period has passed (runs daily)';
public function handle(AccountEraser $eraser): int
{
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Modules\Identity\Erasure;
use App\Models\User;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString;
/**
* `unique:users,email` with an answer for the case that rule cannot
* explain: the address is held by a soft-deleted account.
*
* The unique index on users.email spans trashed rows on purpose — an
* email address is a login identity, and it must not become
* re-registerable while the account holding it is merely pending erasure.
* But the stock message ("has already been taken") then names a conflict
* the person at the form cannot see or clear from any screen (#1648).
* This rule keeps the refusal and explains it: when the address frees
* itself, or — for accounts deleted before erasure scheduling existed —
* which command frees it.
*
* Staff surfaces only. Public registration keeps the stock rule
* deliberately: telling an anonymous visitor "this address belongs to a
* deleted account" confirms the address had an account here, which is
* exactly the disclosure the generic message avoids.
*/
class AvailableEmailRule implements ValidationRule
{
/**
* @param Closure(string, string|null=): PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || $value === '') {
// required/string/email own that refusal.
return;
}
$holder = User::withTrashed()->where('email', $value)->first();
if ($holder === null) {
return;
}
if (! $holder->trashed()) {
// A living account: the stock unique message said all there
// is to say.
$fail('validation.unique')->translate();
return;
}
if ($holder->erase_after !== null) {
$fail(__('This email address belongs to a deleted account that is scheduled for permanent erasure. The address becomes available on :date. To free it sooner, erase the account with the projectsend:erase-account console command.', [
'date' => $holder->erase_after->toFormattedDateString(),
]));
return;
}
// Deleted before erasure scheduling existed, so no purge will ever
// reach it — only the operator command can free the address.
$fail(__('This email address belongs to a deleted account that has no erasure scheduled. Run the projectsend:erase-account console command to erase it and free the address.'));
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Modules\Identity\Erasure;
use App\Models\User;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
/**
* Stamps the moment a soft-deleted account graduates to permanent
* erasure: now plus the installation's grace period.
*
* Every deletion path calls this right before delete(), self-service and
* administrative alike, so PurgeErasuresCommand eventually reaches every
* deleted account — and the email address its row keeps reserved under
* the unique index is freed. Only self-deletion did this at first, which
* left admin-deleted accounts trashed forever and their addresses
* unusable (#1648).
*/
class ErasureSchedule
{
public function __construct(
private readonly Settings $settings,
) {}
public function apply(User $user): void
{
$graceDays = (int) $this->settings->get(Setting::AccountErasureGraceDays);
$user->forceFill(['erase_after' => now()->addDays($graceDays)])->save();
}
}
@@ -120,7 +120,9 @@ class AccountConversionController extends Controller
'is_system' => $role->is_system,
'client_scoped' => $role->client_scoped,
])->all(),
'clients' => User::query()->where('type', UserType::Client)->orderBy('name')->get()
// Narrowed like `roles` beside it: the picker offers what this
// actor may hand out, which is what store() will accept.
'clients' => User::query()->whereIn('id', $this->accounts->assignableClientIds($actor))->orderBy('name')->get()
->map(fn (User $client): array => ['id' => $client->id, 'name' => $client->name])
->values()->all(),
]);
@@ -152,7 +154,8 @@ class AccountConversionController extends Controller
'assigned_clients' => ['array'],
'assigned_clients.*' => [
'integer',
Rule::exists('users', 'id')->where('type', UserType::Client->value),
// Reach, not a label: see StaffAccounts::assignableClientIds.
Rule::in($this->accounts->assignableClientIds($actor)),
Rule::notIn([$user->id]),
],
// Required only for an account whose credential lives in the
@@ -9,6 +9,7 @@ use App\Models\User;
use App\Modules\Api\Support\PollingQuery;
use App\Modules\Files\DeletedAccountContent;
use App\Modules\Identity\AccountContentDeletion;
use App\Modules\Identity\Erasure\AvailableEmailRule;
use App\Modules\Identity\Http\Resources\Api\StaffUserResource;
use App\Modules\Identity\StaffAccounts;
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
@@ -17,6 +18,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
@@ -118,14 +120,18 @@ class UsersController extends Controller
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users,email'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
'role_id' => ['required', 'integer', Rule::in($this->accounts->assignableRoleIds($actor))],
// No `confirmed`: repeating a password defends against a human
// mistyping into a form, and an API caller has no second field
// to mistype. Password::defaults() still applies.
'password' => ['required', Password::defaults()],
'assigned_clients' => ['array'],
'assigned_clients.*' => ['integer', Rule::exists('users', 'id')->where('type', UserType::Client->value)],
// Only clients you can reach yourself: an unrestricted account may
// assign any client, a client-scoped one only the clients already
// assigned to it. Assigning a client hands over everything that
// client can see, so it follows the same rule as role_id above.
'assigned_clients.*' => ['integer', Rule::in($this->accounts->assignableClientIds($actor))],
]);
$user = $this->accounts->create([
@@ -163,7 +169,11 @@ class UsersController extends Controller
'active' => ['sometimes', 'boolean'],
'password' => ['sometimes', 'nullable', Password::defaults()],
'assigned_clients' => ['sometimes', 'array'],
'assigned_clients.*' => ['integer', Rule::exists('users', 'id')->where('type', UserType::Client->value)],
// Only clients you can reach yourself: an unrestricted account may
// assign any client, a client-scoped one only the clients already
// assigned to it. Assigning a client hands over everything that
// client can see, so it follows the same rule as role_id above.
'assigned_clients.*' => ['integer', Rule::in($this->accounts->assignableClientIds($actor))],
]);
// The same refusal the web screen makes, and for the same reason:
@@ -215,9 +225,15 @@ class UsersController extends Controller
$validated = $this->accountDeletion->validate($request, $user);
$name = $this->accounts->delete($user);
$this->accountDeletion->apply($validated, $user, $name);
// Soft-deleting the account and disposing of its files are two
// separate writes; keep them in one transaction so a failure in the
// second (e.g. the reassignment target deleted between validation
// and apply()'s findOrFail) cannot leave the account deleted with
// its content still pointing at it.
DB::transaction(function () use ($validated, $user): void {
$name = $this->accounts->delete($user);
$this->accountDeletion->apply($validated, $user, $name);
});
return response()->json(status: 204);
}
@@ -89,9 +89,12 @@ class RolesController extends Controller
$this->guardGrantablePermissions($request, $validated['permissions'] ?? []);
$clientScoped = $request->boolean('client_scoped');
$this->guardScopeRemoval($request, removesScope: ! $clientScoped);
$role = Role::query()->create([
'name' => $validated['name'],
'client_scoped' => $validated['client_scoped'] ?? false,
'client_scoped' => $clientScoped,
]);
$this->syncPermissions($role, $validated['permissions'] ?? []);
@@ -135,9 +138,12 @@ class RolesController extends Controller
// Built-in roles have fixed names and a fixed scope flag; only their
// permission set is editable. Custom roles can change name + scope.
if (! $role->is_system) {
$clientScoped = $request->boolean('client_scoped');
$this->guardScopeRemoval($request, removesScope: $role->client_scoped && ! $clientScoped);
$role->update([
'name' => $validated['name'],
'client_scoped' => $validated['client_scoped'] ?? false,
'client_scoped' => $clientScoped,
]);
}
@@ -212,6 +218,46 @@ class RolesController extends Controller
}
}
/**
* The same rule for the other half of what a role carries.
*
* `client_scoped` decides how much of the library the role reaches,
* which makes it authority in exactly the sense the docblock above
* describes -- and the larger part of it, since it is what stands
* between a limited staff member and every file on the installation.
* Both writers of the flag went through nothing at all, so
* `manage_users` alone was enough to mint a role without the limit,
* or to lift it off the actor's own, and then to hold it.
*
* Phrased as "removes the limit" rather than "is not limited", so
* that only what this request actually changes is checked -- the same
* reasoning that has guardGrantablePermissions look at the diff.
* Editing an already-unlimited role's permissions is not this actor
* lifting a limit, and StaffAccounts::mayGrant is what stops them
* holding the result either way.
*
* Callers resolve the flag with Request::boolean() and hand the same
* value to this guard and to the write, deliberately. The `boolean`
* validation rule accepts "0" and 0 as well as false but does not
* cast, so reading the validated array and comparing it strictly
* would let a request through here that the model's `boolean` cast
* then stores as false anyway -- the guard and the write disagreeing
* about one value is exactly the shape this guard exists to prevent.
*/
private function guardScopeRemoval(Request $request, bool $removesScope): void
{
$actor = $request->user();
assert($actor !== null);
if (! $removesScope || ! $actor->isClientScoped()) {
return;
}
throw ValidationException::withMessages([
'client_scoped' => __('Your own role is limited to the clients assigned to you, so a role you create or edit cannot drop that limit.'),
]);
}
/**
* @param list<string> $permissions
*/
@@ -15,7 +15,8 @@ use App\Modules\Identity\Social\SocialProvider;
use App\Modules\Identity\Social\SocialSettings;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\RedirectResponse as SymfonyRedirectResponse;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
@@ -57,13 +58,13 @@ class SocialLoginController extends Controller
}
/** Begin a sign-in. */
public function redirect(Request $request, string $provider): SymfonyRedirectResponse|RedirectResponse
public function redirect(Request $request, string $provider): Response
{
return $this->begin($request, $provider, 'login');
}
/** Begin connecting a provider to the signed-in account. */
public function connect(Request $request, string $provider): SymfonyRedirectResponse|RedirectResponse
public function connect(Request $request, string $provider): Response
{
return $this->begin($request, $provider, 'link');
}
@@ -130,7 +131,7 @@ class SocialLoginController extends Controller
return redirect()->intended(route('dashboard', absolute: false));
}
private function begin(Request $request, string $provider, string $intent): SymfonyRedirectResponse|RedirectResponse
private function begin(Request $request, string $provider, string $intent): Response
{
$case = $this->provider($provider);
$settings = SocialSettings::for($case);
@@ -142,7 +143,13 @@ class SocialLoginController extends Controller
$request->session()->put([self::INTENT => $intent, self::PROVIDER => $case->value]);
return $this->gateway()->redirect($settings);
// Inertia::location(), not the redirect itself. Connecting starts
// as an Inertia XHR from the settings screen, and an XHR follows a
// 302 to the provider cross-origin, where CORS kills it before the
// person ever leaves the page. The 409 + X-Inertia-Location pair
// makes the client navigate top-level instead; a plain browser
// request — the login flow — passes through unchanged.
return Inertia::location($this->gateway()->redirect($settings));
}
private function completeLink(Request $request, SocialSettings $settings, SocialIdentity $identity): RedirectResponse
@@ -9,6 +9,7 @@ use App\Models\User;
use App\Modules\Api\Auth\ApiTokens;
use App\Modules\Files\DeletedAccountContent;
use App\Modules\Identity\AccountContentDeletion;
use App\Modules\Identity\Erasure\AvailableEmailRule;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\StaffAccounts;
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
@@ -17,6 +18,7 @@ use App\Support\Pagination;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
@@ -112,11 +114,14 @@ class UsersController extends Controller
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users,email'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
'role_id' => ['required', 'integer', Rule::in($this->accounts->assignableRoleIds($this->actor()))],
'password' => ['required', 'confirmed', Password::defaults()],
'assigned_clients' => ['array'],
'assigned_clients.*' => ['integer', Rule::exists('users', 'id')->where('type', UserType::Client->value)],
// Reach, not a label: see StaffAccounts::assignableClientIds.
// The list is client-typed already, so this is one rule where
// an exists() plus a type filter used to be two.
'assigned_clients.*' => ['integer', Rule::in($this->accounts->assignableClientIds($this->actor()))],
]);
$user = $this->accounts->create([
@@ -126,7 +131,12 @@ class UsersController extends Controller
'password' => $validated['password'],
], $validated['assigned_clients'] ?? []);
return redirect()->route('users.edit', $user)->with('success', __('User created.'));
// Same create-without-edit rule as ClientsController::store().
$target = $this->actor()->can('edit_users')
? redirect()->route('users.edit', $user)
: redirect()->route('users.create');
return $target->with('success', __('User created.'));
}
public function edit(User $user): Response
@@ -171,7 +181,10 @@ class UsersController extends Controller
'active' => ['required', 'boolean'],
'password' => ['nullable', 'confirmed', Password::defaults()],
'assigned_clients' => ['array'],
'assigned_clients.*' => ['integer', Rule::exists('users', 'id')->where('type', UserType::Client->value)],
// Reach, not a label: see StaffAccounts::assignableClientIds.
// The list is client-typed already, so this is one rule where
// an exists() plus a type filter used to be two.
'assigned_clients.*' => ['integer', Rule::in($this->accounts->assignableClientIds($this->actor()))],
]);
// Deactivating yourself is refused here rather than in StaffAccounts
@@ -216,9 +229,15 @@ class UsersController extends Controller
$validated = $this->accountDeletion->validate($request, $user);
$name = $this->accounts->delete($user);
$this->accountDeletion->apply($validated, $user, $name);
// Soft-deleting the account and disposing of its files are two
// separate writes; keep them in one transaction so a failure in the
// second (e.g. the reassignment target deleted between validation
// and apply()'s findOrFail) cannot leave the account deleted with
// its content still pointing at it.
DB::transaction(function () use ($validated, $user): void {
$name = $this->accounts->delete($user);
$this->accountDeletion->apply($validated, $user, $name);
});
return redirect()->route('users.index')->with('success', __('User deleted.'));
}
@@ -259,13 +278,15 @@ class UsersController extends Controller
}
/**
* The client roster, for the assigned-clients picker.
* The client roster, for the assigned-clients picker — narrowed to
* what this actor may actually hand out, the same way roleOptions()
* is narrowed to the roles they may grant.
*
* @return array<int, array{id: int, name: string}>
*/
private function clientOptions(): array
{
return User::query()->where('type', UserType::Client)->orderBy('name')->get()
return User::query()->whereIn('id', $this->accounts->assignableClientIds($this->actor()))->orderBy('name')->get()
->map(fn (User $client): array => ['id' => $client->id, 'name' => $client->name])
->values()->all();
}
@@ -7,6 +7,7 @@ namespace App\Modules\Identity\Http\Middleware;
use App\Modules\Identity\TwoFactor\TwoFactorEnforcement;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Support\WriteSafeRedirect;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -48,6 +49,6 @@ class EnforceTwoFactor
return $next($request);
}
return redirect()->route('two-factor.show')->with('two_factor_enforced_notice', true);
return WriteSafeRedirect::apply($request, redirect()->route('two-factor.show')->with('two_factor_enforced_notice', true));
}
}
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Modules\Identity\Http\Middleware;
use App\Support\WriteSafeRedirect;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -25,9 +26,9 @@ class EnsureAccountIsActive
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login')->withErrors([
return WriteSafeRedirect::apply($request, redirect()->route('login')->withErrors([
'email' => __('Your account has been deactivated.'),
]);
]));
}
return $next($request);
@@ -6,6 +6,7 @@ namespace App\Modules\Identity\Http\Middleware;
use App\Models\User;
use App\Modules\Identity\UserType;
use App\Support\WriteSafeRedirect;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -28,6 +29,6 @@ class EnsureSetupIsComplete
return $next($request);
}
return redirect()->route('setup');
return WriteSafeRedirect::apply($request, redirect()->route('setup'));
}
}
+78 -5
View File
@@ -7,7 +7,10 @@ namespace App\Modules\Identity;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Identity\Erasure\ErasureSchedule;
use App\Modules\Identity\Models\Role;
use App\Modules\Platform\Seats\SeatAllowance;
use App\Modules\Identity\Permissions\PermissionChecker;
use App\Modules\Identity\Permissions\SystemRole;
use Illuminate\Support\Collection;
@@ -34,6 +37,9 @@ class StaffAccounts
public function __construct(
private readonly ActivityLogger $activity,
private readonly PermissionChecker $permissions,
private readonly ErasureSchedule $erasure,
private readonly SeatAllowance $seats,
private readonly StaffLibraryScope $library,
) {}
/**
@@ -45,8 +51,17 @@ class StaffAccounts
* permission into every permission and makes the rest of the matrix
* decorative.
*
* An administrator holds every permission by construction, so this is
* always true for them and the admin experience is unchanged.
* A role's `client_scoped` flag is part of that authority, and the
* larger part: a role without it reaches the whole library, while a
* client-scoped actor reaches only the clients assigned to them. So
* one may not hand out a role that is not client-scoped -- to a
* colleague, to a new account, or to themselves, which is the case
* that matters, since the role picker is how an account changes role
* and an account may edit its own.
*
* An administrator holds every permission by construction and is
* never client-scoped, so this is always true for them and the admin
* experience is unchanged.
*/
public function mayGrant(User $actor, Role $role): bool
{
@@ -58,6 +73,10 @@ class StaffAccounts
return false;
}
if ($actor->isClientScoped() && ! $role->client_scoped) {
return false;
}
$held = $this->permissions->grantedKeys($actor);
$granting = $role->permissions()->pluck('permission')->all();
@@ -90,6 +109,39 @@ class StaffAccounts
return array_values($this->assignableRoles($actor)->map(fn (Role $role): int => $role->id)->all());
}
/**
* Client ids this actor may put on a staff account's roster — the same
* rule as mayGrant(), applied to reach instead of to authority.
*
* An assigned client is not a label: it is everything that client can
* see, handed to whoever holds it. So a client-scoped actor may hand
* out the clients they hold and no others — including to themselves,
* which is the case that matters, since guardTarget() lets anybody
* edit their own account and `assigned_clients` was never checked
* against the actor at all. Without this a scoped staff member with
* `edit_users` could PATCH their own id with every client id on the
* installation and read the whole library from then on.
*
* An unrestricted actor gets the full roster back rather than null, so
* every caller can validate against one list instead of composing a
* conditional rule. That list is already client-typed, which is why it
* replaces the `exists:users,id where type = client` rule rather than
* joining it.
*
* @return list<int>
*/
public function assignableClientIds(User $actor): array
{
$ids = $this->library->assignableClientIds($actor);
if ($ids !== null) {
return $ids;
}
return array_values(User::query()->where('type', UserType::Client)
->pluck('id')->map(fn ($id): int => (int) $id)->all());
}
/**
* The same rule applied to an existing account: if the actor could not
* grant the target's role, they have no business editing or deleting
@@ -183,6 +235,12 @@ class StaffAccounts
*/
public function create(array $attributes, array $assignedClients = []): User
{
// Before the write, so a refusal creates nothing. Both staff
// controllers reach this, web and API; the other two doors into a
// staff seat are AccountConversion::toStaff() and the console
// command, which asks deliberately not to — see SeatAllowance.
$this->seats->guardStaff();
$user = User::create([
'type' => UserType::Staff,
'active' => true,
@@ -294,14 +352,29 @@ class StaffAccounts
}
/**
* Soft-delete the account and record it. Returns the name, which the
* caller needs afterwards for the content-reassignment step — by then
* the model is trashed and reading it back is needless ceremony.
* Soft-delete the account, schedule its permanent erasure and record
* it. Returns the name, which the caller needs afterwards for the
* content-reassignment step — by then the model is trashed and reading
* it back is needless ceremony.
*
* **This is only half of deleting somebody.** What happens to the
* files and folders they own is the other half, and it lives in
* AccountContentDeletion: validate() to make the caller choose
* between cascading and reassigning, apply() to carry it out. A
* caller that stops here leaves their content pointing at an account
* that no longer exists.
*
* The trap is that it looks like it works. validate() returns an
* empty array when the account owns nothing, so an account with no
* files deletes perfectly through this method alone — and keeps
* doing so until somebody deletes a colleague who had actually done
* some work. Both existing callers pair the two; a new one must too.
*/
public function delete(User $user): string
{
$name = $user->name;
$this->erasure->apply($user);
$user->delete();
$this->activity->log(Action::UserDeleted, context: ['name' => $name]);
@@ -14,6 +14,7 @@ use BaconQrCode\Renderer\RendererStyle\Fill;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use PragmaRX\Google2FA\Google2FA;
@@ -112,20 +113,45 @@ class TwoFactorService
/**
* Consume a recovery code; each code works exactly once.
*
* Read the list, filter it, write the whole list back is not once.
* Two requests that both read before either writes each store their
* own filtered copy, and the second write puts back the code the
* first removed -- so a spent code is available again, and the same
* code offered twice is accepted twice. Neither lets in anybody who
* was not already holding a code, which is why this is a promise not
* being kept rather than a door standing open. The promise is the
* sentence above, and it is the reason recovery codes are printed
* out and crossed off.
*
* Decide from the row as it stands, read back under a lock inside
* the transaction that writes it -- the same shape
* SendNotificationDigest uses to claim the rows it is about to
* delete. The lock is what makes it atomic against a request
* arriving at the same moment; the re-read is what makes the
* decision right, and it is the half that can be demonstrated in a
* test, since SQLite ignores lockForUpdate.
*
* The caller's own instance is what gets saved, so it does not walk
* away holding a list the database no longer has.
*/
public function consumeRecoveryCode(User $user, string $code): bool
{
/** @var list<string>|null $codes */
$codes = $user->two_factor_recovery_codes;
return DB::transaction(function () use ($user, $code): bool {
$locked = User::query()->whereKey($user->getKey())->lockForUpdate()->first();
if ($codes === null || ! in_array($code, $codes, true)) {
return false;
}
/** @var list<string>|null $codes */
$codes = $locked?->two_factor_recovery_codes;
$user->forceFill([
'two_factor_recovery_codes' => array_values(array_diff($codes, [$code])),
])->save();
if ($codes === null || ! in_array($code, $codes, true)) {
return false;
}
return true;
$user->forceFill([
'two_factor_recovery_codes' => array_values(array_diff($codes, [$code])),
])->save();
return true;
});
}
}
@@ -7,9 +7,11 @@ namespace App\Modules\Notifications\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Notifications\NotificationPreference;
use App\Modules\Notifications\NotificationPreferences;
use App\Modules\Notifications\NotificationTypeDefinition;
use App\Modules\Notifications\NotificationTypeRegistry;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
@@ -30,21 +32,12 @@ class NotificationPreferencesController extends Controller
$user = $request->user();
assert($user !== null);
// Only types that can email at all have anything to opt in or out
// of — a pure in-app type has no toggle to show. Either route
// counts: Notifier sending a mail class directly, or the digest
// buffering and sending one.
$emailable = array_values(array_filter(
$this->types->all(),
fn ($type) => $type->mailNotification !== null || $type->digestMail !== null,
));
return Inertia::render('settings/notifications', [
'types' => array_map(fn ($type) => [
'types' => array_map(fn (NotificationTypeDefinition $type): array => [
'key' => $type->key,
'label' => $type->label,
'email_enabled' => $this->preferences->emailEnabledFor($user, $type),
], $emailable),
], $this->emailable()),
]);
}
@@ -55,7 +48,11 @@ class NotificationPreferencesController extends Controller
$validated = $request->validate([
'preferences' => ['required', 'array'],
'preferences.*.type' => ['required', 'string'],
// Against the registry, not merely "a string": a preference row
// for a type nothing can send is a row that will never be read
// again, and the screen only ever offers back what edit() gave
// it.
'preferences.*.type' => ['required', 'string', Rule::in($this->emailableKeys())],
'preferences.*.email_enabled' => ['required', 'boolean'],
]);
@@ -68,4 +65,31 @@ class NotificationPreferencesController extends Controller
return back();
}
/**
* Only types that can email at all have anything to opt in or out of —
* a pure in-app type has no toggle to show. Either route counts:
* Notifier sending a mail class directly, or the digest buffering and
* sending one.
*
* Shared by both halves on purpose, so what the screen offers and what
* it accepts back cannot drift apart.
*
* @return list<NotificationTypeDefinition>
*/
private function emailable(): array
{
return array_values(array_filter(
$this->types->all(),
fn (NotificationTypeDefinition $type): bool => $type->mailNotification !== null || $type->digestMail !== null,
));
}
/**
* @return list<string>
*/
private function emailableKeys(): array
{
return array_map(fn (NotificationTypeDefinition $type): string => $type->key, $this->emailable());
}
}
@@ -19,7 +19,14 @@ namespace App\Modules\Platform\Capabilities;
*/
enum Capability: string
{
// Community-only — cut where the installation is managed for you.
// Both editions. It was Community-only while a managed installation's
// staff accounts were expected to be created from outside — but a
// platform does not know whether Alice should be an Account Manager,
// any more than it knows where her files go when she leaves, and the
// seat count it does own is enforced by PROJECTSEND_PLATFORM_MAX_STAFF_USERS
// rather than by closing the screen. Capacity is the platform's; who
// fills it is the tenant's. Same division managed storage already uses:
// the bucket is provisioned, what goes in it is not.
case UsersManage = 'users.manage';
case StorageConfigure = 'storage.configure';
case EmailTransportConfigure = 'email.transport.configure';
@@ -43,6 +50,15 @@ enum Capability: string
// package (github.com/projectsend/cloud-modules), never in this repo.
case Branding = 'branding.customize';
// Cloud-only — the storage backend is ours, supplied by the
// environment when the instance is provisioned and not the customer's
// to see or change. The counterpart of StorageConfigure above rather
// than a contradiction of it: one edition configures its own bucket,
// the other is given one. Behaviour lives in the private
// projectsend/cloud-modules package; without it this capability is
// simply inert and files stay on local disk.
case StorageManaged = 'storage.managed';
// Cloud-only — managed installations supply CAPTCHA keys centrally, so
// protection is on before anybody finds the settings screen. The
// feature itself is in both editions and behind no capability: this
@@ -50,21 +66,51 @@ enum Capability: string
// inside a self-hosted package.
case CaptchaManagedKeys = 'captcha.managed_keys';
// Cloud-only — letting an AI assistant act on this installation on
// somebody's behalf. Code lives in the private
// projectsend/cloud-modules package; without it this capability is
// inert, which is the point: the edition boundary here is which
// package is installed, not a flag an installation can set. Present
// in this enum even so, because a package cannot extend a closed one
// — core has to publish the key before anything can gate on it.
// Cloud-only — staff seats on a managed instance belong to the
// platform that sold them rather than to the instance, so the tenant's
// own /users screens stay closed (see UsersManage above) and a control
// plane creates, deactivates and password-resets them from outside.
//
// The seat *number* deliberately does not live here. There are no
// billing or plan tiers in this application to key off — the same
// reason config/api.php gives for not inventing an installation-level
// rate limit — so the limit arrives from the environment and this
// capability only says who is in charge.
//
// Declared before the module that implements it exists, and that is
// the point: a capability added after a release is invisible to every
// image built from one, which is exactly how StorageManaged came to
// sit unusable for a fleet that had everything else in place.
case PlatformManaged = 'platform.managed';
case AiConnector = 'ai.connector';
/**
* @return list<Edition>
*/
public function editions(): array
{
return match ($this) {
self::UsersManage,
self::StorageConfigure,
self::EmailTransportConfigure,
self::SystemUpdates,
self::SchedulerMonitoring,
self::CustomAssets => [Edition::Community],
self::UsersManage => [Edition::Community, Edition::Cloud],
self::Branding,
self::CaptchaManagedKeys => [Edition::Cloud],
self::StorageManaged,
self::CaptchaManagedKeys,
self::PlatformManaged,
self::AiConnector => [Edition::Cloud],
};
}
@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Platform\Capabilities\Capability;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use App\Modules\Platform\Mail\MailOAuthBrokers;
use App\Modules\Platform\Mail\MailOAuthConnection;
use App\Modules\Platform\Mail\MailOAuthException;
use App\Modules\Platform\Settings\MailConfigApplier;
use App\Modules\Platform\Settings\MailProviderSettings;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
/**
* Connecting the mailbox an OAuth mail provider sends as, and cutting it
* loose again.
*
* Mirrors SocialLoginController's shape — a session marker written
* before the redirect is what ties the callback to an exchange somebody
* here actually started, and refuses a stray or replayed one — but with
* its own state parameter instead of Socialite, because this flow wants
* raw tokens with a send scope, not a user identity (see
* MailOAuthBroker). Community-only like the rest of the transport
* configuration: on cloud, outgoing mail is the platform's relay and
* there is nothing to connect.
*/
class EmailOAuthController extends Controller
{
private const STATE = 'mail_oauth.state';
private const PROVIDER = 'mail_oauth.provider';
public function __construct(
private readonly CapabilityRegistry $capabilities,
private readonly MailOAuthBrokers $brokers,
private readonly MailConfigApplier $mailConfig,
private readonly ActivityLogger $activity,
) {}
/** Begin connecting: off to the provider's consent screen. */
public function connect(Request $request): RedirectResponse|Response
{
abort_unless($this->capabilities->has(Capability::EmailTransportConfigure), 404);
$provider = MailProviderSettings::current()->provider;
if (! $provider->isOAuth()) {
return back()->with('error', __('The selected mail provider does not use a connected mailbox.'));
}
$connection = MailOAuthConnection::for($provider);
if (! $connection->configured()) {
return back()->with('error', __('Enter and save the application (client) ID and secret first.'));
}
$state = Str::random(40);
$request->session()->put(self::STATE, $state);
$request->session()->put(self::PROVIDER, $provider->value);
// Inertia::location(), not redirect()->away(): the button posts
// through Inertia's XHR, and a plain 302 to another origin makes
// the XHR follow it into a CORS wall — the consent screen never
// appears and the page just reloads. The 409/X-Inertia-Location
// handshake turns it into a real top-level navigation (and falls
// back to an ordinary redirect for a non-Inertia request).
return Inertia::location(
$this->brokers->for($provider)->authorizeUrl($connection, $state, route('system-settings.email.oauth.callback')),
);
}
/** The provider sent the admin's browser back with a code (or a refusal). */
public function callback(Request $request): RedirectResponse
{
abort_unless($this->capabilities->has(Capability::EmailTransportConfigure), 404);
$expectedState = $request->session()->pull(self::STATE);
$startedProvider = $request->session()->pull(self::PROVIDER);
$provider = MailProviderSettings::current()->provider;
// Nobody started this exchange from here — or the provider was
// switched mid-flight, in which case the code belongs to a
// configuration that no longer exists.
if (! is_string($expectedState) || $startedProvider !== $provider->value || ! $provider->isOAuth()) {
return redirect()->route('system-settings.email.edit')->with('error', __('That connection attempt could not be completed. Please try again.'));
}
$state = $request->query('state');
if (! is_string($state) || ! hash_equals($expectedState, $state)) {
return redirect()->route('system-settings.email.edit')->with('error', __('That connection attempt could not be completed. Please try again.'));
}
// The admin clicked "Cancel" on the consent screen, or the
// provider refused. Their description is safe to show — this is
// an authenticated administrator on their own settings page.
$error = $request->query('error');
if (is_string($error) && $error !== '') {
$description = $request->query('error_description');
return redirect()->route('system-settings.email.edit')
->with('error', __('The mailbox was not connected: :reason', [
'reason' => is_string($description) && $description !== '' ? $description : $error,
]));
}
$code = $request->query('code');
if (! is_string($code) || $code === '') {
return redirect()->route('system-settings.email.edit')->with('error', __('That connection attempt could not be completed. Please try again.'));
}
$connection = MailOAuthConnection::for($provider);
try {
$this->brokers->for($provider)->exchange($connection, $code, route('system-settings.email.oauth.callback'));
} catch (MailOAuthException $e) {
return redirect()->route('system-settings.email.edit')
->with('error', __('The mailbox was not connected: :reason', ['reason' => $e->getMessage()]));
}
$this->activateConnection();
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'email', 'action' => 'mailbox_connected']);
return redirect()->route('system-settings.email.edit')
->with('success', __(':account connected. Outgoing email now sends as this mailbox.', [
'account' => (string) $connection->account_email,
]));
}
/**
* Drop the tokens; keep the app registration, so reconnecting is one
* click through the consent screen rather than a form refill.
*/
public function disconnect(): RedirectResponse
{
abort_unless($this->capabilities->has(Capability::EmailTransportConfigure), 404);
$provider = MailProviderSettings::current()->provider;
if (! $provider->isOAuth()) {
return back()->with('error', __('The selected mail provider does not use a connected mailbox.'));
}
$connection = MailOAuthConnection::for($provider);
$connection->fill([
'access_token' => null,
'refresh_token' => null,
'token_expires_at' => null,
'account_email' => null,
'last_error' => null,
])->save();
$this->activateConnection();
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'email', 'action' => 'mailbox_disconnected']);
return back()->with('success', __('Mailbox disconnected. Outgoing email is paused until one is connected again.'));
}
/**
* The same three steps EmailSettingsController::update() ends with,
* for the same reason: this request must already see the new
* transport, and the long-running queue worker must not keep sending
* (or failing) with the old one.
*/
private function activateConnection(): void
{
$this->mailConfig->flush();
$this->mailConfig->apply();
Artisan::call('queue:restart');
}
}
@@ -9,6 +9,7 @@ use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Platform\Capabilities\Capability;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use App\Modules\Platform\Mail\MailOAuthConnection;
use App\Modules\Platform\Notifications\TestEmailNotification;
use App\Modules\Platform\Settings\MailConfigApplier;
use App\Modules\Platform\Settings\MailProvider;
@@ -66,7 +67,27 @@ class EmailSettingsController extends Controller
'label' => $provider->label(),
'host' => $provider->defaultHost(),
'port' => $provider->defaultPort(),
'oauth' => $provider->isOAuth(),
'needs_tenant' => $provider->needsTenant(),
], MailProvider::cases()),
// Keyed by provider so the form can switch providers without
// a round-trip; tokens and the secret never leave the server
// — only "is one stored" and the connection's health.
'mail_oauth_connections' => collect(MailProvider::cases())
->filter(fn (MailProvider $provider): bool => $provider->isOAuth())
->mapWithKeys(function (MailProvider $provider): array {
$connection = MailOAuthConnection::for($provider);
return [$provider->value => [
'client_id' => $connection->client_id ?? '',
'has_client_secret' => $connection->client_secret !== null && $connection->client_secret !== '',
'tenant_id' => $connection->tenant_id ?? '',
'connected' => $connection->usable(),
'account_email' => $connection->account_email,
'last_refreshed_at' => $connection->last_refreshed_at?->toIso8601String(),
'last_error' => $connection->last_error,
]];
}),
'test_result' => $request->session()->get('mail_test_result'),
]);
}
@@ -79,23 +100,43 @@ class EmailSettingsController extends Controller
{
$canConfigureTransport = $this->capabilities->has(Capability::EmailTransportConfigure);
// Peeked at before validation because it decides which rule set
// the rest of the transport fields get; an unknown value falls
// through to the SMTP rules, whose `provider` rule then rejects
// it with the proper validation error.
$requestedProvider = $canConfigureTransport
? MailProvider::tryFrom((string) $request->input('provider'))
: null;
$wantsOAuth = $requestedProvider?->isOAuth() ?? false;
$rules = [
'email_notifications_enabled' => ['required', 'boolean'],
'admin_notification_emails' => ['required', 'array', 'min:1'],
'admin_notification_emails.*' => ['email', 'max:255'],
'from_address' => ['required', 'email', 'max:255'],
// With an OAuth provider the sender is the connected mailbox,
// not a form field — the form doesn't submit one.
'from_address' => [$wantsOAuth ? 'nullable' : 'required', 'email', 'max:255'],
'from_name' => ['required', 'string', 'max:255'],
];
if ($canConfigureTransport) {
$rules += [
'provider' => ['required', Rule::in(array_map(fn (MailProvider $p): string => $p->value, MailProvider::cases()))],
'host' => ['required', 'string', 'max:255'],
'port' => ['required', 'integer', 'between:1,65535'],
'username' => ['nullable', 'string', 'max:255'],
'password' => ['nullable', 'string', 'max:255'],
'encryption' => ['required', Rule::in(['none', 'tls', 'ssl'])],
];
$rules['provider'] = ['required', Rule::in(array_map(fn (MailProvider $p): string => $p->value, MailProvider::cases()))];
if ($wantsOAuth) {
$rules += [
'client_id' => ['required', 'string', 'max:255'],
'client_secret' => ['nullable', 'string', 'max:255'],
'tenant_id' => ['nullable', 'string', 'max:255'],
];
} else {
$rules += [
'host' => ['required', 'string', 'max:255'],
'port' => ['required', 'integer', 'between:1,65535'],
'username' => ['nullable', 'string', 'max:255'],
'password' => ['nullable', 'string', 'max:255'],
'encryption' => ['required', Rule::in(['none', 'tls', 'ssl'])],
];
}
}
$validated = $request->validate($rules);
@@ -109,23 +150,69 @@ class EmailSettingsController extends Controller
// Transport fields are simply never read from the request when the
// capability is absent — a hand-crafted PATCH can't smuggle a
// custom relay into a cloud install through this endpoint either.
if ($canConfigureTransport) {
$mailProvider->fill([
'provider' => $validated['provider'],
'host' => $validated['host'],
'port' => $validated['port'],
'username' => $validated['username'] ?? null,
'encryption' => $validated['encryption'],
]);
if ($canConfigureTransport && $requestedProvider !== null) {
$mailProvider->provider = $requestedProvider;
// A blank password keeps whatever is already stored — the field
// is never round-tripped to the browser (only `has_password` is).
if (is_string($validated['password'] ?? null) && $validated['password'] !== '') {
$mailProvider->password = $validated['password'];
if ($wantsOAuth) {
// The SMTP columns keep their values — switching to an
// OAuth provider and back must lose nothing.
$connection = MailOAuthConnection::for($requestedProvider);
// A different app registration invalidates tokens minted
// by the old one (the next refresh would present the new
// client_id against them and die) — drop them now so the
// page honestly shows "not connected" instead of a
// connection that fails on first send.
$clientIdChanged = $connection->client_id !== null
&& $connection->client_id !== ''
&& $connection->client_id !== $validated['client_id'];
$connection->client_id = $validated['client_id'];
$connection->tenant_id = ($validated['tenant_id'] ?? null) !== null && trim((string) $validated['tenant_id']) !== ''
? trim((string) $validated['tenant_id'])
: null;
// A blank secret keeps whatever is already stored, like
// the SMTP password below (only `has_client_secret` is
// ever round-tripped to the browser).
if (is_string($validated['client_secret'] ?? null) && $validated['client_secret'] !== '') {
$connection->client_secret = $validated['client_secret'];
}
if ($clientIdChanged) {
$connection->fill([
'access_token' => null,
'refresh_token' => null,
'token_expires_at' => null,
'account_email' => null,
'last_error' => null,
]);
}
$connection->save();
} else {
$mailProvider->fill([
'host' => $validated['host'],
'port' => $validated['port'],
'username' => $validated['username'] ?? null,
'encryption' => $validated['encryption'],
]);
// A blank password keeps whatever is already stored — the field
// is never round-tripped to the browser (only `has_password` is).
if (is_string($validated['password'] ?? null) && $validated['password'] !== '') {
$mailProvider->password = $validated['password'];
}
}
}
$mailProvider->from_address = $validated['from_address'];
// Absent while an OAuth provider is selected (the connected
// mailbox is the sender) — the stored value survives for a later
// switch back to SMTP.
if (is_string($validated['from_address'] ?? null) && $validated['from_address'] !== '') {
$mailProvider->from_address = $validated['from_address'];
}
$mailProvider->from_name = $validated['from_name'];
$mailProvider->save();
@@ -157,9 +244,18 @@ class EmailSettingsController extends Controller
'recipient' => ['required', 'email', 'max:255'],
]);
$host = config('mail.mailers.smtp.host');
$port = config('mail.mailers.smtp.port');
$hostPort = (is_string($host) ? $host : '').':'.(is_scalar($port) ? (string) $port : '');
// What "via" means depends on the active transport: host:port
// only describes SMTP; an OAuth mailer is best named by its
// mailer key (e.g. "microsoft-graph").
$mailer = config('mail.default');
if ($mailer === 'smtp') {
$host = config('mail.mailers.smtp.host');
$port = config('mail.mailers.smtp.port');
$hostPort = (is_string($host) ? $host : '').':'.(is_scalar($port) ? (string) $port : '');
} else {
$hostPort = is_string($mailer) ? $mailer : '';
}
// Which of the two this is has to travel with the message rather
// than be inferred from its text: the frontend colours the result,
@@ -9,12 +9,17 @@ use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Platform\Settings\ExternalStorageConfigApplier;
use App\Modules\Platform\Settings\ExternalStorageSettings;
use App\Modules\Platform\Settings\StorageProvider;
use Aws\S3\S3Client;
use Closure;
use Google\Cloud\Storage\StorageClient;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
use RuntimeException;
use Throwable;
/**
@@ -37,6 +42,7 @@ class ExternalStorageSettingsController extends Controller
return Inertia::render('system/settings/storage', [
'active' => $settings->active,
'provider' => $settings->provider->value,
// Never name a top-level Inertia prop "key" — Inertia's React
// renderer spreads page props onto the component via
// `{ key: <internal-remount-key>, ...props }`, and a prop
@@ -45,6 +51,10 @@ class ExternalStorageSettingsController extends Controller
// the component as an actual prop (React always strips `key`).
'access_key' => $settings->key ?? '',
'has_secret' => $settings->secret !== null && $settings->secret !== '',
// Same treatment as the secret: never round-tripped, only
// whether one is stored. A service account key file is more
// sensitive than an access key, not less.
'has_key_file' => $settings->key_file !== null && $settings->key_file !== '',
'bucket' => $settings->bucket ?? '',
'region' => $settings->region ?? '',
'endpoint' => $settings->endpoint ?? '',
@@ -56,35 +66,56 @@ class ExternalStorageSettingsController extends Controller
public function update(Request $request): RedirectResponse
{
$request->merge(['provider' => $request->input('provider', StorageProvider::S3->value)]);
$validated = $request->validate([
'active' => ['required', 'boolean'],
'access_key' => ['required', 'string', 'max:255'],
'secret' => ['nullable', 'string', 'max:255'],
// 'sometimes', not 'required': absent means S3, which is what
// every payload written before this choice existed meant, and
// stops a browser holding a stale bundle from failing to save
// on a field it cannot see.
'provider' => ['sometimes', Rule::enum(StorageProvider::class)],
'bucket' => ['required', 'string', 'max:255'],
'region' => ['required', 'string', 'max:255'],
'root' => ['nullable', 'string', 'max:255'],
// Required only for the provider that uses them, so switching
// to GCS does not demand an AWS region that means nothing.
'access_key' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'],
'secret' => ['nullable', 'string', 'max:255'],
'region' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'],
'endpoint' => ['nullable', 'string', 'max:255'],
'use_path_style' => ['required', 'boolean'],
'root' => ['nullable', 'string', 'max:255'],
// Checked for shape here rather than left to fail at the first
// upload: a key file is pasted, and a paste that lost its last
// line is the likeliest way this goes wrong.
'key_file' => ['nullable', 'string', self::serviceAccountKeyRule()],
]);
$settings = ExternalStorageSettings::current();
$settings->fill([
'active' => $validated['active'],
'key' => $validated['access_key'],
'provider' => $validated['provider'],
'key' => $validated['access_key'] ?? null,
'bucket' => $validated['bucket'],
'region' => $validated['region'],
'region' => $validated['region'] ?? null,
'endpoint' => $validated['endpoint'] ?? null,
'use_path_style' => $validated['use_path_style'],
'root' => $validated['root'] ?? null,
]);
// A blank secret keeps whatever is already stored — the field is
// never round-tripped to the browser (only `has_secret` is).
// A blank credential keeps whatever is already stored — neither
// field is ever round-tripped to the browser (only the has_*
// flags are), so blank means "unchanged", not "cleared".
if (is_string($validated['secret'] ?? null) && $validated['secret'] !== '') {
$settings->secret = $validated['secret'];
}
if (is_string($validated['key_file'] ?? null) && $validated['key_file'] !== '') {
$settings->key_file = $validated['key_file'];
}
$settings->save();
$this->configApplier->flush();
@@ -101,43 +132,31 @@ class ExternalStorageSettingsController extends Controller
}
/**
* Verifies the submitted (or, if the secret field was left blank, the
* already-stored) credentials can actually reach the bucket, mirroring
* v1's connection test — this exists specifically to catch a typo'd
* key/bucket/region before switching uploads over to it.
* Verifies the submitted (or, where a credential field was left
* blank, the already-stored) details can actually reach the bucket,
* mirroring v1's connection test — this exists specifically to catch
* a typo'd key/bucket/region before switching uploads over to it.
*/
public function testConnection(Request $request): RedirectResponse
{
$request->merge(['provider' => $request->input('provider', StorageProvider::S3->value)]);
$validated = $request->validate([
'access_key' => ['required', 'string', 'max:255'],
'secret' => ['nullable', 'string', 'max:255'],
'provider' => ['sometimes', Rule::enum(StorageProvider::class)],
'bucket' => ['required', 'string', 'max:255'],
'region' => ['required', 'string', 'max:255'],
'access_key' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'],
'secret' => ['nullable', 'string', 'max:255'],
'region' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'],
'endpoint' => ['nullable', 'string', 'max:255'],
'use_path_style' => ['nullable', 'boolean'],
'key_file' => ['nullable', 'string', self::serviceAccountKeyRule()],
]);
$settings = ExternalStorageSettings::current();
$secret = (is_string($validated['secret'] ?? null) && $validated['secret'] !== '')
? $validated['secret']
: $settings->secret;
try {
$config = [
'version' => 'latest',
'region' => $validated['region'],
'credentials' => [
'key' => $validated['access_key'],
'secret' => (string) $secret,
],
'use_path_style_endpoint' => (bool) ($validated['use_path_style'] ?? false),
];
if (is_string($validated['endpoint'] ?? null) && $validated['endpoint'] !== '') {
$config['endpoint'] = $validated['endpoint'];
}
(new S3Client($config))->headBucket(['Bucket' => $validated['bucket']]);
match (StorageProvider::from($validated['provider'])) {
StorageProvider::S3 => $this->probeS3($validated),
StorageProvider::Gcs => $this->probeGcs($validated),
};
$result = __('Success: connected to bucket ":bucket".', ['bucket' => $validated['bucket']]);
} catch (Throwable $e) {
@@ -146,4 +165,91 @@ class ExternalStorageSettingsController extends Controller
return back()->with('storage_test_result', $result);
}
/**
* @param array<string, mixed> $validated
*/
private function probeS3(array $validated): void
{
$config = [
'version' => 'latest',
'region' => $validated['region'],
'credentials' => [
'key' => $validated['access_key'],
'secret' => (string) $this->storedIfBlank($validated, 'secret'),
],
'use_path_style_endpoint' => (bool) ($validated['use_path_style'] ?? false),
];
if (is_string($validated['endpoint'] ?? null) && $validated['endpoint'] !== '') {
$config['endpoint'] = $validated['endpoint'];
}
(new S3Client($config))->headBucket(['Bucket' => $validated['bucket']]);
}
/**
* @param array<string, mixed> $validated
*/
private function probeGcs(array $validated): void
{
$keyFile = json_decode((string) $this->storedIfBlank($validated, 'key_file'), true);
if (! is_array($keyFile)) {
throw new RuntimeException(__('No service account key has been saved yet.'));
}
$bucket = (new StorageClient(['keyFile' => $keyFile]))->bucket($validated['bucket']);
// Listing one object rather than asking whether the bucket exists.
// A least-privilege key — roles/storage.objectAdmin scoped to this
// bucket, which is what the whole design rests on — can read and
// write objects but cannot read the bucket's own metadata, so
// $bucket->exists() reports failure for a key that works perfectly.
// An empty bucket is a valid answer here, and returns no rows.
iterator_to_array($bucket->objects(['maxResults' => 1]), false);
}
/**
* A credential field left blank means "keep what is stored" on save,
* so the connection test has to read it the same way — otherwise
* testing an unchanged configuration would always fail.
*
* @param array<string, mixed> $validated
*/
private function storedIfBlank(array $validated, string $field): ?string
{
$submitted = $validated[$field] ?? null;
if (is_string($submitted) && $submitted !== '') {
return $submitted;
}
return ExternalStorageSettings::current()->{$field};
}
/**
* A pasted service account key, checked for the parts that have to be
* there. Not a credential check — that is what Test connection is for.
*/
private static function serviceAccountKeyRule(): Closure
{
return function (string $attribute, mixed $value, Closure $fail): void {
$decoded = json_decode((string) $value, true);
if (! is_array($decoded)) {
$fail(__('That does not look like a service account key file: it is not valid JSON.'));
return;
}
foreach (['client_email', 'private_key'] as $required) {
if (! isset($decoded[$required]) || ! is_string($decoded[$required]) || $decoded[$required] === '') {
$fail(__('That service account key file is missing its :field.', ['field' => $required]));
return;
}
}
};
}
}
@@ -34,6 +34,7 @@ class PublicListingSettingsController extends Controller
return Inertia::render('system/settings/public-listing', [
'public_listing_enabled' => $this->settings->get(Setting::PublicListingEnabled),
'public_listing_slug' => $this->settings->get(Setting::PublicListingSlug),
'public_listing_preview_enabled' => $this->settings->get(Setting::PublicListingPreviewEnabled),
]);
}
@@ -42,10 +43,12 @@ class PublicListingSettingsController extends Controller
$validated = $request->validate([
'public_listing_enabled' => ['required', 'boolean'],
'public_listing_slug' => ['required', 'string', 'max:255', 'regex:/^[a-z0-9]+(-[a-z0-9]+)*$/'],
'public_listing_preview_enabled' => ['required', 'boolean'],
]);
$this->settings->set(Setting::PublicListingEnabled, $validated['public_listing_enabled']);
$this->settings->set(Setting::PublicListingSlug, $validated['public_listing_slug']);
$this->settings->set(Setting::PublicListingPreviewEnabled, $validated['public_listing_preview_enabled']);
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'public_listing']);
@@ -61,6 +61,7 @@ class SchedulerMonitoringController extends Controller
'projectsend:purge-api-request-logs' => (string) __('Purge API request logs'),
'projectsend:purge-failed-jobs' => (string) __('Purge failed jobs'),
'projectsend:purge-notifications' => (string) __('Purge read notifications'),
'projectsend:refresh-mail-oauth-tokens' => (string) __('Refresh mail OAuth tokens'),
];
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Installation\Console;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use App\Modules\Platform\Seats\SeatAllowance;
use Illuminate\Console\Command;
/**
* What this installation is, as a fact rather than a screen.
*
* Written for whatever watches a managed installation from outside the
* container. Everything here is already visible to any signed-in
* administrator — a version, an edition, which capabilities the edition
* grants, how many accounts exist against how many are allowed. Nothing
* is a secret and nothing is a credential.
*
* ### Why a command and not a shell one-liner
*
* A reconciler that observes tenants has to be able to say it never sends
* instructions, only reads state. `docker exec … php -r '…'` is an
* instruction with the caller's argv in it, however harmless the argv;
* a named command is an observation, the same kind of thing as reading a
* directory size. The distinction is the whole reason this exists rather
* than a documented incantation.
*
* ### The counts are the enforcing code's own
*
* `used` comes from SeatAllowance, which is what refuses the account past
* the limit. Two counts that merely agree will diverge eventually — over
* an inactive account, or a soft-deleted one — and the divergence looks
* like a billing fault rather than a counting one. So there is one
* definition and this reads it.
*/
class StatusCommand extends Command
{
protected $signature = 'projectsend:status {--json : Emit machine-readable JSON on stdout}';
protected $description = 'Report this installation\'s version, edition, capabilities and seat usage';
public function handle(CapabilityRegistry $capabilities, SeatAllowance $seats): int
{
$status = [
'version' => (string) config('projectsend.version'),
'edition' => $capabilities->edition()->value,
'capabilities' => $capabilities->enabledKeys(),
'seats' => [
'staff' => [
'used' => $seats->staffUsed(),
// null is unlimited, and is emitted as null rather than
// as 0 or as an absent key: a reader that mistook one
// for the other would report an installation selling
// unlimited accounts as one that may hold none.
'limit' => $seats->staffLimit(),
],
'clients' => [
'used' => $seats->clientUsed(),
'limit' => $seats->clientLimit(),
],
],
];
if ($this->option('json')) {
$this->line((string) json_encode($status, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
$this->line("ProjectSend {$status['version']} ({$status['edition']})");
$this->line('Capabilities: '.(implode(', ', $status['capabilities']) ?: 'none'));
$this->line('Staff seats: '.$this->seatLine($status['seats']['staff']));
$this->line('Clients: '.$this->seatLine($status['seats']['clients']));
return self::SUCCESS;
}
/**
* @param array{used: int, limit: int|null} $seat
*/
private function seatLine(array $seat): string
{
return $seat['limit'] === null
? "{$seat['used']} of unlimited"
: "{$seat['used']} of {$seat['limit']}";
}
}
@@ -5,35 +5,56 @@ declare(strict_types=1);
namespace App\Modules\Platform\Installation;
/**
* Whether this installation runs from a container image or from files on a
* server somebody administers directly.
* Whether this installation runs from a container image, from a container
* the operator builds themselves, or from files on a server somebody
* administers directly.
*
* It exists because the application tells administrators how to upgrade, and
* the two answers have nothing in common. A container is replaced —
* `docker compose pull && docker compose up -d`, with the entrypoint running
* the migrations on the way up. A manual install is a sequence somebody
* performs by hand: back up, take the site down, unpack the release over the
* directory, migrate, refresh the caches, bring it back (INSTALL.md).
* the three answers have nothing in common. A published container is
* replaced — `docker compose pull && docker compose up -d`, with the
* entrypoint running the migrations on the way up. A container built from a
* checkout has to be given new code and rebuilt. A manual install is a
* sequence somebody performs by hand: back up, take the site down, unpack
* the release over the directory, migrate, refresh the caches, bring it back
* (INSTALL.md).
*
* Printing the container command to someone who installed from a zip is
* worse than printing nothing: it names a tool they do not have, for a stack
* they are not running, at the exact moment they are trying to do the right
* thing. That was the behaviour before this class existed — the command was
* a hardcoded string in two React components, written when Docker was the
* only supported path.
* Printing the wrong one of those is worse than printing nothing. For a
* manual install the container command names a tool they do not have, for a
* stack they are not running, at the exact moment they are trying to do the
* right thing — that was the behaviour before this class existed, when the
* command was a hardcoded string in two React components. For a stack built
* from a checkout it is worse still, because the command runs: `pull` skips
* services that have no image to pull and `up -d` then finds every container
* already current, so the update reports success and changes nothing, and
* the dashboard goes on offering the same release forever (#1661).
*
* The detection is the presence of the file a container runtime leaves in
* the root filesystem. It is a deliberately conservative signal: something
* exotic enough to run neither Docker nor Podman is reported as a manual
* install, which is the safer wrong answer of the two — the manual
* instructions are steps a person follows and check for themselves, while
* the container command is one they would paste.
* Two signals, in order:
*
* 1. The published image sets PROJECTSEND_IMAGE. A positive marker set at
* build time is the only one a bind mount can neither forge nor hide.
* 2. Failing that — images published before that variable existed — a
* working tree in the install directory. The image is built from an
* unpacked release artifact and has none; the Compose stack in the
* repository bind-mounts the repository itself.
*
* Being in a container at all is the presence of the file a container
* runtime leaves in the root filesystem. It is a deliberately conservative
* signal: something exotic enough to run neither Docker nor Podman is
* reported as a manual install, which is the safer wrong answer of the
* three — the manual instructions are steps a person follows and checks for
* themselves, while the container commands are ones they would paste.
*/
class Installation
{
public function kind(): InstallationKind
{
return $this->inContainer() ? InstallationKind::Container : InstallationKind::Manual;
if (! $this->inContainer()) {
return InstallationKind::Manual;
}
return $this->builtFromSource()
? InstallationKind::ContainerSource
: InstallationKind::Container;
}
/**
@@ -43,6 +64,33 @@ class Installation
protected function inContainer(): bool
{
// Docker writes the first; Podman writes the second.
return file_exists('/.dockerenv') || file_exists('/run/.containerenv');
//
// Suppressed, and it has to stay that way. Shared hosting sets
// open_basedir to the webspace, and probing a path outside it is a
// warning rather than a false — which the framework's error handler
// turns into an exception, so the one call that asks which install
// this is took the whole dashboard down with it (#1663). Under `@`
// the warning is filtered and the probe answers false, which is the
// right answer anyway: a host that restricts PHP to a vhost
// directory is not the container image.
return @file_exists('/.dockerenv') || @file_exists('/run/.containerenv');
}
/**
* Protected for the same reason as inContainer(), and answered the same
* way in tests.
*/
protected function builtFromSource(): bool
{
// getenv() rather than env(): once the configuration is cached,
// env() outside a config file returns null, and the answer would
// silently flip on the installs most likely to have cached it.
if (getenv('PROJECTSEND_IMAGE') === '1') {
return false;
}
// A worktree checkout writes .git as a file rather than a
// directory, so ask whether it exists, not what it is.
return file_exists(base_path('.git'));
}
}
@@ -11,9 +11,17 @@ namespace App\Modules\Platform\Installation;
*/
enum InstallationKind: string
{
/** Runs from an image: upgrading is pulling a new one. */
/** Runs from the published image: upgrading is pulling a new one. */
case Container = 'container';
/**
* Runs from a container the operator builds themselves, out of a
* checkout of the repository: upgrading is new code first, then a
* rebuild. Pulling does nothing here — there is no published image
* behind these containers to pull.
*/
case ContainerSource = 'container-source';
/** Runs from files on a server someone administers: upgrading is INSTALL.md's sequence. */
case Manual = 'manual';
}
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Mail\Console;
use App\Models\User;
use App\Modules\Identity\Permissions\Permission;
use App\Modules\Identity\Permissions\PermissionChecker;
use App\Modules\Identity\UserType;
use App\Modules\Notifications\Notifier;
use App\Modules\Platform\Mail\MailOAuthBrokers;
use App\Modules\Platform\Mail\MailOAuthConnection;
use App\Modules\Platform\Mail\MailOAuthException;
use App\Modules\Platform\Settings\MailConfigApplier;
use Illuminate\Console\Command;
/**
* Keeps every connected OAuth mailbox able to send, and says so early
* when one no longer can.
*
* Transports already refresh on demand at send time; what they cannot do
* is refresh on an installation that sends rarely — and a delegated
* refresh token dies of pure disuse (Microsoft's sliding inactivity
* window). A daily refresh keeps the window sliding, and doubles as the
* health check: the delegated flow's one real weakness is that a grant
* can die silently (password reset, Conditional Access change), which
* for a portal whose password-reset mails ride on this connection must
* surface as a warning, not as a support ticket weeks later.
*/
class RefreshMailOAuthTokensCommand extends Command
{
protected $signature = 'projectsend:refresh-mail-oauth-tokens';
protected $description = 'Refresh connected OAuth mailbox tokens and flag connections that need to be reconnected (runs daily)';
public function handle(MailOAuthBrokers $brokers, Notifier $notifier, PermissionChecker $permissions, MailConfigApplier $mailConfig): int
{
$connections = MailOAuthConnection::query()->get()->filter(
fn (MailOAuthConnection $connection): bool => $connection->usable(),
);
if ($connections->isEmpty()) {
$this->info('No connected OAuth mailboxes; nothing to refresh.');
return self::SUCCESS;
}
foreach ($connections as $connection) {
$hadError = $connection->last_error !== null;
try {
$brokers->for($connection->provider)->refresh($connection);
$this->info("Refreshed {$connection->provider->value} ({$connection->account_email}).");
// Back from the dead (an admin fixed things upstream
// without reconnecting): the applier may have been
// resolving "not ready" and must see the recovery.
if ($hadError) {
$mailConfig->flush();
}
} catch (MailOAuthException $e) {
$this->error("Could not refresh {$connection->provider->value}: {$e->getMessage()}");
if (! $e->needsReconnect) {
continue;
}
// Only on the transition into the broken state — the
// notification would otherwise repeat daily for as long
// as nobody reconnects, and a nagging alert trains
// people to ignore the one that matters.
if (! $hadError) {
$recipients = array_values(User::query()->where('type', UserType::Staff)->get()
->filter(fn (User $staff): bool => $permissions->allows($staff, Permission::EditSettings))
->all());
$notifier->send('mail_oauth_connection_broken', $recipients, data: [
'provider' => $connection->provider->label(),
'account' => (string) $connection->account_email,
]);
}
$mailConfig->flush();
}
}
return self::SUCCESS;
}
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Mail;
use App\Modules\Platform\Settings\MailProvider;
use Illuminate\Support\Facades\Http;
use Symfony\Component\Mailer\Exception\TransportException;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mailer\Transport\AbstractTransport;
/**
* Sends through the Gmail API as the connected Google account.
*
* Gmail's messages.send takes the raw RFC 822 message base64url-encoded
* — the same "ship Symfony's exact bytes" approach as
* MicrosoftGraphTransport, and for the same reason: re-describing an
* already-rendered message in a vendor's JSON shape is a second
* serializer to get subtly wrong. Gmail reads recipients from the MIME
* headers and strips Bcc on delivery.
*
* Gmail rewrites the From header to the authenticated account (or one
* of its configured send-as aliases), which is why MailConfigApplier
* pins mail.from.address to the connected account while this provider
* is active. The connection row is read fresh on every send — a queue
* worker holds this transport for its whole life, and tokens change
* underneath it.
*/
class GmailTransport extends AbstractTransport
{
public function __construct(private readonly GoogleMailBroker $broker)
{
parent::__construct();
}
protected function doSend(SentMessage $message): void
{
$connection = MailOAuthConnection::for(MailProvider::Gmail);
if (! $connection->usable()) {
throw new TransportException('Gmail is selected as the mail provider, but no account is connected.');
}
try {
$token = $this->broker->freshAccessToken($connection);
} catch (MailOAuthException $e) {
throw new TransportException('Could not get a Google access token: '.$e->getMessage(), 0, $e);
}
$response = Http::withToken($token)->post('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', [
'raw' => rtrim(strtr(base64_encode($message->toString()), '+/', '-_'), '='),
]);
if (! $response->successful()) {
$status = $response->json('error.status');
$detail = $response->json('error.message');
throw new TransportException(
'Gmail refused the message (HTTP '.$response->status()
.(is_string($status) && $status !== '' ? ', '.$status : '').')'
.(is_string($detail) && $detail !== '' ? ': '.$detail : '.'),
);
}
}
public function __toString(): string
{
return 'gmail-api';
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Mail;
/**
* Google OAuth tokens for sending through the Gmail API.
*
* Same delegated shape as Microsoft's: an administrator signs into the
* Google account the installation should send as, and the token can
* send as that account and nothing else. The app registration lives in
* Google Cloud Console (an OAuth client of type "Web application");
* a Workspace admin can mark it Internal, everyone else runs it in
* testing/published status — in testing, Google expires the refresh
* token after 7 days, which the daily health check surfaces as a
* reconnect warning rather than silent dead mail.
*/
class GoogleMailBroker extends OAuthCodeFlowBroker
{
/**
* gmail.send is the one permission sending needs; openid/email buy
* the id_token the connected account's address is read from — no
* userinfo call, no broader Gmail access.
*/
private const SCOPE = 'openid email https://www.googleapis.com/auth/gmail.send';
public function authorizeUrl(MailOAuthConnection $connection, string $state, string $redirectUri): string
{
// access_type=offline is what makes Google issue a refresh token
// at all, and prompt must include 'consent' because Google only
// hands one out while showing the consent screen — a silent
// re-auth returns none, and this flow cannot run on borrowed
// time. select_account for the same reason as Microsoft's: the
// sending account is usually not the one the admin is signed
// into.
return 'https://accounts.google.com/o/oauth2/v2/auth?'.http_build_query([
'client_id' => (string) $connection->client_id,
'response_type' => 'code',
'redirect_uri' => $redirectUri,
'scope' => self::SCOPE,
'state' => $state,
'access_type' => 'offline',
'prompt' => 'select_account consent',
]);
}
protected function tokenEndpoint(MailOAuthConnection $connection): string
{
return 'https://oauth2.googleapis.com/token';
}
protected function scope(): string
{
return self::SCOPE;
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Mail;
/**
* One OAuth mail provider's token machinery: building the consent URL,
* turning the returned code into tokens, and keeping those tokens fresh.
*
* Deliberately not Socialite: a mail connection needs raw tokens with a
* send scope, not a user identity, and Socialite's user() call would
* drag in a userinfo permission (User.Read on Graph) that sending mail
* does not need. Implementations write their results straight onto the
* MailOAuthConnection row and save it.
*/
interface MailOAuthBroker
{
/** The provider consent URL the admin's browser is sent to. */
public function authorizeUrl(MailOAuthConnection $connection, string $state, string $redirectUri): string;
/**
* Exchange the callback's authorization code for tokens and record
* them, along with the connected mailbox's address, on the connection.
*
* @throws MailOAuthException
*/
public function exchange(MailOAuthConnection $connection, string $code, string $redirectUri): void;
/**
* Refresh the access token (rotating the refresh token when the
* provider hands back a new one) and record the outcome — including
* `last_error` on failure, so the settings page and the scheduled
* health check read one source of truth.
*
* @throws MailOAuthException
*/
public function refresh(MailOAuthConnection $connection): void;
/**
* An access token currently valid for at least a small safety margin,
* refreshing first when needed — what transports call at send time.
*
* @throws MailOAuthException
*/
public function freshAccessToken(MailOAuthConnection $connection): string;
}

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