318 Commits

Author SHA1 Message Date
ignacionelson fba5f30436 Release 2.4.0 2026-09-08 09:33:14 -03:00
ignacionelson 0f66f9030c Cut the unreleased notes down to what an operator needs
Each entry was three paragraphs explaining itself. Somebody deciding
whether to upgrade reads a list, and a list that takes ten minutes is one
they skim — so the reasoning is gone and the fact is what is left.

What stayed long is Upgrade notes, deliberately: those are the two things
somebody has to *do*, and a one-liner that says "allow headroom" without
saying how much or when is a note they have to come back and ask about.

This makes the section shorter than 2.3.0 and 2.2.1 above it. Those are
published and stay as they are; the style changes from here.
2026-09-08 09:06:00 -03:00
ignacionelson 7c16733c16 Stop a managed instance being able to hide the project news
I shipped both daily calls as the same kind of thing — an operator's
preference — and only one of them is. That was wrong in the direction that
matters, because it handed a decision over rather than keeping it.

An update notice on a hosted tenant is useless: they cannot act on it, the
image is ours, and the screen that would show it is closed by capability.
So that check does not run there at all, which is right and unchanged.

News is the reverse. Announcements about the product are exactly what a
hosted customer should be told, and a Cloud client with view_news sees
that card today. One administrator switching it off for everybody on that
instance is not a decision the platform meant to hand over — so on a
managed instance the news now runs whatever any setting says, including a
row left behind by an instance that used to be self-hosted.

Capability::NewsConfigure, Community-only, and the thing it gates is the
*choice* rather than the news. A self-hosted operator keeps the switch,
because there nobody else decides what their installation reaches out for.
An edition difference through the capability registry rather than an
edition check, as everything here is.

Gated in all three places rather than only the screen: the command ignores
the setting without the capability, the controller neither sends nor reads
the field, and the checkbox is absent. There is a test that a hand-crafted
PATCH cannot do what the missing checkbox could not, and the guard is
proved load-bearing — remove it and the managed-instance test goes red.

The changelog and product highlights said "two switches" and now say what
is actually true, including that neither appears on Cloud and why they are
absent for opposite reasons.
2026-09-08 02:34:00 -03:00
ignacionelson d7d7acce85 Put the announcement behind the header icon too, from one source
A message worth showing was only on the dashboard, which means somebody
who works in Files and Clients all day never meets it. It now also sits
behind an icon next to the notification bell, and that is on every page.

**One shared prop, not two.** "The same message in both places" is the
requirement, and two props would have drifted the first time anybody
edited one — so the hook moved out of DashboardController into
HandleInertiaRequests, and the dashboard reads the same shared value the
header does. The band and the dropdown also share the component that
renders the words, for the same reason: the reliable way to keep two
renderings identical is not to have two.

Renamed with it. ResolvingDashboardCallout was accurate for about an hour
and became a lie the moment it appeared somewhere else; it is
ResolvingAnnouncement now, and the prop is `announcement`. Free to rename
because nothing has shipped yet — the only other reference was
cloud-modules', by string, updated alongside.

The icon follows UpdateAvailableIcon beside it: absent entirely when there
is nothing to say rather than a dead control, and a plain dot instead of a
count, because there is only ever one of these and a "1" would invite
somebody to look for the second.

Two tests worth naming. One asserts the message reaches a page that is not
the dashboard, which is the whole point of the addition. The other asserts
a client is shown nothing even from a listener that sets it
unconditionally — a client's header carries the bell too, and staff
messages must not reach it however careless the listener.
2026-09-08 02:17:56 -03:00
ignacionelson 334b11d562 Give packages a way into the sidebar and the top of the dashboard
Two seams, in the shape docs/extension-points-architecture.md settles on:
a Laravel event with a mutable payload, dispatched unconditionally, and
with nothing listening the documented default holds. A community
installation gets an empty list and a null callout, which is exactly what
it had before.

ResolvingNavigationLinks exists because the sidebar is a hardcoded array
in app-sidebar.tsx, so a package could not contribute to it at all — the
nav entry was a separate manual edit every time a package grew a screen,
and being manual it was forgotten more than once. Staff-only, decided in
HandleInertiaRequests rather than trusted to each listener: these render
in the administration area, and a client's portal shows their own files
and nothing about the installation. There is a test that a listener adding
unconditionally still reaches no client.

ResolvingDashboardCallout is one band above the widget grid rather than a
widget in it. The grid is a closed list of keys that dashboard.tsx renders
one by one and each viewer arranges, so a message that mattered would sit
wherever somebody dragged it, or under a fold, or switched off. One at a
time, first listener wins: a dashboard that can accumulate banners
accumulates them, and the second is what teaches people to skip the first.

Core learns nothing about what either seam carries. Titles, URLs and copy
all arrive from the listener, and that is not fastidiousness — the first
caller is the hosted edition's link to its own customer portal and its
pitch to free instances, which is commercial copy belonging to one
offering and has no business sitting in the public repository because the
sidebar happens to live here.

An external link renders as a plain anchor opening in a new tab, never an
Inertia <Link>: Link expects a page component back and another origin will
not give it one, so it fails without saying so. It is also never marked
active — nothing outside this app is the page you are on.
2026-09-08 02:07:08 -03:00
ignacionelson da1f432d87 Let an installation stop calling home, two different ways
Every instance reached projectsend.org twice a day and an operator could
stop neither. The news feed had no switch of any kind — FetchNewsCommand
went straight to the request, touching Settings only to write results back.
The update check had one, but its default is on, and a managed fleet had
been setting PROJECTSEND_CHECK_FOR_UPDATES=false for months against code
that reads no such variable: check_for_updates is a database setting, so
the environment never touched it and updates were enabled fleet-wide the
whole time.

They look like one problem and are two, which is why they are fixed
differently.

**The news feed gets a Setting**, its own key, default on. A Cloud client
with view_news sees that card today — DashboardController gates it on the
permission alone, with a comment saying in as many words that it is both
editions and carries no capability. So switching it off is an operator's
choice rather than an edition's, and it must stay reachable everywhere.
Its own key rather than riding on check_for_updates because they are two
different wants: "do not tell me about releases" and "do not show me the
project's news" are asked separately, and an installation with no outbound
access at all wants both.

**The update check gets a capability guard**, ahead of the setting it
already had, and deliberately not a Setting of its own. On a managed
installation the result is unreachable rather than unwanted: the
dashboard's System card and the update UI are both gated on
Capability::SystemUpdates, which is Community-only, and the image is
chosen by whoever provisioned the instance. A Setting would encode a fact
about the edition as a preference — leaving it switchable back on per
tenant, buying a nightly call for a number no screen can draw, and putting
the reason in a provisioning script rather than beside the code. A
self-hosted install holds the capability and loses nothing: its own
setting still decides.

Both guards return success rather than failure. A scheduled task that was
asked not to run has not failed, and reporting it as one would put a red
line in the scheduler history every night for an installation behaving
exactly as configured.

The news switch is on the General settings screen, outside the
can_manage_updates block that hides the update toggle where the capability
is absent — a setting only reachable by editing a database row is a row,
not a switch. Seven tests, and the two that matter go red when either
guard is removed. Sixteen locales translated in the same commit rather
than left for the pass, since a release is close.
2026-09-08 01:27:08 -03:00
ignacionelson 82dd475f8f Write up what #1724 and #1733 mean for an operator
Two entries under Unreleased, both for the same reason: an operator would
otherwise be surprised.

The shorter download link is a behaviour change with a cost attached — a
resumed download more than a minute old is refused where an hour tolerated
it — so it says that plainly rather than only advertising the benefit. It
also says who is not affected, since installations on local disk never used
one of these links at all, and neither do zip bundles.

The upload fix is an ordinary bug fix and would normally need no entry, but
it moves peak temporary disk from "the file plus one part" to "the file
twice over" while assembling. That is a sizing question somebody with a
small temp volume has to answer, so it gets an upgrade note. Nothing to
configure — just headroom.
2026-09-07 19:25:30 -03:00
ignacionelson b758fca19c Merge pull request #1724 from fix/assemble-keeps-parts-for-retry
Keep an upload's parts until its bytes are stored
2026-09-07 19:24:06 -03:00
ignacionelson 02946abf85 Stop the delivery docblock naming nginx as the only local path
#1733 explains its two lifetimes by contrasting a presigned URL with
X-Accel-Redirect, "nginx serves these bytes, now, to this request". That
was true when the branch was written and stopped being true on 1 September,
when FileDelivery gave the local path four methods — auto, nginx, xsendfile
and PHP streaming.

The argument survives intact: every one of those authorises exactly one
response and nothing that outlives it, which is the property the contrast
rests on. Only the naming was stale, and a docblock that says "nginx" to
an operator running Apache reads as "this does not apply to me".

Found resolving the merge, not by the author — the branch predates the
change it collided with.
2026-09-07 19:24:00 -03:00
ignacionelson b7ac44e77b Merge pull request #1733 from fix/presigned-download-window
Give a download's presigned URL a minute rather than an hour

Conflicted against FileDelivery, which landed on main after this branch
was written: main added a constructor where the branch added two
constants. Both belong; the resolution keeps each.
2026-09-07 19:23:52 -03:00
ignacionelson 50a6a19455 Translate the client file editor into all sixteen locales
The eight strings the portal file editor added, which had been sitting in
English since the feature landed — the deliberate trade, but the pass is
due now that the English has settled.

Nothing else came up. The scan reports eight missing per locale and they
are all from this feature, so no unrelated drift crept in alongside it.

Written against each catalogue's own established voice rather than
translated fresh: Spanish stays informal, and "Expires on" takes the verb
its neighbouring "Leave empty for a file that never expires" already uses
in each language. Quoting follows each locale too — Russian keeps its
guillemets, Japanese its corner brackets, Chinese and Vietnamese their
curly quotes — matching how the sibling folder-deletion warning already
reads there.

Every :name placeholder survives verbatim, checked rather than assumed,
and the diff is additive: the one deleted line per file is the previous
last entry re-emitted with a comma.

Checked on the screen, not only in the file, which is the part a parsing
JSON cannot show: the editor rendered in Spanish with every label and hint
in place, "Vence el" agreeing with the hint below it, and no console
errors. The dev instance's Client role was snapshotted, granted the keys
for the run, and restored; the throwaway file it needed is gone.

Locale suite green, 13 tests. The 249 orphans each catalogue reports are
older than this work and left alone deliberately — scan.php cannot see a
key held as data or supplied by a package, and a wrongly deleted entry
reverts a screen to English in silence.
2026-09-07 12:48:10 -03:00
ignacionelson 8de28059db Say when a folder choice publishes the file
Found reviewing the client file editor rather than building it.

File::isEffectivelyPublic() is "my own flag OR my folder's", and
Folder::uploadableBy() admits a client to a public folder on
upload_to_public_folders — a different key from upload_public. So a client
can make a file world-readable without touching the public switch, and
without holding the key that switch is behind.

That is what those two keys have always meant and what uploading into such
a folder has always done, so this does not refuse it. What was new is
where the choice is made. The upload page is entered from a folder the
client has already navigated to, where the list shows a Globe badge on a
public folder. The editor's picker is a flat list of names, and it is the
first place a destination is chosen with none of that context — so the
consequence was invisible exactly where it mattered most.

Public folders now carry the badge in the picker, and choosing one says in
words that anyone will be able to open the file without signing in. Two
tests: that the side door genuinely publishes and is labelled, and that a
private folder is not labelled — a warning on everything is a warning on
nothing.

The rest of the review found no defect. Ownership, the per-field keys, the
staff-scope trap and mass assignment were already covered; a client
deleting a file that staff later revised was checked directly and moves
the chain's recipients onto the successor without widening them, which is
what it is supposed to do. The write path was driven in a real browser —
rename, publish and delete through the actual form and dialog — because a
green suite over a write that 419s in every browser is a mistake this
repository has made before. Bytes gone, audit trail complete, and
file.made_public records the slug.
2026-09-07 12:09:27 -03:00
ignacionelson ea214fc27e Give the client portal a file editor
The authorization landed last commit; this is the way in. A client with
edit_files now gets an Edit action on the files they uploaded, opening a
form with every field their role actually grants, and a Delete beside it.

One page for every theme, not one per theme. portal/edit-file.tsx picks
its shell from the `theme` prop exactly as portal/upload.tsx does, because
a form with eight fields behind five separate permissions, rebuilt four
times, is four places for a field to go quietly missing. What *is*
per-theme is only the entry point: one <FileRowActions /> in each theme's
row actions group, the file twin of the FolderRowActions that was already
there.

Row actions gate on can_update/can_delete, sent per file by
MyFilesController and answered by FilePolicy — never on is_mine, which is
half the question. Holding the file is one half and the role's keys are
the other, and a theme that reads is_mine offers an Edit button that
403s. Written into docs/theming-files-checklist.md so the next theme does
not have to rediscover it.

The folder picker offers only folders the client could have uploaded to,
so it cannot present a destination the save would refuse. Publishing says
in plain words that anyone with the link will be able to open the file
without signing in, and says so differently when the installation has no
public page configured, because there the switch would do nothing visible.

Hiding a control is a courtesy, never the enforcement. Every can_* prop
here is the same question ApplyFileEdits asks when the form posts, and the
tests assert both ends.

Verified in a real browser over CDP rather than only by types and tests,
which say nothing about whether a page mounts: 23 edit actions on the
client's 23 own files and none on the file shared with them, the editor
mounting with its real values, every gated field present, no console
errors. The dev instance's Client role was snapshotted before the run and
restored to exactly what it was.

Refs #1771
2026-09-07 11:15:22 -03:00
ignacionelson 922be7226c Let a client edit and delete the files they uploaded
A client could upload a file and then never touch it again. No rename, no
description, no expiry, no categories, no delete — the portal has three
file routes and all three are GET. Meanwhile the Roles screen happily
grants the Client role edit_files, delete_files, set_file_categories,
set_file_expiration_date and upload_public, and every one of them was
inert, because the routes that honour them are `staff`-gated rather than
permission-gated. That is what #1771 hit: a permission granted, saved, and
silently doing nothing.

A client owns what they uploaded. Ownership is now what lets them edit and
delete it, subject to the same per-field keys staff are subject to.

The obvious implementation is a trap, and it is worth writing down. Both
policy methods began `if (! $user->isStaff()) return false;` and both end
in StaffLibraryScope, whose allowsFile() reads `if (! isClientScoped())
return true` — and isClientScoped() is `isStaff() && role->client_scoped`,
so it is false for every client. Delete the early return and a client
falls into the branch meaning "this staff member is unrestricted" and is
handed the whole library. Same for folders(), which returns an unfiltered
query: a client could move their file into any folder on the installation.
So clients get their own branch, reaching neither. The portal asks
Folder::uploadableBy() instead — a file cannot be moved somewhere it could
not have been uploaded.

edit_others_files and delete_others_files stay inert for clients by
construction. A client has no others' files, only files somebody showed
them, and being shown a file is not being given it.

Which fields an editor may write moved into ApplyFileEdits, shared by the
staff editor, /api/v1 and the portal. There were two copies of the same
eight permission checks and this would have been the third; the checks are
easy, which is exactly why the drift would have been invisible. Callers
normalise their own request shape, this gates and writes and logs. Expiry
reading and writing came along too, as FileExpiry — three copies, of which
only the API's could read a timestamp.

Clients do not choose the public slug. It is derived from the name they
already picked, because an installation-wide unique slug a client sets is
a name to squat and an existence oracle to probe with.

One consequence for later, written up in docs/api-todo.md: the policy now
says yes to a client for file writes, so `staff-token` is the only thing
holding the API boundary where there used to be two independent refusals.
ActorBoundaryTest pins it, and asserts the policy passes first so the test
cannot quietly stop testing the middleware.

Also corrects a stale comment that claimed a deleted file's bytes stay on
disk. They have not since File::booted() grew a `deleted` hook; nothing
ever forceDelete()s a File row, so "until a purge lands" would have meant
never — which is why a client's delete frees their quota by exactly what
it frees on disk.

The UI comes next; this is the authorization, the routes and the tests.

Fixes #1771
2026-09-07 02:37:26 -03:00
ignacionelson 1e30e83f11 Stop projectsend:captcha-off claiming a success it did not have
The command writes Setting::CaptchaProvider = 'none'. On an installation
using the platform's managed keys, Captcha::resolve() returns
managedConfig() — read from config — before it ever looks at that setting,
so the write lands somewhere nothing reads and every form stays protected.

The command then printed "CAPTCHA is off". That is false in the worst
direction: the person running this is locked out and debugging, and the
message sends them away from the one thing that would have explained why
they are still being challenged.

It now says it changed nothing, and names PROJECTSEND_CAPTCHA_DISABLED,
which is checked ahead of the key source and is therefore the only one of
the two escape hatches that works on a managed installation. The docblock
said those two were equivalent; they never were.

Deliberately not gated behind captcha.configure. Gating it would take a
self-hosted operator's way back in — the alternative being a hand-edited
database row — to close something that on a managed installation does
nothing anyway. Reaching it needs a shell in the container, which needs an
RCE, at which point the CAPTCHA is not the problem.

The command had no test at all. It has three now, including one that pins
the ordering inside resolve(): if the environment check ever moves below
the key source, a locked-out operator loses their last way in.
2026-09-07 01:24:13 -03:00
ignacionelson d32788e4a1 Put the CAPTCHA settings screen behind a capability
The screen is open in both editions and stays that way by default, so a
self-hosted installation loses nothing: nobody else supplies its keys, and
nobody else is affected by what it decides.

What the key buys is the ability to take it away. A hosted fleet puts every
tenant on one parent domain and one sending reputation, so an administrator
who turns their own CAPTCHA off is spending everybody else's deliverability
rather than only their own. That is not the shape LDAP and social login
have, which is why those two stay ungated and this one does not.

Gated all-or-nothing on the route, read included, exactly as Storage and
Branding are. Per-field gating in the controller would not have closed it:
switching the CAPTCHA off needs none of the gated fields — `provider: none`
does it, and so does unticking the four per-form switches while leaving good
keys in place — so the PATCH had to be closed too, and the middleware closes
both verbs at once. Which keys the screen may offer is still the separate,
narrower question Capability::CaptchaManagedKeys answers per field.

An operator withdraws it by naming captcha.configure in
PROJECTSEND_CAPABILITIES_DISABLED. Note that the key also joins the list
`projectsend:status` and GET /api/v1/me report, which is additive — the
OpenAPI document types capabilities as an untyped array, so nothing there
needed regenerating.
2026-09-07 01:20:22 -03:00
Eliana Bracciaforte c3503a0651 Merge pull request #1769 from projectsend/docs/readme-projectsend-cloud
Name the official hosted version near the top of the README
2026-09-04 09:22:10 -03:00
Eliana Bracciaforte 51477cbd02 Name the official hosted version near the top of the README
ProjectSend Cloud already appears in LICENSING.md and CONTRIBUTING.md,
but not in the README — the first thing people and search engines read.
One paragraph after the intro names it, says who runs it, and points to
LICENSING.md for where the line between the free core and Cloud sits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-04 09:19:53 -03:00
ignacionelson 7c9847981a Patch 8 pending security advisories in dependencies
league/commonmark 2.9.0 -> 2.10.0 fixes an XSS bypass and three DoS
issues; nanoid, qs, brace-expansion, and @humanfs/node bumped via
npm audit fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019iQNYLu5a65foArRdE9zzx
2026-09-03 11:57:15 -03:00
ignacionelson 7da4635f13 Say which clients a scoped staff member may be told about
A staff member limited to their own assigned clients could read the names
and ids of clients on nobody's roster but their own, out of ordinary file
metadata.

The file boundary was never wrong. Sharing means a file can legitimately
reach a scoped viewer through client A while client B uploaded it, or
while B also receives it -- StaffLibraryScope::buildFiles is right to
permit that, and a B-only file is still a 403. What was wrong is that
every response then went on to name B. FileResource serialised the loaded
uploader and each assignment unfiltered; ShareTargets::assigned took no
viewer at all, so the details panel published the recipient list as it
stands and forSubject narrowed available_clients while handing
assigned_clients straight through. FoldersController::fileRow,
FilesController::edit, FileDetailsController and ClientFilesController
each named the uploader the same way. The API's uploaded_by filter asked
the question without any name attached: it answered "does this client of
yours put files in front of a client of mine" for any id a caller cared
to try.

12a8ebe3 said the rule out loud while fixing topClientsByStorage -- "the
file was theirs to read and the uploader's name was not theirs to see" --
and then the rule stayed in that widget. So it is a class now.
ClientIdentityScope is the one decision, asked by every surface that
names a client, and it deliberately answers about clients only: a
colleague's name is not a client identity, and hiding it would hide who
uploaded most of the library from the people who work in it. Groups go
through it too, on the same argument -- a group is a list of clients
wearing one name -- which the report did not cover but is the same leak.

Two judgement calls worth naming. assigned() keeps returning the whole
truth and gains a warning, because VisibleCommentScope resolves
notification recipients from it and a recipient filtered out of that list
is one who never hears about a message addressed to them; assignedFor()
is the display half. And FileResource asks at serialisation rather than
in its callers' eager loads, which is the opposite of how the version
counterparts next door are narrowed: that one is set-shaped and folds
into a query, this one is a per-row roster check across eight call sites
in four controllers, two of them re-loading assignments after a write.

The tests assert on whole response bodies rather than on named keys. The
leak was never in one field -- the same name arrived through the
uploader, through the recipient list and through four screens -- so a
body that does not contain the name anywhere is the only assertion that
would have caught all of it. Ten of the eighteen fail without this
change; the rest are the negative controls, including that an unscoped
administrator still sees every name and that the uploaded_by filter still
works for a client on the roster and for staff.

Reported by @Noorkhalel, GHSA-whmp-p9hv-r7j7. Their write-up named every
affected surface and the root cause in each, which is most of why this
took one pass.
2026-09-03 00:56:41 -03:00
ignacionelson ddf09677f0 Document the branding endpoints where their callers are
Branding moved into the application on 2026-08-28 and its two read-only
endpoints came with it unchanged -- same paths under
/api/v1/modules/branding, same capability, same ability. Their
documentation did not: the guide still sent readers to
packages/cloud-modules/docs/api.md, so endpoints that every installation
now carries were described in a private repository almost none of their
callers can open.

The OpenAPI document is still not the place for them. OpenApiContractTest
skips api/v1/modules/* on purpose: that document is served
unauthenticated and has to be identical on every installation, while a
module's paths exist only where the module does. So this is a plain
markdown file beside the guide, and published like it -- ignored docs are
maintainer notes, and this one is for integrators.

It is the cloud-modules file moved across, minus the attribution switch:
that half stayed Cloud-only and has no API surface at all. The gate is
described as every edition holding branding.customize, with a hosted plan
able to subtract it, because that is what the enum now says.
2026-09-02 18:56:55 -03:00
ignacionelson 9b2aea4812 Say what the actions cast actually costs if it goes
The comment said a reader unmarshalling a map breaks on an empty array.
Checked against the reader since, and it is worse than that: the hosted
platform decodes the block into a typed struct and discards a block it
cannot read, and Go refuses a JSON list into a map outright. A [] here
loses the whole usage block -- downloads and uploads with it -- on the
day a tenant happens to have no counted activity, with nothing logging a
fault. The quietest installations would be the ones that went quiet.

Comment only. The cast was already right; what was missing was the
reason it is load-bearing, which is exactly the kind of condition this
week kept proving nobody had written down.
2026-09-01 02:05:29 -03:00
ignacionelson 96107fdcd5 Release 2.3.0 2026-09-01 01:18:18 -03:00
ignacionelson eecd5b804d Write the 2.3.0 changelog entry
Turns the Unreleased section into a numbered entry and adds this
cycle's work to it: downloads on Apache and LiteSpeed, branding in
core, the build fact, the scheduler check, and the run of boundary
fixes.

The three security entries already in Unreleased are carried across
word for word rather than summarised. Their "Who this affected"
paragraphs are the part a reader decides on, and a one-line retelling
would have thrown that away. Their two upgrade notes move across whole
for the same reason, joined by the two this release adds.

Leaves an empty Unreleased scaffold for the next cycle. The version is
not stamped anywhere else yet -- config/projectsend.php and the tag are
still on 2.2.1, so this is the entry waiting for a release rather than
a released one.
2026-09-01 01:18:08 -03:00
ignacionelson 616aa49867 Translate the download delivery screens into all sixteen locales
The 29 English strings added by the file-delivery work: the System
widget row and its explanation dialog, and the settings panel that
repeats it.

Product nouns left alone throughout -- PHP, nginx, Apache, LiteSpeed,
X-Sendfile, mod_xsendfile, XSendFilePath, PROJECTSEND_FILE_DELIVERY,
INSTALL.md, S3, Google Cloud. "PHP" as a whole string stays "PHP",
which is why every locale gains one entry counted as untranslated, the
same way API and OK are.

Purely additive: appended rather than merged in sorted position, so each
diff is 29 new lines plus a comma on the line that used to be last.

Verified: scan reports 0 missing in all sixteen, 13 locale tests pass,
and the Spanish settings screen was read out of the running app rather
than out of the file.
2026-09-01 01:18:08 -03:00
Ignacio Nelson 525c464327 Merge pull request #1740 from denkfabrik-li/fix/update-keeps-cache-signal
Leave the caches update.sh's own update command needs to see
2026-09-01 01:17:46 -03:00
ignacionelson 97596da7d0 Refuse a stored path carrying a control character
Found reviewing the delivery work. The path is written into
X-Accel-Redirect or X-Sendfile, and a CR or LF in a header value is
header injection. PHP's header() refuses to emit one, so the real effect
is a 500 on every download, preview and thumbnail of that file rather
than a split response -- a file permanently broken by its own name.

Paths are generated here as Y/m/{uuid}.{ext}, so this should be
unreachable. The extension is not generated: it comes from the
uploader's filename, and on a migrated installation from a v1 database.
The upload routes all check the extension against an allowlist, which no
control character can match -- but upload_type_restriction can be set to
none, and the importer does not consult that policy at all.

assertRelative() was documented as the backstop for what a path may be
and only covered traversal, which is the half that cannot happen here.
Low severity, and the guard should have covered it either way.
2026-08-31 22:57:02 -03:00
ignacionelson 2ebadf0793 Mark the downloads settings link the way the dashboard marks it
Same amber, same underline, same warning triangle as the System widget
row. The two say the same thing about the same installation, so looking
different made the settings one read as an ordinary footnote rather than
the thing to go and read.
2026-08-31 22:49:06 -03:00
ignacionelson 25e4f77b63 Make the whole downloads row open the explanation
The warning triangle alone did not read as clickable. The label is now
an underlined button and the value and icon are a second one, so either
half opens the dialog and the row looks like the one thing on this card
you are meant to act on.

Two buttons rather than one wrapping the row: a dt/dd pair cannot be
nested inside a single button without losing the description-list
semantics that tie the value to its name. The value button carries an
aria-label because "PHP, button" describes nothing on its own.

Only when PHP is sending the files. On the fast path the row stays plain
text, since there is nothing to go and read.
2026-08-31 22:44:31 -03:00
ignacionelson 90ed2d60b9 Shorten the PHP downloads explanation
It was 2975 characters and scrolled. Same four questions answered and
nothing dropped -- what is happening, what it costs, why it is set that
way, the three ways out -- in 1696, which fits the dialog without
scrolling. The three fixes are a list rather than three headed
paragraphs, since each is one instruction.

A wall of text explaining a performance trade-off is self-defeating: the
person who most needs to read it is the one who opened the dashboard for
something else.
2026-08-31 22:37:06 -03:00
ignacionelson d6fd5a917d Send downloads the way the web server in front of us understands
Uploads live outside the web root, so PHP authorizes every download and
then hands the file to the web server with a header naming it. Four
routes decided that for themselves and all four hard-coded nginx's
spelling. On Apache or LiteSpeed nothing acts on the header, so the
empty body PHP sent goes to the visitor: files upload fine, thumbnails
are broken images, and downloads arrive as 0 bytes, with every other
page working. Reported as #1765 from an Apache 2.4 install, and before
that as #1266, #1215, #870 and #1271.

It is also a regression from v1, which had a download_method setting --
php, apache_xsendfile, litespeed, nginx_xaccel -- defaulting to php. v1
therefore worked on any server out of the box and v2 did not, and a v1
Apache user migrating lost every download with nothing to tell them why.

So the four sites now go through one FileDelivery, and it picks:

  auto (default)  nginx when SERVER_SOFTWARE says nginx, else php
  nginx           X-Accel-Redirect, a URL path via the internal location
  xsendfile       X-Sendfile, an absolute path (Apache mod_xsendfile,
                  LiteSpeed)
  php             BinaryFileResponse

Defaulting to auto rather than nginx is the point of the change: a
default that assumes nginx leaves an Apache install exactly as broken as
it is today until somebody reads INSTALL.md. Slow beats empty.

Auto never picks xsendfile, even where the module is loaded.
mod_xsendfile also needs XSendFilePath to allow the storage directory,
which cannot be seen from here, and choosing it on the strength of the
module being present would trade a silent failure an administrator can
diagnose from the dashboard for one nobody can.

BinaryFileResponse rather than a readfile loop because it answers Range
requests. nginx does that itself on the fast path, so hand-rolling it
would have broken seeking through a video on exactly the installations
this fallback exists for. Verified end to end: 206 with the right
Content-Range through the live stack.

Two guards. Every method checks the path cannot climb out of the storage
area -- nginx resolves `..` in the URL it is handed as happily as PHP
would -- and the two methods that hand over a filesystem path resolve it
and prove it lands inside the root. Callers pass paths from rows they
just authorized, so this is a backstop; it is here because the cost of
being wrong once is handing over any file the web server can read.

The dashboard's System panel names the method, with a warning icon and a
dialog when PHP is doing the sending: what is happening, what it costs
(one worker held for the whole of each download, so a few large
simultaneous ones can occupy every worker while the processor sits
idle), why it is set that way, and the three ways out. Written to be
accurate rather than reassuring -- nothing is broken, it does not scale
-- and the notice stays even when php was chosen deliberately, because
the trade-off is the same either way. /system/settings/downloads repeats
it, which is where somebody coming from v1 goes looking for the
dropdown.

An environment variable rather than a stored setting: it describes the
server this installation runs on, not a preference, and a value in the
database travels to a different server in a restore and is wrong there.
Read only in config/projectsend.php, so config:cache cannot blank it.

The suite pins itself to nginx. Left at auto it would detect no server
at all, fall back to php, and quietly retire the coverage of the
mechanism most installations actually use.
2026-08-31 22:31:27 -03:00
ignacionelson 6340b71dca Report recent usage and whether the scheduler is alive
projectsend:status could say what an installation holds and how many
accounts it has, but nothing about whether anybody was using it. Adds a
`usage` block -- downloads split staff/clients/anonymous, uploads, and
five allowlisted action counts -- plus `activity.last_client_login_at`,
`health.scheduler` and `health.failed_jobs_latest_at`.

The scheduler is the one worth having on its own. `health.queues`
catches a dead worker; nothing caught a dead scheduler, and its first
symptom is not a stalled feature but an expired file that is still
downloadable, because the job that was going to remove it stopped
running weeks ago. Nothing about the installation looks wrong while that
is true.

`failed_jobs_latest_at` exists because the count beside it cannot say
whether anything is wrong *now*, and reading it as though it could is a
category error rather than a threshold wanting tuning. The table is
swept daily, so the count spans a retention window -- one the
installation chooses, and one that can be set to keep-forever by
somebody who treats a failed job as evidence rather than debris. Two
identical installations therefore report different numbers, and on a
keep-forever one the count grows until any fixed threshold trips. A
timestamp is independent of how long rows are kept: 27 failures whose
newest is three weeks old is an installation that has been healthy for
three weeks and has not been swept yet.

`usage` is a rolling window with no lifetime totals, and that is a
correctness decision rather than a presentational one: activity_log is
never pruned, so a lifetime count over it gets slower every day of the
installation's life while a windowed one stays flat. The window is
emitted as `window_days` rather than left for the reader to assume.

The actions are an allowlist, not a `group by action`. This document
leaves the installation and Action gains cases most weeks, so an open
group-by would ship new action names outward with nobody having decided
they should go -- and some of them (account.erased, two_factor.reset)
are somebody's compliance event, not a business metric. It is also ~30x
cheaper: five keyed counts ride (action, created_at) while a group-by
starts from created_at and reads rows. The scheduler's failure message
and the queue exception text are omitted for the same reason; they are
the fields here that can carry a path or a stack trace, and a count with
a timestamp says "go and look", which is all a watcher is owed.

The two indexes ship as a pair and the migration explains at length why.
Measured at 2.1M rows: adding (action, created_at) alone fixes the
windowed counts and takes last_staff_login_at -- already running hourly
on every tenant -- from 0.63s to 7.7s, because the planner switches to
it, still needs actor_type, and does a scattered primary-key lookup per
row. With both, that query is answered from the index without reading a
row at all (0.0004s) and the whole new usage block costs ~70ms.

Also documents the keys as a contract, the way `capabilities` already
is. This one fails worse: a renamed capability key breaks a comparison
somebody is watching, a renamed usage key produces a chart that is
silently empty, and nobody gets paged for a flat line.
2026-08-31 18:51:47 -03:00
ignacionelson fb931819e2 Translate the auth and settings screens into all sixteen locales
#1762 put six screens through the translator and deliberately left the
new keys for a focused pass; this is that pass, plus the one older gap
the scan turned up -- the directory-account notice on the password form.

Twenty-seven keys each, written to match what the catalogue beside them
already says: de and tr formal, es, nl, pl and zh_CN informal, and each
locale's own established vocabulary rather than a fresh choice per file
(nl keeps wachtwoord, tr keeps parola, ru keeps "адрес эл. почты").

"Or, return to" and "log in" are rendered as one sentence with a space
between them, so each pair was chosen to read as a phrase in that
language rather than translated word by word -- Turkish reorders it to
"Ya da giriş sayfasına dön", Japanese to "または ログインに戻る".

Additive only: 432 insertions, nothing reordered or reformatted. The
scan now reports zero missing across all sixteen.
2026-08-29 16:27:50 -03:00
ignacionelson 41b22d003e Merge pull request #1764 from denkfabrik-li/fix/placeholder-case-convention
Honour Laravel's placeholder case convention in t()
2026-08-29 16:00:17 -03:00
ignacionelson 78d5067c6b Merge pull request #1763 from denkfabrik-li/fix/zip-poll-stops-with-its-page
Stop the zip poll when its page goes away
2026-08-29 15:58:11 -03:00
ignacionelson b7cc5e8615 Merge pull request #1762 from denkfabrik-li/fix/auth-settings-pages-translated
Run the auth and settings screens through the translator
2026-08-29 14:42:04 -03:00
denkfabrik-li ed82d748ea Run the auth and settings screens through the translator
use-translation.ts states the rule: every user-facing string in a
component must go through t(). Five screens never called it at all --
forgot-password, reset-password, confirm-password, verify-email and
settings/password had zero occurrences of useTranslation -- so a client
who had chosen Spanish reset their password in English, from the browser
tab down to the submit button. settings/profile had the hook but used it
for two strings, leaving its heading, labels and the whole
email-verification notice hardcoded around them.

The password page also carried a second, smaller mistake the miss was
hiding: its <Head> title said "Profile settings", copied from the
profile page, so the tab named the wrong screen in every language.
It says "Password settings" now, the wording its own breadcrumb and
the sibling "Notification settings" title already use.

Every string on the six screens goes through t() now. The two
module-level breadcrumb arrays moved inside their components to reach
the hook -- the shape two-factor, notifications and the other settings
pages already have. Where a key already exists in the catalogs (Email
address, Password, Confirm password, New password, Log out and friends,
shared with the login screen) the existing translations light up
immediately; the keys new to the catalogs fall back to their English
text, exactly what those lines rendered before, until the locales pick
them up.

TranslationUsageTest is the guard, a source scan like
DateFormattingUsageTest and for the same reason: no JavaScript test
runner gates this class of miss. It fails on any page under pages/auth
or pages/settings that never uses the hook -- those screens always carry
copy of their own, so a page there without it is a page somebody forgot
-- and on any literal <Head title="..."> anywhere, which is both a
user-facing string and where the copy-paste title above lived. Both
scans go red on the tree without this change: five pages and six
literal titles.
2026-08-29 08:58:00 +02:00
denkfabrik-li fe3b7b7018 Honour Laravel's placeholder case convention in t()
The frontend translator replaced placeholders by exact match only:
`:${name}`, nothing else. But the catalogs it consumes are Laravel JSON
catalogs, and Laravel's convention has always been three forms — :name
receives the value as-is, :Name capitalized, :NAME upper-cased. The
backend translator honours all three; fifteen values in lang/nl.json
and one in lang/tr.json already rely on it. Dutch writes "Add :name" as
":Name toevoegen" because the noun opens the phrase there and gets the
capital; Turkish does the same with "Go to page :page" as ":Page
sayfasına git". Through this hook, those sixteen values rendered the
literal ":Name" and ":Page" instead of the replacement — the value was
right for the language and wrong only for the half of the app that
reads it with an exact-match replace.

t() builds the three variants per replacement now, longest placeholder
first — strtr's implicit rule made explicit, so with :name and :names
both in play, :name cannot eat the front half of :names. Ties keep
insertion order, which resolves a fully-colliding key to the as-is
value, the same answer the backend's assignment order produces.

No test accompanies this: there is no JavaScript test runner in the
project, and the PHP suite exercises the backend translator, which was
never wrong. Counter-checked by running both implementations over the
affected catalog values in node — the old replace leaves ":Name
toevoegen" and ":Page sayfasına git" literal, the new one renders
"Bestand toevoegen" and "2 sayfasına git" — plus the existing in-repo
call shapes (":used of :limit", ":name — files"), which come out
byte-identical to before.
2026-08-29 08:57:49 +02:00
denkfabrik-li 6783fa0b81 Stop the zip poll when its page goes away
useZipDownload sets an interval that polls zip-downloads/{id} every two
seconds until the build reports ready or failed. The only paths that
ever cleared it were those two answers and close() — there was no
unmount cleanup at all, no useEffect in the file. But the pages that
hold the hook are Inertia pages: navigating away unmounts them without
close(), and the interval keeps hitting the endpoint every two seconds
for as long as the tab lives, polling for a download nobody can receive
any more. A zip stuck in pending — the exact case the polling exists
for — polls forever.

Three holes, one leak:

- No cleanup on unmount. A useEffect returning stopPolling closes the
  main path.
- An unmount while the store POST is still in flight: its then() runs
  after the cleanup already did, and would set a fresh interval on the
  dead component. The unmounted flag makes that then() a no-op.
- A second start() while a poll is running overwrote pollRef and
  orphaned the first interval the same way. start() stops the previous
  poll first now.

No test accompanies this: there is no JavaScript test runner in the
project, and the PHP suite never mounts a component. Verified with
tsc, eslint and prettier, and by reading the two consumers —
files/index.tsx and use-portal-files.ts — both of which only ever
clear the interval through the dialog's onClose today.
2026-08-29 08:57:49 +02:00
ignacionelson 4556ccf691 Merge pull request #1761 from denkfabrik-li/fix/self-hosted-font
Serve the interface font from the installation, not from a font CDN
2026-08-29 02:34:53 -03:00
ignacionelson 85572eb45e Write up the image's production defaults for operators
#1760 changes how an existing installation behaves on the next pull, and
the part worth saying out loud is the one nobody could see: "reject
known-breached passwords" was reporting itself as on while doing nothing.

Filed under Security with who was actually affected -- not anyone
following the documentation -- and under Upgrade notes with the one thing
that stops working: APP_ENV and APP_DEBUG edited inside storage/.env.
2026-08-29 02:32:40 -03:00
ignacionelson 227a08dfce Merge pull request #1760 from denkfabrik-li/fix/image-defaults-to-production
Have the production image default to production
2026-08-29 02:32:21 -03:00
ignacionelson 43e9985b2b Write up the quickstart's loopback binding for operators
#1759 changes a file people copy verbatim, so the change has to reach
them somewhere other than a diff: anyone who copied the old example and
reaches the app on <server-ip>:8080 will find it stops answering.

Filed under Security with the reason it was a finding at all -- a
published Docker port is not covered by a host firewall, so the port was
often open without anyone intending it -- and under Upgrade notes with
what to do when the proxy lives on another machine.
2026-08-29 02:30:15 -03:00
ignacionelson f931c6a492 Merge pull request #1759 from denkfabrik-li/fix/quickstart-binds-to-loopback
Publish the quickstart on loopback, since it trusts any proxy
2026-08-29 02:29:52 -03:00
ignacionelson 1a3260a397 Merge pull request #1758 from denkfabrik-li/fix/confirm-password-asks-the-directory
Let the confirm-password screen ask where the password lives
2026-08-29 02:05:49 -03:00
ignacionelson 07e7132747 Merge pull request #1757 from denkfabrik-li/fix/dont-flash-stored-secrets
Stop a rejected settings form flashing the credential it carried
2026-08-29 01:21:13 -03:00
ignacionelson f4fd194991 Merge pull request #1756 from denkfabrik-li/fix/provider-link-password-confirm
Make linking a provider re-prove the password
2026-08-29 01:16:16 -03:00
ignacionelson ea45943f40 Merge pull request #1755 from denkfabrik-li/fix/credential-checks-rate-limited
Give every password check in front of an account its own bucket
2026-08-29 01:10:53 -03:00
ignacionelson ce96313710 Merge pull request #1754 from denkfabrik-li/fix/api-group-members-response-scope
Narrow the membership an API member write hands back
2026-08-29 01:09:46 -03:00
ignacionelson 77dd5ff90b Merge pull request #1753 from denkfabrik-li/fix/account-conversion-list-scope
Narrow the conversion list to the clients its own refusal allows
2026-08-29 01:07:22 -03:00
ignacionelson 8984aba7d8 Merge pull request #1752 from denkfabrik-li/fix/preference-writes-bounded
Bound the two preference endpoints by their own registries
2026-08-28 23:41:46 -03:00
ignacionelson 188848b549 Merge pull request #1751 from denkfabrik-li/fix/credentials-in-boot-cache
Keep the mail and storage credentials out of the boot-config cache
2026-08-28 22:56:37 -03:00
denkfabrik-li 7264c44fd7 Serve the interface font from the installation, not from a font CDN
app.blade.php is the root template for all three interfaces, and it opened
with two lines pointing at a third party:

    <link rel="preconnect" href="https://fonts.bunny.net">
    <link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />

Every visitor to /login, /register, /forgot-password, /s/{token} and every
public listing page therefore made a request to a host the operator did
not choose and could not switch off, before they had done anything at all
-- handing it their IP address, their user agent, and through Origin the
hostname of the installation they were visiting. On the signed-out pages
that is a visitor who has agreed to nothing, and an operator who often has
told their own users that this server is where their files live.

There was no self-hosted copy in the repository, no setting, no mention in
INSTALL.md, DOCKER.md or SECURITY.md, and no SRI on the tag.

The font now ships with the application, through @fontsource/instrument-sans
-- the same font, the same three weights the URL asked for, from a
versioned dependency rather than binaries pasted into the repository.
Vite fingerprints and emits them like any other asset.

Cost, measured on this build: twelve files, 192 KB on disk. A browser
fetches only woff2 and only the subsets it needs, which is 73 KB for all
six woff2 files together and typically 41 KB (latin, three weights) for a
page in English. Against that, every page load loses a DNS lookup, a TLS
handshake and a round trip to another origin, so signed-out pages get
faster rather than slower.

This is a privacy change rather than a vulnerability fix, and worth saying
plainly: the share token does not leak this way. Referrer-Policy:
strict-origin-when-cross-origin is set in both nginx configs and in the
INSTALL.md snippet, so the path never travelled in the Referer. What
travelled was the visit itself.

Not changed: public/.htaccess still sets no security headers at all, so an
Apache installation has no Referrer-Policy. That is a real gap and a
separate change.

Verified: `npm run build` succeeds and emits the faces; no reference to
the CDN survives anywhere in public/build; `tsc --noEmit` and prettier are
clean. No test asserts on the font, before or after.
2026-08-29 00:50:02 +02:00
denkfabrik-li 3e24ccd42f Let the confirm-password screen ask where the password lives
ConfirmablePasswordController checked the local hash and nothing else:

    Auth::guard('web')->validate(['email' => ..., 'password' => ...])

An account provisioned from a directory has no local password. It holds a
Str::password(64) generated at provisioning time that nobody has ever
seen, and the application knows this -- LdapAuthenticator::isDirectoryAccount()
is the question, and the sign-in form asks it before deciding what to
check. This screen did not, so it refused those accounts the only password
they have.

That is not a cosmetic refusal. `password.confirm` stands in front of
enrolling in two-factor, so a directory-provisioned client could not enrol
at all. Set TwoFactorEnforcement to `clients` or `all` and EnforceTwoFactor
redirects every request they make to two-factor.show -- a screen whose
"enable" button leads to a door they cannot open. PR #1708 fixed the
routing half of that ("Let an enforced user reach the far side of the
confirm-password screen"); this is the credential half.

The rule now lives in one place. PasswordVerification is the sibling of
SignIn on the other side of the line SignIn draws -- SignIn is everything
after a credential checks out, this is the one question asked before it --
and it exists for the reason SignIn gives for existing: "the way they get
broken is by being written twice". LoginRequest keeps its ordering, its
provisioning and its rate limiting, and delegates the check itself.

Behaviour preserved exactly on the sign-in path: local hash first so an
account that answers locally generates no directory traffic, directory
only for accounts whose credentials live there, the stale-hash re-hash on
the local branch only, and the ldap_dn stamp on the directory branch. All
23 existing LDAP sign-in tests pass unchanged.

One thing this closes on the way past. Because the old check went straight
to the local hash, a directory account's placeholder *would* have confirmed
if anybody ever learned it -- a door the sign-in form does not have, since
it skips the local branch for those accounts. It now behaves the same on
both screens; there is a test.

**What this does not fix, and should be read as a limitation.** Accounts
provisioned by a social provider are in the same position -- a random local
password nobody holds -- and they are not directory accounts, so this
changes nothing for them. Their route to a local password is the password
reset, which #1748 made work end to end by moving auth_source to Local when
the reset completes. A social account that has never done that still cannot
confirm a password, and so still cannot enrol in two-factor.

Tests: three fail against the unfixed pair, including the placeholder case
above. Two more pin what must not change -- a wrong directory password is
still refused, and a local account with LDAP switched on still confirms
against its own hash.
2026-08-29 00:10:59 +02:00
denkfabrik-li 74077993de Have the production image default to production
The entrypoint seeds the .env on the storage volume when there is none:

    if [ ! -f storage/.env ]; then
        cp .env.example storage/.env

.env.example is the development template. It carries APP_ENV=local and
APP_DEBUG=true, and the Dockerfile set no defaults of its own -- its only
ENV was PROJECTSEND_IMAGE=1.

Real environment variables win over that file, so compose.example.yaml
(APP_ENV: production, APP_DEBUG: "false") was never affected, and neither
was anybody following the documentation. Everybody else was: `docker run`
with nothing but a database address, the Portainer / unRAID / TrueNAS
templates people actually use, a Kubernetes manifest naming only
DB/Redis/APP_URL. All of them booted a debug build and nothing said so.

Two things follow, and neither is visible from inside the application:

1. **Every 500 hands its stack trace to whoever caused it**, signed in or
   not -- Laravel's exception page, with the file, the line and the
   surrounding source. docker/production/php.ini sets display_errors=Off
   and that does not help, because Laravel renders the page itself rather
   than letting PHP print it.

2. **"Reject known-breached passwords" never ran.** PasswordPolicy::rule()
   appends ->uncompromised() only when app()->isProduction(). On
   APP_ENV=local an administrator could switch the setting on, watch
   descriptor() advertise it on every password form and the security
   settings screen report it as active, and have it do nothing.

The image now states its own environment.

Set in the Dockerfile rather than in the seeded .env on purpose: the copy
only happens when no .env exists, so seeding would fix a fresh install and
leave every installation already running on a stale one exactly as it is.
As an ENV it takes effect on the next pull.

What it does not outrank: Laravel builds its env repository immutable
(Illuminate\Support\Env), so a real environment variable beats the .env
file. `docker run -e`, compose `environment:` and Kubernetes `env:` all
set real environment variables, so an operator who asks for something
explicitly still gets it -- verified against this image, where a .env
saying local/true is overridden to production/false by the variables.

The trade-off, stated because it is a behaviour change: editing APP_ENV or
APP_DEBUG inside storage/.env no longer has any effect, since these are
real environment variables and that file is not. Turning debug on
deliberately is `-e APP_DEBUG=true`, which still works. That is written
into the Dockerfile comment so the next person finds it there.

Not changed: PasswordPolicy's isProduction() test itself. Tying an
administrator's setting to the environment rather than to the setting is
arguably wrong on its own, but it is a separate question with its own
blast radius, and this change makes the shipped image behave the way that
code already assumes.

No test: the environment an image ships is not observable from the suite.
`docker build --check` reports no warnings on the edited file.
2026-08-29 00:07:12 +02:00
denkfabrik-li da7eb6f67d Publish the quickstart on loopback, since it trusts any proxy
compose.example.yaml does two things that are each fine alone and unsafe
together:

    ports:
      - "8080:80"            # Docker binds 0.0.0.0 unless told otherwise
    environment:
      TRUSTED_PROXIES: "*"   # believe the X-Forwarded-For of whoever connects

Behind a proxy that appends the header, "*" is correct and harmless --
Symfony strips the peer and takes the real client the proxy appended. The
example never gets there. It publishes the container on every interface,
so a visitor can reach port 8080 themselves, and then *they* are the peer
the application has been told to trust. `X-Forwarded-For: 203.0.113.9`
makes request()->ip() return exactly that.

What that costs, all of it on the signed-out surface:

  - the login lockout, keyed on `email|ip` in LoginRequest::throttleKey()
  - throttle:6,1 on register, password-email, password-reset, two-factor
  - throttle:30,1 on share-link, public-browse, public-comment
  - the download log, the activity log, and the `ip_address` recorded on
    guest comments -- which FileComments::post calls "the one handle that
    makes spam actionable"

Rotate the header and every one of them counts a different attacker.

The project's own test states the primitive: TrustedProxiesTest sets
trustedproxy.proxies = '*', sends X-Forwarded-For from a *direct* client,
and asserts the address is taken.

The documentation has always qualified "*" correctly -- .env.example says
it is "only safe when nothing but the proxy can reach the app", and
dockerhub-overview.md repeats it. The example file is what did not meet
its own precondition, and it is the file the Docker Hub description tells
a first-time reader to copy.

Publishing on 127.0.0.1 restores the precondition: a proxy on the host, or
in this compose file, still reaches it; nothing off the machine does. This
repository's own compose.yaml already publishes Adminer that way, for the
same reason.

The two settings are now documented as a pair in all three places that
carry them, including what to do when the proxy is on another host: bind
to the interface it arrives from and name that address in TRUSTED_PROXIES
instead of "*".

DOCKER.md's health-check command changes with it -- it told the reader to
curl <host-ip>:8080 from the same machine, which the new binding does not
answer. It now says 127.0.0.1:8080.

No test: this is packaging and prose. `docker compose config` parses the
edited file.
2026-08-29 00:05:03 +02:00
denkfabrik-li 35d68a792b Stop a rejected settings form flashing the credential it carried
When validation fails, Laravel flashes the request's input into the
session so the form can be repopulated. Its exclusion list is
current_password, password and password_confirmation -- written for the
login and password screens, and covering none of the credentials the
system settings screens take. `dontFlash` did not appear anywhere in this
repository.

So every one of these went into the session in clear the moment its form
was rejected:

    secret          ExternalStorageSettingsController   (S3 secret access key)
    key_file        ExternalStorageSettingsController   (GCS service account JSON)
    bind_password   LdapSettingsController
    client_secret   SocialLoginSettingsController, EmailSettingsController
    secret_key      CaptchaSettingsController

Each is stored with an `encrypted` cast, and config/session.php puts
sessions in the database with `encrypt => false` -- so the rejected save
wrote in clear into the same database the cast exists to protect.

The sharpest one is key_file. serviceAccountKeyRule() exists to catch a
paste that lost its last line, which makes "the request carrying a
service account private key" and "the request that fails validation" the
same request more often than not.

dontFlash() merges rather than replaces, so the framework's three stay.

The cost is that these five come back blank after a failed save. That is
already what they do after a successful one -- every screen here treats
them as write-only, and a blank means "keep what is stored" -- so the
behaviour is now the same either way instead of only on success.

Tests: one per field, each submitting a form that fails validation while
carrying a secret, then reading the old input back the way the form
would. All five fail against the unmodified bootstrap/app.php. A sixth
pins that the framework's own three are still excluded, and each
assertion checks a neighbouring non-secret field still comes back, so
this cannot pass by flashing nothing at all.

Note for the record: this is testable in the existing harness after all.
phpunit.xml sets SESSION_DRIVER=array, but old input is written to the
session whatever the driver backs it, so getOldInput() sees exactly what
a database session would have stored.
2026-08-29 00:02:21 +02:00
denkfabrik-li bde86c10e4 Make linking a provider re-prove the password
Connecting a provider needed nothing but the session. Anyone holding one
could POST /settings/connected-accounts/google, follow the returned
Inertia::location(), sign in at the provider as *themselves*, and
completeLink() would bind their identity to the victim's account.

SocialAccount says what that row is:

    This row *is* the authorization to sign in as that account.

So it is not a preference -- it is a credential, and one that outlives
every way the victim has of ending the session that created it. It
survives a password change, it survives Auth::logoutOtherDevices(), it
survives invalidating every session. Where a stolen session gives an
attacker access until it is noticed, this gives them an account.

routes/settings.php already makes exactly this argument, twenty lines
down, for the two-factor block and the API token routes:

    a token outlives the session that minted it, so a stolen session must
    not be enough to mint one

The link has that property too, and was the one thing on this screen
without the gate. Now it has it.

The gate goes on `connect`, not on the callback: starting the flow is what
writes the intent the callback completes, and the callback deliberately
sits outside every group so a provider sign-in works without a session.

Not changed, deliberately: `connected-accounts.destroy`. Disconnecting
removes a way in rather than adding one, and destroy() already refuses to
remove the last one ("This is the only way you can sign in. Set a password
first"). Putting it behind password.confirm would fall hardest on the
accounts a provider provisioned -- they hold a Str::password(64) nobody
has ever seen -- and leave them unable to disconnect anything at all.
There is a test pinning that it stays reachable.

Also not changed: the account owner still is not told. SocialLoginController
writes an activity log entry, and that sits behind `staff` +
can:view_actions_log, so a client never sees it. Notifying them is a real
gap and a separate change; this one closes the door rather than adding a
bell to it.

Tests: two that fail against the ungated route -- the redirect, and the
whole attack end to end with a stranger identity never binding. The
existing connect() helper now confirms the password, the way
enableTwoFactor() already did, so the rest of the file keeps exercising
the real gate rather than asserting around it.
2026-08-28 23:59:11 +02:00
denkfabrik-li c72adadc44 Give every password check in front of an account its own bucket
POST /confirm-password verified the account's password and counted
nothing. Forty wrong guesses, forty identical refusals, no lockout, no
Retry-After, no log line.

routes/auth.php opens by requiring the opposite:

  **Every `throttle:` below names its own bucket, and must.**

and every other route in the file has one. POST login is the deliberate
exception, and the file says why -- LoginRequest limits it per email *and*
IP, which is a stronger boundary than a per-IP count. confirm-password had
neither of those things.

It is the wrong door to leave unlatched. Re-proving the password is what
stands between a stolen session and disabling two-factor, regenerating
recovery codes, or minting an API token -- credentials that outlive the
session, which is the reason routes/settings.php gives for putting those
routes behind it. An attacker who already holds the session can sit on
this endpoint until the password falls out of it, and then has the
password for everything else too.

Two more with the same shape, in routes/settings.php:

  - PUT /settings/password -- update() validates `current_password`.
  - DELETE /settings/profile -- destroy() validates `current_password`.

Both were equally uncounted, and both answer the same question in the same
way, so an attacker refused at one door simply used the next. Fixing one
of three would have been cosmetic.

All three get named buckets at 6/1, matching the credential-facing routes
already in auth.php. Named rather than bare: a bare `throttle:` keys on
sha1(domain|ip) or sha1(user_id) with no route in it, which is how six
share links once locked a visitor out of the two-factor challenge.

Not changed: POST /logout has no bucket either and does not need one -- it
checks no credential and reveals nothing by being repeated. PATCH
/settings/profile likewise.

Tests: three that fail against the unthrottled routes, and two that pin
what the buckets must not do -- exhausting one must not spend another's,
and one account's guesses must not lock a different account out.
2026-08-28 23:55:58 +02:00
denkfabrik-li 1ed29ec072 Bound the two preference endpoints by their own registries
Both preference writers validated their array as ['required', 'array']
and looped updateOrCreate over it:

    'widgets' => ['required', 'array'],
    'widgets.*.widget_key' => ['required', 'string', Rule::in(WIDGET_KEYS)],

Rule::in answers "is this a key I know", once per element. It says
nothing about how many elements there are, and nothing about whether they
repeat -- so a request could name the same valid key any number of times
and buy a SELECT and an UPDATE for each one.

Measured on this base, sent as JSON (a form-encoded array that size is
truncated by max_input_vars long before it reaches the controller):

    widgets        10 entries    37 queries   1 row
                  500 entries  1044 queries   1 row
                 3000 entries  7051 queries   1 row

    notifications   10 entries    23 queries   1 row
                 2000 entries  2025 queries   1 row

One row, every time. The work is not even data growth -- 3000 entries
write the same single row 3000 times, because updateOrCreate matches on
(user_id, widget_key) and every element after the first is an update of
what the one before it just wrote.

Neither route is behind a throttle: bootstrap/app.php applies
throttleApi() to the API group only, /dashboard/widgets is behind `auth`
alone and /settings/notifications is deliberately outside the `staff`
group, since every account manages its own. So the weakest account on the
installation -- a client with no permission at all -- can reach both, and
the only ceiling is post_max_size.

Both are bounded by the list they already validate against, not by a
number:

  - widgets by count(self::WIDGET_KEYS), the same constant Rule::in reads.
  - preferences by count($this->emailableKeys()), because
    NotificationTypeRegistry is deliberately open -- "never a closed enum,
    since core must not need to know a package's notification type keys at
    compile time" -- so a literal would be wrong the day a module
    registers one.

`distinct` on the key does the other half: a layout has at most one entry
per widget, which is what the screen sends and what the loop assumes.

After: 3000 entries cost 30 queries and write nothing, refused with a 422
instead of half-applied.

Two findings, one cause, one change -- they are the same three words in
two modules, and splitting them would leave the rule stated once and
broken once. Tests live with each controller: two refusals each, both
failing against the unfixed controllers, plus one for the largest
legitimate submission -- a full nine-widget layout, and every emailable
type at once -- so the bound can never be tighter than the screen.
2026-08-28 23:53:12 +02:00
denkfabrik-li 9af0d643b1 Keep the mail and storage credentials out of the boot-config cache
MailConfigApplier and ExternalStorageConfigApplier read their settings
through the `encrypted` casts -- decrypted -- and wrote the result into
the cache store with rememberForever(). The SMTP password, the S3 secret
access key and the whole GCS service account key file, private key
included, went in as plain text under a key that never expires.

The cache store encrypts nothing. On the store INSTALL.md documents for a
manual install (CACHE_STORE=database) and config/cache.php defaults to,
that is the `cache` table of the same database whose dump the `encrypted`
cast exists to survive. On redis it is the redis dump.

The rule already exists, two files away. MailOAuthConnection states it:

  Transports read this row fresh at send time -- tokens must never travel
  through the boot-config cache (see MailConfigApplier, which caches only
  readiness and the account address).

MailConfigApplier's own cache-key comment says the same thing about the
same array: what is deliberately NOT in the cached shape is tokens,
because neither readiness nor an address is a credential. The SMTP
password was in it anyway. SocialSettings::available() names both classes
outright as making the mistake.

So the credentials are read the way the tokens already are: from the row,
at the point that uses them. The cached array keeps everything that is
not a credential, and each applier reads its secret inside the branch
that configures a transport -- an installation on OAuth, on cloud, or one
that has never opened the Email or Storage screen reads nothing extra.

BootSettingsCache grows a second entry point rather than the callers
restating its rule. The cached read already survives a database with no
tables, because booting must not require this application's own database;
an uncached credential read on the same path needs exactly that guarantee
and nothing else, since resolve() can hand back a warm "configured" from
a database that has since stopped answering.

Both cache keys are bumped, as their comments require on a shape change.

Tests: five for the absence, two of them against the database cache store
read as the raw rows an operator would find in a dump, since phpunit.xml
runs the suite on the array store and the cache path was structurally
invisible -- which is why GoogleCloudStorageTest could assert that the private
key is not in the column while it sat in the cache. All five were run
against the unfixed appliers and fail there. The three "still configures
what it no longer caches" tests deliberately pass either way: they pin the
behaviour the fix must not break.
2026-08-28 23:46:19 +02:00
denkfabrik-li 19ee9d9833 Narrow the membership an API member write hands back
Adding or removing a group member answered with the group, and loaded the
relation whole:

    return new GroupResource($group->loadCount('members')->load('members'));

GroupResource gives each member an id, a name and an email. So a
client-scoped staff member who added one of their own clients to a group
was handed, in the same response, the name and address of every other
client in it -- people they may not read anywhere else in the application,
and whom the group edit screen refuses to name for exactly that reason.
syncWithoutDetaching() makes the call idempotent, so the same request
returns the same list as often as it is sent.

The boundary is already written down. GroupResource's docblock:

  both narrow the list to the clients the viewer may act on, and the
  controller loading this relation is where that narrowing is applied

and Api\GroupsController::show() does it for the read of the same group,
noting that "it hands back the membership with addresses". Changing the
membership is not a reason to be told more than reading it is, so both
halves now narrow by the same query, through one private helper rather
than a third copy of it.

members_count is deliberately left whole, matching show(): a size is not
an identity, and it is the number the group listing already reports.

Nothing about who may perform the write changes -- StaffLibraryScope
::allowsGroupMembership() already decided that, and still does. This is
only what the answer is allowed to say.

Tests: added beside the existing "the API twin narrows the membership it
hands back", which covered the read half only. Both write tests fail
against the unfixed controller; the third pins that an unscoped token
still gets every member.
2026-08-28 23:46:18 +02:00
denkfabrik-li cad112522d Narrow the conversion list to the clients its own refusal allows
/users/convert lists the accounts a conversion can be started from. For
the promotion direction those are clients, and the query asked only for
the type:

    User::query()->where('type', UserType::Client)

The write beside it does not. AccountConversion::guardToStaff() ends with

    abort_unless($this->library->canAssignClient($actor, $target), 404);

and says why: a promotion is the most far-reaching thing that can be done
to a client, so reaching one outside the actor's roster "through this door
and no other is not a rule, it is a gap".

The gap was on the way in. A client-scoped staff member holding
manage_users and edit_users was refused the promotion with a 404 -- the
refusal that is careful not to distinguish a stranger from an account that
is not there -- and then shown that same person's name, email, role,
status and consequence counts in the list the refusal came from,
searchable by name or address and paginated to the end.

StaffLibraryScope::clients() is canAssignClient()'s listing half, written
for this: "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." The picker twenty lines below already went
through the same boundary via assignableClientIds().

Only the client direction is narrowed. The staff direction is left exactly
as it was: whoever may demote a staff member may see the staff roster, and
what limits a demotion is guardTarget() on the write, not the listing.

Tests: the listing half added to AccountConversionScopeTest, which until
now covered only the refusals. Two of the five fail against the unfixed
controller -- the stranger's address in the list, and reaching it by exact
search. The other three pin what must not change: the actor still sees
their own client, unscoped staff still see everybody, and the demotion
list still lists staff.
2026-08-28 23:38:48 +02:00
ignacionelson 81bb136e9e Merge pull request #1750 from denkfabrik-li/fix/mail-oauth-alarm-fires-once
RefreshMailOAuthTokensCommand is the daily refresh and, by its own docblock, the health check that goes with it: a delegated grant can die silently, and for a portal whose password-reset mails ride on this connection that must surface as a warning rather than as a support ticket weeks later. It decided whether to warn from last_error -- but last_error has a second writer. OAuthCodeFlowBroker::refresh() records a dead grant and notifies nobody, and freshAccessToken() reaches it from every send. So on an installation that is actually sending mail the send got there first, the command read the column as "already told them", and the warning never went out. last_error is cleared only by a successful refresh, which a dead grant never has, so it never went out later either. The alarm worked on installations that were not using the mailbox and failed on the ones that were.

The anti-nag rule is not the problem and does not change: one notification per broken state is still all anybody gets. The problem is that one column was answering two questions, which the table's own comment describes -- "what the settings page's warning and the admin notification read". The warning wants "is this connection broken", and any writer may answer it, which is why the settings page turning red on a failed send is correct and stays. The notification wants "have the admins been told", and only the notifier can answer that.

broken_notified_at is stamped when the command notifies, and the command asks that instead. It is cleared wherever last_error is cleared -- a successful refresh, a disconnect, a changed client id -- and those three sites now call clearFailure() rather than nulling two columns each, because a connection left healthy but still marked "already told them" would go quiet the next time it died, and a fourth caller is exactly how the first one happened. The send path still records the failure and still notifies nobody: a transport is not a place to decide who gets alarmed.

Verified before merging: 27 passed on the merged tree, 2 failed / 25 passed with app/ reset and the migration and tests kept. The recovery test is green either way by design. This touches the same command and broker as #1739 and the follow-up to it, so the merged result was read rather than trusted: the refresh reporting sits in the try and the notify guard in the catch, they do not interact, and refreshSerially() re-reads the row before refreshing so the broken_notified_at the catch reads is the stored one -- while a stand-aside throws nothing and never reaches the catch at all.

Note for the next release's upgrade notes: this adds a migration, so "nothing to do beyond dropping in the files" no longer holds.

Reported and fixed by @denkfabrik-li.
2026-08-28 18:04:37 -03:00
ignacionelson a2bc3fa163 Merge pull request #1749 from denkfabrik-li/fix/deleted-comment-author-type
file_comments.author_id is cascadeOnDelete and the cascade never fires, because a user is soft-deleted. The row behind a deleted commenter is still there and the column still points at it -- the relation just would not hand it over, and every caller then had to invent a meaning for the absence. They invented different ones: the author type became "guest" on the moderation screen and on the file's own thread, and "client" in the API, each printed beside a name that stayed correct, so one row said "Dana Staff" and "guest" at the same time. The author filter and the name search stopped matching the comment altogether, which is the worse half: a moderator filtering for staff comments did not see a staff comment sitting in front of them, and nothing about that looks like a missing row.

This is the author half of #1717, and DeletedClientThreadTest's docblock already described both columns. FileComment::authorName() was the one place that reached past the relation by hand, which is why the names were right while everything beside them was wrong.

The relation is fixed rather than the five call sites: author() reads a deleted account, and the API resource, the author filter and the name search then need no change at all, because they were already asking the right question of a relation that would not answer it. The two authorType() copies now ask author_id, which after the relation fix answers the same either way -- written that way because "no author row means guest" is exactly the reading that produced the bug.

Verified before merging: tests/Feature/Comments at 186 passed on the trial-merge, 5 failed / 1 passed with app/ reset. The survivor is the guest guard, green either way, which is what says this did not simply relabel everything as staff. scramble:export reproduces the spec byte for byte.

No visibility widens, and that was checked rather than taken on trust: every decision point in VisibleCommentScope and FileCommentPolicy compares author_id directly, five sites, none through the relation. No new field is exposed either -- the API resource reads only id, name and type from the author, and name already went through authorName()'s withTrashed lookup. Deleting an account still takes its comments with it when the grace period ends, since author_id is cascadeOnDelete.

Reported and fixed by @denkfabrik-li.
2026-08-28 18:03:03 -03:00
ignacionelson ef6f8fea56 Merge pull request #1748 from denkfabrik-li/fix/password-reset-credential-source
Two accounts reach the same reset with opposite needs, and it answered both by writing a hash and hoping.

A provider account is asked for something it cannot do. The Connected accounts screen refuses to release an account's last provider -- "Set a password first, then disconnect Google" -- and nothing set auth_source back to Local, so the screen went on asking for what had just been done, with no way out from inside the application. AuthSource already states the rule that closes it, for this case by name: a social account may later set a real password, and social only means the account came into existence without anybody choosing one. A reset by emailed token is where somebody chooses one, and the prop the screen reads is literally auth_source === Local under the name has_local_password.

A directory account is told something untrue. isDirectoryAccount() means the local hash is not consulted at all, so the same reset wrote a password that could never sign anybody in and reported success -- including when the directory it points at is gone, which is exactly the situation that sends somebody to a reset.

The reset now asks where the account's credentials live. social becomes Local, because the new password is the credential now. A directory account is refused, with the reason, and nothing about it moves -- writing Local there would not record something that had happened, it would take the account off its directory as a side effect of a password reset, which is an administrator's decision and already lives in AccountConversion with the password requirement and activity entry that belong to it. Everything else is byte for byte as before.

Verified before merging: 20 passed on the trial-merge, 2 failed / 18 passed with app/ reset, and the wider suites green -- tests/Feature/Auth 96 passed, tests/Feature/Identity 302 passed. Four properties were checked in the framework rather than argued. PasswordBroker::reset() calls validateReset() before the callback, so the refusal only reaches somebody holding a token emailed to that address and nothing is enumerable. It deletes the token after the callback, so a throw leaves the link usable. Every use of AuthSource::Local is in ConnectedAccountsController -- the has_local_password prop and the last-provider guard -- so the social-to-Local flip grants exactly the ability the screen instructs the user to obtain and nothing else, and no new login capability at all, since password login already worked for social accounts. And the check is isDirectoryAccount() rather than an auth_source comparison because LDAP is client-only, so staff are not refused; the test for that is green either way.

One new string is English only for now: "This account signs in through your directory, so its password is not set here."

Reported and fixed by @denkfabrik-li.
2026-08-28 18:01:47 -03:00
ignacionelson 927c8fc991 Translate the bulk edit's second skip reason
#1747 split the bulk edit's skip message in two, because "you don't have permission to edit them" was being said to somebody about files they own. The new sentence arrived English only, so every non-English installation would have read the correct reason in the wrong language.

Translated from its near-twin rather than from scratch: the two messages differ in one clause, so each locale keeps the first sentence it already had, its own register -- de and tr formal, es, nl, pl and zh_CN informal -- and only the reason changes. That way the pair reads as one voice on the same screen, which is where a staff member meets both.

Added immediately after the sibling key in each file, which is also its sorted position, so the diff is one line per locale and nothing else moved.
2026-08-28 17:59:29 -03:00
ignacionelson 144f5fc578 Merge pull request #1747 from denkfabrik-li/fix/bulk-edit-skip-reason
Two different things stop a selected file being changed in a bulk edit, and bulkUpdate() reported both as the first one. Files dropped by the Gate::allows('update') filter are ones this staff member may not edit at all. A file that survives the filter and still changes nothing is a different case: it was editable, and every field they asked to change is one their role does not let them set -- expiry, download limit and categories each sit behind their own permission here, exactly as they do in the single-file editor. So a staff member with edit_files but without set_file_expiration_date, editing three files they own, was told "0 of 3 selected files were updated. The rest were skipped because you don't have permission to edit them." They own all three, and editing is precisely what they may do: the sentence was both wrong and unactionable, since nothing in it points at the permission that actually stopped the edit.

The two cases get their own sentences now. Every skip being a file they may not edit keeps the existing string, unchanged, so its sixteen translations stay in use. Anything else gets a new one, "because you don't have permission to make those changes", which is also true when both reasons are in play, so a mixed selection is described correctly rather than approximately. Which files get changed is untouched, as is the silent-skip convention and the 422 when nothing at all is authorised.

Verified before merging: 14 passed on the trial-merge, 2 failed / 12 passed with app/ reset -- the field-permission case and the mixture. The pure edit-permission case is green either way, which is what says the existing message was not disturbed. FilesController overlaps #1728, already merged, and its expiryDateFor work is intact in the merged tree.

The new string arrived English-only; the sixteen catalogs are filled in the commit that follows.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:58:14 -03:00
ignacionelson 383c3b2ff5 Merge pull request #1746 from denkfabrik-li/fix/expired-file-staff-access-comment
File::isExpired() documented the rule the whole application is supposed to follow: once past, the file is hidden from clients and the public site but staff keep full access. The second half is not true of a client-scoped staff member. StaffLibraryScope::buildFiles() builds their library as own uploads plus what each assigned client may see, and that second half runs through File::scopeVisibleToClient, which ends in notExpired() -- a client-side rule. So an expired file they held only through a client leaves their library and answers 403 on download, while their own expired upload stays and an unscoped administrator is unaffected. Api\FilesController stated it the same way, "Only the client branch of the visibility rules drops them", which reads as though a staff caller is unaffected when a client-scoped one is reached through that very branch.

This does not change that behaviour. c8078f65 weighed widening it and decided against, because scopeVisibleToClient is the single source of truth for client file access and the highest-stakes function to go changing for a dashboard widget, and relabelled the widget instead. That decision lived in a commit message and one widget's label; nothing in the code said it, and the docblock nearest the rule went on promising the opposite -- which is how the next person re-derives "staff keep full access" and widens the scope to match.

Documentation and characterisation only. isExpired() now states the boundary and why it is where it is, the API comment is corrected, and ExpiredFileStaffAccessTest pins all three cases.

Verified before merging: 3 passed on the trial-merge. The counter-check has to be inverted for a characterisation test -- these pass on unmodified main by construction, so the question is whether they fail when the boundary moves. Deleting the closing notExpired() from scopeVisibleToClient gives 1 failed / 2 passed, and it is the third case, the one carrying the decision, that falls. File.php overlaps #1726 and Api/FilesController.php overlaps #1727, both already merged, and both are intact in the merged tree. scramble:export reproduces the spec unchanged.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:56:11 -03:00
ignacionelson b9807bf610 Record the comment moderation read boundary as a security fix
#1745 closes an unauthorised read, so it belongs in the changelog rather than only in the merge log: CHANGELOG.md ships in the zip and renders in the application, and it is where somebody running an installation finds out whether a release is about them.

Written for that reader rather than for the codebase. Permission to moderate comments was letting somebody read them, which is not the same thing, and the entry says what was exposed -- the text, staff-only notes, the client each conversation belongs to, and a visitor's IP -- because "a scoping issue" tells an operator nothing about whether to worry.

It also says who was affected and what to do, which matters more than usual here: no role ProjectSend ships is affected, and the one configuration that is -- a custom role that moderates comments but may open no file -- stops being able to moderate at all. Somebody meeting that on a Monday morning should find the answer in the release notes rather than in a bug report.
2026-08-28 17:44:10 -03:00
ignacionelson d91cf97bcb Merge pull request #1745 from denkfabrik-li/fix/moderation-view-read-permission
FilePolicy::view() has two halves for a staff member: one of the three file keys (upload / edit_files / edit_others_files), AND StaffLibraryScope. Every comment surface that spans files narrowed by the library half alone -- VisibleCommentScope::across(), pendingTotal(), and the API's GET /comments/pending. A role holding moderate_comments and no file key at all therefore read, on /comments, every comment in the installation: the text, staff-only notes, the client's name in conversation, and a visitor's IP, while getting a 403 on every file those comments were about. POST /api/v1/comments/{id}/approve was the same door on the write side, and its response carries the comment body, so an id was enough to read one.

The project already states the rule this breaks in four places, including across()'s own docblock -- "a moderation screen is not a way around the visibility model: moderating means deciding about comments you can already see" -- and only the cross-file queries did not ask it.

The cross-file queries now take their files from ViewableFileScope, which is FilePolicy::view() expressed as a query and already in the codebase for exactly this, instead of from StaffLibraryScope, which is only its second half. The permission half becomes a named method there, permitsAnyFile(), because three modules now ask it, and FileCommentPolicy::moderate() asks it in both of its forms. This is the other half of #1698, which library-scoped the same screen: library is not readability.

Verified before merging: tests/Feature/Comments at 180 passed on the trial-merge; with app/ reset and the new test file kept, 5 failed / 2 passed. The two green either way are the right two -- the premise, that the file itself 403s for this viewer, and the guard that a moderator who does hold a file key still moderates the whole installation.

Compatibility was the question worth asking, and it is clean: the only shipped roles holding moderate_comments are Account Manager, which also holds Upload, EditFiles and EditOthersFiles, and System Administrator, which holds everything. No shipped role loses moderation. The only configuration whose behaviour changes is a custom role granting moderate_comments with no file key, which is precisely the leaking one.

This PR also edits docs/api/openapi.json, which #1727 edited too, so the merged result was checked rather than trusted: scramble:export on the merged tree reproduces the committed file byte for byte, with both endpoints' descriptions present.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:43:30 -03:00
ignacionelson 89b3d34c8f Merge pull request #1744 from denkfabrik-li/fix/version-link-duplicate-share-notice
FileVersions::link() resolves its audience before the merge, and its own comment says the ordering is the whole dedupe: these are the people who could already see both files, so anyone the merge is about to reach for the first time is excluded and gets file_shared from FileSharing::assign() instead. The merge then undid it. moveAssignmentsToRoot() handed every one of the revision's targets to assign() under the comment "firstOrCreate inside, so a target the root already has is a no-op rather than a duplicate notification" -- but firstOrCreate makes the assignment row idempotent, not the three side effects below it. The activity entry, the in-app notification and the digest all ran unconditionally, so a client who already held both files was told a file had been shared with them about a file they had had all along, on top of the file_new_version they were owed. Two notifications for one action, for exactly the people the early resolve exists to protect.

A target the root already holds is now skipped rather than handed to assign(). Nobody is gaining access in that case, so the activity entry would have been as untrue as the notification -- which is the rule copyAssignmentsFrom() states outright for its own case, and why it inserts directly instead of going through FileSharing. The two stale comments are corrected with it.

Deliberately not changed: assign() itself, and so the behaviour ShareNotificationsTest pins, where re-posting an existing assignment through the share endpoint still notifies again. That test says the condition for changing it -- it should stop for files and folders at once, which is the point of them sharing one implementation -- and a version merge is not somebody choosing to share again.

Verified before merging: 10 passed on the trial-merge, 2 failed / 8 passed with app/ reset, and the whole tests/Feature/Files directory at 548 passed. The case where somebody genuinely gains the root still gets file_shared is green either way, which guards against skipping too much. The method was read whole rather than just the hunk: $file->assignments()->delete() still runs for a skipped target, so no row is left dangling and nobody loses reach.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:41:39 -03:00
ignacionelson d09cb602c1 Merge pull request #1743 from denkfabrik-li/fix/read-redirect-covers-every-door
Three middleware answer before HandleInertiaRequests and so repeat its 302-to-303 upgrade themselves: EnsureSetupIsComplete, EnsureUserIsActive and EnforceTwoFactor. This file has a write case for each. The rule has a second half -- a read still gets a plain 302, because a 303 there is an upgrade nobody asked for -- and that half was checked once, on the deactivation door, under the name "leaves a read alone in every one of those cases". So a change that upgraded reads at the setup door or the two-factor door would have gone through with the suite green and this test still claiming it would not.

One case per door now, as a dataset. The setup case reads a guest-reachable GET for the same reason the write case posts to /timezone: anything behind auth is answered by the guest redirect before EnsureSetupIsComplete ever sees it. No production code changes -- all three doors answer a read with 302 today, which is what the new cases assert.

Verified before merging: 9 passed on the trial-merge, and the mutation counter-check was run here rather than taken from the PR. With EnsureSetupIsComplete answering 303 to everything, this branch's file goes 1 failed / 8 passed and main's version goes 7 passed. The write case for that door stays green under the mutation, which is right: 303 is what a write should get. The mutation itself was confirmed live first, by making the middleware throw and watching the response become a 500 -- a first attempt at it bound no argument and was a silent no-op, which would have looked exactly like the new test failing to notice.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:38:47 -03:00
ignacionelson b7a94d4479 Merge pull request #1742 from denkfabrik-li/fix/file-permissions-test-reads-config
FILES_WEB_SERVER_READABLE exists so a web server running as a different user can traverse the directories a download lives in. It asked for 0755 from a key that is never consulted: FilesystemManager::createLocalDriver() passes directory_visibility ?? visibility ?? private as the default visibility for directories, and this disk sets visibility to public two lines above with no directory_visibility, so Flysystem reads dir.public and never looks at dir.private. The mode came out 0755 anyway, because 0755 is Flysystem's default for a public directory -- the right answer from the wrong place, which is the kind that stops being right quietly. Adding a directory_visibility to this disk, an ordinary hardening move, or a change to that Flysystem default would have been enough to break the flag silently on exactly the hosts that need it.

Both directory keys are now named, so the intent survives whichever branch Flysystem takes. Nothing widens: the flag-off path is still literally the old configuration, spread rather than ternary, and under the flag 0755 was already the effective mode.

And the test could not have caught it, because it was not testing this configuration: filesDiskWith() restated the shipped branch inline, verbatim down to the 0755, so it kept passing against its own copy however the real one changed. It now requires config/filesystems.php and replaces only the root. Two housekeeping fixes ride along: the scratch root is per parallel worker, the way Tests\TestCase already does it for upload parts, because eight workers sharing one real directory means one worker's afterEach deletes another's tree mid-test; and the tree is cleared before each test as well as after, so a killed run does not poison the next one.

Verified before merging: 3 passed on the trial-merge, and the mutation counter-check was run here rather than taken from the PR. With the shipped dir.public changed to 0750, this branch's test goes 1 failed / 2 passed and main's version of the same file goes 3 passed -- the old one genuinely could not see a change to the shipped configuration.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:34:33 -03:00
ignacionelson fdcdad7fb2 Merge pull request #1741 from denkfabrik-li/fix/zips-queue-check-reachable
ensure_worker_watches_zips() exists because a worker unit written before zip downloads had their own queue watches default only, and a zip enqueued to zips then waits forever with nothing to say why. It was called from exactly one place: inside the branch that reloads PHP-FPM, nested inside the branch that found a worker unit -- so it ran only when a PHP-FPM unit had been detected. A worker is a different unit from PHP-FPM, and not finding one says nothing about the other: a host running mod_php, or one whose FPM unit is named in a way this script does not recognise, still has a systemd worker that may predate the zips queue, and it got no check and no mention.

The check now also runs in the else branch, where it costs nothing -- its own first line returns immediately unless systemd and a worker unit are both present -- and the worker is restarted after it, paired exactly as the FPM branch pairs them. That pairing is the point: the new --queue argument reaches the worker only when systemd next starts it from ExecStart, and the queue:restart projectsend:update signals cannot deliver it, because that makes a worker pick up new code and it has already run by the time this block is reached. Editing the unit without a restart would leave the operator told that zip downloads were fixed while they still could not finish, which is worse than the silence it replaces: silence sends somebody looking and a success message does not.

Under --no-restart it is said rather than done. Editing a unit file is exactly what that flag asks us not to do, but a worker that cannot finish a zip is broken whether or not we may touch it, and this is the only place that knows to mention it.

Verified before merging: bash -n parses, and the restart block was driven through four host shapes with say, warn, systemctl and the check itself stubbed, on both this branch and main, rather than relying on the transcript in the PR. Before: fpm+worker reached; worker without fpm silent; --no-restart silent; no systemd silent. After: the first two both reached and restarted, --no-restart not reached but said so, no systemd reached and a no-op. That no-op rests on update.sh:330, which returns unless systemd and a worker unit are both present, so it was read rather than assumed.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:32:24 -03:00
ignacionelson ff26fac9c5 Say when the scheduled mail refresh stood aside
#1739 put the nightly OAuth refresh under the same lock a send holds, which is right -- but standing aside for the lock holder still printed "Refreshed <provider> (<account>)". No token request was made, so the line describes something that did not happen, and scheduler output is read precisely by somebody trying to work out what did.

refreshSerially() now answers whether it refreshed, and the command says which of the two happened. Standing aside is a healthy outcome: somebody else is refreshing this very connection, which slides the token window just as well as doing it again would. It is just not a refresh, and it should not claim to be one.

The existing test for the stand-aside now asserts the output too, and it fails against the old message.

Same reasoning as d8ef21b, which said when the worker check was skipped rather than skipping it quietly.
2026-08-28 17:27:30 -03:00
ignacionelson a7e883ef70 Merge pull request #1739 from denkfabrik-li/fix/scheduled-mail-refresh-lock
OAuthCodeFlowBroker::freshAccessToken() serialises refreshes per connection, and its comment says why: both providers rotate the refresh token as they hand out a new access token, so a refresh token is good for exactly one use, and "a worker racing the nightly refresh command" means the slower one spends a token the faster one has already replaced. The provider answers that with invalid_grant, which is the same thing it says about a genuinely revoked grant -- last_error gets written, the settings page turns red, and every admin is told to re-consent a connection that was never broken. RefreshMailOAuthTokensCommand called refresh() directly, outside that lock: it was the racer the comment names rather than a party to the arrangement it describes, and the false alarm landed on the connection the daily run exists to protect.

The command now goes through refreshSerially(), which takes the same lock -- named once, in one place, for both callers -- re-reads the row inside it, and refreshes. Unlike freshAccessToken() it refreshes a token that is still usable, which is the point of the daily run: a delegated refresh token dies of disuse and this keeps the window sliding. The lock is taken rather than waited for, unlike the send path: nobody is standing at a screen for a scheduled job, and a held lock means somebody is refreshing this very connection right now, which slides the window and establishes its health just as well. refresh() stays lock-free, because making it self-locking would deadlock the send path that already holds the lock.

Verified before merging: 24 passed on the trial-merge, 1 failed / 23 passed with app/ reset. PHPStan level 8 clean across app/Modules/Platform/Mail. Adding a method to the MailOAuthBroker interface breaks nothing: OAuthCodeFlowBroker is its only implementer, and MailOAuthBrokers is a registry rather than an implementation.

Known nit, fixed in a follow-up rather than here: when refreshSerially() stands aside because the lock is held, the command still prints "Refreshed <provider> (<account>)".

Reported and fixed by @denkfabrik-li.
2026-08-28 17:26:24 -03:00
ignacionelson 90009b7029 Merge pull request #1738 from denkfabrik-li/fix/totp-replay-claim-atomically
TwoFactorService::verify() asked Cache::has(), verified the code, then Cache::put(). Between the read and the write the key is free, so two requests carrying the same code could both be told yes -- which is precisely what the replay guard exists to prevent, and the window an intercepted code has is the whole of its validity either side.

Cache::add() writes only if the key is absent, so of two requests carrying the same valid code exactly one gets true back, and has() is gone: a failed claim is "already used". Verification still runs first, so a wrong code never touches the cache and cannot burn the window for the code the person is about to type correctly. The 90-second claim, the key's shape, and the recovery codes are all unchanged.

Verified before merging: 11 passed on the trial-merge, 1 failed / 10 passed with app/ reset. The existing "a totp code cannot be replayed" test is green either way, because it covers the sequential case, which was never the problem. Being on the authentication path, the wider suites were run too: tests/Feature/Identity and tests/Feature/Auth together, 393 passed.

Cache::add() is only as atomic as the store under it, so every store an installation could realistically run was checked in the vendored framework rather than assumed: database (the default when CACHE_STORE is unset) decides on insertOrIgnore(...) > 0 against the cache table's primary key; redis and memcached have native atomic adds; and the file store takes an exclusive flock before it reads and writes.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:24:28 -03:00
ignacionelson 9508750c60 Merge pull request #1737 from denkfabrik-li/fix/transfer-range-utc-bounds
resolveTransferRange() builds every boundary in the viewer's zone, deliberately: "last week" should end when their evening does, not at whatever hour UTC midnight falls on for them. Its docblock then claimed the instants "compare against the UTC column directly". They did not -- the query builder formats a Carbon in whatever zone the object carries and discards the offset, so the viewer's midnight reached the database as a UTC string. For Asia/Tokyo the window really began at 2026-08-21T15:00:00Z while the query asked for 2026-08-22 00:00:00: nine hours at each end, both in the same direction, so the first nine hours of the viewer's window were missing from the chart and the last nine hours of somebody else's day were counted into it.

The comparison now converts to UTC, one ->copy()->utc() per boundary. The copy matters: the originals keep the viewer's zone, so the day cursor and the grouping below still put an evening upload on the right bar, which is the half that really is about the viewer's calendar. Every other date filter already goes through LocalDay::start()/end(), which return UTC, which is why the activity log and the download history never had this.

Verified before merging: 20 passed on the trial-merge, 1 failed / 19 passed with app/ reset. Shares DashboardController and its test file with #1722, already merged, so the merged tree was checked -- that PR's visibleToClient change is intact.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:22:37 -03:00
ignacionelson 9c6f4df5bc Merge pull request #1736 from denkfabrik-li/fix/scoped-creator-keeps-client
A client-scoped staff member with create_clients created a client and lost it in the same request. guardTarget() answers 404 for anything off their roster, and StaffLibraryScope::clients() leaves it out of their list -- so the record existed, was logged, was welcomed by email, and was invisible to the person who made it. store() redirects to the edit page, which is exactly where they landed on a 404. The API twin had the same shape: a scoped token got a 404 from every route that binds the client it had just created.

The new client is now attached to the creator's roster when the creator is client-scoped, on both sides. That is where a client they created belongs -- the roster is the same list assignedClients already uses for everything else they may reach. Unscoped creators gain nothing: they see every client already, and a roster entry would change what assignedClients means for them. Nothing is attached retroactively.

The widening this involves is self-limited: the only thing added is an account the creator just made, which starts with no files, no folders and no group memberships, so assignableClientIds gains nothing to reach. Seats do not move either, since they are counted from active and account_requested.

Verified before merging: 34 passed across both suites on the trial-merge, 2 failed / 32 passed with app/ reset. This is the busiest file set of the series -- it shares ClientsController with #1718 and Api/ClientsController plus the API test file with #1723 -- so the merged result was read rather than trusted: #1718's reassign_candidates gating and #1723's patchCustomFieldValues are both intact alongside it. scramble:export reproduces the committed docs/api/openapi.json byte for byte.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:20:44 -03:00
ignacionelson 2903a1da6d Merge pull request #1735 from denkfabrik-li/fix/editable-once-checkbox
ClientPortalCustomFields::save() writes '0' for an unticked checkbox, and filled('0') is true in Laravel. isLocked() asked whether anything is stored, so an editable_once checkbox locked itself the first time the client saved the page it sits on, whatever they had chosen. A box they never ticked could then never be ticked, and the one edit the setting promises was spent on a decision they had not made. A text field left empty stores null and stays open; that asymmetry was the bug, and '0' is the absence of a decision in exactly the way null is for every other type.

A checkbox now locks on a stored '1' and nothing else. Every other type keeps filled(). What save() stores is unchanged -- '0' remains a recorded "no", as the API's client create also writes it -- and the behaviour after a real tick is unchanged too: the client still cannot untick it, and the test pinning that is untouched.

Verified before merging: 6 passed on the trial-merge, 1 failed / 5 passed with app/ reset. The editable-once text field test is green either way, which confines the change to checkboxes. The relaxation is safe because the lock is enforced on the write path and not only rendered: isLocked() gates rules(), which drops the field from validation, and save(), which skips it, so the ticked-to-unticked direction stays closed server-side.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:18:38 -03:00
ignacionelson c11cb3cc63 Merge pull request #1734 from denkfabrik-li/fix/quota-message-inherited-default
ClientStorageUsage::quotaMb() exists because a client's own storage_quota_mb of 0 does not mean "unlimited" -- it means "no quota of their own", and the site default is what is then enforced. Both chunked-upload quota checks enforced the resolved limit through quotaBytes() and then printed the raw column in the rejection, so a client with no quota of their own and a site default of 1 MB was told "This upload would exceed your storage quota of 0 MB." That is every client who was never given a quota, including every self-registered one, and the sentence appears at the one moment somebody is trying to find out what their limit is.

Both now print quotaMb(), which is what the check enforced. The API's single-request upload already did exactly this for the same sentence, so the three copies agree. The enforcement itself is untouched -- only the number in the message changes -- and the unlimited case never reaches these branches, because quotaBytes() > 0 guards them.

Verified before merging: 16 passed on the trial-merge, 2 failed / 14 passed with app/ reset. The "a client with a quota of their own still sees their own number" test is green either way. The string itself is unchanged, so no locale file needs anything.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:17:17 -03:00
ignacionelson 5117511946 Merge pull request #1732 from denkfabrik-li/fix/public-preview-log-debounce
FileThumbnailController::preview() writes at most one FilePreviewed row per viewer per file per five minutes, because watching a video is a single deliberate act that the browser turns into dozens of Range requests. Its docblock ended by naming the route where the same act happens without an account -- PublicGroupsController::preview -- and that route logged unconditionally. Five requests for the same public file wrote five rows where the signed-in twin wrote one, so one visitor watching one clip buried the public half of the activity log, which is the half an operator reads to see what the outside world is doing.

The window moves into a shared PreviewLog, next to PreviewKind, which those two routes already share for the same reason. Keying is unchanged for a signed-in viewer. An anonymous visitor has no account to key on, so the request IP stands in -- the same substitute ApiServiceProvider's rate limiter makes for an unauthenticated caller. It is a cache key with a five-minute life and never reaches the log, which keeps its own decision about recording an IP.

Downloads are deliberately untouched and stay one row per download: each is a transfer, and DownloadAllowance::used() counts those rows to enforce a per-file cap, so swallowing one would hand out free downloads.

The limit this leaves open, since the IP is a stand-in and not an identity: two anonymous visitors behind one address share a key, so within five minutes the second one's view of the same file is not recorded. That is the same trade the signed-in side has always made per account, and the alternative is the row-per-Range-request this fixes.

Verified before merging: 24 passed across the public-preview and thumbnail suites on the trial-merge, which also confirms this co-exists with #1725 -- the two share both controllers and change different methods in each. With app/ reset and PreviewLog deleted, 1 failed / 9 passed. The signed-in route's existing debounce tests pass unchanged, which is what says the shared class did not move that side. request()->ip() honours the trusted-proxy configuration, so a forged X-Forwarded-For cannot defeat the window from outside.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:13:05 -03:00
ignacionelson b6f4770795 Merge pull request #1731 from denkfabrik-li/fix/zip-build-failure-hygiene
BuildZipDownloadJob already draws this line in its write-failure branch: "What the requester sees stays generic: a libzip string means nothing to them and can name a server path. An operator needs the opposite, so the reason goes to the log instead." Thirty-seven lines below it, the catch-all around the whole build stored $e->getMessage() in the row the requester polls -- and ZipDownloadsController hands that column straight back to whoever asked, clients included. A client asking for an archive of a file whose disk is no longer configured read "Disk [a-disk-that-is-not-configured] does not have a configured driver." verbatim. The reason now goes to the log with the exception class, and the row carries the same kind of sentence fail() already uses.

Two more in the same method. tempnam() creates the file, and $tempFiles[] was appended only after the copy finished, so every throw in between left a zip-src- file in the system temp directory that nothing ever removed; it is now registered the moment it exists. And the copy itself was unchecked -- a copy that stops early is a truncated member added to the archive as though it were the file, so the build reports ready and the recipient gets something that opens and is wrong. stream_copy_to_stream and the flushing fclose are both checked now, and both handles close on every path.

Deliberately not changed: comparing the copied byte count against files.size, which would fail perfectly good archives whenever that column is stale; the write-failure branch and its wording; and the skipped-files reporting, which still says which files and why, so only the catch-all went generic.

Verified before merging: 37 passed on the trial-merge, 2 failed / 35 passed with app/ reset. The leak was confirmed at the consuming end rather than inferred -- ZipDownloadsController:169 returns the error column to the requester.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:05:30 -03:00
ignacionelson 037439e1f2 Merge pull request #1730 from denkfabrik-li/fix/provisioning-over-deleted-address
The unique index on users.email spans soft-deleted rows -- AvailableEmailRule is built on exactly that -- so a deleted account keeps its address until erasure removes the row. The registration form learns this from validation. The machine paths have no form to validate: a directory or an identity provider hands over an address and ClientProvisioning::provision() inserts it, so a client deleted earlier signing in through a provider that may auto-provision got a QueryException, and what the person met was a 500 in the middle of their sign-in. Same shape through LDAP at POST /login.

Both provisioners now ask ClientProvisioning::addressIsFree() first and refuse. The social flow reuses the refusal it already gives every other identity it cannot provision -- "There is no account here for that address." -- which is also all a stranger should learn: whether an address was once an account here is not the provider's to publish. The LDAP flow falls through to the ordinary failed sign-in.

The deleted account is deliberately not resurrected and not linked. Restoring one because a directory still lists the address is a decision for a person, not a side effect of somebody signing in -- and a linking shortcut here would be an account takeover. Everything about an address belonging to a live account is untouched.

Verified before merging: 47 passed on the trial-merge, 2 failed / 45 passed with app/ reset. addressIsFree() queries withTrashed(), the same span as the unique index it protects, so the check and the constraint agree. Worth noting that both new warning lines record the email address, which is consistent with what these paths already log but is PII in the application log.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:03:34 -03:00
ignacionelson 6b99e37d01 Merge pull request #1729 from denkfabrik-li/fix/api-surface-by-route
Two places asked "is this the API?", and each got it wrong in the opposite direction.

EnsureCapability asked $request->expectsJson(). Whether a feature exists in this installation's edition is a property of the installation, not of what the caller is willing to parse: the same capability-gated API route answered 403 capability_unavailable to Accept: application/json and a bare 404 to Accept: */*, which is curl's default, while routes/api.php promises the 403 in as many words. The mirror image was worse -- an Inertia visit to a capability-gated web screen accepts JSON, so it took the API branch and announced the feature by name, where the whole point of the 404 is that an unavailable feature is absent rather than teased.

ProblemDetails asked $request->is('api/*'). Two staff pages live under that prefix -- the API dashboard at /api and the OpenAPI reference at /api/docs, both from routes/web.php -- so a signed-out visitor to /api/docs got a 401 problem+json telling them to send a Bearer token instead of the login redirect every other page gives.

One question now, asked once, in App\Support\ApiSurface: under the API prefix, and not part of the web middleware group. The group is what actually separates the two surfaces -- sessions, cookies and CSRF on one side, tokens on the other -- and it keeps answering correctly for a future /api/v2 without being edited. An unmatched path has no route to ask and stays the API's answer, which is what the existing "a missing API route is a problem+json 404" test pins. EnsureStaff keeps its expectsJson() check: there the question really is about the caller.

Verified before merging: the discriminator was checked in the running application rather than assumed -- /api/docs and /api resolve to [web, auth, staff], /api/v1/files to [api, auth:sanctum, api-active, staff-token, token-can:...]. 12 passed on the trial-merge; with app/ reset and ApiSurface deleted, 3 failed / 9 passed, every new test and no old one. Wider suites green: tests/Feature/Api 239 passed, tests/Feature/Platform 485 passed. scramble:export reproduces main's docs/api/openapi.json byte for byte. Worth recording that the blast radius here is the shape of a refusal and never whether one happens: both call sites run after authentication and authorization.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:02:05 -03:00
ignacionelson f676e09bb2 Merge pull request #1728 from denkfabrik-li/fix/expiry-timezone-drift
The edit screen is given a file's expiry as a calendar date read back in the viewer's own zone -- deliberately, or "a file set to expire on the 12th reopens showing the 11th". Every save posts that date back, touched or not, and update() derived a fresh instant from it every time. So the expiry drifted by the difference between two people's zones on any other edit: a file set from Pacific/Auckland moved 19 hours later the moment somebody in Buenos Aires renamed it, and moved again on the next save from a third zone. A file could quietly outlive the expiry somebody set for it, through an edit that had nothing to do with expiry.

The instant is now re-derived only when the posted date differs from the one the form was given, compared against the same string through a named pair: expiryDateFor() renders it, expiryInstant() reads it back, and the edit screen calls the render half so the two cannot drift apart. What a changed date means is unchanged -- still the end of that day in the zone of whoever changed it. bulkUpdate() needs nothing: its expiry is an explicit set / clear / no_change action, so an untouched expiry is never posted at all.

Verified before merging: 22 passed on the trial-merge, 1 failed / 21 passed with app/ reset. The "a real change still lands in the editor's zone" and "clearing still clears" tests are green either way. Edge cases walked: a posted date against no stored expiry still sets it, and a posted null against a stored null leaves the column alone rather than writing.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:00:16 -03:00
ignacionelson eb3d6e321d Merge pull request #1727 from denkfabrik-li/fix/api-expiry-end-of-day
FilesController::expiryInstant() exists because a calendar day ends where the person naming it lives: the web form posts a bare YYYY-MM-DD, which Eloquent would otherwise store as midnight UTC, so "expires on the 12th" would cut the file off partway through the 11th for anyone in the Americas. PATCH /api/v1/files/{id} took the same field, validated it as a date, and stored it exactly as it arrived -- so the same value that meant end-of-the-12th on the web meant start-of-the-12th over the API, and earlier still for a caller west of Greenwich.

A bare YYYY-MM-DD now means the end of that day in the caller's timezone, through the same LocalDay::end() the web path uses. A value carrying a time is unchanged: that is an instant the caller named on purpose, the API can express one where a date input cannot, and it is stored as it arrives. null still clears the expiry, and the validation rule and permission gate are untouched.

Note for the release notes: this lengthens the life of a file whose expiry an existing integration sets with a bare date, by up to a day. That is the correct meaning and the one the web has always had, but it is a behaviour change for callers who were relying on the old one.

Verified before merging: 19 passed on the trial-merge, 1 failed / 18 passed with app/ reset. The timestamp and clearing tests are green either way. The bare-date branch is gated on a strict ^\d{4}-\d{2}-\d{2}$ match, so nothing else takes it. scramble:export on the merged tree reproduces the committed docs/api/openapi.json byte for byte.

Reported and fixed by @denkfabrik-li.
2026-08-28 16:58:34 -03:00
ignacionelson d89807b237 Merge pull request #1726 from denkfabrik-li/fix/rendition-cleanup-independent
FileDiskCleanup::delete() wrapped two deletions in one try: the original upload, on whatever disk the row names, and every cached rendition, which is always on the local files disk. Storage::disk() throws outright for a name with no configured driver -- precisely the state the original's disk is in whenever this fails at all -- so the catch swallowed it and the renditions were never reached. Nothing looks for them afterwards: OrphanFileScanner skips the rendition directories on purpose, as derived artifacts rather than orphaned uploads. A file whose external disk had been removed or renamed therefore kept every cached copy of itself indefinitely on the disk that still worked, including the client-facing ones, which for a shared image may be the only copies anyone ever generated.

The two attempts are now separate, each with the tolerance the class was written for: a storage failure still never turns a delete click into a 500, and the warning is still the whole report.

Also corrected: File::booted() justified deferring the byte removal with "the worst case is bytes left on disk with no row, which OrphanFileScanner already finds and reports". That is not this path -- the row is soft-deleted, and knownPaths() counts a trashed row's path as claimed, deliberately, so a scan never offers to double-adopt a file still inside its erasure grace period. The comment now says what actually happens, which is that FileDiskCleanup's warning is the only record.

Verified before merging: 8 passed on the trial-merge, 1 failed / 7 passed with app/ reset.

Reported and fixed by @denkfabrik-li.
2026-08-28 16:56:47 -03:00
ignacionelson 7ff2674e4f Merge pull request #1725 from denkfabrik-li/fix/rendition-written-atomically
Both thumbnail routes treat "the file exists" as "the rendition is cached", and nothing ever invalidates one: RenderedImageCache::flush() runs on ImageRenderingChanged, which no code in core raises. Whatever sits at the path is what every later viewer gets. ThumbnailGenerator::generate() encoded straight onto that path, so a render that died partway -- a full volume, a killed worker -- left a half-written file that was then served as the rendition indefinitely, and two requests rendering the same file at once encoded into the same path together.

Write side: the image is written beside its destination and renamed into place. rename() within a directory is atomic and replaces what is there, so the path holds either the previous rendition or a complete new one, and the loser of a race leaves a whole image rather than a mixture of two. Renditions always cache on the local files disk and the generator is handed $disk->path(), so both files are on the same filesystem and the atomicity is real. Read side: an empty file is not a rendition, so both routes replace one rather than serve it -- writing through a temporary file means core can no longer create that state, but an installation that ran an older version can already have it on disk and nothing else will ever clear it.

The cache itself is unchanged: a non-empty rendition is still reused without further checks, because decoding every cached image on every request to prove it is intact would cost the cache its point. The RenderingImage seam still fires before the encode.

Verified before merging: 14 passed on the trial-merge, 2 failed / 12 passed with app/ reset. The third test, about the generator's own temporary file, passes either way and the PR says so rather than leaving it to be found.

Reported and fixed by @denkfabrik-li.
2026-08-28 16:55:29 -03:00
ignacionelson 262cb2457a Merge pull request #1723 from denkfabrik-li/fix/api-patch-custom-fields
Api\ClientsController::update() states the rule eighteen lines above the bug: "PATCH semantics, unlike the web form which always submits every field: an absent key means 'leave alone', not 'clear'." Every column obeyed it. The custom fields did not -- they went through saveCustomFieldValues(), which is create()'s pass: it walks every field there is and writes null for the ones the request did not carry. A PATCH naming one field emptied all the others, with nothing in the response to say so and no second copy of the value anywhere.

The write pass is still shared but is now entered two ways: create() keeps writing every field, and update() writes only the fields the request named. Creating a client is deliberately unchanged -- it is not a partial update, and a checkbox nobody ticked is a recorded "no" rather than an absent row. Clearing a field by naming it with an empty value still clears it, and the validation rules are untouched.

Verified before merging: 23 passed on the trial-merge, 1 failed / 22 passed with app/ reset. The two guard tests -- a named empty value still clears, create still records every field -- are green either way, so the write path was not simply switched off. The keys reaching whereIn() are stripped to real field ids by validateCustomFieldValues() before they get there. scramble:export re-run on the merged tree produces a docs/api/openapi.json identical to main's, so the published spec does not move.

Reported and fixed by @denkfabrik-li.
2026-08-28 16:47:40 -03:00
ignacionelson a285f86b93 Merge pull request #1722 from denkfabrik-li/fix/portal-dashboard-visible-files
DashboardController::clientDashboard() built its own whereHas('assignments') query instead of using File::scopeVisibleToClient -- "the single source of truth for client file access", as that scope's own docblock puts it. The copy reproduced the assignment half and stopped there, so the page disagreed with the portal it introduces, in both directions. Over: the scope ends in notExpired(), so an expired file was gone from /my-files and refused on download while the dashboard went on counting it and printing its name. Under: a file in a folder shared with the client, a file the client uploaded through the portal themselves, and a revision -- which owns no assignment row and inherits its original's recipients through SharingIdentity -- were all missing from the count and the list.

The hand-rolled query is gone and the scope is used, one query object cloned for the count exactly as before. groups_count and the storage figures are untouched: they answer different questions and have their own tests.

Verified before merging: 19 passed on the trial-merge, 2 failed / 17 passed with app/ reset. The existing "clients get the portal dashboard with their own numbers" test is unchanged and green either way, so a directly assigned live file counts as it always did. PHPStan level 8 clean on the changed file.

Reported and fixed by @denkfabrik-li.
2026-08-28 16:16:49 -03:00
ignacionelson bc68a24ef5 Merge pull request #1721 from denkfabrik-li/fix/api-dashboard-activity-log-scope
ApiUsage::recentActions() read the activity log without ActivityLogScope::apply(). It was the only ActivityLog::query() outside ActivityLogger and AccountEraser that skipped it. Its only boundary was view_actions_log -- the permission ActivityLogScope's own docblock says "is not the whole answer for a client-scoped staff member", because a log row carries the subject's name. The Client Manager system role is client_scoped and ships with that permission, so this was the default configuration and not an exotic one: the same person who gets a 403 on a file and an empty /activity read that file's name off /api?all=1.

ApiUsage now takes ActivityLogScope and applies it to the recent-actions query, on both sides of the install-wide branch rather than only in the install-wide arm -- the own-actor filter already stays inside what the scope allows, and a boundary that exists in only one arm of an if is one refactor away from not existing. The token inventory, request counts and endpoint table keep ApiUsageScope alone: those rows are about the viewer's own credentials rather than library content.

Verified before merging: 17 passed on the trial-merge and 1 failed / 16 passed with app/ reset. The two tests guarding against narrowing further than /activity does -- a viewer's own actions stay whole, an unscoped viewer's feed is unchanged -- are green either way. ActivityLogScope::apply() wraps its conditions in a single where(Closure), so it composes with the origin and actor_id filters around it without a precedence trap, and ApiUsage is never constructed with new, so the added dependency is wired by the container everywhere.

Reported and fixed by @denkfabrik-li.
2026-08-28 16:14:57 -03:00
ignacionelson 1644d634d5 Merge pull request #1720 from denkfabrik-li/fix/group-reach-expired-file
groupReachesNoFurther() decides whether a client-scoped staff member may edit a group, by asking whether anything shared with it sits outside their library. f1b35cc9 settled that answer for deleted files: start from the live row, because a deleted file is not reach, because nobody can reach it. An expired file is the same case and was not covered. File::scopeVisibleToClient ends in notExpired(), so the moment a file expires it leaves every member's /my-files and the download answers 403 -- but it also leaves files(), where its absence reads as "outside my library". The group then became unmanageable for good: the rep could not add anyone, and could not undo their own membership change either.

The reach query now skips expired files as it already skips deleted ones. File::scopeVisibleToClient is unchanged -- what expiry does to a scoped viewer's library was settled deliberately in c8078f65, and this is about what counts as reach, not about what anyone may open. The folder half needs nothing, because folders do not expire.

The limit this leaves open, stated rather than implied: a membership added while a file was expired outlives the expiry, so if somebody later clears expires_at the client reaches a file that was outside the actor's library when the decision was made. f1b35cc9 leaves exactly the same opening for a file restored from the trash, and closing either would mean the guard weighing rows nobody can currently reach.

Verified before merging: this changes the file half of the same method #1719 changed the folder half of, so the merged tree was read rather than trusted -- both halves now skip expired files consistently. 29 passed on the merged tree, 1 failed / 28 passed with app/ reset. The "an expired file does not excuse a live one that is still out of reach" test is green either way.

Reported and fixed by @denkfabrik-li.
2026-08-28 15:59:24 -03:00
ignacionelson abbe9a3acc Merge pull request #1719 from denkfabrik-li/fix/group-reach-subtree
StaffLibraryScope::groupReachesNoFurther() asks whether anything shared with a group sits outside the viewer's library, and its docblock says the folder half covers "the folders whose subtrees it can browse". It compared the folder ids the assignment names and stopped there. But a folder shared with a group hands its members the whole subtree -- File::scopeVisibleToClient matches on folder placement, and a folder is visible to a client when it or an ancestor is shared with them -- so the guard passed on a subtree it had never looked into. A scoped rep could add their own client to a group holding a folder they own, and a stranger's file inside it went to that client, and then into the rep's own library, because files() is "own uploads plus everything my clients can see". That is exactly the widening the first test in the file exists to refuse.

The folder half now walks each assigned folder's subtree via subtreeFolderIds(), and the files inside it are checked too: a folder can be in the library while a file in it is not, since somebody else's upload into a folder this rep owns is neither their own nor their clients'. Expired files are skipped for the reason deleted ones are -- membership grants nobody access to one, and something nobody can reach is not reach.

Verified before merging: 27 passed on the trial-merge, and 2 failed / 25 passed with app/ reset to main. The "subtree wholly inside the library stays manageable" test is green either way, which is what says the guard was tightened rather than closed. subtreeFolderIds() walks a materialised path prefix, so it is one query per assigned folder with no recursion.

Reported and fixed by @denkfabrik-li.
2026-08-28 15:51:33 -03:00
ignacionelson 3dc407a777 Merge pull request #1718 from denkfabrik-li/fix/reassign-candidates-scope
reassign_candidates is the delete dialog's picker -- every active account in the installation, by name and role label -- and it was narrowed by nothing. Two lines above it on the clients index sits the listing itself, narrowed through StaffLibraryScope with a comment saying why. A client-scoped rep with manage_clients therefore read the name and role of every client in the installation, including the ones they can reach nothing of. The can('delete_clients') filter meant to hide the picker runs in React, which decides what is rendered, not what is sent.

The client half of the candidate list now goes through the same StaffLibraryScope as the listing beside it, and each screen sends the picker only to a viewer holding the delete permission it exists for. Staff accounts are not narrowed, here or anywhere else in the application. Privacy settings keeps the whole installation deliberately: that picker sets the erasure default stored once for everybody, behind edit_settings, so narrowing it by whoever happens to be editing would store the wrong answer.

Verified before merging: the four new tests pass on the trial-merge and go 3 failed / 16 passed with app/ reset to main, so they are testing the fix and not something else. Every call site of the changed candidates() signature was checked.

Reported and fixed by @denkfabrik-li.
2026-08-28 15:46:17 -03:00
ignacionelson d8ef21bb6a Say when the worker check was skipped rather than skipping it quietly
ensure_worker_watches_zips reads the unit file with `systemctl show -p
FragmentPath`, and an empty answer meant an immediate, silent return. The
common cause is a mistyped --worker: systemd does not know the unit, the
check never runs, and the operator finishes the update believing their
worker was inspected.

Which produces precisely the outcome the function exists to prevent. Its
own comment says a worker that does not watch the zips queue finishes no
zip downloads while cheerfully sending every email, and that nothing says
why. Skipping the check in silence is a quieter way to arrive there.

It now warns, names the consequence, and says what to check. The
read-only case is separated out too: a unit file somebody else owns
cannot be repaired, but it can still be read, so a worker that is missing
the queue is diagnosed rather than passed over.

Worth recording why this was looked at. The portal session found a deploy
script that had printed `next run` followed by nothing for its whole
life, because `systemctl show` answers an unknown property with an empty
value and a zero exit -- a line always blank is worse than no line, since
somebody believes a check is being performed. FragmentPath here is
correct, verified against a real unit; the failure was the same shape one
step further on, in what an empty answer was taken to mean.
2026-08-28 14:33:47 -03:00
ignacionelson 479dc61d2d Move branding into core, and leave white-labelling behind
Logo and watermark belonged in the private package for one reason: that
is where they were written. Nothing about them needs a hosted platform,
and an installation wanting its own mark on the pages it serves is the
ordinary case rather than the exotic one. They are core's now, and every
installation has them.

Hiding "Powered by ProjectSend" did not come. That is what a hosted
customer pays for, and its gate is not a capability key but the absence
of the code: cloud-modules keeps the listener, so an installation without
that package holds the column and has nothing able to read it. Flipping
an edition variable buys nothing, which was true before and stays true.
Core renders the switch where Capability::AttributionHide is held and has
no route that can save it -- there is a test asserting exactly that, which
fails the day white-labelling quietly becomes free.

The migrations move with their original filenames on purpose. A Cloud
tenant already ran them under those names, so Laravel skips them there
and the table and its data are untouched; a fresh install or a community
one runs them from here for the first time.

What got better on the way rather than merely moving:

The watermark listeners take core's real RenderingImage and
ResolvingImageRendering instead of duck-typed `object` payloads, and the
tests construct the genuine events rather than anonymous stand-ins that
imitated their shape. The package had to do it that way -- it builds with
no host present -- so three PHPStan ignore entries existed to describe
what the type system could not see. They are gone.

ModuleBoundaryTest asserted "branding is cloud-only, and the suite runs as
community", which was never what it was testing. It now reads the
capability off the route and subtracts it, so the invariant holds for
whichever module is installed.

The 43 branding strings arrived in all sixteen locales from the package's
own catalogues rather than being retranslated, and the package's are
pruned to the one string it still uses.

A hosted plan without branding subtracts branding.customize and
attribution.hide from the instance's environment. The row is never
deleted by that: a downgrade is usually an expired card rather than a
decision, and wiping somebody's artwork over a billing event is a loss
they would find weeks later with no way to know what it used to be.
Hiding reverses; deleting does not.
2026-08-28 13:27:10 -03:00
ignacionelson 530f30606d Let a plan take a capability away, and split branding from white-labelling
Groundwork for moving Branding out of the private package. Two changes,
both about who decides what an installation may do.

An edition grants capabilities; an operator may now take some away, via
PROJECTSEND_CAPABILITIES_DISABLED. Subtractive only, and that asymmetry is
the whole design: a variable that could *add* would put the hosted
edition's proprietary screens one line of .env away on every self-hosted
install, which is not a gate at all. So the list is intersected with what
the edition already allows and can only make the answer smaller.

This is not the plan tier core has always refused to invent. There are
still no billing tiers here to key off -- the objection config/api.php
makes about rate limits stands. It is the operator stating a fact about
this installation, exactly as PROJECTSEND_PLATFORM_MAX_STAFF_USERS does
for seats: the platform knows what it sold, the installation is told and
enforces. Unknown keys are ignored rather than fatal, because the variable
outlives both the plan that wrote it and the release that named the key,
and refusing to boot over a stale one would be an outage on upgrade day.

The registry takes the list as a constructor argument rather than reading
config itself, which keeps it a value object testable without an
application -- the failure that surfaced it was a unit test with no
container.

And branding.customize is now both editions, with the white-label half
split into attribution.hide, which stays Cloud-only. Dressing an
installation in its own logo is not a hosted concern; taking ProjectSend's
name off somebody's public pages is what a hosted customer pays for. The
gate on the second is not the key but that the only code able to answer
"hide it" ships in the private package, so flipping an edition variable
buys nothing.

EnsureCapabilityMiddlewareTest had to pick a new Cloud-only example for
the second time -- branding after users.manage. It now uses
storage.managed, and records what to ask if it ever needs a third.

The code move itself is the next commit; nothing user-visible changes yet,
because the screens still live in cloud-modules.
2026-08-28 13:11:57 -03:00
ignacionelson afc2c74617 Say who depends on the activity log never being pruned
last_staff_login_at is a MAX() over activity_log, and the docblock
already said the log is never pruned. It did not say that anything
depends on it. Something does now: the hosted platform warns, pauses and
finally removes a free instance nobody has signed in to, counting from
this field.

So retention or pruning added to activity_log would break nothing here --
every test would pass, the field would keep answering, and old
installations would quietly start looking dormant to the process that
deletes them. That is the shape of failure worth naming in advance,
because the person adding a retention policy would have no reason to look
at this file.

Same note as the one on SeatAllowance's counting rules and on
ManagedStorageBackend::describe(): an assumption with a reader outside
this repository is a contract, and the place to record it is where
somebody would otherwise change it.
2026-08-28 12:11:29 -03:00
ignacionelson d62c62f788 Let an installation say which build it is
A version string is a decision somebody made. A commit is a fact, and the
two come apart exactly when it matters: an image built from the tag and
one built from the branch that tag sits on carry the same version and
different code. The fleet spent a day reporting 2.2.0 from images that
were not the released 2.2.0, and nothing inside any of them could have
said so -- which is why 2.2.1 was cut for a control plane rather than for
users.

So every artifact now carries config/build.php, written by
build-release.sh and never committed, and projectsend:status reports it
as `build`: the commit, the ref it describes to, the channel and the
build time.

All four are null on a source checkout, because there is no such file
there. That is the honest answer rather than a missing one -- "I was not
built" and "I will not say" are different facts, and this file's whole
null discipline exists because a reader that cannot tell them apart
eventually acts on the wrong one. An empty string is treated as no
answer for the same reason: a build step that ran and produced nothing
must not read as "answered" to anything checking presence.
2026-08-28 11:57:43 -03:00
denkfabrik-li 7be81d3586 Tell the admins the mailbox is dead, even when a send noticed first
The daily refresh doubles as the health check for a connected OAuth
mailbox, and its own docblock says why that matters: a grant can die
silently, "which for a portal whose password-reset mails ride on this
connection must surface as a warning, not as a support ticket weeks
later".

It decided whether to warn by reading last_error -- but the send path
writes that column too. OAuthCodeFlowBroker::refresh() records the
failure and notifies nobody, and freshAccessToken() reaches it from every
send. So on an installation that actually sends mail, the send lands
first, the command reads the column as "already told them", and the
warning never goes out. last_error is cleared only by a successful
refresh, which a dead grant never has, so it never goes out again either.

Measured on main, one dead grant, two orders:

  nobody sends, command first   1 notification, then quiet   correct
  a password-reset mail first   0 ... 0 ... 0                never

The alarm worked on installations that were not using the mailbox and
failed on the ones that were.

The anti-nag rule is not the problem and does not change. The problem is
that last_error answers "is this broken", which any writer may set, while
the command needs "have the admins been told", which only the notifier
can. The table's own comment shows the conflation -- one column described
as "what the settings page's warning and the admin notification read".

So the notification gets its own column. broken_notified_at is stamped
when the command notifies, and cleared wherever last_error is cleared: a
successful refresh, a disconnect, a changed client id. The three call
sites go through MailOAuthConnection::clearFailure() rather than nulling
two columns each, because a connection left marked "already told them"
while healthy would go quiet the next time it died -- the same bug in a
new place.
2026-08-28 14:41:38 +02:00
denkfabrik-li 92f50fdb85 Read a comment's author even after the account is deleted
`author_id` is cascadeOnDelete and the cascade never fires, because users
are soft-deleted: the row behind a deleted commenter is still there and
the column still points at it. The plain relation handed back null
anyway, and every caller invented its own meaning for that absence.

Measured on main, one staff member's staff-only comment, before and after
the account is deleted:

  /comments screen        Dana Staff / staff  ->  Dana Staff / guest
  the file's own thread   Dana Staff / staff  ->  Dana Staff / guest
  GET /api/v1/.../comments      type staff    ->  type client
  filter author_type=staff            1 row   ->  0 rows
  search "Dana"                       1 row   ->  0 rows
  unfiltered                          1 row   ->  1 row

Three surfaces, three different wrong answers, each next to a name that
stayed correct -- so a row can read "Dana Staff" and "guest" at once. A
moderator filtering for staff comments does not see a staff comment that
is sitting in the list in front of them.

This is the author half of what #1717 fixed for client_context_id, and
DeletedClientThreadTest's docblock already describes both columns.

The fix is the relation, not the five call sites: author() reads a
deleted account, which is what authorName() already reached for by hand.
The resource, the filter and the search then need no change at all. The
two authorType() copies now ask author_id rather than the relation --
which after this answers the same either way, and is the rule
isFromGuest() and authorName() already follow.

Nothing that decides who may read a comment goes through this relation.
VisibleCommentScope and FileCommentPolicy both compare author_id
directly, so no visibility widens.
2026-08-28 14:24:03 +02:00
denkfabrik-li 27c289a4d6 Let a password reset know where the account's credentials live
Two accounts reach the same reset with opposite needs, and it treated
both as "write a hash and hope".

A provider-created account is told, on the Connected accounts screen, to
"set a password first, then disconnect Google" -- and doing it changed
nothing, because nothing ever set auth_source back to Local.
AccountConversion is the only writer, and that is an administrator. So the
screen went on asking for something that had already been done, and the
person could not release their last provider without help.

AuthSource states the rule that closes this: `social` means the account
came into existence without anybody choosing a password, and, in as many
words, "a social account may later set a real password". A reset by
emailed token is where somebody does. The screen's has_local_password prop
is literally auth_source === Local, so the write is what completes the
sentence it prints.

A directory account is the opposite case and gets the opposite answer.
isDirectoryAccount() means the local hash is not consulted at all, so the
reset reported success and left the person with a password that cannot
sign them in -- including when the directory it points at is gone, which
is exactly when somebody reaches for a reset. It is refused now, with the
reason, and nothing about the account moves: taking one off its directory
is an administrator's decision through AccountConversion, not a side
effect of a reset.

The refusal sits where the token has already been validated, not where the
link is asked for. That endpoint answers "A reset link will be sent if the
account exists" to everybody on purpose, and refusing there would tell a
stranger both that an address is an account and how it signs in. Throwing
before the write also leaves the token unspent, since PasswordBroker
deletes it after the callback returns.
2026-08-28 14:01:24 +02:00
denkfabrik-li 5e60d2ef88 Say what expiry does to a client-scoped staff member's library
File::isExpired() documents the rule the application is supposed to
follow: once past, the file is hidden from clients and the public site
"but staff keep full access to view, download, and manage it".

The second half is not true of a client-scoped staff member.
StaffLibraryScope::buildFiles() builds their library as their own uploads
union what each assigned client may see, and that second half runs
through File::scopeVisibleToClient, which ends in notExpired() -- a
client-side rule. Measured on main, with a rep holding one client and a
file the administrator uploaded and shared with that client:

    before expiry   in_library true    GET .../download -> 200
    after expiry    in_library false   GET .../download -> 403

    the rep's own expired upload                  in_library true
    an unscoped administrator, same expired file  in_library true

Api\FilesController says it the same way -- "Only the client branch of
the visibility rules drops them" -- which reads as though a staff caller
is unaffected, when a client-scoped one is reached through that very
branch.

This does not change that behaviour. c8078f65 weighed exactly this and
decided against it: widening it 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. The widget was relabelled instead.

That decision lives in a commit message and in one widget's label.
Nothing in the code said it, and the docblock nearest the rule went on
promising the opposite -- which is how the next person re-derives "staff
keep full access" and widens something.

So both comments now state the boundary and why it is where it is, and
ExpiredFileStaffAccessTest makes it executable: an unscoped staff member
keeps an expired file, a client-scoped one keeps their own expired
upload, a client-scoped one loses a client's file when it expires.

Not changed: scopeVisibleToClient, StaffLibraryScope, and the
expired-files widget. If the boundary should move, that is a separate
conversation and a separate change.

Counter-check inverted, since these pass on unmodified main by
construction -- there is no behaviour fix for them to prove. What they
have to do is fail if the boundary moves, so the mutation is the widening
itself. Deleting the closing notExpired() call from scopeVisibleToClient
turns the file red, 1 failed / 2 passed, and it is the third case, the
one carrying the decision, that falls.

Suite 2108 passed / 2 skipped, 11416 assertions, PHPStan level 8 clean.
Measured on base 06c364d2, where main itself is 2105 / 2.
2026-08-28 06:56:09 +02:00
denkfabrik-li 21cae2acb1 Stop a version link telling people about a file they already had
FileVersions::link() resolves its notification audience before the merge,
and says why:

    RESOLVED BEFORE THE MERGE, and the ordering is the whole dedupe:
    these are the people who could already see both files, so anyone the
    merge below is about to reach for the first time is excluded here and
    gets file_shared from FileSharing::assign() instead. Resolve it
    afterwards and every newly-added client receives two emails about one
    action.

The merge then undoes it. moveAssignmentsToRoot() hands every one of the
revision's targets to FileSharing::assign(), under a comment claiming
that firstOrCreate makes a target the root already has a no-op. It makes
the assignment row idempotent; the three side effects under it --
activity entry, in-app notification, digest -- run unconditionally.

Measured on main:

    client already holds the root and the revision, then both are linked
      file_shared      (Report)     <- wrong, they have had it all along
      file_new_version (Report v2)  <- right
      assignment rows on the root: 1

    client holds only the revision, then both are linked
      file_shared      (Report)     <- right, the merge does hand it over

Two notifications for one action, for exactly the people the early
resolve was meant to protect.

So a target the root already holds is skipped rather than handed to
assign(). Nobody is gaining access in that case, and the activity entry
would be as untrue as the notification. copyAssignmentsFrom() directly
below already states that rule for its own case, which is why it inserts
directly instead of going through FileSharing. Both stale comments are
corrected with it.

Not changed: FileSharing::assign() itself, and so the behaviour
ShareNotificationsTest pins -- re-posting an existing assignment through
the share endpoint still notifies again. That test names the condition
for ever changing it, "it should stop being sent for both at once", and
that is a decision about files and folders together. This is narrower: a
version merge is not somebody choosing to share again, and it already
had a stated intent to send exactly one notification.

Three cases in ShareNotificationsTest -- the target already on the root,
the target gaining it, and a group already on the root. Reverting
FileVersions alone leaves 2 failed / 8 passed in that file; the middle
case passes without the fix, because it guards against skipping too much
rather than against the duplicate notice.

Suite 2108 passed / 2 skipped, 11415 assertions, PHPStan level 8 clean.
Measured on base 06c364d2, where main itself is 2105 / 2.
2026-08-28 06:56:09 +02:00
ignacionelson 2029309126 Release 2.2.1 2026-08-28 01:52:41 -03:00
denkfabrik-li defe488391 Ask about the zips queue on every path that could answer it
ensure_worker_watches_zips() exists because a worker unit written before
zip downloads had their own queue watches 'default' only, and a zip
enqueued to 'zips' then waits forever with nothing saying why. It is
called from exactly one place: inside the branch that reloads PHP-FPM,
nested inside the branch that found a worker unit.

So it runs only when a PHP-FPM unit was detected. Driving the restart
block through four host shapes, with everything it touches stubbed:

  systemd + fpm + worker      reached, restarted
  systemd + worker, no fpm    silent
  --no-restart                silent
  no systemd at all           silent

The second line is the one that matters. A worker unit is a different
service from PHP-FPM, and not finding one says nothing about the other: a
host running mod_php, or one whose FPM unit is named in a way this script
does not recognise, can still have a systemd worker that predates the
zips queue. That host gets no check and no mention.

The check now runs in the else branch too, where it costs nothing -- its
own first line returns immediately unless systemd and a worker unit are
both present -- and the worker is restarted after it, paired exactly as
the FPM branch pairs them. That pairing is the point rather than a
flourish: the new --queue argument reaches the worker only when systemd
next starts it from ExecStart. The queue:restart that projectsend:update
signals cannot deliver it, because that makes a worker pick up new *code*
and it has already run by the time this block is reached, so the worker
came back on the old command line. Editing the unit without the restart
would leave the operator told that zip downloads were fixed while they
still could not finish -- worse than the silence it replaces, since
silence sends somebody looking.

Under --no-restart it is said rather than done: editing a unit file is
exactly what that flag asks us not to do, but a worker that cannot finish
a zip is broken whether or not we are allowed to touch it, and this is the
only place that knows to mention it.

Same four shapes afterwards:

  systemd + fpm + worker      reached, restarted
  systemd + worker, no fpm    reached, restarted
  --no-restart                not reached, but said so
  no systemd at all           reached, no restart (returns immediately)

No test: the suite cannot drive a shell script that restarts services.
bash -n parses, and the harness above is the evidence.
2026-08-28 06:45:49 +02:00
ignacionelson d83d2d9acb Translate the three strings the last release cycle added
Two seat counters and one folder-delete refusal, in all sixteen locales.
Additive only: nothing already in a catalogue was reordered or reworded,
so the diff is three lines per file.

Polish, Czech and Russian get three plural forms where the English has
two. Those languages inflect a noun by the number in front of it -- one
case for 2-4, another for 5 and up -- and the framework's selector picks
between three segments for them, so writing only the English pair would
have produced "5 pliki" where it has to be "5 plikow". Verified through
trans_choice at 1, 3 and 7.
2026-08-28 01:45:07 -03:00
denkfabrik-li fc5651faad Check the read half of the redirect rule at every door, not one
Three middleware answer before HandleInertiaRequests and so have to
repeat its 302→303 upgrade themselves: EnsureSetupIsComplete,
EnsureUserIsActive and EnforceTwoFactor. This file has a write case for
each, and the rule has a second half -- a read still gets a plain 302,
because a 303 there would be an upgrade nobody asked for.

That half was checked once, on the deactivation door, under a name that
said otherwise: "leaves a read alone in every one of those cases". The
setup door and the two-factor door were not covered at all, so a change
that upgraded reads at either of them would have gone through with the
suite green and this test's name still claiming it would not.

Both are covered now, as a dataset with one case per door. The setup case
reads a guest-reachable GET for the same reason the write case posts to
/timezone: anything behind `auth` is answered by the guest redirect before
EnsureSetupIsComplete sees it.

No production code changes; today all three doors answer a read with 302,
which is what the new cases assert. Demonstrated by mutation rather than
reversion: making EnsureSetupIsComplete upgrade every redirect to 303
fails this file (1 failed / 8 passed) and passes the old one (7 passed).
2026-08-28 06:40:54 +02:00
denkfabrik-li b838036a9a Set the directory permission Flysystem actually reads
FILES_WEB_SERVER_READABLE asks for 0755 on the directories a download has
to be traversed through, and asks for it from a key that is never
consulted.

FilesystemManager::createLocalDriver passes
`directory_visibility ?? visibility ?? private` to
PortableVisibilityConverter::fromArray() as the default visibility for
directories. This disk sets `visibility` to public two lines above, and no
`directory_visibility`, so directories are public and the converter reads
`dir.public`. The configuration names only `dir.private`.

The mode is 0755 regardless, because 0755 is Flysystem's default for a
public directory -- the right answer from the wrong place. Adding
`directory_visibility` to this disk, or a change to that default, is all
it would take for the flag to stop doing what it says. Measured on main,
with the flag on:

  dir.private 0755 → 0750   directory stays 0755   (nothing reads it)
  dir.public  0755 → 0750   directory becomes 0750 (this is the key)

Both are named now, so the intent survives either way round.

FilePermissionsTest could not have caught this, because it was not testing
this configuration. filesDiskWith() restated the shipped branch inline,
verbatim down to the 0755, so it went on passing against its own copy
however the real one changed. It now requires config/filesystems.php and
replaces only the root, which is what makes the mutation above visible to
it.

Two more things in the same helper, both about the suite rather than the
subject: the scratch root is per worker now (Tests\TestCase does the same
for upload parts, and eight workers sharing one directory means one
worker's afterEach deletes another's tree mid-test), and it is cleared
before each test as well as after, so a killed run does not poison the
next one.
2026-08-28 06:40:54 +02:00
denkfabrik-li 5a9133bb07 Give a download's presigned URL a minute rather than an hour
StoredFileResponse hands external storage a presigned URL for an hour,
whatever the delivery is for. That URL is a bearer credential: whoever
holds it fetches the file without passing any of the caller's checks
again, and it outlives them. A download cap spent in the meantime, an
expires_at that falls inside the hour, an assignment withdrawn -- none of
them reach it, and nothing here can revoke one. It is also forwardable,
which the local path is not: X-Accel-Redirect authorises one response to
one request.

The two deliveries do not need the same window, so they no longer share
one.

A download has to survive being followed -- a redirect and a request --
which a minute covers with room to spare. An object store checks the
signature when the request arrives rather than while it runs, so a
transfer that starts inside the window finishes however long it takes.

A preview keeps the hour, because it is watched rather than fetched: the
player holds the URL and issues a Range request every time somebody seeks
past the buffer, so a minute would break playback of anything longer than
a minute. The class docblock now says that this is the trade being made,
instead of leaving it in a single number.

Two tests, one per window. Without the fix the download link is an hour
long.
2026-08-28 06:40:53 +02:00
denkfabrik-li 02eafb473b Refresh a mailbox on the schedule under the lock a send would hold
freshAccessToken() serialises refreshes per connection, and its comment
says why: both providers rotate the refresh token as they hand out an
access token, so the token is good for exactly one use, and "a worker
racing the nightly refresh command means the slower one spends a token the
faster one has already replaced. The provider answers that with
invalid_grant, which is the same thing it says about a genuinely revoked
grant: last_error gets written, the settings page turns red, and every
admin is told to go and re-consent a connection that was never broken."

The nightly refresh command called refresh() directly, outside that lock.
It was the racer the comment names, not a party to the arrangement it
describes.

It now goes through refreshSerially(), which takes the same lock -- named
once, in one place, for both callers -- re-reads the row inside it, and
refreshes. Unlike freshAccessToken() it refreshes a token that is still
usable, which is the point of the daily run: a delegated refresh token
dies of disuse and this keeps the window sliding.

The lock is taken rather than waited for, unlike the send path. Nobody is
standing at a screen for a scheduled job, and a held lock means somebody
is refreshing this very connection right now -- which slides the window
and establishes its health just as well as doing it again would.

One test: with the lock held, the command sends no token request and
leaves the connection untouched. Without the fix it spends the refresh
token the holder is already spending.
2026-08-28 06:40:53 +02:00
denkfabrik-li 674781e57a Claim a TOTP code atomically instead of checking then writing
verify() asked Cache::has(), verified, then Cache::put(). Between the read
and the write the key is free, so two requests carrying the same code
could both be told yes -- which is exactly what the replay guard exists to
prevent, and the window an intercepted code has is the whole of its
validity either side.

The claim is now the answer: Cache::add() writes only if the key is
absent, so of two requests carrying the same valid code exactly one gets
true back. That is the same mechanism, for the same reason, as the
preview log's debounce -- "Cache::add is the whole mechanism: it writes
only if the key is absent ... without a read-then-write race between two
of them".

Verification still happens first, so a wrong code never touches the cache
and cannot burn the window for the code the person is about to type
correctly.

One test, modelling the interleaving it is about: the winner's claim has
landed, and the loser's has() answers from before that write. Without the
fix the loser is signed in.
2026-08-28 06:40:52 +02:00
denkfabrik-li f39ad46dd6 Leave the caches update.sh's own update command needs to see
INSTALL.md tells an operator to cache routes, views and events once, and
promises: "You only run these once: projectsend:update notices they are in
place and rebuilds them for you after every update."

It cannot, for anybody who updates with update.sh. The script wipes
bootstrap/cache/*.php while replacing the application files, and
UpdateInstallation::warmCaches() decides what to rebuild by asking
file_exists() on those very paths -- forty lines later. Every installation
looks like one that never cached anything.

Measured in a copy of the tree, the two orderings:

  wiping everything, as the script does it
    → "Cleared the compiled configuration, events, routes and views."
    → bootstrap/cache is empty afterwards

  keeping the route and event caches
    → "Rebuilt the route, event and view caches — they were in place before."
    → routes-v7.php and events.php are back

So the site quietly loses route, event and view caching on every update,
and the operator is never told.

The wipe now names what it removes rather than taking the directory:

  - packages.php and services.php, because they must not survive the swap:
    they name the old release's package providers, and the first artisan
    run after the copy would try to load classes this version no longer
    ships.
  - config.php, for a sharper reason. It is read at every boot, so leaving
    it means projectsend:update reads the *previous* release's version out
    of it. Measured with a doctored version inside a cached config: the run
    said "Re-applied 2.2.0" while the release on disk was 9.9.9, recorded
    that old version as the one applied, and ran the migrations under the
    old configuration. With it removed: "Updated from 2.2.0 to 9.9.9".

The route and event caches stay, since neither is read at boot -- both are
arrays of class names, consulted when a route is matched or an event
dispatched -- and projectsend:update clears them itself moments later.

Removing config.php here would have taken the command's "a cached
configuration was found" warning with it, since it warns about what it
finds. The script now says it, in the same words, at the moment it removes
the file.

No test: the suite covers the command's decision (UpdateCommandTest pins
the whole rewarm matrix) and cannot run a shell script that replaces an
installation. The measurements above are the evidence; bash -n parses.
2026-08-28 06:40:51 +02:00
denkfabrik-li a1773cad5e Count a shared folder's contents as reach, not just the folder
groupReachesNoFurther() asks whether anything shared with a group sits
outside the viewer's library. Its docblock says the folder half covers
"the folders whose subtrees it can browse". It compares the folder ids the
assignment names and stops there.

A folder shared with a group hands its members the whole subtree --
File::scopeVisibleToClient matches on folder placement, and a folder is
visible to a client when it or an ancestor is shared with them. So the
guard passed on a subtree it had never looked into.

Measured on main: a scoped rep's own folder, a subfolder somebody else
created inside it, and that person's file in the subfolder.

  parent in the rep's library     true
  subfolder in it                 false
  the file in it                  false
  add their own client to a group holding the parent   302, allowed
  the client can then reach the file                   true

And because files() is "own uploads plus everything my clients can see",
the file lands in the rep's own library on the next request. That is the
widening this guard exists to refuse -- the first test in the file is
called "a scoped staff member cannot widen their own library through a
group".

The folder half now walks each assigned folder's subtree, and the files
inside it are checked too: a folder can be in the library while a file in
it is not, since somebody else's upload into a folder this rep owns is
neither their own nor their clients'. Expired files are skipped for the
reason the deleted ones are -- membership grants nobody access to one.

Three tests: the subfolder case, the stranger-file case, and a subtree
wholly inside the library, which stays manageable. The first two go red
without the fix.
2026-08-28 06:40:51 +02:00
denkfabrik-li 17fc9ff4cb Compare the transfers window against the column's own timezone
resolveTransferRange() builds every boundary in the viewer's zone, which
is right and deliberate: "last week" should end when their evening does.
Its docblock then claims the instants "compare against the UTC column
directly". They do not. The query builder formats a Carbon in whatever
zone the object carries and drops the offset, so the viewer's midnight
arrives at the database as a UTC string.

For Asia/Tokyo, measured:

  the instant the window really starts   2026-08-21T15:00:00+00:00
  what the query asked for               2026-08-22 00:00:00

Nine hours at each end, in the same direction: the first nine hours of
the viewer's window are missing from the chart, and the last nine hours
of somebody else's day are counted into it. Every zone east or west of
UTC gets a chart that is quietly wrong at both edges, which is worse than
one that is obviously wrong.

The comparison now converts; the day cursor a few lines below does not,
because that half genuinely is about the viewer's calendar and is what
puts an evening upload on the right bar.

One test, in Asia/Tokyo, with an upload in the first hour of the viewer's
window. Without the fix it is missing from the chart.
2026-08-28 06:40:50 +02:00
denkfabrik-li 776d3d99f4 Put a client on the roster of the scoped staff member who created them
A client-scoped staff member with create_clients creates a client and
loses it immediately. guardTarget() answers 404 for anything off their
roster, and StaffLibraryScope::clients() leaves it out of their list -- so
the record exists, is logged, is welcomed by email, and is invisible to
the person who made it. store() redirects to the edit page, which is where
they land:

  POST /clients        → 302 → /clients/4
  on_creator_roster    → false
  GET /clients/4/edit  → 404
  clients listed       → ["Mine"]     the new client is not there

Their own roster is where a client they created belongs, so it is attached
there. An unscoped creator gains nothing: they see every client already,
and a roster entry would change what assignedClients means for them.

The API twin does the same, for the same reason -- a scoped token gets a
404 from every route that binds the client it just created.

Three tests: the scoped creator can open and list the client, an unscoped
creator gains no roster entry, and the API twin behaves like the web. The
first and third go red without the fix.
2026-08-28 06:40:50 +02:00
denkfabrik-li 9ddd39c41d Refuse to provision over a deleted account's address instead of crashing
The unique index on `email` spans soft-deleted rows -- AvailableEmailRule
is built on exactly that, so a deleted account keeps its address until
erasure removes the row. The registration form learns this from
validation. The machine paths have no form: a directory or an identity
provider hands over an address and provision() inserts it.

Measured on main, a client deleted last week signing in through a
provider that may auto-provision:

  GET /auth/google/callback → 500   (QueryException, unique constraint)

Same shape through LDAP at POST /login. Nothing is created, nothing is
signed in, and what the person meets is a server error.

Both provisioners now ask ClientProvisioning::addressIsFree() first and
refuse. The social flow already has a refusal for an identity it cannot
provision -- "There is no account here for that address." -- which is also
all a stranger should learn: whether an address was once an account here
is not the provider's to publish. The LDAP flow falls through to the
ordinary failed sign-in.

Deliberately not resurrecting the deleted account. Restoring one because
a directory says the address exists is a decision for a person, not a
side effect of somebody logging in.

Two tests, one per path: the sign-in is refused, nothing is created, and
the trashed row is still trashed. Both go red without the fix.
2026-08-28 06:40:50 +02:00
denkfabrik-li 19c449ee20 Stop an editable-once checkbox locking before anybody ticks it
save() writes '0' for an unticked checkbox, and filled('0') is true in
Laravel -- so isLocked(), which asks whether anything is stored, locked
the field the first time the client saved the page it sits on, whatever
they had chosen. A box they never ticked could then never be ticked, and
the one edit the setting promises was spent on a decision they had not
made.

A text field left empty stores null and stays open. That asymmetry is the
bug: '0' is the absence of a decision, which is what null means for every
other type.

So a checkbox locks on a stored '1' and nothing else. Everything else is
unchanged, including the existing case of a client ticking the box and
then being unable to untick it.

Two tests: an unrelated save leaves the box open and the tick that follows
still lands and locks it; and an editable-once text field behaves exactly
as before. Without the fix the first goes red.
2026-08-28 06:40:49 +02:00
denkfabrik-li 4b998cda92 Fail a zip build without handing the requester the server's reason
The write-failure branch already draws the line and says why: "What the
requester sees stays generic: a libzip string means nothing to them and
can name a server path. An operator needs the opposite ... so the reason
goes to the log instead." Thirty-seven lines below it, the catch-all
around the whole build stored $e->getMessage() in the row the requester
polls. Measured, a client asking for an archive of a file on a disk that
is no longer configured was told:

  "Disk [a-disk-that-is-not-configured] does not have a configured driver."

The reason now goes to the log with the exception class, and the row
carries the same kind of sentence fail() already uses.

Second, the temp files. tempnam() creates the file, and $tempFiles[] was
appended only after the copy had finished -- so every throw in between (a
disk that will not resolve, a stream that will not open) left a zip-src-
file in the system temp directory that nothing ever removes. It is now
registered the moment it exists.

Third, in the same method: the copy itself was unchecked. A copy that
stops early is a truncated member added to the archive as though it were
the file, so the build reports ready and the recipient gets something that
opens and is wrong. Both the copy and the fclose that flushes it are
checked now, and both handles close on every path.

Two tests: the failure message names nothing about the server, and a build
that throws mid-copy leaves no temp file behind. Both go red without the
fix.
2026-08-28 06:40:49 +02:00
denkfabrik-li 4164678ebc Delete a file's renditions even when its own disk cannot be resolved
FileDiskCleanup wraps both deletions in one try. The first is the original
upload, on whatever disk the row names; the second is every cached
rendition, always on the local files disk. Storage::disk() throws outright
for a name with no configured driver -- which is the state the original's
disk is in whenever this fails at all -- so the catch swallowed it and the
renditions were never reached.

Nothing looks for them afterwards. OrphanFileScanner skips the rendition
directories on purpose (they are derived artifacts, never orphaned
uploads), so a file whose external disk had been removed or renamed kept
every cached copy of itself, indefinitely, on the disk that was working.

The two attempts are now separate, each with the same tolerance the class
was written for: a storage failure still never turns a delete click into a
500, and the warning is still the report.

While here, the comment in File::booted() that justifies deferring the
byte removal claimed "the worst case is bytes left on disk with no row,
which OrphanFileScanner already finds and reports". Not on this path: the
row is soft-deleted, and knownPaths() counts a trashed row's path as
claimed -- deliberately, so a scan never offers to double-adopt a file
still inside its erasure grace period. The comment now says what actually
happens.

One test: a file whose disk cannot be resolved loses its renditions. It
goes red without the fix, next to the existing test that the delete itself
still succeeds.
2026-08-28 06:40:48 +02:00
denkfabrik-li fc758c701a Write a rendition through a temporary file, and never serve an empty one
Both thumbnail routes treat "the file exists" as "the rendition is
cached", and nothing ever invalidates one: RenderedImageCache::flush()
runs on ImageRenderingChanged, which no core code raises. Whatever is at
the path is what every later viewer gets.

ThumbnailGenerator encoded straight onto that path. A render that died
partway -- a full volume, a killed worker -- left a half-written file
that was then served as the rendition for good, and two requests
rendering the same file at once encoded into one path together.

It now writes beside the destination and renames into place. rename()
within a directory is atomic and replaces what is there, so the path is
either the previous rendition or a complete new one, and the loser of a
race leaves a whole image rather than a mixture of two. The temporary
file is removed on the way out either way.

The read side gets the other half: an empty file is not a rendition, so
both routes replace one rather than serve it. Writing through a temporary
file means this state can no longer be created here, but an installation
that ran an older version can already have it on disk, and nothing else
will ever clear it.

Three tests: an empty rendition is replaced on the signed-in route and on
the public one, and a successful render leaves nothing half-written
behind. Without the fix the first two go red; the third is about the fix's
own temporary file and passes either way.
2026-08-28 06:40:48 +02:00
denkfabrik-li f2b705beee Keep an upload's parts until its bytes are stored
complete() holds a lock whose comment promises "the lock's TTL releases
the claim if a completion dies mid-flight, so a later retry still works".
A retry has nothing to work from but the parts, and assemble() unlinked
each one inside the loop that read it -- so everything that can fail
afterwards took the retry with it.

Measured on main, with a disk refusing the write (the case the guard forty
lines further down was written for, found against a real GCS bucket):

  first complete  → 422, 0 parts left, the half-written copy left behind
  retry           → 422 "Upload is incomplete: missing parts."

For good: listParts() is empty, so no later attempt can ever succeed, and
the client has to send the whole file again. The abandoned copy sat in the
session directory until the sweeper came round.

The parts now go when abort() clears the session directory -- which
already ran on success -- and a failure deletes only the half-written copy
it made. The cost is temp space: peak usage during assembly is the whole
file twice over rather than the file plus one part. The docblock says so.

Also checked while here: every read and every write in the concatenation.
A failing fwrite is loud in practice, since Laravel's error handler turns
the warning into an ErrorException, but loud there is a 500 carrying a PHP
message where this method's other storage failure is a sentence the person
uploading can act on. A short write arriving without a warning would be
worse: the byte count and the checksum describe the buffer that was read,
so an unchecked one records a truncated file with a checksum matching
bytes that were never stored.

Two tests: the retry after a refused write now succeeds, and a temporary
directory that refuses writes (/dev/full, skipped where it does not exist)
fails the upload with this method's own message. Without the fix both go
red.
2026-08-28 06:40:47 +02:00
denkfabrik-li 250e8664d3 Stop a client PATCH clearing custom fields it never mentioned
update() states the rule eighteen lines above the bug: "PATCH semantics,
unlike the web form which always submits every field: an absent key means
'leave alone', not 'clear'." Every column obeys it. The custom fields did
not, because they went through create()'s pass, which walks every field
there is and writes null for the ones the request did not carry.

Two fields filled, a PATCH naming one:

  status         → 200
  named field    → "Robin"
  the other one  → null      (was "ATU12345678")

Nothing says so in the response, and there is no other copy of the value.

The write pass is now shared but entered two ways: create() keeps writing
every field, since a new client has no values and a checkbox nobody ticked
is a recorded "no"; update() writes only the fields the request named.

Three tests: the untouched field survives, a named empty value still
clears, and create still records every field. Without the fix the first
goes red.
2026-08-28 06:40:47 +02:00
denkfabrik-li 640c5db591 Stop an expiry moving because somebody else saved the file
The edit form is given a file's expiry as a calendar date, read back in
the viewer's own zone -- deliberately, so a file set to expire on the 12th
does not reopen showing the 11th. Every save posts that date back, whether
or not anybody touched it, and update() derived a fresh instant from it
every time.

So the expiry drifts by the difference between two people's zones on any
other edit. A date set from Pacific/Auckland stores 2026-09-12T11:59:59Z;
a colleague in UTC-3 opens the file, sees the same 12th, renames it, and
the file now expires at 2026-09-13T06:59:59Z -- 19 hours later, with
nobody having gone near the date.

The instant is now re-derived only when the posted date differs from the
one the form was given, compared against the same string through a named
pair: expiryDateFor() renders it, expiryInstant() reads it back. The edit
screen uses the same method it is compared against, so the two cannot
drift apart.

bulkUpdate() needs nothing: its expiry is an explicit set/clear/no_change
action, so an untouched expiry is never posted in the first place.

Three tests: the rename leaves the instant alone, a real change still
lands in the editor's own zone, and clearing still clears. Without the fix
the first goes red.
2026-08-28 06:40:46 +02:00
denkfabrik-li e1cd010f9d Give an API expiry date the same meaning the web gives it
FilesController::expiryInstant exists because a calendar day ends where
the person naming it lives: the web form posts a bare YYYY-MM-DD, and
storing that as it arrives would cut a file off at midnight UTC -- "expires
on the 12th" ending partway through the 11th for anyone in the Americas.

The API takes the same field, validates it as a date, and stores it raw:

  web  → 2026-09-12T23:59:59+00:00   (end of the day, as the docblock means)
  API  → 2026-09-12T00:00:00+00:00   (raw)

Same value, same field, same file, two meanings -- and the earlier of the
two is a file that dies at the start of the day it was promised.

A bare date now means the end of that day in the caller's timezone, as it
does on the web. A value carrying a time is unchanged: it is an instant
the caller named on purpose, the API can express one and a date input
cannot. The endpoint's docblock says both, so the OpenAPI document does
too.

Three tests: the day, the timestamp, and clearing. Without the fix the
first goes red.
2026-08-28 06:40:46 +02:00
denkfabrik-li c2dd2c758a Debounce the public preview log the way the signed-in one already is
FileThumbnailController::preview() writes at most one FilePreviewed row
per viewer per file per five minutes, because a browser turns one video
into a long tail of Range requests against the same URL. Its docblock
names the anonymous route as the place the same act happens without an
account -- and that route logs unconditionally.

Measured: five requests for the same public file, five
PublicFilePreviewed rows, against one for the signed-in twin. One visitor
watching one clip buries the public half of the activity log, which is
also the half an operator reads to see what the outside world is doing.

The window is now a shared PreviewLog, next to PreviewKind, which the two
preview routes already share for the same reason. Keying is unchanged for
a signed-in viewer; an anonymous one has no account to key on, so the
request IP stands in -- the same substitute the API's rate limiter makes
for an unauthenticated caller. It is a cache key with a five-minute life
and never reaches the log, which keeps its own decision about recording an
IP (ActivityLogger::shouldRecordIp, Setting::DownloadIpLogging).

Three tests: the replay is one row, two visitors are two rows, and the
window is per file. Without the fix the first goes red.
2026-08-28 06:40:45 +02:00
denkfabrik-li 9d4b096c19 Narrow the reassignment picker to what a viewer may see
`reassign_candidates` is the delete dialog's picker: every active account
in the installation, by name and by role label. The same list is shared
on the clients index, the users index, both edit screens and privacy
settings, and it was narrowed by nothing.

Two lines above it on the clients index sits the listing itself, narrowed
through `scope->clients($viewer)` with a comment saying why: "a
client-scoped staff member is not shown the name and email of somebody
they can reach nothing of". The picker beside it handed over every client
in the installation, plus every staff account and its role name. The
filter by `can('delete_clients')` happens in React, which decides what is
rendered, not what is sent.

So the client half of the candidate list goes through the same
StaffLibraryScope as the listing, and each screen sends the picker only to
a viewer holding the delete permission it exists for. Staff accounts are
not narrowed -- they are not narrowed anywhere else either -- and an
unscoped viewer's list is unchanged, because StaffLibraryScope::clients()
returns every client for them.

Privacy settings keeps the whole installation on purpose: that picker sets
the erasure default stored once for everybody, behind edit_settings, so
narrowing it by whoever happens to be editing would store the wrong
answer. The parameter is nullable for that one caller, and the docblock
says so.

Four tests. Without the fix three go red; the fourth is the guard that an
administrator still sees every active account.
2026-08-28 06:40:45 +02:00
denkfabrik-li 763777d282 Say which permission a bulk edit was actually missing
Two different things stop a selected file being changed, and bulkUpdate()
reported both as the first one.

Files dropped by the Gate::allows('update') filter are ones the staff
member may not edit at all. A file that survives the filter and still
changes nothing is a different case: it was editable, and every field they
asked to change was one their role does not let them set -- expiry,
download limit, categories, each behind its own permission, exactly as the
single-file editor treats them.

Measured with edit_files but without set_file_expiration_date, three files
they own, expiry the only change: "0 of 3 selected files were updated. The
rest were skipped because you don't have permission to edit them." They
own all three and editing is precisely what they may do, so the sentence is
both wrong and unactionable.

The two cases now have their own sentences. The existing string is kept
for the case it describes -- every skip a file they may not edit -- so its
sixteen translations stay in use. The new one covers a field permission,
and covers a mixture of both reasons, since "permission to make those
changes" is true either way.

The new key is English only; a locale without it falls back to English,
which is a translated-but-wrong sentence traded for an untranslated
correct one.

Three tests: each reason on its own, and the mixture. Without the fix the
first and third go red.
2026-08-28 06:40:44 +02:00
denkfabrik-li f424fe5365 Decide what is an API request from the route, not from the caller's headers
Two places asked "is this the API?" and got it wrong in opposite ways.

EnsureCapability asked $request->expectsJson(). Whether a feature exists
in this installation's edition is a property of the installation, not of
what the caller is willing to parse, so the same route answered
differently per header: `Accept: application/json` got the 403
`capability_unavailable` routes/api.php promises, `Accept: */*` -- curl's
default -- got a bare 404 `not_found`. The mirror image is worse: an
Inertia visit to a capability-gated *web* screen accepts JSON, so it got
403 with Laravel's default error body, naming the exception class, where
the point of the 404 is that an unavailable feature is absent rather than
teased.

ProblemDetails asked $request->is('api/*'). Two staff pages live under
that prefix -- the API dashboard at /api and the OpenAPI reference at
/api/docs, both registered in routes/web.php -- so a signed-out visitor to
either got 401 problem+json, "Send a valid API token in the Authorization
header as \"Bearer <token>\"", instead of the login redirect every other
page gives them.

Both now ask App\Support\ApiSurface: under the API prefix, and not part of
the `web` middleware group. The group is what actually separates the two
-- sessions and CSRF on one side, tokens on the other -- and it keeps
answering correctly for a future /api/v2 without being edited. An
unmatched path has no route to ask, which is the API's answer anyway: a
404 under its prefix is one it should describe in its own format, and the
existing test for that stays green.

Three tests, in the two files that already own these rules. Without the
fix all three go red.
2026-08-28 06:40:44 +02:00
denkfabrik-li cd8da6a117 Name the quota a client is actually held to when an upload is refused
Both chunked-upload quota checks resolve the limit through
ClientStorageUsage::quotaBytes(), which falls back to the site default
when a client has no quota of their own -- and then print
`$user->storage_quota_mb` in the rejection. For every client who was never
given an explicit quota that column is 0, so the message reads "This
upload would exceed your storage quota of 0 MB." at the one moment
somebody is trying to find out what their limit is.

The API's single-request upload already prints
`$this->storageUsage->quotaMb($user)` for the same sentence
(Api/FilesController.php:208). The two chunked copies now do the same.

Three tests: the inherited default is named at session creation and again
at completion, and a client with a quota of their own still sees their own
number. Without the fix the first two go red, the third stays green.
2026-08-28 06:40:43 +02:00
denkfabrik-li db1dd71f3c Stop an expired file locking a group shut for a scoped staff member
groupReachesNoFurther() asks whether anything shared with a group sits
outside the viewer's library. `f1b35cc9` established the shape of the
answer for deleted files: start from the live row, because "a deleted file
is not reach, because nobody can reach it".

An expired file is the same case. Membership grants nobody access to it --
File::scopeVisibleToClient ends in notExpired(), so it has left every
member's /my-files and the download answers 403 -- but it is equally gone
from files(), where its absence reads as "outside my library". The group
then locks for a scoped staff member: they cannot add a member, cannot
rename it, and cannot remove their own client again.

So the reach query skips expired files as it already skips deleted ones.
Expiry is reversible where deletion is not, and that needs no special
handling: the guard asks what is reachable at the moment somebody is added
or removed, and the file counts again the moment it stops being expired.

Not changed: File::scopeVisibleToClient, whose treatment of expiry was
settled deliberately in c8078f65. This is about what counts as reach, not
about what a scoped viewer may open.

Two tests, next to the deleted-file pair they mirror: the lockout, and the
half that must not soften -- a live out-of-reach file is still reach with
an expired sibling next to it. Without the fix the first goes red.
2026-08-28 06:40:43 +02:00
denkfabrik-li c8de16101f Gate the comment moderation surfaces on reading, not just on the library
FilePolicy::view() has two halves for staff: one of the three file keys
(upload / edit_files / edit_others_files), AND StaffLibraryScope. Every
comment surface that spans files narrowed by the library half alone.

A role holding moderate_comments and no file key therefore got a 403 on
every file in the installation while reading every comment written about
them on /comments: the text, staff-only notes, the client name a
Clients-visibility comment carries, and a visitor's IP address. The API
queue answered the same way, and approving through it hands the body back
in the response, so it was a reading door as well as a writing one.

The class says this is not supposed to happen -- across()'s own docblock
("a moderation screen is not a way around the visibility model"), the
route comment on /comments ("the list itself is still narrowed by
VisibleCommentScope, so holding the permission does not widen what a
viewer may read"), and routes/api.php ("reading and writing a comment is
gated by 'may see this file', the same three keys the file endpoints
use"). FileCommentPolicy::view() enforces it for a single comment, by
running the file's own gate first. Only the cross-file queries did not.

So they now take their files from ViewableFileScope, which is
FilePolicy::view() expressed as a query, instead of from StaffLibraryScope,
which is only its second half: across(), pendingTotal() and the API's
pending list. The permission half moves into a named method on that class,
since three modules now ask the same question.

FileCommentPolicy::moderate() gets it too, in both forms. Its row form is
otherwise unchanged -- the library check still runs by file id, so a
comment on a soft-deleted file behaves exactly as before.

No system role changes behaviour: Account Manager and System Administrator
are the two that ship with moderate_comments, and both hold upload. What
changes is a hand-built role that holds moderation and nothing else.

Seven tests. Without the fix, five go red; the other two are the premise
(that the viewer really is refused the file itself) and the guard that a
moderator who may read files still moderates the whole installation.

docs/api/openapi.json regenerated for the one changed description.
2026-08-28 06:40:42 +02:00
denkfabrik-li cb53120779 Show the portal dashboard the files a client can actually open
clientDashboard() restates the assignment half of
File::scopeVisibleToClient in a whereHas of its own. The scope is the
single source of truth for client file access and ends in notExpired(),
which the copy leaves off, so the two disagree in both directions.

Over: an expired file stays counted and keeps its name on the dashboard
after /my-files has stopped listing it and the download answers 403. Under:
everything that reaches a client another way is missing -- a file inside a
folder shared with them, a file they uploaded through the portal
themselves, and a revision, which owns no assignment row at all and
inherits its original's recipients through SharingIdentity.

Replaced by the scope itself, which is what /my-files runs. The existing
test for the page is unchanged and still passes: a directly assigned,
unexpired file counts exactly as before.

Two tests, one for each direction. Without the fix both go red.
2026-08-28 06:40:42 +02:00
denkfabrik-li 84e9f6e2fe Scope the API dashboard's recent actions to what the viewer may read
ApiUsage::recentActions() is the only ActivityLog query outside
ActivityLogger and AccountEraser that does not run through
ActivityLogScope::apply(). Its whole boundary is view_actions_log -- the
permission whose own scope class says, in as many words, that it "is not
the whole answer for a client-scoped staff member".

The Client Manager system role is client_scoped and ships with that
permission, so this is the default configuration. Such a viewer opening
/api?all=1 reads the fifteen most recent API log rows for the entire
installation, each with its subject_name: the names of files and clients
they get a 403 on. /activity, the download history and the dashboard's
recent-activity widget all narrow the same rows; the API dashboard was
missed.

The scope is applied on both sides of the install-wide branch. The
own-actor filter for the narrow view already stays inside what the scope
allows, and a boundary that exists in only one arm of an `if` is one
refactor away from not existing.

Three tests: the scoped viewer sees only the entry about a file in their
library, their own actions stay whole even when the subject is outside it,
and an unscoped viewer's feed is unchanged. Without the fix the first goes
red; the other two are green either way and guard against narrowing too far.
2026-08-28 06:40:41 +02:00
ignacionelson 06c364d29a Report storage, health and what packages loaded in projectsend:status
Five more facts for whatever watches an installation from outside the
container, and one seam so a package can add its own.

Storage is the one that was about to be wrong. It is summed from the rows
that record it, not measured on the volume: measuring the directory was
correct until external storage went live and silently stopped being, since
an upload that resolves to a bucket leaves nothing on disk to measure. A
figure taken from the filesystem freezes while the account keeps filling,
and on a managed installation that figure is what a customer is shown and
billed against. `by_disk` splits the same sum by where the bytes went,
which is the only way to see what is still sitting locally from before a
cutover. Trashed files are excluded because they hold no bytes -- File's
deleted hook takes them.

Health is what a container cannot show from outside. A queue worker dying
is invisible to anything watching the process: it is still up, and zips
quietly stop building while mail stops going out. Same for a deploy whose
migrations failed -- the application answers every request and is a schema
behind. An unreachable queue reports null rather than zero, because an
unreachable Redis is not an empty queue and reading the second as the
first is how a dead worker looks healthy.

The two-factor enforcement setting is echoed back the way EnforceTwoFactor
reads it, fallback included: reporting a stricter rule than the middleware
actually applies would be worse than reporting none.

And ResolvingInstallationStatus, so a package can report what core cannot
know. The managed storage backend and the version of the package providing
it live in cloud-modules, which this repository must not reference, and a
platform that writes eight environment variables only ever knows what it
asked for. Those came apart once: a bucket provisioned, a token minted,
every variable correct, and an image whose copy of the package predated
the module that reads them. Files went to local disk with the
configuration sitting perfectly right beside them.

Two shapes are cast to objects deliberately. An empty PHP array encodes as
[], so an installation with no packages -- or holding no files -- would
answer a map-shaped field with a list, and a reader unmarshalling it
breaks on the day it happens to be empty rather than the day it is
written. There is a test for each.

Requested by the ProjectSend Cloud control plane, whose storage figure
stops growing the moment a tenant's uploads start reaching the bucket.
2026-08-28 01:32:25 -03:00
Ignacio Nelson 046be36861 Merge pull request #1710 from denkfabrik-li/fix/folder-delete-file-authority
FoldersController::destroy() authorized delete on the folder and nothing else, while FolderService::delete() soft-deletes every file in the subtree and File's deleted hook takes the bytes off disk. So a staff member refused a file one route over could destroy it by deleting the folder around it -- permission and library boundary both unasked.

MyFoldersController::destroy() already draws this line for the client half of the same cascade, and says why: owning the folder is not authority over content someone else put in it. This is the staff half of that sentence.

Verified before merging: the four bug tests fail on main and pass here, and the SQL predicate was read line by line against FilePolicy::delete -- it is a faithful negation, including the null-uploader case and the short-circuit for an unscoped viewer holding both delete permissions. Membership of the check is one COUNT, not a policy call per file. Suite at 2099, PHPStan clean.

Behaviour change, deliberately accepted: a folder delete that used to succeed now refuses, naming how many files are in the way. The likely case is somebody who owns a folder another account uploaded into. The alternative is irreversible loss of files the same person is refused individually.

Not taken: deleting what the actor may and keeping the rest. Half a tree is worse than either answer. Naming the blocking files would be friendlier than counting them and is worth doing later -- the list has to hide any file the viewer cannot see, which is its own small design question.

Reported and fixed by @denkfabrik-li.
2026-08-28 01:20:57 -03:00
Ignacio Nelson 4a35c25894 Merge pull request #1717 from denkfabrik-li/fix/deleted-client-comment-context
file_comments.client_context_id is cascadeOnDelete, but users are soft-deleted, so the cascade never fires: the column goes on pointing at a row that is still there while the relation resolves to null. resolveClientContext() branched on the relation, so "this is Alice's conversation" read as "this has no conversation" -- and a null context on a clients comment is the branch every client on the file reads. A staff reply into a departed client's private thread became a circular, and canAssignClient() was skipped on the way.

That is the invariant docs/feature-comments.md calls the rule everything hangs off: a clients comment carrying client_context_id = C is never returned to any non-staff viewer other than C, because one customer learning another exists is worse than leaking a comment's text.

Verified before merging: both new tests are red on main and green here, and the three that must not move stay green either way. Suite at 2093, PHPStan clean.

The second half is the same root cause through the other column. authorName() read a deleted client's comment as "Anonymous", which is what a visitor's comment looks like -- and a visitor's comment is governed by different rules, so the two must not be able to look the same. Whether the author is a visitor is now decided by author_id alone, the question isFromGuest() already asks.

Accepted consequence: a soft-deleted client's name is visible on their old comments during the erasure grace period, where it previously read as Anonymous. It goes for good when erasure removes the row.

Reported and fixed by @denkfabrik-li.
2026-08-28 01:14:48 -03:00
Ignacio Nelson 58497ef776 Merge pull request #1716 from denkfabrik-li/fix/sole-administrator-self-deletion
ProfileController::destroy() validated the current password and soft-deleted, without asking guardLastAdministrator() -- the rule the other four doors ask, at the one door where the account being removed is certainly signed in. The sole administrator could empty their own installation, and EnsureSetupIsComplete, which asks exists() and so skips trashed rows, then handed the first-run setup form to whoever loaded the page next. That form creates an active System Administrator, unauthenticated.

Verified before merging: on main the sole administrator's self-deletion succeeds and setup reopens; both new tests are red there and green here. Suite at 2088, PHPStan clean.

Two locks, because one of these questions is asked at five doors and the other at one. The guard closes the door. And "has this installation been set up" stops meaning "does it have a working administrator right now" -- a trashed staff row is still evidence that setup happened, counted now in both the middleware and SetupController::setupIsComplete(), which have to agree or the result is a redirect loop or an open form.

Worth recording: erasure force-deletes a self-deleted account after its grace period, so the second lock would expire on its own. It does not matter because the first lock stops the installation reaching that state, but a future change to either should know the other is not permanent.

An installation that has already lost its last administrator now finds setup shut. That is the point: recovery is php artisan projectsend:admin, which is also how every unattended container installs itself.

Reported and fixed by @denkfabrik-li.
2026-08-28 01:12:14 -03:00
Ignacio Nelson d751314196 Merge pull request #1715 from denkfabrik-li/fix/zip-duplicate-entries
The job walked the loose file ids and then every selected folder's subtree, adding whatever each pass found. A selection reaching the same file both ways got it twice: two copies of the same bytes, a total_size inflated by the repeat -- which is what the size cap is checked against -- and a file limited to a single download handed over in three copies while the log recorded one, because delivery logs per contained file and DownloadAllowance counts those records.

Verified before merging: the three new tests fail on main and pass here. Suite at 2082, PHPStan clean.

Two halves, because one fix does not cover both shapes. The added-ids list becomes a map keyed by id and the folder pass skips what is already in, before the per-file re-checks, so a duplicate does not spend an allowance twice either. And a folder sitting inside another selected folder is dropped before either is walked, which also settles which path the surviving entry keeps rather than leaving it to row order.

One measured cost, accepted: the pruning compares every selected folder with every other. The pathological case -- ten thousand sibling folders, the selection cap -- benchmarks at around twenty seconds of CPU, in a background worker, on a selection that would take far longer to compress. A sort-by-path-length version would be cheaper if it ever matters.

Reported and fixed by @denkfabrik-li.
2026-08-28 01:06:43 -03:00
Ignacio Nelson 00d118559d Merge pull request #1714 from denkfabrik-li/fix/group-edit-library-scope
Every group route asked StaffLibraryScope whether this viewer may act on this group except the two that read it. So a client-scoped staff member could open the edit screen of a group they cannot change, read its membership with addresses, and get the whole client roster in available_clients besides. The API twin returned the same membership.

Verified before merging: the three new tests fail on main and pass here. Two things checked beyond the report -- group membership is edited through separate, already-guarded routes, so narrowing the displayed list cannot remove anybody on save; and scramble:export regenerates byte-identical, as claimed. Suite at 2078, PHPStan clean.

The fix has two halves because one guard does not cover both shapes. Reading the group now asks the same reach question the write half asks. And both lists narrow through StaffLibraryScope::clients(), because a group nobody has shared anything with reaches nowhere, stays open to everybody, and can still hold a stranger's client.

Unscoped viewers are unaffected: clients() returns the whole roster for them and allowsGroupChange() is true by construction.

Reported and fixed by @denkfabrik-li.
2026-08-28 01:03:23 -03:00
Ignacio Nelson abaca20261 Merge pull request #1713 from denkfabrik-li/fix/api-self-deactivation-boolean
The  validation rule accepts 0 and "0" as well as false and does not cast, so a strict comparison against the validated array let two of the three spellings past the self-deactivation guard -- and the model's own boolean cast then stored exactly the value the guard had just decided was not a deactivation.

Reproduced on main before merging: {"active": false} is refused, {"active": 0} and {"active": "0"} both return 200 and switch the account off. Green on the branch, suite at 2074, PHPStan clean.

The fix reads the flag once with Request::boolean() and gives that same value to the guard and to the write -- the rule RolesController::guardScopeRemoval already documents for the same reason. Validation is unchanged, so the accepted inputs are the same; one of them just stops meaning two different things on its way through the method.

Follow-up for the release: this is a caller-visible change (200 to 422) and wants a line in api-changelog.md.

Reported and fixed by @denkfabrik-li.
2026-08-28 01:00:55 -03:00
Ignacio Nelson b16d780ebe Merge pull request #1712 from denkfabrik-li/fix/storage-durability-dashboard-assertion
The test named for carrying the durability verdict to the system widget asserted only has('system'), and system is an unconditional key of the render array -- the controller's own comment beside storage_durability says as much. So the assertion could not fail.

Confirmed here by deleting the line that supplies the verdict: the new assertion fails with "Property [system.storage_durability] does not exist", where the old one stayed green.

Test-only, no application code.

Reported and fixed by @denkfabrik-li.
2026-08-28 00:55:45 -03:00
Ignacio Nelson 602c7bed94 Merge pull request #1708 from denkfabrik-li/fix/confirm-password-under-enforcement
EnforceTwoFactor exempts by route name, and only the GET half of the confirm-password screen had one -- Route::named() answers false for a null name, so the submission was never exempt. Enrolling requires password confirmation, so with enforcement on nobody could enrol at all: the form rendered, its POST was redirected to two-factor.show, auth.password_confirmed_at was never written, and every account on the installation was left with logout as its only working route. Including the administrator who turned the setting on.

Reproduced on main before merging: POST /confirm-password redirects to /settings/two-factor and the session flag stays unset. The widened pattern was checked against the route table -- password.confirm* reaches password.confirm and the newly named password.confirm.store and nothing else; password.reset, password.store and the rest are not under that prefix. Exempting the submission grants nothing further, since every other route stays bounced and store() still validates the password.

Reported and fixed by @denkfabrik-li.
2026-08-28 00:24:36 -03:00
Ignacio Nelson 76f79d53a0 Merge pull request #1711 from denkfabrik-li/fix/update-tests-clear-compiled
Ten tests ran the real projectsend:update, which runs clear-compiled, which deletes bootstrap/cache/packages.php and services.php -- one copy for the whole checkout, shared by all eight workers of a parallel run. A worker booting in the window between that delete and its own rebuild reads an empty package manifest, registers no package service providers, and dies rendering the next page with "Target [Inertia\Ssr\Gateway] is not instantiable", in a file that has nothing to do with updates.

Verified here rather than taken on trust: a probe running the real update inside a test on main deletes the manifests, exactly as described. The branch is green at 2066 with PHPStan clean, and touches no application code.

The file already owned a double and explained why the artisan call is a seam; this extends it to the whole file and adds a test asserting the compiled caches survive.

Reported and fixed by @denkfabrik-li.
2026-08-28 00:19:04 -03:00
ignacionelson 3f81dd5eab Merge pull request #1709 from denkfabrik-li/fix/seat-cap-approval-doors
Two doors onto the client seat cap did not ask it. Both update()
methods -- the edit screen and PATCH /api/v1/clients/{id} -- clear
account_requested when a pending client is activated, under a comment
saying that counts as approval, and approval is the moment a seat is
spent. So a managed installation sitting at its cap kept taking clients
on for as long as registrations arrived, and self-registration is open
to strangers, so the supply of pending rows is not the operator's to
control.

Verified rather than taken on trust: the two new door tests were run
against the unguarded controllers and fail there, and every place in
app/ that clears the flag was enumerated to check no third door was
missed. There is none -- the other six already ask, and a conversion
refuses a pending account outright rather than approving it sideways.

The guard sits inside the approval branch, so an installation at its cap
can still rename a client it already holds. That is pinned by a test of
its own.

Conflicted with tonight's seat work in SeatAllowanceTest, which had
added an import beside the one this adds. Resolved by keeping both;
suite green at 2065 and PHPStan clean after resolution.

Reported and fixed by @denkfabrik-li.
2026-08-27 23:30:33 -03:00
Ignacio Nelson 1cefdee610 Merge pull request #1707 from denkfabrik-li/fix/tests-workflow-single-concurrency
The tests workflow has not parsed since c05927c1 added a second top-level `concurrency:` key four lines below the one that was already there. YAML refuses a duplicate key, so GitHub created a run and scheduled no jobs -- verified here with symfony/yaml ("Duplicate key concurrency detected at line 66") and against the run list: every run since is zero-job, including the commit v2.2.0 is tagged at and all five pushed tonight.

The linter workflow carries one block and kept running, which is why the tree read as checked when the suite had not run at all.

Reported and fixed by @denkfabrik-li.
2026-08-27 23:29:34 -03:00
ignacionelson f2e7820f5c Say that the seat counts now have a reader outside this application
The docblock argued for one definition by describing a control plane
showing "2 of 3 seats used" next to an application refusing the fourth,
and the two disagreeing. That was written as a thing to avoid. As of
today it is a screen: the hosted fleet console reads these numbers per
tenant out of projectsend:status --json.

Which makes two rules here load-bearing somewhere nobody editing this
file would think to look -- a deactivated staff account still holds a
seat, a client awaiting approval does not. Changing either changes what
a support person is told before it changes what a customer hits, and
the note is here so that is a decision rather than a surprise.
2026-08-27 23:25:17 -03:00
ignacionelson a92feed3ad Correct the fifth stale Community-only comment, in QuickStart
The quick-start list gates its "Add the rest of your team" step on
Capability::UsersManage, which is right and unchanged: it is the seam an
edition difference would travel through. The comment above it still gave
the old reason -- that a managed installation has no staff accounts of
its own to hand out -- which the capability opening on both editions
made false. The step has appeared on a managed installation's list since
623ad68, and GettingStartedTest already says so.

Found by sweeping every repo for the same claim after four others turned
up: core, both module packages, the migration tool, the customer portal
and the private docs. The remaining ones are in the portal's own
planning documents, which are its to correct.
2026-08-27 23:13:43 -03:00
ignacionelson 73d93495c9 Report the last staff sign-in in projectsend:status
A platform can see that an installation is running. It cannot see
whether anybody is still using it, and the difference is what separates
a customer from an abandoned free instance holding a database.

So the status probe gains one field:

    "activity": { "last_staff_login_at": "2026-08-24T21:13:32+00:00" }

Null means no staff account has ever signed in, and the key is emitted
either way. That is the whole care in this change: "they said never" and
"we got no answer" have to stay distinguishable, because collapsing them
is how a broken probe reads as a dormant fleet.

Only interactive sign-ins count. Laravel's Login event does not fire for
token authentication, so an integration polling every hour cannot make
an empty installation look busy -- which matters when the reading is
used to decide something.

Derived from the activity log rather than denormalised onto users. A
column would cost a migration, a listener change and a backfill to save
one indexed MAX() over a table with a handful of rows on exactly the
installations anybody asks this about. Nothing prunes the log, and
erasure anonymises entries rather than removing them -- actor_type
survives on purpose -- so the answer does not change when the person who
gave it is forgotten.

Requested by the ProjectSend Cloud control plane, which has no other way
to learn the date. Recorded in docs/api-todo.md as deliberately a
command rather than an endpoint, for the reason the command exists at
all: it observes, it does not accept instructions.
2026-08-27 22:58:47 -03:00
ignacionelson 2eb23dbc07 Stop four comments saying user management is Community-only
It stopped being true in 623ad68, when users.manage opened on both
editions. The code moved and these did not, which is the worst kind of
comment: confidently wrong, and about the very rule a reader comes to
them to learn.

PlatformManaged claimed the tenant's own /users screens stay closed,
directly contradicting the UsersManage comment eleven lines above it.
routes/web.php said the same about the group it gates. The API
controller's docblock opened with "**Community only.**", and the
conversion screen's said a managed installation creates staff accounts
elsewhere.

Each now says what is actually true, and says the division the change
turned on: a platform sells the seats, the tenant decides who sits in
them. What limits a managed plan is the seat cap, not a shut door -- so
the API answers 422 at the limit rather than 403, which is a different
sentence to whoever is reading it.
2026-08-27 22:11:21 -03:00
ignacionelson 13b56186f4 Say the seat limit before the form, not after it
On a managed installation with its staff seats full, /users/create opened
as though there were room. You typed a name, an address and a password
you had to invent, pressed Save, and the plan limit came back as a
validation error under the email field -- which reads as a complaint
about the address rather than a fact about the plan.

A full installation is an ordinary state on a plan sold by the seat, so
it is now stated up front. The list carries the seat position, the
button goes dead once the last seat is taken and says why, and the
create screen turns away anyone who reaches it by link or bookmark. The
guard in store() is untouched: that is still the rule, this is only the
door.

The refusal is worded once, in SeatAllowance, and the screen is handed
that sentence rather than writing its own -- two wordings of one limit
is how somebody ends up believing there are two limits. `full` is
derived there too, from the same comparison the guard refuses on, so a
screen cannot disagree with it about the edge (used > limit, after an
operator lowers a limit) and offer a button for a form that cannot be
submitted.

Clients get the same treatment: the cap exists there too, and reached it
the same way. Self-hosted installations have no limit, so they are shown
nothing about one.
2026-08-27 21:02:54 -03:00
denkfabrik-li e272f19045 Keep a private reply private after the client is deleted
file_comments.client_context_id is cascadeOnDelete, but users are
soft-deleted, so the cascade never fires: the column keeps pointing at a
row that is still there while the Eloquent relation resolves to null.
resolveClientContext branched on the relation, and a null context on a
Clients comment is the branch every client on the file reads -- so a
staff reply into one client's private thread became a circular to all of
them, with the canAssignClient check skipped on the way.

VisibleCommentScope says so in its own docblock: "A Clients comment
carrying client_context_id = C is never returned to any non-staff viewer
other than C ... A Clients comment with a null context is a staff message
to everyone on the file, and every client with access reads it."

Measured on main, with one file shared with two clients and the first of
them deleted after commenting:

  column client_context_id      3
  relation clientContext        null
  POST reply into her thread    201, stored with client_context_id null
  read by the other client      yes

Ask the column, and refuse when the account behind it is gone. There is
nobody left to answer, and the one outcome that must not follow from a
filled column is the broadcast, so this throws rather than falling
through to it.

authorName() had the same root cause from the other column: its docblock
claimed author_id cascades so there is no deleted author, and a deleted
client's comment was going out as "Anonymous" -- which is what a guest
comment looks like, and a guest comment is read by different rules. Guest
is now decided by author_id alone, the same question isFromGuest() asks,
and a trashed author is read with withTrashed(). Nothing comes back only
once the grace-period erasure has removed the row for real.

That read costs one query per comment whose author is trashed. Measured
on a ten-comment thread: 11 queries before, 21 after, against 20 for the
same thread with every author alive. Left as a lazy read rather than
eager-loading with withTrashed() at every call site, because the callers
would each have to remember it and the cost only applies to comments
whose author is gone.

Five tests, two measured red against the unfixed code (2 failed / 3
passed) -- one per column. The three that stay green either way are the
branches that must not move: a staff message with no context still
reaches everybody, a reply into a live client's thread still lands in
that thread alone, and a genuine guest comment is still anonymous.

Full suite passes (2053 passed / 2 skipped), PHPStan level 8 clean.
2026-08-28 01:44:25 +02:00
denkfabrik-li 28e18497b5 Refuse the last administrator deleting themselves, and keep setup shut
ProfileController::destroy() validates current_password and soft-deletes.
It never asks StaffAccounts::guardLastAdministrator(), and every other
door does: Staff update(), guardDeletable(), and both directions of the
role conversion. This is the one door where the account being removed is
certainly signed in.

An installation with a single administrator therefore had a button that
emptied it. Measured on main:

  DELETE /settings/profile   302, the account is gone
  live staff rows            0    (the row is trashed, not removed)
  anonymous GET /            302 -> /setup
  anonymous POST /setup      a new active System Administrator

EnsureSetupIsComplete asks ->exists(), which excludes trashed rows, and
routes/web.php registers GET and POST setup with no auth and no guest
middleware -- correctly, since a fresh installation has nobody to
authenticate. SetupController::store() re-checks the same condition, so
both halves agreed with each other and both were wrong once the last
staff row was trashed.

Two locks, because one of them is asked at five doors and the other at
one.

First: destroy() now asks guardLastAdministrator(), the same call with
the same message as everywhere else. An administrator with a colleague
still goes, a non-administrator staff member still goes, and a client
still closes their own account.

Second: "has this installation been set up" is not the same question as
"does it have a working administrator right now", and only the first one
belongs in EnsureSetupIsComplete. A trashed staff row is still evidence
that setup happened, so it now counts -- in the middleware and in
SetupController::setupIsComplete(), which have to agree or the result is
either a redirect loop or an open form.

That second lock holds even if a future door forgets the first one.
Measured with the guard bypassed entirely and the row trashed directly:
GET / answers with the login screen and POST /setup creates nothing.

Worth stating plainly: an installation that has already lost its last
administrator will now find setup shut rather than open. That is the
point -- the recovery path for it is `php artisan projectsend:admin`,
which is also how every unattended container installs itself, not a form
that anybody on the internet can reach.

Six tests, two measured red against the unfixed code (2 failed / 4
passed) -- one per lock. The other four are the boundaries: a colleague
present, a staff member who is not an administrator, a client, and a
genuinely fresh installation that must still reach setup.

Two existing tests needed saying more clearly rather than changing:
ProfileUpdateTest's deletion cases now create a second administrator, so
that what they assert is self-deletion and not this new refusal; and
GettingStartedTest's "fresh installation" cases forceDelete rather than
delete, because a soft-deleted staff row is no longer a fresh
installation -- which is the whole of the second lock.

Full suite passes (2054 passed / 2 skipped), PHPStan level 8 clean.
2026-08-28 01:35:41 +02:00
denkfabrik-li b44c6bf098 Add a file to a zip once, however many ways the selection reaches it
BuildZipDownloadJob walks the loose file ids and then every selected
folder's subtree, and adds whatever each pass finds. A selection can
reach the same file from more than one of them, and nothing noticed:

  file_ids [f], folder_ids [Reports]
    -> ['report.pdf', 'Reports/report.pdf']

  file_ids [f], folder_ids [Reports, Reports/Q1]
    -> three entries, file_count 3, total_size three times the file

Two copies of the same bytes in one archive, and total_size is what the
size cap is checked against, so a selection could also be refused for a
weight it does not have.

The one that costs more than bandwidth is delivery. It logs one
FileDownloaded per contained file, and DownloadAllowance counts those
records -- so a file limited to a single download left in three copies
while the log recorded one. Measured: three entries, one record.

Two causes, so two halves.

`$added` is now keyed by id instead of being appended to a list, and the
folder pass skips a file already in the archive. A lookup rather than a
scan because the selection cap is 10000 sources. The loose pass runs
first, so a file picked both ways sits under its loose name; either
answer is defensible, but it has to be the same one every run.

And a selected folder inside another selected folder is dropped before
either is walked. Zipping both would reach every file in the inner one
twice, and which path the surviving entry ended up under would be decided
by the order the rows came back in. Keeping the outer folder keeps the
fuller path -- Reports/Q1/report.pdf rather than Q1/report.pdf.

Containment is decided on the materialized path, so it is one comparison
per pair with no queries: a folder's path starts with an ancestor's
subtreePathPrefix(), and both end in '/', so /5/ cannot match /50/.

Not changed: the per-file re-checks inside the folder pass. Visibility
and the download allowance are still re-derived per file, and the skip
happens before them, so a duplicate never spends an allowance twice
either. Nor the selection endpoint -- a caller may send whatever
selection they like, and the job is where it is resolved.

Four tests. Three measured red against the unfixed job (3 failed / 32
passed): the loose-plus-folder case, the nested-folder case, and the
three-way case asserted through delivery rather than through the archive.
The fourth -- two selected folders that merely share a name are both
zipped -- is green either way and guards the pruning against being about
names rather than containment.

Full suite passes (2052 passed / 2 skipped), PHPStan level 8 clean.
2026-08-28 01:27:26 +02:00
denkfabrik-li eade690f73 Hold the group edit screen to the same library boundary as the rest
Every other group route asks StaffLibraryScope whether this viewer may
act on this group. GroupsController::update() and ::destroy() do, and so
do their API twins -- all four with abort_unless(allowsGroupChange, 404).
The two that read do not: edit() and Api\GroupsController::show() had no
boundary at all.

What they hand over is the membership, name and email per member, plus
the whole client roster of the installation as available_clients. So a
client-scoped staff member could open a group whose contents they cannot
see, read off every client on the installation, and only be refused when
they pressed save.

Two halves, because the leak has two shapes:

- The group itself. Reading it now asks the same reach question the write
  half asks, one step earlier, with the same 404 -- a group that reaches
  past the viewer's library is not theirs to open either.
- The lists inside it. Both narrow through StaffLibraryScope::clients(),
  the listing half of the rule this screen's buttons are already guarded
  with: allowsGroupMembership refuses removing a member outside the
  roster, and refuses adding a client outside it. Naming them anyway,
  with their address, is the mistake ClientsController made before
  clients() existed -- that method's own docblock says so.

The reach guard alone would not have been enough. A group nobody has
shared anything with reaches nowhere, so it stays open to everybody --
and it can still hold a stranger's client. That case is why the lists
narrow separately, and there is a test for it.

members_count is left whole on purpose: a size is not an identity, and it
is the same number the group listing already reports.

GroupResource's docblock claimed members are safe to expose because "the
group edit screen already shows [them] to anyone holding edit_groups".
That was a claim about a screen, and it stopped being true the moment the
screen narrowed. Reworded to say what now holds it up, and where.

Not changed: the group listing. It reports names and member counts, not
identities, and every button on it is guarded. Nor Api\GroupsController::
index(), for the same reason. Nor the API document -- scramble:export is
byte-identical, because GET /groups/{group} already documented a 404.

Four tests. Three measured red against the unguarded controllers (3
failed / 21 passed): the group cannot be opened at all, the edit screen
stops naming strangers, and the API twin narrows what it hands back. The
fourth -- an unscoped viewer keeps the whole roster and every member -- is
green either way and guards against the fix over-refusing.

Full suite passes (2052 passed / 2 skipped), PHPStan level 8 clean.
2026-08-28 01:19:36 +02:00
denkfabrik-li 3e15237f90 Refuse self-deactivation over the API however the boolean is written
Api\UsersController::update() compares the validated value strictly:

    if ($user->is($actor) && ($validated['active'] ?? true) === false) {

The `boolean` rule accepts 0 and "0" as well as false, and it does not
cast. `0 === false` is false, so the refusal never fires -- and the
model's own `boolean` cast then stores as false exactly the value the
guard had just decided was not a deactivation.

Measured against main, with a second administrator present so that
guardLastAdministrator is not what answers:

    {"active": false}  -> 422, still active
    {"active": 0}      -> 200, active is now false
    {"active": "0"}    -> 200, active is now false

The method's own docblock says it is "Refused with a 422 if the change
would leave the installation with no active administrator, or if you
would be deactivating yourself", and the web screen does refuse. This is
the API half of that sentence.

RolesController::guardScopeRemoval documents the rule this breaks, in the
same words: callers resolve the flag with Request::boolean() and hand the
same value to the guard and to the write, deliberately, because 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".

So read it once, with Request::boolean(), and give that one value to both.

Not changed: the validation rule. It stays `boolean`, so the accepted
inputs are the same as before -- what changes is that one of them stops
meaning two different things on its way through. Nor anything about
deactivating somebody else: all three forms still work, and there are
tests saying so.

Six cases from two datasets. Two measured red against the unfixed
controller (2 failed / 4 passed): 0 and "0" on yourself. `false` was
already refused, and the three "somebody else" cases are green either way
-- they guard against the fix over-refusing, not against the bug.

Full suite passes (2054 passed / 2 skipped), PHPStan level 8 clean.
2026-08-28 01:06:02 +02:00
denkfabrik-li 9cc469b111 Make the storage durability dashboard test assert the verdict
The test named for carrying the verdict to the system widget only
asserted that the 'system' key exists. It is an unconditional key of the
Inertia::render array and is allowed to be null, and Inertia's has() is a
key check, so the assertion held whether or not the verdict was in there.
Deleting 'storage_durability' from DashboardController::systemInfo() left
the file green.

Substitute the class the way the rest of the file already does and assert
the payload, as InstallationKindTest does for install_kind next door.
2026-08-28 00:36:32 +02:00
denkfabrik-li 4469648d82 Stop the update tests emptying bootstrap/cache for every other worker
`UpdateWelcomeTest > staff who may not read system information are not
interrupted` fails on a parallel run roughly one time in six, with

    BindingResolutionException: Target [Inertia\Ssr\Gateway] is not
    instantiable

in a file that has nothing to do with updates. Run alone it is green
every time. The cause is not in that file.

`clear-compiled` deletes bootstrap/cache/packages.php and
bootstrap/cache/services.php. There is one of each for the whole
checkout, and `pest --parallel` gives eight worker processes the same
one. Instrumented over three full runs, the real command ran 12 times per
run -- 11 from UpdateCommandTest, 1 from StaleCodeNoticeTest -- and the
other workers observed the package manifest missing at boot 46 times.

What that costs is in PackageManifest::getManifest():

    if (! is_file($this->manifestPath)) {
        $this->build();
    }

    return $this->manifest = is_file($this->manifestPath) ?
        $this->files->getRequire($this->manifestPath) : [];

A worker that loses the second is_file() to another worker's unlink gets
`[]`: no discovered packages, so no package service providers, so
Inertia's is never registered and `Inertia\Ssr\Gateway` is never bound.
The next page it renders dies in the compiled root view, where
`@inertia` resolves that interface. Any test in any file, whichever one
happened to be booting.

Both halves measured. Building the manifest with inertia-laravel in
`dont-discover` reproduces the reported failure exactly -- same test,
same exception, same frame (`app('Inertia\Ssr\Gateway')` from the
compiled app.blade.php). And 12 real `clear-compiled` calls per run is
the count above.

UpdateCommandTest already owns a double for this, and says why in its own
docblock: the artisan call is a seam. Nine of its tests and one in
StaleCodeNoticeTest simply do not use it. None of them asserts that a
command ran -- they assert EnsureSystemRoles, the settings writes, the
activity log and the welcome marker, and the double touches none of
those. So the seam now covers the file, through a beforeEach rather than
per test, because the next test added here should not have to know any of
this.

The double moves to tests/Support and its helper to tests/Helpers.php,
for the reason that file documents: Pest hands whole files to workers, so
a class declared in one test file does not exist for another.

Not changed: UpdateInstallation. `clear-compiled` belongs in a real
update. Also not changed: giving each worker its own bootstrap/cache
through APP_PACKAGES_CACHE and friends. That would make the destruction
cheap rather than remove it, and nothing in the suite needs those
commands to run at all.

One new test, on the files rather than on the recorded call list -- a
future double that forgot to intercept one command would still satisfy a
call-list assertion. Counter-checked: with the beforeEach removed it goes
red on both manifests being gone (1 failed / 22 passed).

Eight consecutive parallel runs green after the change; the manifests'
mtimes are untouched by a full run, where before they were rewritten
every time. Full suite passes (2049 passed / 2 skipped). PHPStan level 8
clean -- it analyses `app` only, so it does not cover this change.

Pre-existing and left alone: pint reports `ordered_imports` on
UpdateCommandTest.php. Its import block is misordered on main too.
2026-08-28 00:32:57 +02:00
denkfabrik-li 26205082c2 Stop a folder deleting the files inside it that its owner may not delete
FoldersController::destroy() authorizes `delete` on the folder and nothing
else. FolderService::delete() then soft-deletes every file in the subtree,
and File::booted()'s `deleted` hook takes the bytes off disk. There is no
restore.

FilePolicy::delete asks two questions the folder route never reaches:
`delete_others_files` for somebody else's upload, and
StaffLibraryScope::allowsFile on top of it. Measured with a role holding
create_own_folders, delete_files, upload and edit_files -- the shape the
Client Manager system role already has, minus delete_others_files:

  DELETE /files/{someone-elses}   403, the file is still there
  DELETE /folders/{their-folder}  302, the file and its bytes are gone

MyFoldersController::destroy already refuses the client half of this exact
cascade, and says why: "Owning the folder is not authority over content
someone else put in it... Refuse rather than silently destroy them." This
is the staff half of the same sentence.

Counted rather than asked per file. A folder can hold thousands, Gate
resolves a fresh policy for every check, and a per-row policy check on a
listing is the cost 0a8b609e went to some trouble to remove. Both halves
of FilePolicy::delete are expressible in SQL: the permission half is
constant for the viewer, and the library half is the query
StaffLibraryScope already memoises per request. Somebody holding both
delete permissions with no library scope short-circuits before the query
runs at all, so the common case pays nothing.

Not changed, deliberately:

- The service. FolderService::delete stays dumb. Its other caller applies
  the client rule ("files you did not upload"), which is a different
  predicate, and putting both in one place is the drift this codebase
  keeps refactoring away from.
- The client half. MyFoldersController is already correct.
- Nothing partial. A blocked folder is left whole rather than emptied of
  what the actor may delete -- half a tree is worse than either answer.

Worth saying plainly: this is a behaviour change. A folder delete that
used to succeed now refuses, and somebody will notice. The alternative is
irreversible loss of files the same person is refused one route over.

Six tests. Four measured red against the unguarded controller (4 failed /
2 passed), one per half of the predicate: the permission half, its
message, a nested file, and the library half -- that last one with both
delete permissions held, so only StaffLibraryScope can refuse. The two
that stay green either way are the other side of the question -- that a
folder holding only your own files still goes, and that an administrator
holding both permissions is unaffected. They guard against the fix
over-refusing, not against the bug.

Full suite passes (2054 passed / 2 skipped), PHPStan level 8 clean.

The new string is English only, per CONTRIBUTING.md -- translations are
their own pass.
2026-08-28 00:32:33 +02:00
denkfabrik-li ab6e9eecf3 Ask the seat cap where a pending client is approved through edit()
SeatAllowance says a cap is only a cap if every door asks, and has a test
per door for that reason. Two doors do not ask.

The moment a seat is spent is the moment `account_requested` is cleared.
Five places do that. approve(), both store()s and ClientProvisioning ask
guardClient(); AccountConversion asks it through guardToClient(). The two
update()s -- web and API -- clear the flag with no guard at all, under a
comment that names exactly what they are doing:

    // Activating a pending account through the edit screen counts as
    // approval and clears the request flag.

Measured with clients: 0, one pending registration:

  POST /account-requests/{id}/approve       refused, flag still set
  PATCH /clients/{id}          active=true  approved, clientUsed() 0 -> 1
  PATCH /api/v1/clients/{id}   active=true  approved, clientUsed() 0 -> 1

A managed installation at its cap therefore keeps taking clients on, from
the edit screen or a PATCH, for as long as registrations keep arriving --
and self-registration is open to strangers, so the supply is not the
operator's to control.

Inside the branch, not above it. Above it, an installation sitting at its
cap could not rename a client it already holds, which would trade one
wrong refusal for another. There is a test pinning that.

The field is `active` rather than the default `email`: on this screen the
administrator is toggling `active`, and an error under the email field
would point at the wrong thing. approve() has no form of its own, so it
keeps the default.

Three tests, per door as the file's other eight are. The two door tests
were measured red against the unguarded controllers (2 failed / 18
passed). The third -- that editing an existing client still works at the
cap -- is green either way: it guards against the fix being written a
line too high, not against the bug.

Full suite passes (2051 passed / 2 skipped), PHPStan level 8 clean.

One thing worth knowing that this branch does not touch: on a parallel
run, `UpdateWelcomeTest > staff who may not read...` fails roughly one run
in six on untouched main, with `BindingResolutionException: Target
[Inertia\Ssr\Gateway] is not instantiable`. Measured over 24 baseline runs
before this change existed. It is not this fix, and it is not in scope
here, but it will start being visible as soon as the workflow parses
again.
2026-08-28 00:19:05 +02:00
denkfabrik-li 1dc274e896 Let an enforced user reach the far side of the confirm-password screen
EnforceTwoFactor exempts by route name, and only the GET half of
confirm-password has one. routes/auth.php:95 names the form
`password.confirm`; :98 registers its submission with no name at all, and
Route::named() answers false for a null name.

So the loop the exemption exists to prevent is still there, one step
further along. With Setting::TwoFactorEnforcement set to staff, clients
or all, an un-enrolled account walks:

  GET   /dashboard                  -> two-factor.show
  GET   /system/settings/security   -> two-factor.show
  PATCH /system/settings/security   -> two-factor.show
  POST  /settings/two-factor        -> /confirm-password   (RequirePassword)
  GET   /confirm-password           -> 200, the form renders
  POST  /confirm-password           -> two-factor.show     <- not exempt

`auth.password_confirmed_at` is never written, so enrolling can never
start, and every route that is not on the exemption list stays shut --
including Settings -> Security, the one screen that could turn
enforcement back off. Logout is the only door left; recovery is CLI or
database access. It takes one administrator turning the setting on to
reach it, and it reaches every account on the installation at once,
including their own.

The fix is the name. `password.confirm*` then covers both halves of one
screen, matching `two-factor.*` in the same expression; the namespace
belongs entirely to a flow enrolment already depends on being reachable,
and the route table has nothing else under it -- `password.confirm` (GET)
and `password.confirm.store` (POST) are the two it reaches.

Exempting the submission grants nothing further. store() validates the
password, writes a session flag and redirects; the redirect it issues
enters this middleware like any other request, so Settings -> Security is
still answered with two-factor.show after confirming. What changes is
that enrolment can now be started.

Two tests, both measured red against the unfixed middleware: the password
confirmation sticks, and enrolment can be started afterwards (the secret
is written and the screen reports `pending`).

Also named the redirect the existing test settles for. `->assertRedirect()`
with no target passes on this middleware bouncing the request back to
two-factor.show, which is the shape that file exists to refuse. It is a
clarification rather than a guard -- that assertion is green either way,
since the redirect it sees comes from RequirePassword.

Full suite passes (2050 passed / 2 skipped), PHPStan level 8 clean.
2026-08-27 23:53:53 +02:00
denkfabrik-li 7045da7450 Leave the test workflow one concurrency block, so it parses again
c05927c1 added a `concurrency:` block on the premise that the suite never
got one. It already had one, four lines above -- the hunk header of that
diff reads `@@ -50,6 +50,23 @@ concurrency:`, which is the existing block
it was appended below.

A YAML mapping cannot carry the same key twice, so the file has not
loaded since. GitHub still creates a run and then schedules nothing:

  553f5fd2  (last green)  run 33036453748  jobs=1  ci -> success
  d58e4830  (main)        run 33114046849  jobs=0  failure

Every run since has that shape, and the run list names it in passing:
those runs appear as `.github/workflows/tests.yml` where the green ones
appear as `tests`, because the `name:` key sits inside the file that did
not parse. `linter` is unaffected -- it carries one block -- which is why
351da21e shows a green linter beside a failed tests run, and the tree
reads as half-checked rather than unchecked.

Reproduced with a parser rather than inferred from the job count:

  before -> THREW: Duplicate key "concurrency" detected at line 66.
  after  -> parsed ok, top-level keys: name,on,concurrency,jobs

Kept the second block, verbatim, because it is the one c05927c1 meant to
end up with and its comment carries the reasoning -- including the
tradeoff that an intermediate commit on `main` can end up with no run of
its own. The two group keys are interchangeable: `github.workflow` is
constant within a workflow, so `tests-${{ github.workflow }}-${{ github.ref }}`
and `tests-${{ github.ref }}` produce the same grouping. Worth knowing
that lint.yml still uses the first shape, if you would rather the two
files read alike.

No test. The failure is loud on the next push, and a test that parses a
workflow file would be a second place to keep the same rule.
2026-08-27 23:53:37 +02:00
ignacionelson d58e48301f Move the seat number to the end of the sentence
It read "limited to 1 staff accounts" -- the number sat directly in front
of a countable noun, which is the message a free-tier customer meets the
first time they try to add anybody.

Adding plural forms would fix English and not much else. Polish, Czech and
Russian inflect the noun by the number in front of it, on a three-way split
that a two-form string cannot express, so ':count kont' cannot be right for
every value however many variants it carries. Ending the sentence on the
number means no language has to agree with it -- the same shape the other
counted strings here already use.

Both strings rewritten in all sixteen locales rather than left to the next
translation pass, since the old key would otherwise go missing and block a
build. Checked at 1 and at 25 in English, Spanish, German, Polish and
Russian.
2026-08-27 17:35:10 -03:00
ignacionelson c49811f3c0 List the issues a release closed
The summary reads well and says nothing a reader can chase. The numbers and
titles are the way back to the original report, so they go at the end where
they are available without being in the way -- summary at the top for
whoever is deciding whether to upgrade, paper trail at the bottom for
whoever is looking for their own bug.

Generated from the closed-since date rather than hand-picked, and titled
'closed since' rather than 'fixed in' so no per-issue judgement is needed
about how each one was resolved.
2026-08-27 16:50:11 -03:00
ignacionelson 351da21e8d Stop the text half of an email printing its link twice in brackets
Laravel's notification view writes the subcopy URL as [$url]($url).
The HTML half parses that into an anchor; the text half parses nothing,
so it arrives as literal brackets around a duplicated address. With the
button line above it the URL appeared three times in one message.

It reads as broken, and it reads broken in a specific direction: a long
opaque token, the recipient's address in the query string, and a
duplicated link in brackets is the shape of a phishing template. On a
password reset, which is often the first mail an installation ever sends
somebody, from a domain with no reputation yet.

Fixed the way every other component in that message already handles the
same split -- one name, two files, Laravel picks per half. Which meant
publishing the framework's view for a one-line change, so there is a note
in it saying to re-copy on upgrade.

Seen in a real reset mail, not in a test.
2026-08-27 15:29:25 -03:00
ignacionelson c172d0d645 Cut 2.2.0 down to the list and the notes
The detail underneath was 400 lines of two-and-three-sentence entries.
Written to be complete, and complete is not the same as read: the list at
the top already says what changed, and the long version mostly restated it
at length for somebody who had stopped reading.

The credits do not go with it. Most of the boundary work in this release
came from outside, and dropping the names to save space would be taking
somebody's contribution off the record to tidy a file. One line at the end
instead of twenty inline.

TRUSTED_PROXIES said 'see the fix below' and there is no longer a below.
2026-08-27 14:54:24 -03:00
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
391 changed files with 33693 additions and 1898 deletions
+18
View File
@@ -6,6 +6,15 @@ PROJECTSEND_EDITION=community
# configured at /system/settings/captcha.
# PROJECTSEND_CAPTCHA_DISABLED=true
# How downloads leave the server. Left unset (or "auto"), ProjectSend hands
# files to nginx when it is running behind nginx, and streams them through
# PHP on anything else -- which works everywhere but holds a PHP worker for
# the whole of each download. Set "xsendfile" for Apache with mod_xsendfile
# (or LiteSpeed) once XSendFilePath allows storage/app/files, "nginx" when
# an nginx proxy in front is the one serving /protected-files/, or "php" to
# stream deliberately. The dashboard's System panel shows which is in use.
# PROJECTSEND_FILE_DELIVERY=auto
# Optional: uid/gid the app/web containers' internal user runs as, so the
# bind-mounted repo needs no permission fixes. Defaults to 1000; override
# if your host user's `id -u`/`id -g` differ.
@@ -61,6 +70,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.
+64 -1
View File
@@ -5,10 +5,61 @@ on:
branches:
- develop
- main
paths-ignore:
# Files no code reads and no test covers. Deliberately NOT listed:
# CHANGELOG.md, which ReleaseNotes parses and ReleaseNotesTest
# covers, and docs/, whose only two tracked files are served by
# ApiDocsController and OpenApiController. A malformed edit to
# either is exactly the thing that must not skip the suite.
#
# Repeated verbatim under pull_request: GitHub Actions does not
# support YAML anchors.
- 'README.md'
- 'CONTRIBUTING.md'
- 'SECURITY.md'
- 'LICENSING.md'
- 'CLA-ENTITY.md'
- 'CLA-INDIVIDUAL.md'
- 'INSTALL.md'
- 'UPDATE.md'
- 'DOCKER.md'
- 'MIGRATING-FROM-V1.md'
- 'docker/production/dockerhub-overview.md'
- '.github/screenshots/**'
pull_request:
branches:
- develop
- main
paths-ignore:
- 'README.md'
- 'CONTRIBUTING.md'
- 'SECURITY.md'
- 'LICENSING.md'
- 'CLA-ENTITY.md'
- 'CLA-INDIVIDUAL.md'
- 'INSTALL.md'
- 'UPDATE.md'
- 'DOCKER.md'
- 'MIGRATING-FROM-V1.md'
- 'docker/production/dockerhub-overview.md'
- '.github/screenshots/**'
# A second push supersedes the first — the later run covers a superset of
# what the earlier one was checking, so finishing both buys nothing and
# costs a runner.
#
# Keyed on the ref, so `main` and a branch never cancel each other. A push
# to a branch that also has a pull request open produces two events with
# two different refs, which is why they do not fight either.
#
# The tradeoff worth naming: on `main` this means an intermediate commit
# can end up with no run of its own when two pushes land together. That is
# accepted here — what is being verified is the state of the branch, and
# the run that survives is the one that includes both commits. If a commit
# ever needs its own green tick (a bisect, a release audit), push it alone.
concurrency:
group: tests-${{ github.ref }}
cancel-in-progress: true
jobs:
ci:
@@ -87,5 +138,17 @@ jobs:
- name: Static Analysis
run: ./vendor/bin/phpstan analyse --no-progress
# `--parallel` rather than a shorter suite. One process took 191s of
# this job's 4m30s; the same 1763 tests across the runner's cores
# take about a third of that, with nothing skipped. paratest is
# already a dev dependency (via Pest), so this needs no new install.
#
# `:memory:` explicitly: parallel testing gives each process its own
# database, and an in-memory one per process is what the suite is
# verified against locally. The job-level DB_DATABASE above is a file
# path, which parallel workers would have to create and migrate
# individually — a difference in behaviour with nothing to gain.
- name: Tests
run: ./vendor/bin/pest
run: ./vendor/bin/pest --parallel
env:
DB_DATABASE: ':memory:'
+11
View File
@@ -39,3 +39,14 @@ yarn-error.log
/database/seeders/DevDataSeeder.php
/docs/*.md
!/docs/api-guide.md
!/docs/api-modules.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/
# Written into an artifact by build-release.sh, never into a checkout.
/config/build.php
+380 -2
View File
@@ -10,8 +10,386 @@ Anything under **Upgrade notes** is something you have to do, not something we d
## Unreleased
This section collects changes as they land; the release process turns it into a numbered entry when
a version is cut.
This section collects changes as they land; the release process turns it into a numbered entry
when a version is cut.
## 2.4.0 — 8 September 2026
Clients can now look after the files they uploaded, and this release closes three ways somebody
could see a little more than they should.
**New**
- **Clients can edit and delete the files they uploaded**, with the name, description, expiry,
categories, download limit and public flag each behind the permission that already governs it.
A file shared *with* a client is still not theirs to touch.
- **A switch to stop this installation fetching the project news**, on Settings → General. On by
default; off means the request is never made.
**Closed holes in who can see what**
- A staff member limited to their assigned clients could read other clients' names, and their IDs,
out of file details and the uploader filter. Reported by
[@Noorkhalel](https://github.com/Noorkhalel) (GHSA-whmp-p9hv-r7j7).
- Download links to external storage now last a minute instead of an hour. Previews keep the hour.
- Eight advisories in bundled dependencies, including an XSS bypass in the markdown renderer that
builds your email templates.
**Fixed**
- A failed upload keeps its parts, so retrying it works instead of needing the whole file again.
- `projectsend:captcha-off` no longer claims success on an installation whose CAPTCHA keys are
supplied centrally, where it changed nothing.
### Upgrade notes
- **Resuming an interrupted download from external storage more than a minute after it started now
fails.** Start it again from ProjectSend. Local-disk installations and zip bundles are unaffected.
- **If your temporary directory is on a small or separate volume, allow headroom for twice your
largest allowed upload.** Only while a file is being assembled, and nothing needs configuring.
Thanks to [@Noorkhalel](https://github.com/Noorkhalel), [@denkfabrik-li](https://github.com/denkfabrik-li)
and [@mehmedturk](https://github.com/mehmedturk) for reporting and fixing.
### Issues closed since 2.3.0
The summary above is what changed. This is the paper trail, for anyone who wants to read the
original report.
- [#1765](https://github.com/projectsend/projectsend/issues/1765) — Projectsend 2.2.1 thumbnail issue after file upload
- [#1771](https://github.com/projectsend/projectsend/issues/1771) — Permissions granted to the Client role are not applied to client accounts
## 2.3.0 — 1 September 2026
If you run ProjectSend on Apache or LiteSpeed, this is the release to take. It installed fine on
both before. Then every download arrived empty and every thumbnail was broken. That is fixed, and
you do not have to configure anything. Installations on nginx were never affected and nothing
changes for them.
The rest is mostly security work. Most of it is the same kind of thing: a screen or an API endpoint
that showed a little more than the person asking was allowed to see.
**New**
- **Downloads work on any web server.** Your files sit outside the web root, so ProjectSend checks
permission on every download before anything is sent. The fast way to finish is to hand the file
to the web server. Each web server wants that asked for differently, and until now ProjectSend
only knew how to ask nginx. On Apache and LiteSpeed it asked anyway, nothing answered, and the
visitor got an empty file. Now it works out what it is talking to. If it cannot hand the file
over, it sends the file itself, which is slower under load but works everywhere.
- **Apache and LiteSpeed can still have the fast version.** Install `mod_xsendfile` (LiteSpeed
needs no module), point `XSendFilePath` at your storage directory, and set
`PROJECTSEND_FILE_DELIVERY=xsendfile`. See the upgrade notes.
- **The dashboard tells you which way downloads are going out.** If PHP is sending them, there is a
warning next to it and a short explanation of what that costs you and how to change it. This is
the kind of thing that is invisible until the day the site falls over, so it says so up front.
- **Your logo and your watermark, on every installation.** Upload a logo and it replaces ours in
the sidebar and on your public pages. Add a watermark and it goes on the thumbnails and previews
your clients and visitors see. Staff still see the originals, and the watermark is never written
into the stored file, so you can turn it off again.
- **You can find out which build you are running.** Two images can say "2.2.1" and contain
different code. `projectsend:status` now reports the commit it was built from.
- **You will know if the nightly jobs stop running.** When the scheduler dies, nothing looks wrong.
You find out weeks later, when a file you expired is still downloadable. ProjectSend now reports
when its scheduled work last ran and whether any of it failed.
- **You get told when the mailbox stops working**, even when a send noticed the problem before the
scheduled check did.
**Closed holes in who can see what**
- [#1745](https://github.com/projectsend/projectsend/pull/1745) — Gate the comment moderation
surfaces on reading, not just on the library. Permission to moderate comments was letting somebody
read them, which is not the same thing: on the moderation screen and through the API, a role that
could moderate comments but could not open any file was shown every comment in the installation —
the text, staff-only notes, the client each conversation belongs to, and a visitor's IP address —
about files it would be refused on. Approving a comment over the API handed back its body the same
way.
**Who this affected.** Only installations with a custom role built that way. None of the roles
ProjectSend ships is affected: Account Manager, the only one that moderates comments, can read
files as well, and so can a System Administrator. If you did build such a role, it can no longer
moderate — give it one of the file permissions (upload, edit files, or edit other people's files)
and it works again, now seeing only the comments on files it can actually open.
- [#1759](https://github.com/projectsend/projectsend/pull/1759) — Publish the example Docker
quickstart on the loopback address instead of every network interface. The example set
`TRUSTED_PROXIES: "*"`, which tells ProjectSend to believe the client address forwarded by
whoever connects to it. That is right behind a reverse proxy and wrong when anyone can reach the
container directly, because then anyone can claim any address: enough to walk past the login
lockout, every rate limit, and the address written to the download log and to guest comments.
**Who this affected.** Installations started from `compose.example.yaml` or from the Docker Hub
page, where port 8080 was reachable from outside the machine. A published Docker port is not
covered by a host firewall such as `ufw`, so this was often open without anyone intending it.
- [#1760](https://github.com/projectsend/projectsend/pull/1760) — Have the Docker image default to
production. On first boot the image copied its settings from the development template, which sets
`APP_ENV=local` and `APP_DEBUG=true`. Two things followed that you could not see from inside the
application: every server error showed its stack trace — file, line and surrounding source — to
whoever triggered it, signed in or not; and **"reject known-breached passwords" never actually
ran**, while the security settings screen went on reporting it as switched on.
**Who this affected.** Anyone who started the container without setting those two values: a plain
`docker run` with a database address, the Portainer, unRAID and TrueNAS templates, or a Kubernetes
manifest naming only the database and `APP_URL`. Installations using `compose.example.yaml`, which
sets both correctly, were never affected.
- The client portal dashboard lists only files that client can open. The API dashboard's recent
activity is cut the same way.
- Three lists were showing more than the viewer was allowed to see: the reassignment picker, the
account conversion list, and the membership an API member write handed back.
- Mail and storage credentials no longer end up in the boot configuration cache. A settings form
that gets rejected no longer sends the credential back to the browser.
- Connecting a sign-in provider asks for your password again. Every password prompt in front of an
account now has its own rate limit instead of sharing one. A two-factor code is claimed in a
single step, so the same code cannot be used twice.
- An expired file no longer locks a whole group shut for staff assigned to particular clients. A
shared folder's contents count towards what a client can reach. A client is added to the roster
of the staff member who created them.
- Whether something is an API request is decided by the route, not by a header the caller sets.
- The interface font is served from your own installation. Loading a page no longer tells a font
CDN who is reading it.
- A stored filename can no longer push a control character into a response header.
**Fixed**
- The zip progress bar stops polling when you leave the page.
- A zip that fails to build no longer tells the person who asked for it why, in the server's words.
- Previews are written to a temporary file first, so a half-written one is never served. A file's
previews are deleted even when its storage cannot be reached.
- An expiry date no longer moves because somebody else saved the file at the same time. Setting one
through the API means what it means on the web form.
- Updating a client through the API no longer wipes custom fields the request never mentioned.
- The transfers chart lines up with the timezone its data is stored in.
- Creating an account over a deleted one's email address is refused instead of crashing.
- A comment still shows who wrote it after that account is deleted.
- Marking a file as a new version no longer emails people about a file they already had.
- The password reset and confirm-password screens say where the account's password actually lives,
which matters if you use LDAP or a sign-in provider.
- A refused upload names the quota you are actually up against. A bulk edit that is refused says
which permission was missing.
- Uploaded folders get the permissions the storage library actually asks for.
- The public preview log no longer records the same view repeatedly.
- Updating with `update.sh` no longer silently switches off route, event and view caching. The
script wiped the compiled caches while replacing the files, which is also how ProjectSend
recognised that you had cached them in the first place — so it rebuilt nothing, and every update
quietly left the site slower than the install instructions promised.
- Every new screen in this release is translated into all sixteen languages.
**Before you upgrade, read the notes below.**
### Upgrade notes
- **This upgrade adds two indexes to the activity log, and on a big installation that takes
minutes.** It is the slowest part. Nothing goes offline while it runs — the application keeps
answering — but do not expect the migration to finish in seconds.
- **On Apache or LiteSpeed you need to do nothing, but there is something worth doing.** Downloads
will start working on their own. PHP will be sending them, which ties up a worker process for the
whole of each download. That is fine on a quiet site and not fine on a busy one. To move to the
fast path: install `mod_xsendfile` (LiteSpeed needs no module), allow your storage directory with
`XSendFilePath`, then set `PROJECTSEND_FILE_DELIVERY=xsendfile` in `.env`. The dashboard will
confirm the change.
- **If you copied the example Docker file, `http://<your-server-ip>:8080` will stop answering.**
That is the change. Reach the application through your reverse proxy, as `APP_URL` describes. If
your proxy runs on a different machine, publish the port on the interface it arrives from and
replace `TRUSTED_PROXIES: "*"` with that address or subnet — the two settings only make sense
together.
- **Docker: `APP_ENV` and `APP_DEBUG` set inside `storage/.env` no longer take effect.** The image
now sets them itself, and a real environment variable always beats that file. If you had turned
debug on by editing `storage/.env`, pass `-e APP_DEBUG=true` (or `environment:` in compose)
instead. Anything you already set that way keeps working unchanged.
Thanks to [@denkfabrik-li](https://github.com/denkfabrik-li), who wrote all forty-four pull
requests in this release, and to [@prbt2016](https://github.com/prbt2016), who reported the Apache
download failure that started the delivery work.
### Pull requests merged since 2.2.1
The summary above is what changed. This is the paper trail, for anyone who wants to read the
original change. No issues were closed in this cycle — the work arrived as pull requests.
- [#1718](https://github.com/projectsend/projectsend/pull/1718) — Narrow the reassignment picker to what a viewer may see
- [#1719](https://github.com/projectsend/projectsend/pull/1719) — Count a shared folder's contents as reach, not just the folder
- [#1720](https://github.com/projectsend/projectsend/pull/1720) — Stop an expired file locking a group shut for a scoped staff member
- [#1721](https://github.com/projectsend/projectsend/pull/1721) — Scope the API dashboard's recent actions to what the viewer may read
- [#1722](https://github.com/projectsend/projectsend/pull/1722) — Show the portal dashboard the files a client can actually open
- [#1723](https://github.com/projectsend/projectsend/pull/1723) — Stop a client PATCH clearing custom fields it never mentioned
- [#1725](https://github.com/projectsend/projectsend/pull/1725) — Write a rendition through a temporary file, and never serve an empty one
- [#1726](https://github.com/projectsend/projectsend/pull/1726) — Delete a file's renditions even when its own disk cannot be resolved
- [#1727](https://github.com/projectsend/projectsend/pull/1727) — Give an API expiry date the same meaning the web gives it
- [#1728](https://github.com/projectsend/projectsend/pull/1728) — Stop an expiry moving because somebody else saved the file
- [#1729](https://github.com/projectsend/projectsend/pull/1729) — Decide what is an API request from the route, not from the caller's headers
- [#1730](https://github.com/projectsend/projectsend/pull/1730) — Refuse to provision over a deleted account's address instead of crashing
- [#1731](https://github.com/projectsend/projectsend/pull/1731) — Fail a zip build without handing the requester the server's reason
- [#1732](https://github.com/projectsend/projectsend/pull/1732) — Debounce the public preview log the way the signed-in one already is
- [#1734](https://github.com/projectsend/projectsend/pull/1734) — Name the quota a client is actually held to when an upload is refused
- [#1735](https://github.com/projectsend/projectsend/pull/1735) — Stop an editable-once checkbox locking before anybody ticks it
- [#1736](https://github.com/projectsend/projectsend/pull/1736) — Put a client on the roster of the scoped staff member who created them
- [#1737](https://github.com/projectsend/projectsend/pull/1737) — Compare the transfers window against the column's own timezone
- [#1738](https://github.com/projectsend/projectsend/pull/1738) — Claim a TOTP code atomically instead of checking then writing
- [#1739](https://github.com/projectsend/projectsend/pull/1739) — Refresh a mailbox on the schedule under the lock a send would hold
- [#1740](https://github.com/projectsend/projectsend/pull/1740) — Leave the caches update.sh's own update command needs to see
- [#1741](https://github.com/projectsend/projectsend/pull/1741) — Ask about the zips queue on every path that could answer it
- [#1742](https://github.com/projectsend/projectsend/pull/1742) — Set the directory permission Flysystem actually reads
- [#1743](https://github.com/projectsend/projectsend/pull/1743) — Check the read half of the redirect rule at every door, not one
- [#1744](https://github.com/projectsend/projectsend/pull/1744) — Stop a version link telling people about a file they already had
- [#1745](https://github.com/projectsend/projectsend/pull/1745) — Gate the comment moderation surfaces on reading, not just on the library
- [#1746](https://github.com/projectsend/projectsend/pull/1746) — Say what expiry does to a client-scoped staff member's library
- [#1747](https://github.com/projectsend/projectsend/pull/1747) — Say which permission a bulk edit was actually missing
- [#1748](https://github.com/projectsend/projectsend/pull/1748) — Let a password reset know where the account's credentials live
- [#1749](https://github.com/projectsend/projectsend/pull/1749) — A deleted account is still the person who wrote the comment
- [#1750](https://github.com/projectsend/projectsend/pull/1750) — Tell the admins the mailbox is dead, even when a send noticed first
- [#1751](https://github.com/projectsend/projectsend/pull/1751) — Keep the mail and storage credentials out of the boot-config cache
- [#1752](https://github.com/projectsend/projectsend/pull/1752) — Bound the two preference endpoints by their own registries
- [#1753](https://github.com/projectsend/projectsend/pull/1753) — Narrow the conversion list to the clients its own refusal allows
- [#1754](https://github.com/projectsend/projectsend/pull/1754) — Narrow the membership an API member write hands back
- [#1755](https://github.com/projectsend/projectsend/pull/1755) — Give every password check in front of an account its own bucket
- [#1756](https://github.com/projectsend/projectsend/pull/1756) — Make linking a provider re-prove the password
- [#1757](https://github.com/projectsend/projectsend/pull/1757) — Stop a rejected settings form flashing the credential it carried
- [#1758](https://github.com/projectsend/projectsend/pull/1758) — Let the confirm-password screen ask where the password lives
- [#1759](https://github.com/projectsend/projectsend/pull/1759) — Publish the quickstart on loopback, since it trusts any proxy
- [#1760](https://github.com/projectsend/projectsend/pull/1760) — Have the production image default to production
- [#1761](https://github.com/projectsend/projectsend/pull/1761) — Serve the interface font from the installation, not from a font CDN
- [#1762](https://github.com/projectsend/projectsend/pull/1762) — Run the auth and settings screens through the translator
- [#1763](https://github.com/projectsend/projectsend/pull/1763) — Stop the zip poll when its page goes away
- [#1764](https://github.com/projectsend/projectsend/pull/1764) — Honour Laravel's placeholder case convention in t()
## 2.2.1 — 28 August 2026
A security release. Most of it closes ways somebody could reach past a boundary the rest of the
application already enforced — including two that could lock you out of your own installation.
**Merged**
- [#1708](https://github.com/projectsend/projectsend/pull/1708) — Let an enforced user reach the far side of the confirm-password screen
- [#1716](https://github.com/projectsend/projectsend/pull/1716) — Refuse the last administrator deleting themselves, and keep setup shut
- [#1710](https://github.com/projectsend/projectsend/pull/1710) — Stop a folder deleting the files inside it that its owner may not delete
- [#1714](https://github.com/projectsend/projectsend/pull/1714) — Hold the group edit screen to the same library boundary as the rest
- [#1717](https://github.com/projectsend/projectsend/pull/1717) — Keep a private reply private after the client is deleted
- [#1713](https://github.com/projectsend/projectsend/pull/1713) — Refuse self-deactivation over the API however the boolean is written
- [#1709](https://github.com/projectsend/projectsend/pull/1709) — Ask the seat cap where a pending client is approved through edit()
- [#1715](https://github.com/projectsend/projectsend/pull/1715) — Add a file to a zip once, however many ways the selection reaches it
- [#1707](https://github.com/projectsend/projectsend/pull/1707) — Leave the test workflow one concurrency block, so it parses again
- [#1711](https://github.com/projectsend/projectsend/pull/1711) — Stop the update tests emptying bootstrap/cache for every other worker
- [#1712](https://github.com/projectsend/projectsend/pull/1712) — Make the storage durability dashboard test assert the verdict
**Also fixed**
- The plain-text version of an email no longer shows the link twice, wrapped in brackets.
- The message you get when an account would exceed a limit no longer reads "limited to 1 staff
accounts".
### Upgrade notes
- **Nothing to do.** Drop in the new files and run `php artisan migrate` as usual; this release adds
no migrations, no settings and no new environment values.
- **One thing changes behaviour.** If somebody on your team has been deleting a folder as a way of
clearing out files other people uploaded, that now refuses and says how many files are in the way.
It is the same rule the file list has always applied one screen over — the folder was the way
around it, and what it removed was not recoverable.
Thanks to [@denkfabrik-li](https://github.com/denkfabrik-li), who reported, diagnosed and fixed
every one of the above.
### Issues closed since 2.2.0
The summary above is what changed. This is the paper trail, for anyone who wants to read the
original report.
- [#1706](https://github.com/projectsend/projectsend/issues/1706) — V1 migration imports $2a$ bcrypt hashes that cause HTTP 500 on login
## 2.2.0 — 27 August 2026
A big release. Most of it closes holes in who can see what. The rest is a handful of new things.
**New**
- Google Cloud Storage can hold your files, alongside S3.
- You can set a maximum size for a zip download.
- Zip downloads no longer hold up your email.
- ProjectSend warns you if nothing is building your zip downloads.
- A deleted account's email address can be used again.
**Closed holes in who can see what**
- A staff member limited to their own clients now stays limited everywhere: client records, file
names, groups, comment moderation, and the dashboard's activity and expired-file lists.
- Private notes on a publicly shared file stay private.
- Clients no longer see the names of folders they cannot open.
- The maximum file size now applies to large uploads too.
- A download limit now holds when a zip is collected.
- A large upload cannot be finished twice at once.
- A two-factor recovery code can only be used once.
- Your notification settings accept only the switches the screen offers.
**Fixed**
- People whose accounts came from ProjectSend v1 can sign in again.
- Sessions no longer break behind a reverse proxy.
- No more 502 Bad Gateway behind a reverse proxy.
- Saving after your session expires takes you to the login page, not an error.
- An upload that cannot be stored now fails instead of vanishing.
- Downloads, thumbnails and share links work when files are kept in cloud storage.
- A zip download is never offered when the archive was not actually written.
- Deleting an account either finishes completely or does nothing at all.
- A file can no longer be put into a folder that has been deleted.
- Declining a group membership request now happens once, not twice.
- Creating something with a create-only role no longer ends in an error page.
- Connecting Google or Microsoft to an account you already have now works.
- Downloads work on cPanel and Plesk, where the web server is not PHP's user.
- The dashboard no longer fails on shared hosting.
- An installation built from source is no longer told to pull an image.
- `docker logs` now shows the web server's log.
- The repair tool no longer mistakes cached previews for stray files.
- One confirmation message instead of two.
**Before you upgrade, read the two notes below.** One of them needs you to do something if you
installed ProjectSend by hand.
### Upgrade notes
- **Manual installs: your background worker needs one more queue.** Building a zip download now runs
on its own queue, so a worker started before this version watches the wrong one — it will keep
sending email perfectly while no zip download ever finishes, and nothing in any log will say why.
`update.sh` spots this and offers to fix the service file for you, keeping a copy of the old one,
so for most people there is nothing to do but say yes. If you upgrade by hand, change the
`ExecStart` line in `/etc/systemd/system/projectsend-worker.service` to read
`queue:work --queue=default,zips …`, then `sudo systemctl daemon-reload && sudo systemctl restart
projectsend-worker`. INSTALL.md has the full file, and the two-worker setup if you would rather
keep the two kinds of work apart. Docker installations need no change.
If it is ever missed, ProjectSend now says so on screen: staff who can see system information get
a banner naming the problem and the fix.
- **If you run behind a reverse proxy, check `TRUSTED_PROXIES`.** It is now read correctly, which it
was not before. Set it in `.env`, and do not run `config:cache`, which stops `.env` being read at
all.
Thanks to [@denkfabrik-li](https://github.com/denkfabrik-li), who found, diagnosed and fixed most
of the boundary work above, and to [@mstewart14](https://github.com/mstewart14),
[@elibrachas](https://github.com/elibrachas), [@mueller7382](https://github.com/mueller7382) and
[@pabloalvarez44](https://github.com/pabloalvarez44) for reports and fixes.
### Issues closed since 2.1.0
The summary above is what changed. This is the paper trail, for anyone who wants to read the
original report.
- [#1627](https://github.com/projectsend/projectsend/issues/1627) — Errors while installing via Docker
- [#1648](https://github.com/projectsend/projectsend/issues/1648) — A deleted account's email address can never be used again
- [#1661](https://github.com/projectsend/projectsend/issues/1661) — Docker update instructions do not update ProjectSend when using official Compose setup
- [#1662](https://github.com/projectsend/projectsend/issues/1662) — Preview files not available on v2.1.0
- [#1663](https://github.com/projectsend/projectsend/issues/1663) — Dashboard 500s on shared hosting: container detection trips open_basedir
- [#1664](https://github.com/projectsend/projectsend/issues/1664) — INSTALL.md: the nginx-in-front-of-Apache path needs the buffer advice too
- [#1668](https://github.com/projectsend/projectsend/issues/1668) — INSTALL.md: X-Accel downloads fail when nginx and PHP-FPM run as different users
- [#1672](https://github.com/projectsend/projectsend/issues/1672) — Projectsend 2 behind Traefik issues 419 when logging in or hitting an error?
- [#1673](https://github.com/projectsend/projectsend/issues/1673) — Projectsend 2: Setting Widget Columns throws error
- [#1675](https://github.com/projectsend/projectsend/issues/1675) — Success toast shows twice after create/delete redirects
- [#1706](https://github.com/projectsend/projectsend/issues/1706) — V1 migration imports $2a$ bcrypt hashes that cause HTTP 500 on login
## 2.1.0 — 18 August 2026
+239 -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,192 @@ 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.
`"*"` means "trust whoever connected to me", so it belongs with a published port only the proxy can
reach — which is why `compose.example.yaml` publishes on `127.0.0.1`. If anybody can open the
container's port directly, they are the proxy as far as this setting is concerned, and the
`X-Forwarded-For` they send is the address the rate limiters and the download log will use. Where
the proxy runs on another host, publish on the interface it arrives from and name that address or
subnet here instead of `"*"`.
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://127.0.0.1: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 +263,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 +288,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 +319,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 +367,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 +386,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.
+207 -61
View File
@@ -21,7 +21,7 @@ to create a database — this is not an install you can do over FTP alone.
| **PHP** | 8.4 or newer, both the command-line PHP and PHP-FPM |
| **PHP extensions** | `bcmath` `ctype` `curl` `dom` `fileinfo` `filter` `gd` `iconv` `intl` `json` `ldap` `mbstring` `openssl` `pcntl` `pdo_mysql` `session` `simplexml` `tokenizer` `zip` |
| **Database** | MySQL 8.0 or newer (we test on 8.4 LTS) |
| **Web server** | **nginx**, with PHP-FPM — see the note below |
| **Web server** | Any, with PHP-FPM. **nginx is strongly recommended** — see the note below |
| **Disk space** | The app itself is small; plan for whatever your users will upload |
A few notes on that list:
@@ -29,51 +29,113 @@ A few notes on that list:
- **`ldap` is required even if you never use LDAP.** One of the libraries ProjectSend depends on
declares it, so PHP will refuse to start the app without it. On Debian/Ubuntu it is
`php8.4-ldap`; on RHEL-family systems, `php-ldap`.
- **nginx is not a preference, it is a requirement.** See [Why nginx](#why-nginx) — it is worth
two minutes of reading before you commit to a server, because Apache cannot be made to work by
configuring it differently.
- **nginx is recommended, not required.** ProjectSend runs on Apache and LiteSpeed too, and
downloads work on them out of the box. What differs is *how* the bytes are sent: on nginx the
web server sends them, and everywhere else PHP does, which costs a worker process for the
duration of every download. See [How downloads are sent](#how-downloads-are-sent) before you
commit to a server — it is a capacity decision, not a compatibility one.
- **Redis is optional.** The Docker setup uses it, but a manual install works fine with the
database for sessions, cache and queues. If you already have Redis, see
[Optional extras](#optional-extras) below.
### Why nginx
### How downloads are sent
Your uploaded files do not live under `public/`. They sit in `storage/app/files/`, outside the web
root, where no URL can reach them — which is the whole point: a file is only yours to download if
ProjectSend says so, and a file sitting in a guessable public folder has already lost that
argument.
So every download has to pass through a permission check. The obvious way to do that is to let PHP
read the file and echo it back to the browser, and that is what most PHP applications do. It works,
and it is a bad idea at any real size: a single 5 GB download occupies a PHP process for its entire
duration, so a handful of people downloading at once can exhaust every worker your server has while
the CPU sits idle. Resumable downloads, byte ranges and progress bars all have to be reimplemented
by hand, usually incorrectly.
So every download has to pass through a permission check in PHP first. What happens *after* that
check passes is the thing this section is about, and ProjectSend can do it two ways.
ProjectSend does the other thing. PHP checks permissions, logs the download, and then answers with
an empty response carrying a header that says *"nginx, please send this file."* nginx streams the
bytes with the same code it uses for any static file — sendfile, byte ranges, resume support, no
PHP process held open — and the visitor never sees the real path. The header is
`X-Accel-Redirect`, and the matching `location /protected-files/` block in
[step 6](#step-6--point-your-web-server-at-it) is marked `internal`, which is what stops anyone
from requesting that path directly.
**PHP sends the file.** It opens the file and writes it out to the visitor. This works on every
web server and needs no configuration, which is why it is what ProjectSend falls back to. The cost
is that one PHP worker process is occupied for the whole of each download — three minutes for a
large file on a slow connection is three minutes that worker cannot answer anything else. A
handful of concurrent large downloads can therefore occupy every worker you have and the site
stops responding, with the processor idle and the workers all waiting on network transfers.
**Apache has no equivalent that ProjectSend can use.** Apache's closest feature, `mod_xsendfile`,
reads a differently-named header (`X-Sendfile`) that ProjectSend does not send, and it is not
installed by default anyway. LiteSpeed has its own third spelling. On any of them the application
installs fine and every page works — you can log in, upload, manage clients, browse the library —
but **every download returns an empty response or a 404**, because nothing is listening for the
instruction PHP just gave. There is no setting to change; the header names simply do not match.
**The web server sends the file.** PHP answers with an empty response and a header naming the
file, and finishes immediately; the web server streams the bytes with the same code it uses for
any static file — `sendfile`, byte ranges, resume support, no PHP process held open — and the
visitor never sees the real path. This is what you want on anything busy.
Two ways out, if nginx really is impossible on your hosting:
The second option needs a header, and **each web server reads a different one**, which is why
ProjectSend has to know which one it is talking to. It works this out from the server itself and
you can override it.
- Put nginx in front of Apache as a reverse proxy, serving `/protected-files/` itself. This works
but is more moving parts than just using nginx.
- Store your files in S3-compatible object storage instead (see
[Storing files somewhere other than this server](#storing-files-somewhere-other-than-this-server)).
Files kept there are never on your server's disk, so downloads become a signed, expiring redirect
to the storage provider and the web server is not involved at all. This is a genuine, supported
path — just decide it before people start uploading, not after.
| Your server | What ProjectSend does | What you need to configure |
|---|---|---|
| nginx | `X-Accel-Redirect` | The `location /protected-files/` block in [step 6](#step-6--point-your-web-server-at-it). Detected automatically |
| Apache | PHP sends the file, unless you enable `mod_xsendfile` | See below |
| LiteSpeed / OpenLiteSpeed | PHP sends the file, unless you turn on X-Sendfile | See below |
| Anything else | PHP sends the file | Nothing |
**The dashboard tells you which one is in use.** The System panel has a "Downloads sent by" line,
with a warning icon and an explanation whenever PHP is doing the sending. You do not have to
remember to check this file.
#### Enabling X-Sendfile on Apache or LiteSpeed
Apache needs [`mod_xsendfile`](https://github.com/nmaier/mod_xsendfile) installed and enabled, and
a directive allowing it to serve your storage directory:
```apache
XSendFile On
XSendFilePath /home/projectsend/storage/app/files
```
LiteSpeed and OpenLiteSpeed read the same header without an extra module; enable it in the server
configuration.
Then tell ProjectSend to use it, in `.env`:
```dotenv
PROJECTSEND_FILE_DELIVERY=xsendfile
```
**ProjectSend will not switch this on by itself**, even when it can see the module is loaded,
because it cannot see whether `XSendFilePath` allows the storage directory. Guessing wrong there
produces empty downloads rather than slow ones, and an empty download is a much worse failure than
a slow one — so this stays something you turn on having configured it.
#### Choosing explicitly
`PROJECTSEND_FILE_DELIVERY` accepts:
| Value | Meaning |
|---|---|
| `auto` | The default. nginx if the server says it is nginx, PHP otherwise |
| `nginx` | Always `X-Accel-Redirect`. Use this if nginx is proxying another server |
| `xsendfile` | Always `X-Sendfile`, for Apache with `mod_xsendfile`, or LiteSpeed |
| `php` | Always PHP. Correct and slow, and never wrong |
The one case `auto` gets wrong is **nginx reverse-proxying Apache**: PHP is talking to Apache, so
it picks PHP streaming, and downloads work but do not use the nginx in front. Set
`PROJECTSEND_FILE_DELIVERY=nginx` and make sure the front nginx serves `/protected-files/`. While
you are there, give the proxy some header headroom — 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))
#### Or take your server out of it entirely
Store your files in object storage — 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. Decide this before people start
uploading, not after.
---
@@ -182,6 +244,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 on nginx: PHP checks permissions and then hands the web server the path with
`X-Accel-Redirect` (see [How downloads are sent](#how-downloads-are-sent)), 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 +431,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 +443,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 +503,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 +531,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.
---
@@ -462,9 +593,15 @@ That is correct behaviour until the first administrator exists. Finish step 7. I
created one and it still happens, ProjectSend cannot reach your database — check `storage/logs/`.
**Pages load but downloads give a 404, or download a 0-byte file.**
The `/protected-files/` block is missing from your nginx config, or its `alias` path does not match
where you installed ProjectSend. It must point at `storage/app/files/` and end with a slash. If you
are on Apache or LiteSpeed, no configuration will fix this — see [Why nginx](#why-nginx).
On nginx, the `/protected-files/` block is missing from your config, or its `alias` path does not
match where you installed ProjectSend. It must point at `storage/app/files/` and end with a slash.
On any server, check the "Downloads sent by" line in the dashboard's System panel against the
server you are actually running. A 0-byte download means ProjectSend sent a header the server did
not act on — most often `PROJECTSEND_FILE_DELIVERY` set to `nginx` or `xsendfile` on a server that
is neither, or set to `xsendfile` without `XSendFilePath` allowing the storage directory. Setting
`PROJECTSEND_FILE_DELIVERY=php` always works and is the quickest way to confirm that is the
problem. See [How downloads are sent](#how-downloads-are-sent).
**Uploads fail partway through.**
`client_max_body_size` in nginx, or `upload_max_filesize` / `post_max_size` in `php.ini`, is
@@ -502,13 +639,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.
---
+8 -2
View File
@@ -23,6 +23,11 @@ page to download it.
No public link passed around by email, no third-party service holding your clients' documents, no
per-seat pricing. It runs on your server, and the files stay there.
Prefer not to run the server yourself? [ProjectSend Cloud](https://projectsend.cloud) is the
official hosted version of ProjectSend, run by the same team — every subscription funds this free
software. The line between the free core and Cloud, and the commitments that go with it, are set
out in [LICENSING.md](LICENSING.md).
## What it does
**For the people you send to**
@@ -47,7 +52,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 +83,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
@@ -3,9 +3,9 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Modules\Identity\PasswordVerification;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
@@ -22,16 +22,21 @@ class ConfirmablePasswordController extends Controller
/**
* Confirm the user's password.
*
* Through PasswordVerification, so this asks the same question the
* sign-in form asks: is this the account's password, from wherever
* that account's password lives. Checking only the local hash refused
* every directory-provisioned account the password it actually has --
* their local hash is a Str::password(64) nobody has ever seen -- and
* this screen stands in front of enrolling in two-factor, so those
* accounts could not enrol at all.
*/
public function store(Request $request): RedirectResponse
public function store(Request $request, PasswordVerification $passwords): RedirectResponse
{
$user = $request->user();
assert($user !== null);
if (! Auth::guard('web')->validate([
'email' => $user->email,
'password' => $request->password,
])) {
if (! $passwords->verify($user, (string) $request->string('password'))) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
@@ -3,6 +3,8 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Modules\Identity\AuthSource;
use App\Modules\Identity\Ldap\LdapAuthenticator;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -16,6 +18,10 @@ use Inertia\Response;
class NewPasswordController extends Controller
{
public function __construct(
private readonly LdapAuthenticator $ldap,
) {}
/**
* Show the password reset page.
*/
@@ -46,10 +52,55 @@ class NewPasswordController extends Controller
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function ($user) use ($request) {
$user->forceFill([
// A directory account's password lives in the directory and
// the local hash is not consulted at all, which is what
// isDirectoryAccount() means. Writing one here reported
// success and changed nothing anybody could use -- including
// when the directory it points at is gone, which is exactly
// when somebody reaches for a reset.
//
// Refused here rather than where the link is asked for: that
// endpoint answers "A reset link will be sent if the account
// exists" to everybody on purpose, and a refusal there would
// tell a stranger both that an address is an account and how
// it signs in. By this point the caller holds a token that
// was emailed to the address, so the explanation reaches the
// account holder and nobody else.
//
// Throwing before the write also leaves the token unspent:
// PasswordBroker deletes it after the callback returns, so
// the link still works if an administrator converts the
// account in the meantime.
if ($this->ldap->isDirectoryAccount($user)) {
throw ValidationException::withMessages([
'email' => [__('This account signs in through your directory, so its password is not set here. Ask an administrator if you cannot sign in.')],
]);
}
$attributes = [
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
];
// `social` records that the account came into existence
// without anybody choosing a password, which AuthSource
// states outright -- along with "a social account may later
// set a real password". This is that moment, and nothing
// else in the application writes it: the Connected accounts
// screen reads `auth_source === Local` as
// `has_local_password`, so without this line its refusal
// goes on asking for a password that has just been set.
//
// The two branches of this method are the same rule read
// twice: `social` is where the account came from and the
// hash here is what signs it in, so choosing one settles it;
// `ldap` is the authentication path itself, so nothing
// chosen here settles anything.
if ($user->auth_source === AuthSource::Social) {
$attributes['auth_source'] = AuthSource::Local;
}
$user->forceFill($attributes)->save();
event(new PasswordReset($user));
}
@@ -8,6 +8,8 @@ use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientFieldContext;
use App\Modules\Clients\ClientPortalCustomFields;
use App\Modules\Identity\Erasure\ErasureSchedule;
use App\Modules\Identity\StaffAccounts;
use App\Modules\Platform\Localization\TimezoneRegistry;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
@@ -23,6 +25,7 @@ class ProfileController extends Controller
public function __construct(
private readonly ClientPortalCustomFields $customFields,
private readonly TimezoneRegistry $timezones,
private readonly StaffAccounts $accounts,
) {}
/**
@@ -103,12 +106,24 @@ class ProfileController extends Controller
$user = $request->user();
assert($user !== null);
// The rule every other door into this already asks: Staff update(),
// guardDeletable(), and both role-conversion directions. This one
// did not, and self-deletion is the one door where the account
// being removed is certainly signed in — so the last active
// administrator could take themselves out, leaving no live staff
// row at all. EnsureSetupIsComplete then reopens first-run setup to
// anybody who asks, which is the other half of this and is closed
// below.
$this->accounts->guardLastAdministrator(
$user,
removesAdmin: $this->accounts->isAdministratorRole($user->role_id),
);
Auth::logout();
// Self-deletion: soft delete now, permanent GDPR erasure after
// the disclosed grace period (Setting::AccountErasureGraceDays).
$graceDays = (int) app(Settings::class)->get(Setting::AccountErasureGraceDays);
$user->forceFill(['erase_after' => now()->addDays($graceDays)])->save();
app(ErasureSchedule::class)->apply($user);
$user->delete();
app(ActivityLogger::class)->log(Action::UserDeleted, $user, context: ['name' => $user->name]);
@@ -15,6 +15,7 @@ use App\Modules\Platform\Attribution\Attribution;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use App\Modules\Platform\Captcha\Captcha;
use App\Modules\Platform\Installation\Installation;
use App\Modules\Files\Queue\StalledZipBuilds;
use App\Modules\Platform\Localization\LocaleRegistry;
use App\Modules\Platform\Localization\TimezoneRegistry;
use App\Modules\Platform\OfficialLinks;
@@ -23,7 +24,10 @@ use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Updates\LatestReleaseInfo;
use App\Modules\Platform\Updates\RunningCodeState;
use Illuminate\Foundation\Inspiring;
use App\Modules\Platform\Announcements\Events\ResolvingAnnouncement;
use App\Modules\Platform\Navigation\Events\ResolvingNavigationLinks;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
@@ -83,6 +87,19 @@ class HandleInertiaRequests extends Middleware
// ignore this and always show it.
'attribution' => app(Attribution::class)->visible(),
'capabilities' => $capabilities->enabledKeys(),
// Sidebar entries a package asked for. Shared rather than
// passed per page because the sidebar is on every page, and
// dispatched unconditionally so that with nothing listening
// the list is empty and the sidebar is exactly what it was.
// See ResolvingNavigationLinks for why core never learns what
// is in it.
'extra_nav_links' => $this->extraNavLinks($request),
// Shared rather than a dashboard prop, because it is shown in
// two places — the band on the dashboard and the icon beside
// the notification bell everywhere else — and "the same
// message" is the requirement. Two props would drift the day
// somebody edited one.
'announcement' => $this->announcement($request),
// Shared rather than passed by each page: the sign-in buttons,
// the registration form and the Connected accounts nav entry
// all need the same list, and a nav entry to a screen with
@@ -102,6 +119,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 +163,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 +267,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.
@@ -272,4 +317,43 @@ class HandleInertiaRequests extends Middleware
/** @var array<string, string> */
return app('translator')->getLoader()->load($locale, '*', '*');
}
/**
* @return list<array{title: string, url: string, external: bool, icon: string|null}>
*/
private function extraNavLinks(Request $request): array
{
$user = $request->user();
// Staff only, decided here rather than in each listener: these
// render in the administration area, and a client's portal shows
// their own files and nothing about the installation.
$event = new ResolvingNavigationLinks(isStaff: $user !== null && $user->isStaff());
if (! $event->isStaff) {
return [];
}
Event::dispatch($event);
return $event->links;
}
/**
* @return array{title: string, body: string, action_label: string|null, action_url: string|null, tone: string}|null
*/
private function announcement(Request $request): ?array
{
$user = $request->user();
if ($user === null) {
return null;
}
$event = new ResolvingAnnouncement(isStaff: $user->isStaff());
Event::dispatch($event);
return $event->announcement;
}
}
+7 -67
View File
@@ -3,13 +3,12 @@
namespace App\Http\Requests\Auth;
use App\Models\User;
use App\Modules\Identity\Ldap\LdapAuthenticator;
use App\Modules\Identity\Ldap\LdapProvisioner;
use App\Modules\Identity\PasswordVerification;
use App\Modules\Identity\SignIn;
use App\Modules\Platform\Captcha\CaptchaForm;
use App\Support\Rules;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Auth\SessionGuard;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
@@ -115,10 +114,9 @@ class LoginRequest extends FormRequest
/**
* The account whose password checks out, or null.
*
* The local hash is tried first and the directory only on failure, so
* a login that succeeds locally never generates directory traffic.
* The exception is an account whose credentials are known to live in
* the directory, where the local hash is a placeholder nobody holds.
* The rule itself -- local hash first, directory when the credentials
* live there -- is PasswordVerification's, because this is no longer
* the only screen that has to ask it. See that class.
*/
private function verifyCredentials(?User $user): ?User
{
@@ -126,67 +124,9 @@ class LoginRequest extends FormRequest
return null;
}
$ldap = app(LdapAuthenticator::class);
if (! $ldap->isDirectoryAccount($user)
&& Auth::validate($this->only('email', 'password'))) {
$this->upgradeHashIfStale($user);
return $user;
}
$identity = $ldap->attempt(
(string) $this->string('email'),
(string) $this->string('password'),
$user,
);
if ($identity === null) {
return null;
}
$ldap->stamp($user, $identity);
return $user;
}
/**
* Re-hash a password stored under weaker settings than this
* installation now uses.
*
* Laravel does this for you inside SessionGuard::attempt(), but this
* form does not use attempt() — it verifies with Auth::validate() and
* hands the account to SignIn, which calls Auth::login(). Neither
* re-hashes, so without this an account keeps whatever cost it was
* created under forever, and raising BCRYPT_ROUNDS would quietly
* apply to new accounts only.
*
* That is not hypothetical: every account the v1 migration carries
* across arrives as `$2y$08$…`, because v1 hashed at cost 8, and
* would otherwise stay four times cheaper to attack than an account
* created here.
*
* **Only ever called on the local branch.** On the directory branch
* the submitted plaintext is the *LDAP* password and the local hash
* is a `Str::password(64)` placeholder nobody holds; writing the
* directory credential into it would mint a second way into the
* account that keeps working after LDAP is switched off.
*/
private function upgradeHashIfStale(User $user): void
{
$guard = Auth::guard('web');
// getProvider() is on SessionGuard rather than on the StatefulGuard
// contract. This guard is a SessionGuard in every configuration this
// application ships; the check is here so a custom driver degrades
// to "no re-hash" instead of a fatal on the login path.
if (! $guard instanceof SessionGuard) {
return;
}
// No-ops unless the hasher says the stored digest needs it, so
// this costs an already-current account nothing.
$guard->getProvider()->rehashPasswordIfRequired($user, $this->only('password'));
return app(PasswordVerification::class)->verify($user, (string) $this->string('password'))
? $user
: null;
}
/**
+19 -1
View File
@@ -8,6 +8,7 @@ use App\Models\User;
use App\Modules\Api\Auth\ApiTokens;
use App\Modules\Api\Models\ApiRequestLog;
use App\Modules\Audit\ActivityLog;
use App\Modules\Audit\ActivityLogScope;
use App\Modules\Audit\ActivityOrigin;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
@@ -27,6 +28,7 @@ class ApiUsage
{
public function __construct(
private readonly ApiUsageScope $scope,
private readonly ActivityLogScope $activityLog,
) {}
/**
@@ -145,7 +147,23 @@ class ApiUsage
*/
public function recentActions(User $viewer, bool $installWide, int $limit = 15): array
{
$query = ActivityLog::query()->where('origin', ActivityOrigin::Api);
// Narrowed through ActivityLogScope, exactly as the activity page,
// the download history and the dashboard widget are.
// `view_actions_log` decides whether the install-wide view opens at
// all, but it is not the whole answer for a client-scoped viewer: a
// row carries the subject's name, so an unscoped feed reads out file
// and client names to somebody who gets a 403 on the files
// themselves. The Client Manager role ships with the permission, so
// this is the default configuration, not an exotic one.
//
// Applied on both sides of the branch rather than only in the
// install-wide one: the own-actor filter below already stays inside
// what the scope allows, and a boundary that only exists in one arm
// of an `if` is one refactor away from not existing.
$query = $this->activityLog->apply(
ActivityLog::query()->where('origin', ActivityOrigin::Api),
$viewer,
);
if (! $installWide) {
$query->where('actor_id', $viewer->id);
@@ -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");
}
+4 -2
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Modules\Api\Support;
use App\Modules\Platform\Capabilities\CapabilityUnavailable;
use App\Support\ApiSurface;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
@@ -16,7 +17,8 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable;
/**
* RFC 7807 error bodies for /api/* only.
* RFC 7807 error bodies for the API surface only -- see ApiSurface, which
* is the same question the capability middleware asks.
*
* Two properties this class exists to guarantee:
*
@@ -55,7 +57,7 @@ class ProblemDetails
public function shouldHandle(Request $request): bool
{
return $request->is('api/*');
return ApiSurface::matches($request);
}
public function render(Request $request, Throwable $e): JsonResponse
+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,13 @@ 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 Illuminate\Support\Facades\Event;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Delivery\FileDelivery;
use App\Modules\Files\Models\File;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\UserType;
@@ -24,6 +29,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;
@@ -47,9 +53,13 @@ class DashboardController extends Controller
private readonly Settings $settings,
private readonly ApiUsage $apiUsage,
private readonly StorageDurability $storageDurability,
private readonly FileDelivery $fileDelivery,
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 +91,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
@@ -148,8 +158,12 @@ class DashboardController extends Controller
*
* Every boundary is built in the viewer's zone, so "last week" ends
* when their evening does and not at whatever hour UTC midnight falls
* on for them. The returned instants are still absolute — only the
* day edges moved — so they compare against the UTC column directly.
* on for them. The instants are absolute, but they carry that zone —
* and a Carbon handed to the query builder is formatted in its own
* zone, offset discarded, so comparing one against a UTC column asks
* a question nine hours out for a viewer in Tokyo. transferSeries()
* converts before it compares; the day cursor there keeps them as
* they are, because that half really is about the viewer's calendar.
*
* @return array{0: Carbon, 1: Carbon, 2: string}
*/
@@ -206,6 +220,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'),
@@ -235,7 +255,13 @@ class DashboardController extends Controller
$rows = ActivityLog::query()
->whereIn('action', [Action::FileUploaded->value, ...array_map(fn (Action $a): string => $a->value, $downloadActions)])
->whereBetween('created_at', [$from, $to])
// In UTC, because that is what the column is. The query
// builder formats a Carbon in whatever zone the object holds
// and drops the offset, so passing the viewer's midnight
// straight in compares "2026-08-22 00:00:00" against a UTC
// column — nine hours of somebody else's day, at both ends,
// for a viewer in Tokyo.
->whereBetween('created_at', [$from->copy()->utc(), $to->copy()->utc()])
->get(['action', 'actor_type', 'created_at'])
// Bucketed by the viewer's calendar day. Grouping on the UTC
// one puts an evening upload from anywhere west of Greenwich
@@ -276,11 +302,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 +374,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 +420,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 +442,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,32 +456,31 @@ 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();
}
/**
* @return array<string, string|int|bool|array<string, string|null>|null>
* @return array<string, array<string, bool|string|null>|bool|int|string|null>
*/
private function systemInfo(): array
{
@@ -446,28 +505,36 @@ class DashboardController extends Controller
// Installation. Always present, unlike storage_durability, which
// is null whenever the durability question does not apply.
'install_kind' => $this->installation->kind()->value,
// How downloads leave the server, and whether that was
// detected or stated. Reported even when it is the fast path:
// "my downloads are handed to the web server" is worth being
// able to confirm at a glance, not only worth warning about
// when it is false — the same reasoning as storage_durability.
'file_delivery' => $this->fileDelivery->describe(),
];
}
private function clientDashboard(User $client): Response
{
$assignedFiles = File::query()->whereHas('assignments', function ($query) use ($client): void {
$query->where(function ($direct) use ($client): void {
$direct->where('assignable_type', User::class)->where('assignable_id', $client->id);
})->orWhere(function ($viaGroup) use ($client): void {
$viaGroup->where('assignable_type', Group::class)
->whereIn('assignable_id', $client->memberOfGroups()->pluck('groups.id'));
});
});
// File::scopeVisibleToClient is the single source of truth for
// client file access, and this page has to agree with the portal it
// introduces. Restating the assignment half here made it disagree
// in both directions: it counted expired files, which the scope
// ends by excluding and /my-files therefore never shows, and it
// missed everything that reaches a client another way — a file in a
// folder shared with them, their own portal upload, and a revision,
// which owns no assignment row and inherits its original's
// recipients.
$visibleFiles = File::query()->visibleToClient($client);
return Inertia::render('portal/dashboard', [
'files_count' => (clone $assignedFiles)->count(),
'files_count' => (clone $visibleFiles)->count(),
'groups_count' => $client->memberOfGroups()->where('public', true)->count(),
'storage' => [
'used_bytes' => $this->storageUsage->usedBytes($client),
'quota_bytes' => $this->storageUsage->quotaBytes($client) ?: null,
],
'latest_files' => $assignedFiles->orderByDesc('created_at')->limit(5)->get()
'latest_files' => $visibleFiles->orderByDesc('created_at')->limit(5)->get()
->map(fn (File $file): array => [
'id' => $file->id,
'name' => $file->name,
@@ -45,8 +45,14 @@ class DashboardWidgetPreferencesController extends Controller
$validated = $request->validate([
'columns' => ['required', 'integer', 'between:1,4'],
'widgets' => ['required', 'array'],
'widgets.*.widget_key' => ['required', 'string', Rule::in(self::WIDGET_KEYS)],
// Bounded by the allowlist itself, and unique on the key. The
// Rule::in below checks each value; it says nothing about how
// many there are or whether they repeat, and the loop writes
// one row per element. A layout has at most one entry per
// widget, so anything longer than the registry is not a layout
// this screen could have produced.
'widgets' => ['required', 'array', 'max:'.count(self::WIDGET_KEYS)],
'widgets.*.widget_key' => ['required', 'string', 'distinct', Rule::in(self::WIDGET_KEYS)],
'widgets.*.enabled' => ['required', 'boolean'],
'widgets.*.column_index' => ['required', 'integer', 'between:0,3'],
'widgets.*.position' => ['required', 'integer', 'min:0'],
@@ -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.
];
}
}
@@ -158,7 +158,23 @@ class ClientPortalCustomFields
*/
private function isLocked(ClientCustomField $field, BaseCollection $values): bool
{
return $field->client_editability === ClientFieldEditability::EditableOnce
&& filled($values->get($field->id));
if ($field->client_editability !== ClientFieldEditability::EditableOnce) {
return false;
}
$stored = $values->get($field->id);
// A checkbox has a stored value from the first save onwards: an
// unticked box is written as '0', and filled('0') is true. Asking
// "is anything stored" therefore locked the field on the first save
// of the form it sits on, whatever the client had chosen — and a
// box they never ticked can then never be ticked. '0' is the
// absence of a decision, which is the state the other types express
// as null, so it is what an unlocked checkbox looks like.
if ($field->type === ClientCustomFieldType::Checkbox) {
return $stored === '1';
}
return filled($stored);
}
}
@@ -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,
) {}
/**
@@ -46,6 +48,22 @@ class ClientProvisioning
return $this->settings->get(Setting::ClientsAutoApprove) === true;
}
/**
* Whether an address is free for a new account.
*
* The unique index on `email` spans soft-deleted rows — AvailableEmailRule
* is built on exactly that, so a deleted account keeps its address until
* erasure takes the row away. The registration form learns this from
* validation. The machine paths have no form to validate: a directory or
* an identity provider hands over an address and provision() inserts it,
* so without asking first the insert raises a QueryException in the
* middle of somebody's sign-in.
*/
public function addressIsFree(string $email): bool
{
return ! User::withTrashed()->where('email', $email)->exists();
}
/**
* @param bool|null $autoApprove Null asks Setting::ClientsAutoApprove,
* which is the right question for the
@@ -70,6 +88,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;
@@ -25,9 +29,11 @@ use App\Modules\Identity\UserType;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
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 +60,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 +72,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 +93,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
@@ -122,6 +154,20 @@ class ClientsController extends Controller
$this->activity->log(Action::UserCreated, subject: $client);
$creator = $request->user();
assert($creator !== null);
// A client-scoped creator would otherwise lose the client they just
// made. guardTarget() answers 404 for anything off their roster, so
// the record they created is not theirs to open, and
// StaffLibraryScope::clients() leaves it out of their list as well —
// the client exists, is welcomed by email, and is invisible to the
// person who made it. Their own roster is where a client they
// created belongs; an unscoped creator has no roster to add to.
if ($creator->isClientScoped()) {
$creator->assignedClients()->attach($client->id);
}
$this->saveCustomFieldValues($client, $validated['custom_field_values'] ?? []);
if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) {
@@ -133,7 +179,8 @@ class ClientsController extends Controller
public function update(Request $request, User $client): ClientResource
{
abort_unless($client->isClient(), 404);
$this->guardTarget($request, $client);
$validated = $request->validate([
'name' => ['sometimes', 'string', 'max:255'],
@@ -159,7 +206,11 @@ class ClientsController extends Controller
$client->storage_quota_mb = $validated['storage_quota_mb'] ?? 0;
}
// Approval, and so the moment the seat is spent — same rule the
// web edit screen and approve() answer to. Inside the branch, so a
// capped installation can still edit a client it already holds.
if (($validated['active'] ?? false) && $client->account_requested) {
$this->seats->guardClient('active');
$client->account_requested = false;
}
@@ -170,7 +221,7 @@ class ClientsController extends Controller
$client->save();
if (array_key_exists('custom_field_values', $validated)) {
$this->saveCustomFieldValues($client, $validated['custom_field_values']);
$this->patchCustomFieldValues($client, $validated['custom_field_values']);
}
$this->activity->log(Action::UserUpdated, subject: $client);
@@ -203,9 +254,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 +282,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);
}
@@ -314,11 +379,43 @@ class ClientsController extends Controller
}
/**
* Every field, whether or not the request named it — a new client has
* no values yet, and create() is not a partial update.
*
* @param array<int, mixed> $values field id => submitted value
*/
private function saveCustomFieldValues(User $client, array $values): void
{
foreach (ClientCustomField::query()->get() as $field) {
$this->writeCustomFieldValues($client, ClientCustomField::query()->get(), $values);
}
/**
* Only the fields the request actually named.
*
* PATCH semantics, the same rule update() applies to every other
* column: an absent key means "leave alone", not "clear". Sharing
* create()'s "write every field" pass here emptied every custom field
* the caller had not mentioned, which is silent data loss on a request
* that looked like it changed one thing.
*
* @param array<int, mixed> $values field id => submitted value
*/
private function patchCustomFieldValues(User $client, array $values): void
{
$this->writeCustomFieldValues(
$client,
ClientCustomField::query()->whereIn('id', array_keys($values))->get(),
$values,
);
}
/**
* @param Collection<int, ClientCustomField> $fields
* @param array<int, mixed> $values field id => submitted value
*/
private function writeCustomFieldValues(User $client, Collection $fields, array $values): void
{
foreach ($fields as $field) {
$submitted = $values[$field->id] ?? null;
$value = $field->type === ClientCustomFieldType::Checkbox
? ($submitted ? '1' : '0')
@@ -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}%")))
@@ -84,12 +98,29 @@ class ClientsController extends Controller
'clients' => $clients->items(),
'pagination' => Pagination::meta($clients),
'filters' => $filters,
'reassign_candidates' => $this->accountDeletion->candidates(),
// Only for somebody who may actually reassign: the picker is
// part of the delete dialog, and React filtering it out of the
// page is not the same as it never being on the page.
'reassign_candidates' => $viewer->can('delete_clients')
? $this->accountDeletion->candidates($viewer)
: [],
// Null on a self-hosted install: no limit, nothing to say.
'seats' => $this->seats->clientState(),
]);
}
public function create(): Response
public function create(): RedirectResponse|Response
{
// The same courtesy UsersController::create() does: a full
// installation is an ordinary state on a managed plan, so say so
// before somebody fills in a form that cannot be submitted. The
// guard in store() is still the rule; this is only the door.
$seats = $this->seats->clientState();
if ($seats !== null && $seats['full']) {
return redirect()->route('clients.index')->with('error', $seats['message']);
}
return Inertia::render('clients/create', [
'custom_fields' => $this->customFieldDefinitions(),
'default_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
@@ -98,9 +129,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()));
@@ -124,19 +159,63 @@ class ClientsController extends Controller
$this->activity->log(Action::UserCreated, subject: $client);
$creator = $request->user();
assert($creator !== null);
// A client-scoped creator would otherwise lose the client they just
// made. guardTarget() answers 404 for anything off their roster, so
// the record they created is not theirs to open, and
// StaffLibraryScope::clients() leaves it out of their list as well —
// the client exists, is welcomed by email, and is invisible to the
// person who made it. Their own roster is where a client they
// created belongs; an unscoped creator has no roster to add to.
if ($creator->isClientScoped()) {
$creator->assignedClients()->attach($client->id);
}
$this->saveCustomFieldValues($client, $validated['custom_field_values'] ?? []);
if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) {
$client->notify(new ClientWelcomeNotification);
}
return redirect()->route('clients.edit', $client)->with('success', __('Client created.'));
// 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 = $creator->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,
@@ -154,13 +233,16 @@ class ClientsController extends Controller
->where('user_id', $client->id)
->pluck('value', 'client_custom_field_id'),
'content' => $this->accountContent->summarize($client),
'reassign_candidates' => $this->accountDeletion->candidates($client->id),
'reassign_candidates' => $request->user()?->can('delete_clients') === true
? $this->accountDeletion->candidates($request->user(), $client->id)
: [],
]);
}
public function update(Request $request, User $client): RedirectResponse
{
abort_unless($client->isClient(), 404);
$this->guardTarget($request, $client);
$validated = $request->validate(array_merge([
'name' => ['required', 'string', 'max:255'],
@@ -185,8 +267,13 @@ class ClientsController extends Controller
]);
// Activating a pending account through the edit screen counts as
// approval and clears the request flag.
// approval and clears the request flag — which is the moment a
// seat is spent, so the cap is asked here for the same reason
// AccountRequestsController::approve() asks it one screen over.
// Inside the branch, not above it: an installation at its cap must
// still be able to rename a client it already has.
if ($client->account_requested && $validated['active']) {
$this->seats->guardClient('active');
$client->account_requested = false;
}
@@ -219,9 +306,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 +318,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.'));
}
@@ -10,6 +10,7 @@ use App\Modules\Comments\GuestCommentIdentity;
use App\Modules\Comments\Models\FileComment;
use App\Modules\Files\Access\ShareTargets;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Access\ViewableFileScope;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Modules\Identity\UserType;
@@ -51,6 +52,7 @@ class VisibleCommentScope
{
public function __construct(
private readonly StaffLibraryScope $scope,
private readonly ViewableFileScope $viewable,
private readonly ShareTargets $shareTargets,
private readonly GuestCommentIdentity $guests,
) {}
@@ -68,6 +70,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.
@@ -79,6 +125,13 @@ class VisibleCommentScope
* way around the visibility model** — moderating means deciding about
* comments you can already see.
*
* Which is why the files come from ViewableFileScope rather than from
* StaffLibraryScope: FilePolicy::view() is a permission half AND a
* library half, and narrowing by the library alone would hand every
* comment in the installation to a role holding moderate_comments and
* none of the three file keys — somebody who gets a 403 on every file
* these comments are about.
*
* Staff only. A client has no cross-file view of comments and asking
* for one is a mistake rather than an empty result, but returning
* nothing is the safe way to be wrong.
@@ -92,7 +145,7 @@ class VisibleCommentScope
}
return $this->applyVisibility(
FileComment::query()->whereIn('file_id', $this->scope->files($viewer)->select('files.id')),
FileComment::query()->whereIn('file_id', $this->viewable->for($viewer)->select('files.id')),
$viewer,
// Publicness is a property of each file, so it cannot be one
// value for a query spanning many. It does not have to be: the
@@ -112,6 +165,12 @@ class VisibleCommentScope
* than about what this viewer may read, and a moderator who cannot see
* a particular client's thread must still be told the file has
* something waiting.
*
* The file boundary is still the same one, though. ViewableFileScope
* rather than StaffLibraryScope: which files is the part that varies
* per client, whether any is the part that does not, and a badge
* counting the whole installation for somebody who may open none of it
* is a number about other people's files.
*/
public function pendingTotal(User $viewer): int
{
@@ -121,7 +180,7 @@ class VisibleCommentScope
return FileComment::query()
->whereNull('approved_at')
->whereIn('file_id', $this->scope->files($viewer)->select('files.id'))
->whereIn('file_id', $this->viewable->for($viewer)->select('files.id'))
->count();
}
@@ -171,25 +230,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
{
+21 -6
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')
@@ -131,14 +141,19 @@ class CommentPresenter
];
}
/**
* Asked of the column, not of the relation — the same rule
* isFromGuest() and authorName() follow. Since author() reads a
* deleted account too this would now answer correctly either way; it
* is written this way so the next reader does not re-derive "no
* author row means guest", which is what it used to mean here.
*/
private function authorType(FileComment $comment): string
{
$author = $comment->author;
if ($author === null) {
if ($comment->isFromGuest()) {
return 'guest';
}
return $author->isStaff() ? 'staff' : 'client';
return $comment->author?->isStaff() === true ? 'staff' : 'client';
}
}
+38 -3
View File
@@ -7,6 +7,8 @@ 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 App\Modules\Files\Access\ViewableFileScope;
use Illuminate\Support\Facades\Gate;
/**
@@ -20,6 +22,8 @@ class FileCommentPolicy
public function __construct(
private readonly VisibleCommentScope $scope,
private readonly CommentingRules $rules,
private readonly StaffLibraryScope $library,
private readonly ViewableFileScope $viewable,
) {}
public function view(User $user, FileComment $comment): bool
@@ -44,16 +48,47 @@ 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;
}
// Moderating is deciding about comments you can already see, so the
// permission half of file reading is part of the answer in both
// forms. Without one of the three file keys this user gets a 403 on
// every file these comments are about, and approving one hands back
// its body — so this is a reading door, not only a writing one.
if (! $this->viewable->permitsAnyFile($user)) {
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
+18 -1
View File
@@ -220,10 +220,27 @@ class FileComments
return null;
}
// Asked of the column, not of the relation. client_context_id is
// cascadeOnDelete, but a user is soft-deleted, so the cascade
// never fires: the column goes on pointing at a row that is still
// there while the relation resolves to null. Branching on the
// relation therefore read "this is Alice's conversation" as "this
// has no conversation" — and a null context on a Clients comment
// is the branch every client on the file reads (see
// VisibleCommentScope's opening rule). A private reply became a
// circular, and canAssignClient below was skipped on the way.
if ($replyTo->client_context_id === null) {
return null;
}
$client = $replyTo->clientContext;
if ($client === null) {
return null;
// The column points at somebody, and that somebody is gone.
// There is nobody to answer, and the one outcome that must
// not follow from a filled column is the broadcast above, so
// this refuses rather than falling through to it.
throw new AuthorizationException('You cannot reply in this conversation.');
}
if (! $this->library->canAssignClient($author, $client)) {
@@ -8,7 +8,7 @@ use App\Http\Controllers\Controller;
use App\Modules\Comments\FileComments;
use App\Modules\Comments\Http\Resources\Api\FileCommentResource;
use App\Modules\Comments\Models\FileComment;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Access\ViewableFileScope;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Gate;
@@ -30,16 +30,17 @@ class CommentModerationController extends Controller
{
public function __construct(
private readonly FileComments $comments,
private readonly StaffLibraryScope $library,
private readonly ViewableFileScope $viewable,
) {}
/**
* List comments awaiting approval.
*
* Scoped by the same library boundary as everything else: a
* client-scoped token sees pending comments only on files its owner
* could already open. Oldest first, so working through the list means
* working through the backlog.
* Scoped by the same file boundary as everything else — the whole of
* it, not just its library half: a client-scoped token sees pending
* comments only on files its owner could already open, and a token
* whose owner holds no file key at all sees none. Oldest first, so
* working through the list means working through the backlog.
*/
public function index(Request $request): AnonymousResourceCollection
{
@@ -49,7 +50,7 @@ class CommentModerationController extends Controller
$pending = FileComment::query()
->whereNull('approved_at')
->whereIn('file_id', $this->library->files($viewer)->select('id'))
->whereIn('file_id', $this->viewable->for($viewer)->select('id'))
->with(['author', 'clientContext'])
->orderBy('created_at')
->orderBy('id')
@@ -70,9 +71,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);
@@ -150,15 +147,17 @@ class CommentsController extends Controller
];
}
/**
* See CommentPresenter::authorType(): asked of the column, because
* that is what decides whether a comment is a guest's.
*/
private function authorType(FileComment $comment): string
{
$author = $comment->author;
if ($author === null) {
if ($comment->isFromGuest()) {
return 'guest';
}
return $author->isStaff() ? 'staff' : 'client';
return $comment->author?->isStaff() === true ? 'staff' : 'client';
}
/**
@@ -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),
);
}
/**
+38 -5
View File
@@ -76,11 +76,30 @@ class FileComment extends Model
}
/**
* The account that wrote this comment, deleted or not.
*
* `author_id` is cascadeOnDelete and the cascade never fires, because
* a user is soft-deleted: the row behind a deleted commenter is still
* there and the column still points at it. Handing back null for one
* left every caller to invent a meaning for the absence, and they
* invented different ones — the author type became "guest" on two
* screens and "client" in the API, while the name beside it stayed
* correct, and the author filter and the name search stopped matching
* the comment at all.
*
* Whether a comment is from a guest is decided by `author_id` alone.
* isFromGuest() and authorName() already say so; this makes the
* relation agree with them.
*
* Nothing that decides who may *read* a comment goes through here —
* VisibleCommentScope and FileCommentPolicy both compare `author_id`
* directly — so this widens no visibility.
*
* @return BelongsTo<User, $this>
*/
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'author_id');
return $this->belongsTo(User::class, 'author_id')->withTrashed();
}
/**
@@ -113,18 +132,32 @@ class FileComment extends Model
* The name to show. Snapshotted for guests at write time; read live
* for accounts so a rename is reflected everywhere at once.
*
* author_id cascades on delete, so a row that has one always has the
* account behind it — there is no deleted-author case to snapshot
* against, unlike the activity log's actor_name.
* A deleted account is still read. author_id cascades on delete, but
* a user is soft-deleted and the cascade never fires, so the row
* behind a deleted commenter is still there — and reading it through
* the plain relation returned null, which sent a named client's
* comment out as "Anonymous". That is what a guest comment looks
* like, and a guest comment is governed by different rules; the two
* must not be able to look the same. Whether the author is a guest is
* decided by author_id alone, which is also what isFromGuest() asks.
*/
public function authorName(): string
{
if ($this->author_id === null) {
return $this->guest_name ?? (string) __('Anonymous');
}
$author = $this->author;
if ($author !== null) {
return $author->name;
}
return $this->guest_name ?? (string) __('Anonymous');
// Trashed: the row is still there, the relation simply will not
// hand it over. Nothing comes back only once the grace-period
// erasure has removed the row for real.
$name = $this->author()->withTrashed()->value('name');
return is_string($name) ? $name : (string) __('Anonymous');
}
}
@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Access;
use App\Models\User;
use App\Modules\Groups\Models\Group;
/**
* Whether a viewer may be told who a client is.
*
* A different question from whether they may read a file, and the gap
* between the two is the whole reason this exists. A stranger client's
* upload can sit legitimately inside a client-scoped staff member's
* library — shared with a group one of their own clients belongs to, or
* assigned to one of their clients alongside somebody else's. The file is
* theirs to read. The other client's name is not theirs to see.
*
* Commit 12a8ebe3 said exactly that while fixing one dashboard widget, and
* then the rule stayed in that widget. Every other place that serialises a
* file went on publishing the uploader and each recipient by name, so a
* manager assigned to one client could read the names and ids of clients
* on nobody's roster but their own out of ordinary file metadata. That is
* what this class ends: one statement of the rule, asked by every surface
* that names a client.
*
* Two things it deliberately is not:
*
* - It is not a download check. The file boundary is StaffLibraryScope's
* and FilePolicy's, and it is already correct — a file belonging only
* to a client off the roster is a 403 today. This narrows what a
* permitted response is allowed to say, nothing more.
* - It is not applied to staff. A colleague's name is not a client
* identity, and hiding it would hide who uploaded most of the library
* from the people who work in it.
*
* Unscoped staff are unaffected: they may identify everyone, which is what
* `null` means everywhere StaffLibraryScope answers this shape of question.
*/
class ClientIdentityScope
{
/**
* Memoised per viewer, since the listings ask once per row and each
* miss is a roster query. Registered as `scoped`, so this lasts a
* request and is dropped between queue jobs — the same lifetime, and
* for the same reason, as StaffLibraryScope's own memo.
*
* @var array<int, list<int>|null>
*/
private array $clientIds = [];
/** @var array<int, list<int>|null> */
private array $groupIds = [];
public function __construct(private readonly StaffLibraryScope $scope) {}
/**
* Whether $viewer may be told that $subject exists, and what they are
* called.
*
* A null subject is permitted: there is no identity to leak, and every
* caller here is reading an optional relation.
*/
public function permits(?User $viewer, ?User $subject): bool
{
if ($subject === null) {
return true;
}
if (! $subject->isClient()) {
return true;
}
if ($viewer === null) {
return false;
}
if ($viewer->is($subject)) {
return true;
}
$ids = $this->identifiableClientIds($viewer);
return $ids === null || in_array($subject->id, $ids, true);
}
/**
* The same question about a client known only by id — used where a
* caller has a foreign key rather than a loaded model.
*
* An id that belongs to nobody, or to a staff member, is permitted:
* there is no client identity behind it to protect.
*/
public function permitsClientId(?User $viewer, ?int $id): bool
{
if ($id === null) {
return true;
}
return $this->permits($viewer, User::query()->find($id));
}
/**
* Whether $viewer may be told a group exists.
*
* A group is a list of clients wearing one name, so naming one to
* somebody who may reach none of its members says the same thing
* naming a client would. The set is StaffLibraryScope's
* assignableGroupIds — every group holding at least one of the
* viewer's own clients.
*/
public function permitsGroupId(?User $viewer, ?int $id): bool
{
if ($id === null) {
return true;
}
if ($viewer === null) {
return false;
}
$ids = $this->identifiableGroupIds($viewer);
return $ids === null || in_array($id, $ids, true);
}
/**
* A client's name, or null when this viewer may not be told it.
*
* Null rather than a placeholder on purpose: every consumer of these
* fields already renders "no uploader recorded" for a null, because a
* deleted account leaves one behind. Inventing a "Hidden" string would
* be a new thing for sixteen locales to translate and would itself
* announce that there is somebody there to hide.
*/
public function nameOf(?User $viewer, ?User $subject): ?string
{
return $this->permits($viewer, $subject) ? $subject?->name : null;
}
/**
* Drop the entries this viewer may not be told about from a list of
* id/name pairs describing clients.
*
* @param list<array{id: int, name: string}> $pairs
* @return list<array{id: int, name: string}>
*/
public function filterClientPairs(?User $viewer, array $pairs): array
{
if ($this->identifiableClientIds($viewer) === null) {
return $pairs;
}
return array_values(array_filter(
$pairs,
fn (array $pair): bool => $this->permitsClientId($viewer, $pair['id']),
));
}
/**
* @param list<array{id: int, name: string}> $pairs
* @return list<array{id: int, name: string}>
*/
public function filterGroupPairs(?User $viewer, array $pairs): array
{
if ($this->identifiableGroupIds($viewer) === null) {
return $pairs;
}
return array_values(array_filter(
$pairs,
fn (array $pair): bool => $this->permitsGroupId($viewer, $pair['id']),
));
}
/**
* Both halves of a `shares` payload at once, since the two lists are
* always filtered together.
*
* @param array{clients: list<array{id: int, name: string}>, groups: list<array{id: int, name: string}>} $shares
* @return array{clients: list<array{id: int, name: string}>, groups: list<array{id: int, name: string}>}
*/
public function filterShares(?User $viewer, array $shares): array
{
return [
'clients' => $this->filterClientPairs($viewer, $shares['clients']),
'groups' => $this->filterGroupPairs($viewer, $shares['groups']),
];
}
/**
* Whether this viewer is narrowed at all. Callers use it to skip
* per-row work for the common unscoped case.
*/
public function isNarrowed(?User $viewer): bool
{
return $viewer === null || $this->identifiableClientIds($viewer) !== null;
}
/**
* @return list<int>|null
*/
private function identifiableClientIds(?User $viewer): ?array
{
if ($viewer === null) {
return [];
}
// Deliberately the same set as "who may I share with". A client on
// the roster is one this viewer already works with by name; a
// client off it is one they have no business knowing exists.
return $this->clientIds[$viewer->id] ??= $this->scope->assignableClientIds($viewer);
}
/**
* @return list<int>|null
*/
private function identifiableGroupIds(?User $viewer): ?array
{
if ($viewer === null) {
return [];
}
return $this->groupIds[$viewer->id] ??= $this->scope->assignableGroupIds($viewer);
}
}
+30 -2
View File
@@ -28,13 +28,25 @@ use Illuminate\Support\Collection;
*/
class ShareTargets
{
public function __construct(private readonly StaffLibraryScope $scope) {}
public function __construct(
private readonly StaffLibraryScope $scope,
private readonly ClientIdentityScope $identity,
) {}
/**
* The clients and groups a subject is already shared with, as id/name
* pairs. Neutral keys, so callers can nest it ('shares' on the details
* panel) or flatten it (the edit pages' assigned_* props).
*
* **This is the unfiltered truth, and it is not what a screen should
* show.** Everyone a file is really in front of is the right answer for
* deciding something — VisibleCommentScope resolves notification
* recipients from it, and a recipient left out of that list is one who
* never hears about a message addressed to them. It is the wrong answer
* for telling somebody, because a client-scoped viewer may hold a file
* that is also shared with a client they have no business knowing
* exists. Anything rendering these names wants assignedFor() below.
*
* @return array{clients: list<array{id: int, name: string}>, groups: list<array{id: int, name: string}>}
*/
public function assigned(File|Folder $subject): array
@@ -47,6 +59,17 @@ class ShareTargets
];
}
/**
* assigned(), narrowed to the recipients this viewer may be told
* about. The display half of the pair — see the warning above.
*
* @return array{clients: list<array{id: int, name: string}>, groups: list<array{id: int, name: string}>}
*/
public function assignedFor(File|Folder $subject, ?User $viewer): array
{
return $this->identity->filterShares($viewer, $this->assigned($subject));
}
/**
* The assigned lists plus everything still available to share with,
* narrowed to what this viewer is allowed to reach.
@@ -76,7 +99,12 @@ class ShareTargets
->orderBy('name')
->get();
$assigned = $this->assigned($subject);
// assignedFor, not assigned: an edit page listing a recipient this
// viewer may not identify would both name them and offer a control
// for a share the viewer cannot otherwise reach. available_* below
// was already narrowed this way; assigned_* was not, which is the
// asymmetry that made the whole panel a roster listing.
$assigned = $this->assignedFor($subject, $viewer);
return [
'assigned_clients' => $assigned['clients'],
@@ -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,179 @@ 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.
*
* An expired file is the same answer for the same reason. Membership
* in this group grants nobody access to it — File::scopeVisibleToClient
* ends in notExpired(), so it is gone from every member's /my-files and
* the download is refused — while its absence from files() otherwise
* reads as "outside my library" and locks the group exactly as a
* deleted file used to. Expiry is reversible where deletion is not, so
* the file counts as reach again the moment it does: this asks what is
* reachable now, at the moment somebody is added or removed.
*/
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)
->notExpired()
->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);
// The whole subtree, not the folder the assignment names. A folder
// shared with a group hands its members everything inside it —
// File::scopeVisibleToClient matches on folder placement, and a
// folder is visible to a client when it or an ancestor is shared
// with them — so "is anything shared with this group outside my
// library" has to ask about the contents, which is what the
// docblock above already claims ("the folders whose subtrees it
// can browse").
//
// Measured: a scoped staff member's own folder, with a subfolder
// somebody else created inside it and somebody else's file in
// that. The folder is theirs, its contents are not, and adding
// their own client to a group holding the parent handed that
// client the file — which then enters the staff member's own
// library too, because files() is "everything my clients can
// see". That is the widening this guard exists to refuse, and the
// test above it says so in as many words.
$reachable = Folder::query()->whereIn('id', $assignedFolders)->get()
->flatMap(fn (Folder $folder): array => $folder->subtreeFolderIds())
->unique()
->values()
->all();
if ($reachable === []) {
return true;
}
if (Folder::query()
->whereIn('id', $reachable)
->whereNotIn('id', $this->folders($user)->select('id'))
->exists()
) {
return false;
}
// And the files sitting in them. A folder can be inside the
// library while a file in it is not: files() is own uploads plus
// what an assigned client may see, and neither covers somebody
// else's upload into a folder this staff member happens to own.
//
// notExpired() for the same reason the assignment half above skips
// deleted files: membership in this group grants nobody access to
// an expired file, because scopeVisibleToClient ends by excluding
// them, and something nobody can reach is not reach.
return ! File::query()
->whereIn('folder_id', $reachable)
->notExpired()
->whereNotIn('id', $this->files($user)->select('id'))
->exists();
}
}
+16 -6
View File
@@ -40,15 +40,25 @@ class ViewableFileScope
return File::query()->visibleToClient($user);
}
// Mirrors FilePolicy::view()'s staff branch: the permission half is
// a property of the viewer, not the row, so it either opens the
// whole scope or closes it entirely.
$permitted = $user->can('upload') || $user->can('edit_files') || $user->can('edit_others_files');
if (! $permitted) {
if (! $this->permitsAnyFile($user)) {
return File::query()->whereRaw('1 = 0');
}
return $this->scope->files($user);
}
/**
* Whether a staff member holds any of the three keys that open file
* reading at all — the permission half of FilePolicy::view()'s staff
* branch, named once because more than one module has to ask it.
*
* It is a property of the viewer rather than of a row, so it either
* opens the whole scope or closes it entirely. That is also why a
* query narrowed by StaffLibraryScope alone is only half the check:
* the library says *which* files, this says *whether any*.
*/
public function permitsAnyFile(User $user): bool
{
return $user->can('upload') || $user->can('edit_files') || $user->can('edit_others_files');
}
}
@@ -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,55 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Delivery;
/**
* How a file's bytes get from this server's disk to the visitor.
*
* Uploads live outside the web root, so every download passes through a
* permission check in PHP first. What differs is what happens after that
* check passes: PHP can read the file and write it out itself, or it can
* answer with an empty body and a header telling the web server to send
* the file instead.
*
* The header is the fast path and it is not portable — each server reads
* a different one, and a server reading none of them serves the empty
* body, which is how an installation ends up handing out 0-byte
* downloads while every other page works. ProjectSend v1 had this as a
* four-way setting with PHP as the default; v2 hard-coded nginx's
* spelling for its first releases, which is
* https://github.com/projectsend/projectsend/issues/1765.
*/
enum DeliveryMethod: string
{
/**
* nginx: `X-Accel-Redirect`, carrying a *URL path* that the
* `location /protected-files/` block maps back onto the storage
* directory. That block is marked `internal`, which is what stops a
* visitor requesting the path directly.
*/
case Nginx = 'nginx';
/**
* Apache with `mod_xsendfile`, and LiteSpeed, which reads the same
* header: `X-Sendfile`, carrying an *absolute filesystem path*.
*
* Never chosen automatically. The module also needs `XSendFilePath`
* to whitelist the storage directory, and there is no way to detect
* that from here — picking this on the strength of the module being
* loaded would trade one silent failure for another.
*/
case XSendFile = 'xsendfile';
/**
* PHP reads the file and streams it.
*
* Works on every server, and costs a worker process for the duration
* of each download — a handful of large concurrent downloads can
* occupy every worker while the CPU sits idle. That is why it is the
* fallback rather than the default, and why an installation using it
* says so on the dashboard rather than being quietly slow.
*/
case Php = 'php';
}
+251
View File
@@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Delivery;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* Puts a file that lives on this server's local disk on the wire.
*
* The single place that knows how the bytes travel. Four routes used to
* decide that for themselves and all four hard-coded nginx's header, so
* an Apache or LiteSpeed installation served four different flavours of
* empty response — uploads worked, thumbnails were broken images, and
* downloads arrived as 0 bytes. Callers now say *what* to send and this
* decides *how*.
*
* It authorizes nothing. Every caller has already done that its own way
* — a policy, a share token, a public-listing check — and the path it
* passes is always derived from a row it just authorized, never from the
* request. That is load-bearing: `serve()` will send any file under the
* storage root, so a caller that passed user input would have built a
* file-disclosure bug. The root check below is the backstop, not the
* rule.
*
* ### Choosing the method
*
* `PROJECTSEND_FILE_DELIVERY` picks one explicitly. Left at `auto` — the
* default — nginx gets its own fast path and everything else gets PHP
* streaming.
*
* Auto deliberately never chooses `xsendfile`. Apache's `mod_xsendfile`
* needs `XSendFilePath` to whitelist the storage directory as well as
* being loaded, and nothing here can see whether it does; choosing it
* because the module is present would swap a silent failure anybody can
* diagnose from the dashboard for one nobody can. So it stays something
* an operator turns on having configured it.
*
* A value that is not a method falls back to auto rather than throwing.
* A typo in an environment variable should cost speed, not every
* download on the installation.
*/
class FileDelivery
{
/**
* The disk uploads live on. Named rather than injected because the
* whole class is about the local-disk case: a file on S3 never
* reaches here, it is a signed redirect from StoredFileResponse.
*/
private const DISK = 'files';
/** The internal nginx location that maps back onto the storage root. */
private const NGINX_LOCATION = '/protected-files/';
public function __construct(private readonly Request $request) {}
/**
* The method in force, and whether it was detected or stated.
*
* @return array{method: DeliveryMethod, detected: bool}
*/
public function resolve(): array
{
$configured = config('projectsend.file_delivery');
$explicit = is_string($configured) ? DeliveryMethod::tryFrom($configured) : null;
if ($explicit !== null) {
return ['method' => $explicit, 'detected' => false];
}
return ['method' => $this->detect(), 'detected' => true];
}
public function method(): DeliveryMethod
{
return $this->resolve()['method'];
}
/**
* The same answer as a plain array, for a screen or a probe.
*
* Spelled out rather than leaning on a backed enum encoding itself,
* because this shape is read by the dashboard and by whatever watches
* the installation from outside, and neither should change meaning if
* the enum ever grows a JsonSerializable of its own.
*
* @return array{method: string, detected: bool}
*/
public function describe(): array
{
$resolved = $this->resolve();
return [
'method' => $resolved['method']->value,
// True when nobody said which to use. The distinction matters
// to the reader: a detected `php` is an installation that
// could be faster, a stated one is somebody's decision.
'detected' => $resolved['detected'],
];
}
/**
* What the server says it is.
*
* `SERVER_SOFTWARE` is set by the web server itself through the
* FastCGI parameters, so it describes the process actually holding
* the connection to PHP. That is the right thing to ask: the header
* has to be understood by *that* server, not by whatever sits in
* front of it.
*
* The known-wrong case is nginx reverse-proxying Apache, which
* INSTALL.md offers as a way to keep an existing Apache. This reads
* Apache and picks PHP streaming, so downloads work and are slower
* than they need to be — the safe direction, and the reason the
* override exists.
*/
private function detect(): DeliveryMethod
{
$software = $this->request->server('SERVER_SOFTWARE');
$software = strtolower(is_string($software) ? $software : '');
return str_contains($software, 'nginx') ? DeliveryMethod::Nginx : DeliveryMethod::Php;
}
/**
* @param string $path disk-relative, and always derived from an
* already-authorized row — never from the request
* @param int|null $length when the caller already knows it; PHP
* streaming ignores it and measures the file
*/
public function serve(string $path, string $mimeType, string $disposition, ?int $length = null): Response|BinaryFileResponse
{
$this->assertRelative($path);
$headers = array_filter([
'Content-Type' => $mimeType,
'Content-Disposition' => $disposition,
'Content-Length' => $length === null ? null : (string) $length,
], static fn (?string $value): bool => $value !== null);
return match ($this->method()) {
DeliveryMethod::Nginx => response('', 200, [
'X-Accel-Redirect' => self::NGINX_LOCATION.$path,
...$headers,
]),
DeliveryMethod::XSendFile => response('', 200, [
// An absolute filesystem path, unlike nginx's URL path.
// Renaming the header without changing the value is the
// obvious way to "add Apache support" and produces a
// second broken install.
'X-Sendfile' => $this->absolutePathWithin($path),
...$headers,
]),
DeliveryMethod::Php => $this->stream($this->absolutePathWithin($path), $headers),
};
}
/**
* @param array<string, string> $headers
*/
private function stream(string $absolute, array $headers): BinaryFileResponse
{
// A large download can outlive max_execution_time, and the visitor
// sees a truncated file rather than an error. The web server is
// not holding this one open for us.
if (function_exists('set_time_limit')) {
@set_time_limit(0);
}
// BinaryFileResponse rather than a hand-written readfile loop: it
// answers Range requests, which is what makes seeking through a
// long video work. nginx does that for itself on the fast path, so
// rolling our own here would break preview scrubbing on exactly
// the installations this fallback exists for.
//
// Content-Length is deliberately dropped from the headers: the
// response sets its own from the file, and a caller's figure that
// disagrees — a stale `files.size`, or a range being served —
// truncates the download.
unset($headers['Content-Length']);
return new BinaryFileResponse($absolute, 200, $headers);
}
/**
* The path must stay a path *inside* the storage area.
*
* Checked for every method, and without touching the filesystem,
* because nginx resolves `..` in the URL it is handed just as
* happily as a filesystem call would -- and because every method
* puts this value into a response header. Callers pass paths from rows
* they authorized rather than from the request, so this is a
* backstop; it is here because the cost of being wrong about that,
* once, is handing over any file the web server can read.
*/
private function assertRelative(string $path): void
{
abort_if(
$path === ''
|| str_starts_with($path, '/')
|| preg_match('#(^|/)\.\.(/|$)#', $path) === 1
// A control character in the path is header injection, not
// traversal: this value is written into X-Accel-Redirect or
// X-Sendfile, and a CR or LF in a header value splits the
// response. PHP's header() refuses to emit one, so the real
// effect is a 500 on every download, preview and thumbnail
// of that file rather than a split -- a file permanently
// broken by its own name.
//
// Paths are `Y/m/{uuid}.{ext}` and generated here, so this
// should be unreachable. It is checked because the
// extension is not: it is taken from the uploader's
// filename, and on a migrated installation from a v1
// database, which is somebody else's data.
|| preg_match('/[\x00-\x1F\x7F]/', $path) === 1,
404,
);
}
/**
* The absolute path, proven to resolve inside the storage root.
*
* Only the two methods that hand over a *filesystem* path need this,
* and only they can afford it: it resolves symlinks, so it answers
* the question `assertRelative()` cannot — whether the file is really
* where the path says it is.
*
* It also requires the file to exist, which is why nginx does not go
* through it. On that path PHP never opens the file, and adding a
* stat to every download to discover something nginx is about to
* discover anyway would be a cost with no answer attached.
*/
private function absolutePathWithin(string $path): string
{
$disk = Storage::disk(self::DISK);
$absolute = realpath($disk->path($path));
$root = realpath($disk->path(''));
abort_if(
$absolute === false || $root === false || ! str_starts_with($absolute, rtrim($root, '/').'/'),
404,
);
return $absolute;
}
}
@@ -0,0 +1,101 @@
<?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\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\Response;
/**
* 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: handed to FileDelivery, which decides whether the web
* server sends the bytes or PHP does. 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. Every local
* delivery method answers those — nginx's static handler on the fast
* path, BinaryFileResponse when PHP is streaming — each dropping the
* Content-Length passed here in favour of the range actually served.
*
* The two paths are not equally revocable, which is why the lifetimes
* below differ. Every local delivery method authorises one response and
* no more — nginx's X-Accel-Redirect, Apache's X-Sendfile, or PHP
* streaming the bytes itself: these bytes, now, to this request, and
* nothing that outlives it. A presigned URL is a bearer
* credential — whoever holds it can fetch the file without passing the
* caller's checks again, and it outlives them: a download cap that is
* spent in the meantime, an expires_at that falls in between, an
* assignment that is withdrawn. Nothing here can revoke one, so the only
* dial is how long it lasts.
*
* A download needs to survive being followed, which is a redirect and a
* request: a minute is generous. A preview is held by the player for as
* long as somebody watches, and each seek outside the buffer is a fresh
* Range request against the same URL, so it keeps the hour. That is the
* trade, stated rather than left in a single number.
*
* 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
{
/**
* Long enough for a browser, a download manager or a queued transfer
* to follow the redirect and start the request. An object store
* checks the signature when the request arrives, not while it runs,
* so a transfer that begins inside this window finishes however long
* it takes.
*/
private const DOWNLOAD_LINK_SECONDS = 60;
/**
* A preview is watched, not fetched: the player holds this URL and
* issues a Range request every time somebody seeks past the buffer,
* so it has to outlive the viewing rather than the redirect.
*/
private const PREVIEW_LINK_SECONDS = 3600;
public function __construct(private readonly FileDelivery $delivery) {}
/** Shown in place — a preview. */
public function inline(File $file): Response|RedirectResponse
{
return $this->make($file, ContentDisposition::inline($file->original_name), self::PREVIEW_LINK_SECONDS);
}
/** Handed over — a download. */
public function attachment(File $file): Response|RedirectResponse
{
return $this->make($file, ContentDisposition::attachment($file->original_name), self::DOWNLOAD_LINK_SECONDS);
}
private function make(File $file, string $disposition, int $linkSeconds): Response|RedirectResponse
{
if ($file->disk !== 'files') {
$url = Storage::disk($file->disk)->temporaryUrl(
$file->path,
now()->addSeconds($linkSeconds),
['ResponseContentDisposition' => $disposition],
);
return redirect()->away($url);
}
return $this->delivery->serve($file->path, $file->mime_type, $disposition, $file->size);
}
}
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Editing;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Comments\CommentingRules;
use App\Modules\Comments\CommentScope;
use App\Modules\Files\Models\File;
/**
* The one place that decides which fields an editor may actually write.
*
* Three surfaces edit a file — the staff editor, `/api/v1/files/{file}`,
* and now a client's own uploads in the portal — and they had grown two
* copies of the same eight permission checks with a third about to be
* written. The checks are not hard; the problem is that they are *easy*,
* so a new field gets added to one caller and the drift is invisible until
* somebody finds the surface where the gate is missing.
*
* The split is deliberate: **callers normalise, this gates.** A caller
* turns its own request shape into `$changes` — form semantics versus the
* API's `sometimes`, a date string versus an instant — and this decides
* what the actor is allowed to write, writes it, and records what happened.
*
* `$changes` uses array_key_exists semantics throughout: a key that is
* absent is left alone, a key present with `null` is written as null. That
* is the API's existing contract, and the web forms post every field they
* own, so it is also the forms'.
*
* Two things deliberately do NOT live here, because they are the caller's
* and getting them wrong is how a boundary breaks:
*
* - **Whether this actor may edit this file at all.** That is
* `Gate::authorize('update', $file)` and FilePolicy. Nothing below
* re-checks it.
* - **Whether a destination folder is reachable.** Staff ask
* StaffLibraryScope; a client asks `Folder::uploadableBy()`. Those are
* different questions with the same shape, and the staff one answers
* `true` for any client — see FilePolicy::update()'s note.
*/
class ApplyFileEdits
{
public function __construct(
private readonly ActivityLogger $activity,
private readonly CommentingRules $commenting,
) {}
/**
* @param array<string, mixed> $changes only the fields the caller
* wants written; absent keys
* are left as they are
*/
public function apply(User $actor, File $file, array $changes): void
{
$attributes = [];
// Covered by the permission to edit the file at all, which the
// policy has already settled by the time anything reaches here.
foreach (['name', 'description', 'folder_id'] as $field) {
if (array_key_exists($field, $changes)) {
$attributes[$field] = $changes[$field];
}
}
// Only meaningful while the comment scope is `selected`, and only
// offered by a form then — but a request reaching here directly
// must not be able to set a flag the UI is currently hiding.
if (array_key_exists('commentable', $changes) && $this->commenting->scope() === CommentScope::SelectedFiles) {
$attributes['commentable'] = $changes['commentable'];
}
// From here down, every field has a permission of its own, and the
// rule for all of them is the same: lacking it leaves the field
// exactly as it was rather than failing the request. An editor who
// may rename a file but not publish it saves a rename, and the
// public state does not move. The web and the API have always
// behaved this way; it is why the portal can reuse both forms.
if (array_key_exists('expires_at', $changes) && $actor->can('set_file_expiration_date')) {
$attributes['expires_at'] = $changes['expires_at'];
}
if (array_key_exists('download_limit', $changes) && $actor->can('limit_downloads')) {
$attributes['download_limit'] = $changes['download_limit'];
}
if (array_key_exists('download_limit_scope', $changes) && $actor->can('limit_downloads')) {
$attributes['download_limit_scope'] = $changes['download_limit_scope'];
}
$wasPublic = $file->public;
if (array_key_exists('public', $changes) && $actor->can('upload_public')) {
$attributes['public'] = $changes['public'];
// A caller that offers the slug passes what was submitted; one
// that does not simply omits the key and gets a derived slug.
// The client portal is the second kind on purpose — an
// installation-wide unique slug chosen by a client is a name to
// squat and an existence oracle to probe, for no benefit over a
// slug made from the name they already chose.
//
// Omitting the slug on an update keeps the current one: it must
// not silently change just because the name did.
$submitted = is_string($changes['slug'] ?? null) ? trim($changes['slug']) : '';
$attributes['slug'] = $submitted !== ''
? $submitted
: ($file->slug ?: File::uniqueSlugFrom(
is_string($changes['name'] ?? null) ? $changes['name'] : $file->name,
$file->id,
));
}
$file->update($attributes);
// After the write, not inside it: categories are a relation, not a
// column. Gated by their own key, so an editor who may rename but
// not categorise leaves them untouched.
if (array_key_exists('categories', $changes) && $actor->can('set_file_categories')) {
$file->categories()->sync($changes['categories']);
}
$this->activity->log(Action::FileUpdated, subject: $file);
// Publishing and unpublishing are their own entries. A file
// becoming reachable without a login is not a detail of "file
// updated", and it is the line an audit is most likely to be read
// for.
if (! $wasPublic && $file->public) {
$this->activity->log(Action::FileMadePublic, subject: $file, context: ['slug' => $file->slug]);
} elseif ($wasPublic && ! $file->public) {
$this->activity->log(Action::FileMadePrivate, subject: $file);
}
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Editing;
use App\Models\User;
use App\Modules\Files\Models\File;
use App\Modules\Platform\Localization\LocalDay;
use App\Modules\Platform\Localization\TimezoneRegistry;
use Carbon\Carbon;
/**
* Reading and writing a file's expiry in the zone of whoever is looking.
*
* The stored value is an instant. What a person sets is a calendar day,
* and "the 12th" means the end of the 12th where *they* live — otherwise a
* file asked to expire on the 12th dies partway through the 11th for
* anyone west of Greenwich, and gives anyone east of it most of a day
* nobody promised.
*
* The two halves have to agree, which is the whole reason they sit
* together: a form is rendered with asShown() and posts the same string
* back untouched with every other edit, so a caller compares against
* asShown() to tell "the editor changed the date" from "the editor renamed
* the file and the date came along for the ride". Re-deriving on every
* save instead moves the expiry by the difference between two people's
* zones each time somebody edits anything.
*
* Was three private copies — the staff editor, the API, and now the client
* portal — of which the API's was the only one that could read a
* timestamp.
*/
class FileExpiry
{
public function __construct(
private readonly TimezoneRegistry $timezones,
) {}
/**
* The stored instant as the calendar day a form should show, in the
* viewer's zone. Null when the file never expires.
*/
public function asShown(File $file, ?User $viewer): ?string
{
return $file->expires_at?->copy()->setTimezone($this->timezones->resolve($viewer))->toDateString();
}
/**
* The instant a submitted value actually names.
*
* A bare `YYYY-MM-DD` is a calendar day and means the end of it where
* the setter is — what every date input posts. Anything carrying a
* time is an instant somebody named on purpose and is stored as it
* arrives: the API can express a moment, and a date input cannot.
*/
public function instant(?string $value, ?User $setter): ?Carbon
{
if ($value === null) {
return null;
}
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1
? LocalDay::end($value, $this->timezones->resolve($setter))
: Carbon::parse($value);
}
}
+27 -5
View File
@@ -24,15 +24,37 @@ class FileDiskCleanup
{
public function delete(File $file): void
{
try {
Storage::disk($file->disk)->delete($file->path);
$this->attempt($file, fn () => Storage::disk($file->disk)->delete($file->path));
// Every rendition, for every audience — a deleted file's bytes
// must not survive on disk because whoever wrote the cleanup
// only knew about the one copy they had in mind.
// Every rendition, for every audience — a deleted file's bytes must
// not survive on disk because whoever wrote the cleanup only knew
// about the one copy they had in mind.
//
// Attempted separately from the original above, not because the two
// are unrelated but because they are on different disks: renditions
// are always local, and Storage::disk() throws outright for a name
// with no configured driver — which is exactly the state the
// original's disk is in when this fails at all. Sharing one `try`
// meant a file whose source disk had been removed kept every cached
// copy of itself, and nothing looks for those again:
// OrphanFileScanner skips the rendition directories on purpose.
$this->attempt($file, function () use ($file): void {
foreach (ThumbnailGenerator::pathsFor($file->id, $file->mime_type) as $renditionPath) {
Storage::disk('files')->delete($renditionPath);
}
});
}
/**
* Deliberately tolerant, as the class docblock says: the warning is the
* whole report. Nothing else will find these bytes -- the row is
* soft-deleted, and OrphanFileScanner::knownPaths() counts a trashed
* row's path as claimed, so a scan never lists it.
*/
private function attempt(File $file, callable $work): void
{
try {
$work();
} catch (Throwable $exception) {
Log::warning('Could not remove disk bytes for deleted file '.$file->id.': '.$exception->getMessage());
}
+31 -5
View File
@@ -11,9 +11,15 @@ use App\Modules\Files\Models\File;
/**
* Ownership rules as policy methods (brief §6.13): "own" versus
* "others'" files map onto the v1 permission pairs. Clients may only
* view/download what is assigned to them, directly or via a group. For
* client-scoped staff, every action is additionally gated by the
* StaffLibraryScope, so direct access can't reach out-of-scope files.
* view/download what is assigned to them, directly or via a group, and may
* edit or delete only what they uploaded themselves. For client-scoped
* staff, every action is additionally gated by the StaffLibraryScope, so
* direct access can't reach out-of-scope files.
*
* Every method here branches on isStaff() before it reaches the scope.
* That is not stylistic: StaffLibraryScope answers "is this *restricted*
* staff member allowed?", and its "no restriction" answer is `true`. A
* client falling through to it is handed the whole library. See update().
*/
class FilePolicy
{
@@ -33,8 +39,25 @@ class FilePolicy
public function update(User $user, File $file): bool
{
// A client edits what they uploaded and nothing else. Deliberately
// its own branch rather than a shared one, because the staff branch
// below is unsafe for a client in two ways at once.
//
// First, `edit_others_files` must never be reachable here. It is a
// staff key by construction: a client has no "others' files" they
// could hold a legitimate claim over, only files somebody shared
// with them, and being shown a file is not being given it. Granting
// that key to the Client role does nothing, and a test pins that.
//
// Second, and the trap: StaffLibraryScope::allowsFile() returns
// true outright for anyone who is not client-*scoped* staff —
// User::isClientScoped() is `isStaff() && role->client_scoped`, so
// it is false for every client. That predicate means "this staff
// member is unrestricted", and a client reaching it would inherit
// "unrestricted" over the whole library. Nothing here may touch the
// staff scope.
if (! $user->isStaff()) {
return false;
return $file->isOwnedBy($user) && $user->can('edit_files');
}
$permitted = $file->isOwnedBy($user) ? $user->can('edit_files') : $user->can('edit_others_files');
@@ -72,8 +95,11 @@ class FilePolicy
public function delete(User $user, File $file): bool
{
// Their own upload, and only with the key — same two reasons as
// update() above, `delete_others_files` standing in for
// `edit_others_files`.
if (! $user->isStaff()) {
return false;
return $file->isOwnedBy($user) && $user->can('delete_files');
}
$permitted = $file->isOwnedBy($user) ? $user->can('delete_files') : $user->can('delete_others_files');
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Modules\Files;
use App\Modules\Files\Access\ClientIdentityScope;
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 +22,21 @@ 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);
// Same lifetime, same reason: the identity rule memoises a roster
// per viewer and the file listings ask it once per row.
$this->app->scoped(ClientIdentityScope::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.'));
}
}
@@ -10,10 +10,12 @@ use App\Modules\Api\Support\PollingQuery;
use App\Modules\Audit\Action;
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\ClientIdentityScope;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Access\ViewableFileScope;
use App\Modules\Files\DownloadLimitScope;
use App\Modules\Files\Editing\ApplyFileEdits;
use App\Modules\Files\Editing\FileExpiry;
use App\Modules\Files\Http\Resources\Api\FileResource;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
@@ -56,7 +58,10 @@ class FilesController extends Controller
private readonly UploadExtensionPolicy $extensionPolicy,
private readonly ClientStorageUsage $storageUsage,
private readonly ActivityLogger $activity,
private readonly CommentingRules $commenting,
private readonly StaffLibraryScope $scope,
private readonly ClientIdentityScope $identity,
private readonly ApplyFileEdits $fileEdits,
private readonly FileExpiry $expiry,
) {}
/**
@@ -108,6 +113,17 @@ class FilesController extends Controller
}
if (array_key_exists('uploaded_by', $filters) && $filters['uploaded_by'] !== null) {
// A filter is a question, and this one asks "did client N put
// anything into my library". Answered plainly it is an oracle:
// a client-scoped caller could walk the id space and learn
// which clients off their roster share files with clients on
// it, without ever reading a name. So an id this caller may
// not identify matches nothing — indistinguishable from a
// client who has uploaded nothing, which is the point.
if (! $this->identity->permitsClientId($user, (int) $filters['uploaded_by'])) {
$query->whereRaw('1 = 0');
}
$query->where('files.uploaded_by', $filters['uploaded_by']);
}
@@ -128,9 +144,12 @@ class FilesController extends Controller
}
// Expiry is a filter, not a default: staff see expired files in the
// UI too (that is how they notice and act on them). Only the client
// branch of the visibility rules drops them, and it does so inside
// ViewableFileScope where it belongs.
// UI too (that is how they notice and act on them). Dropping them
// is the client branch's rule, applied inside the visibility scopes
// where it belongs — which is also why a client-scoped caller does
// not get their clients' expired files back here whatever this
// filter says: their library is built on that same branch. See
// File::isExpired.
if ($request->has('expired') && ($filters['expired'] ?? null) !== null) {
$request->boolean('expired') ? $query->expired() : $query->notExpired();
}
@@ -174,7 +193,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 */
@@ -261,6 +280,12 @@ class FilesController extends Controller
* without the matching permission leaves that field untouched rather
* than failing the whole request, which mirrors the web interface.
*
* `expires_at` accepts either a calendar day (`2026-09-12`) or a full
* timestamp. A day means the end of that day in the caller's timezone,
* which is what the same value means on the web and what the file's
* own `expires_at` reads back as; a timestamp is taken as the instant
* it names.
*
* `commentable` only has an effect while the installation's comment
* setting is "only files marked as commentable"; under any other
* setting it is ignored, again rather than failing.
@@ -275,7 +300,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,44 +311,47 @@ class FilesController extends Controller
'download_limit_scope' => ['sometimes', Rule::enum(DownloadLimitScope::class)],
]);
$attributes = array_intersect_key($validated, array_flip(['name', 'description', 'folder_id']));
// 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 (array_key_exists('expires_at', $validated) && $user->can('set_file_expiration_date')) {
$attributes['expires_at'] = $validated['expires_at'];
if ($validated['folder_id'] !== $file->folder_id) {
$this->scope->folders($user)->findOrFail($validated['folder_id']);
}
}
if (array_key_exists('download_limit', $validated) && $user->can('limit_downloads')) {
$attributes['download_limit'] = $validated['download_limit'];
// `sometimes` throughout the rules above means $validated already
// holds exactly the fields the caller sent, which is the same
// array_key_exists contract ApplyFileEdits reads — so the payload
// passes through almost untouched. Which of them this token's user
// may actually write is that class's decision, shared with the
// staff editor and the client portal.
$changes = array_intersect_key($validated, array_flip([
'name',
'description',
'folder_id',
'commentable',
'download_limit',
'download_limit_scope',
'public',
'slug',
'categories',
]));
// The one field that needs converting rather than passing along: a
// caller may send a calendar day or a full timestamp, and a day
// means the end of that day where the caller is.
if (array_key_exists('expires_at', $validated)) {
$changes['expires_at'] = $this->expiry->instant($validated['expires_at'], $user);
}
if (array_key_exists('download_limit_scope', $validated) && $user->can('limit_downloads')) {
$attributes['download_limit_scope'] = $validated['download_limit_scope'];
}
if (array_key_exists('commentable', $validated) && $this->commenting->scope() === CommentScope::SelectedFiles) {
$attributes['commentable'] = $validated['commentable'];
}
$wasPublic = $file->public;
if (array_key_exists('public', $validated) && $user->can('upload_public')) {
$attributes['public'] = $validated['public'];
$attributes['slug'] = ($validated['slug'] ?? '') ?: ($file->slug ?: File::uniqueSlugFrom($validated['name'] ?? $file->name, $file->id));
}
$file->update($attributes);
if (array_key_exists('categories', $validated) && $user->can('set_file_categories')) {
$file->categories()->sync($validated['categories']);
}
$this->activity->log(Action::FileUpdated, subject: $file);
if (! $wasPublic && $file->public) {
$this->activity->log(Action::FileMadePublic, subject: $file, context: ['slug' => $file->slug]);
} elseif ($wasPublic && ! $file->public) {
$this->activity->log(Action::FileMadePrivate, subject: $file);
}
$this->fileEdits->apply($user, $file, $changes);
return new FileResource($file->fresh()?->load(['folder', 'uploader', 'categories']) ?? $file);
}
@@ -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'],
]);
@@ -96,7 +98,14 @@ class ChunkedUploadsController extends Controller
if ($quotaBytes > 0 && $this->storageUsage->usedBytes($user) + (int) $validated['size'] > $quotaBytes) {
throw ValidationException::withMessages([
'size' => __('This upload would exceed your storage quota of :quota MB.', ['quota' => (string) $user->storage_quota_mb]),
'size' => __('This upload would exceed your storage quota of :quota MB.', [
// The resolved quota, not the column: a client who
// was never given one of their own carries 0 there
// and inherits the site default, so printing the
// column reads "your storage quota of 0 MB" at the
// moment somebody is asking what their limit is.
'quota' => (string) $this->storageUsage->quotaMb($user),
]),
]);
}
}
@@ -240,6 +249,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 +285,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
@@ -261,7 +313,9 @@ class ChunkedUploadsController extends Controller
$session->delete();
throw ValidationException::withMessages([
'size' => __('This upload would exceed your storage quota of :quota MB.', ['quota' => (string) $user->storage_quota_mb]),
'size' => __('This upload would exceed your storage quota of :quota MB.', [
'quota' => (string) $this->storageUsage->quotaMb($user),
]),
]);
}
}
@@ -272,6 +326,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 +351,7 @@ class ChunkedUploadsController extends Controller
size: $assembled['size'],
checksum: $assembled['checksum'],
description: $session->description,
folderId: $session->folder_id,
folderId: $folderId,
disk: $assembled['disk'],
);
@@ -6,6 +6,7 @@ namespace App\Modules\Files\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Files\Access\ClientIdentityScope;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Models\Category;
use App\Modules\Files\Models\File;
@@ -28,6 +29,7 @@ class ClientFilesController extends Controller
{
public function __construct(
private readonly StaffLibraryScope $scope,
private readonly ClientIdentityScope $identity,
) {}
public function index(Request $request, User $client): Response
@@ -66,7 +68,11 @@ class ClientFilesController extends Controller
'size' => $file->size,
'created_at' => $file->created_at?->toIso8601String(),
'uploaded_by_client' => $file->uploaded_by === $client->id,
'uploader' => $file->uploader?->name,
// Being allowed to browse this client's files does not
// extend to the other clients who shared files with them:
// a file reaches this listing through the client in the
// URL, and its uploader can be somebody else entirely.
'uploader' => $this->identity->nameOf($viewer, $file->uploader),
'downloads_count' => $file->downloads_count,
'can_download' => Gate::forUser($viewer)->allows('view', $file),
'categories' => $file->categories->map(fn (Category $category): array => [
@@ -0,0 +1,61 @@
<?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\Files\Delivery\FileDelivery;
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,
private readonly FileDelivery $delivery,
) {}
public function edit(): Response
{
return Inertia::render('system/settings/downloads', [
'max_zip_download_size_mb' => $this->settings->get(Setting::MaxZipDownloadSizeMb),
// Not a setting, and shown here because this is where somebody
// coming from v1 looks for one: v1 had a "Download method"
// dropdown on its uploads options screen. It is an environment
// variable now rather than a stored setting, because it
// describes the server the installation is running on rather
// than a preference — a value in the database can be restored
// onto a different server and be wrong there.
'file_delivery' => $this->delivery->describe(),
]);
}
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,11 +5,13 @@ 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;
use App\Modules\Audit\DownloadPresenter;
use App\Modules\Comments\CommentingRules;
use App\Modules\Files\Access\ClientIdentityScope;
use App\Modules\Files\Access\DownloadAllowance;
use App\Modules\Files\Access\ShareTargets;
use App\Modules\Files\DownloadLimitScope;
@@ -18,10 +20,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,13 +41,50 @@ 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,
private readonly ShareTargets $shareTargets,
private readonly ClientIdentityScope $identity,
private readonly CommentingRules $commenting,
private readonly FileVersionLinks $versionLinks,
private readonly DownloadAllowance $allowance,
private readonly TimezoneRegistry $timezones,
) {}
public function show(Request $request, File $file): JsonResponse
@@ -58,7 +102,10 @@ class FileDetailsController extends Controller
'size' => $file->size,
'mime_type' => $file->mime_type,
'checksum' => $file->checksum,
'uploader' => $file->uploader?->name,
// Null when the uploader is a client this viewer may not
// be told about, which reads the same as an uploader whose
// account has since been deleted.
'uploader' => $this->identity->nameOf($viewer, $file->uploader),
'folder' => $file->folder?->only('id', 'name'),
'categories' => $file->categories()->orderBy('name')->get()
->map(fn (Category $category): array => ['id' => $category->id, 'name' => $category->name, 'color' => $category->color])
@@ -98,7 +145,7 @@ class FileDetailsController extends Controller
// Resolved from the chain root for a revision (ShareTargets
// does that), so this names who really has the file. The panel
// says where those recipients are set.
'shares' => $this->shareTargets->assigned($file),
'shares' => $this->shareTargets->assignedFor($file, $viewer),
'sharing_root' => $file->isRevision()
? File::query()->find($file->sharingOwnerId())?->only('id', 'name')
: null,
@@ -136,6 +183,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 +254,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 +287,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();
@@ -259,7 +373,7 @@ class FileDetailsController extends Controller
'name' => $folder->name,
'files_count' => $folder->files()->count(),
'children_count' => $folder->children()->count(),
'creator' => $folder->creator?->name,
'creator' => $this->identity->nameOf($viewer, $folder->creator),
'created_at' => $folder->created_at?->toIso8601String(),
'open_url' => route('files.index', ['folder' => $folder->id], false),
// Read-only here, same as a file's shares — sharing (and every
@@ -268,7 +382,7 @@ class FileDetailsController extends Controller
'edit_url' => route('folders.share', $folder, false),
'can_update' => Gate::forUser($viewer)->allows('update', $folder),
'can_view_activity' => $viewer->can('view_actions_log'),
'shares' => $this->shareTargets->assigned($folder),
'shares' => $this->shareTargets->assignedFor($folder, $viewer),
]);
}
@@ -304,15 +418,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 +461,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,27 @@ 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;
use Symfony\Component\HttpFoundation\Response;
/**
* 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: the app checks the policy, and StoredFileResponse
* decides how the bytes travel — a presigned URL when the file lives on
* external storage, and otherwise whichever local delivery method this
* installation's web server understands (see FileDelivery). On nginx that
* is an X-Accel-Redirect and the bytes never traverse PHP at all; on a
* server with no such header PHP streams them, which is slower and works.
*/
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 +43,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);
}
}
@@ -6,24 +6,30 @@ namespace App\Modules\Files\Http\Controllers;
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\FileDelivery;
use App\Modules\Files\Delivery\StoredFileResponse;
use App\Modules\Files\Models\File;
use App\Modules\Files\Preview\PreviewKind;
use App\Modules\Files\Preview\PreviewLog;
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\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\Response;
/**
* Two inline (never `attachment`) views of a file, same X-Accel-Redirect
* pattern as FileDownloadController: a bounded thumbnail for listing rows,
* Two inline (never `attachment`) views of a file, delivered the same way
* FileDownloadController delivers one: a bounded thumbnail for listing rows,
* and a larger view opened in a new tab when a thumbnail is clicked.
* `thumbnail()` stays unlogged — it fires automatically as an `<img src>`
* for every row on every listing render, not a deliberate action, and
@@ -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,
@@ -58,8 +72,12 @@ class FileThumbnailController extends Controller
{
public function __construct(
private readonly ThumbnailGenerator $thumbnails,
private readonly ActivityLogger $activity,
private readonly PreviewLog $previews,
private readonly DownloadAllowance $allowance,
private readonly StoredFileResponse $bytes,
private readonly LocalSourceFile $source,
private readonly Settings $settings,
private readonly FileDelivery $delivery,
) {}
public function thumbnail(Request $request, File $file): Response
@@ -82,66 +100,72 @@ 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);
// Debounced, because a browser turns one video into dozens of
// Range requests — see PreviewLog, which the anonymous twin in
// PublicGroupsController::preview shares.
$this->previews->record(Action::FilePreviewed, $file, $request->user());
$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 redirect()->away($url);
}
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,
]);
return $this->bytes->inline($file);
}
/**
@@ -159,64 +183,42 @@ class FileThumbnailController extends Controller
$disk = Storage::disk('files');
// Existence is the cache, and an empty file is not a rendition: it
// is what a render that died before writing anything leaves behind,
// and serving it hands the viewer a broken image for as long as the
// file lives — nothing invalidates a rendition once it is there.
// ThumbnailGenerator writes through a temporary file now, so this
// state can no longer be created here; it can still be inherited
// from an installation that ran an older version.
if ($disk->exists($path)) {
return $path;
if ($disk->size($path) > 0) {
return $path;
}
$disk->delete($path);
}
$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;
}
private function serve(File $file, string $path): Response
{
return response('', 200, [
'X-Accel-Redirect' => '/protected-files/'.$path,
'Content-Type' => $file->mime_type,
'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;
// No Content-Length: this is the rendition's size, not the
// original file's, and $file->size is the wrong number for it.
return $this->delivery->serve(
$path,
$file->mime_type,
ContentDisposition::inline($file->original_name),
);
}
}
@@ -10,9 +10,12 @@ use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Comments\CommentingRules;
use App\Modules\Comments\CommentScope;
use App\Modules\Files\Access\ClientIdentityScope;
use App\Modules\Files\Access\ShareTargets;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\DownloadLimitScope;
use App\Modules\Files\Editing\ApplyFileEdits;
use App\Modules\Files\Editing\FileExpiry;
use App\Modules\Files\Models\Category;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
@@ -22,13 +25,10 @@ use App\Modules\Files\Uploads\StoreUploadedFile;
use App\Modules\Files\Uploads\UploadExtensionPolicy;
use App\Modules\Files\Versions\FileVersionLinks;
use App\Modules\Files\Versions\FileVersions;
use App\Modules\Platform\Localization\LocalDay;
use App\Modules\Platform\Localization\TimezoneRegistry;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Support\PublicUrl;
use App\Support\Rules;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
@@ -49,10 +49,12 @@ class FilesController extends Controller
private readonly StaffLibraryScope $scope,
private readonly PublicUrl $publicUrl,
private readonly ShareTargets $shareTargets,
private readonly ClientIdentityScope $identity,
private readonly CommentingRules $commenting,
private readonly FileVersions $versions,
private readonly FileVersionLinks $versionLinks,
private readonly TimezoneRegistry $timezones,
private readonly ApplyFileEdits $fileEdits,
private readonly FileExpiry $expiry,
) {}
public function create(Request $request): Response
@@ -76,7 +78,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 +95,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.'),
@@ -153,7 +166,7 @@ class FilesController extends Controller
'original_name' => $file->original_name,
'size' => $file->size,
'mime_type' => $file->mime_type,
'uploader' => $file->uploader?->name,
'uploader' => $this->identity->nameOf($viewer, $file->uploader),
'folder_id' => $file->folder_id,
'public' => $file->public,
'commentable' => $file->commentable,
@@ -162,7 +175,7 @@ class FilesController extends Controller
// calendar date the editor typed — read back in their
// zone, not the server's, or a file set to expire on the
// 12th reopens showing the 11th.
'expires_at' => $file->expires_at?->copy()->setTimezone($this->timezones->resolve($request->user()))->toDateString(),
'expires_at' => $this->expiry->asShown($file, $request->user()),
'expired' => $file->isExpired(),
'download_limit' => $file->download_limit,
'download_limit_scope' => ($file->download_limit_scope ?? DownloadLimitScope::Total)->value,
@@ -197,7 +210,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 +223,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 +259,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,64 +272,58 @@ class FilesController extends Controller
'download_limit_scope' => ['nullable', Rule::enum(DownloadLimitScope::class)],
]);
$attributes = [
// 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();
// Gate::authorize above cannot pass without one.
assert($user !== null);
// 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) {
$this->scope->folders($user)->findOrFail($folderId);
}
// Normalised into the shape ApplyFileEdits reads, then handed
// over: which of these the actor may actually write is that
// class's decision, and it is the same decision the API and the
// client portal get. See its docblock for why the split is here.
$changes = [
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'folder_id' => $validated['folder_id'] ?? null,
'folder_id' => $folderId,
// Present unconditionally; the comment scope decides whether it
// is honoured. Defaulted to the stored value so a form that
// does not render the field cannot clear it.
'commentable' => $validated['commentable'] ?? $file->commentable,
'download_limit' => $validated['download_limit'] ?? null,
'download_limit_scope' => $validated['download_limit_scope'] ?? DownloadLimitScope::Total->value,
'public' => $validated['public'] ?? $file->public,
'slug' => $validated['slug'] ?? '',
'categories' => $validated['categories'] ?? [],
];
// Only meaningful while the comment scope is `selected`, and only
// offered by the page then — but a request reaching here directly
// must not be able to set a flag the UI is currently hiding, the
// same shape as the upload_public gate below.
if ($this->commenting->scope() === CommentScope::SelectedFiles) {
$attributes['commentable'] = $validated['commentable'] ?? $file->commentable;
// The one field that is conditionally *present* rather than
// conditionally honoured, and the reason it cannot move into
// ApplyFileEdits: the form was rendered with the stored instant
// read back as a date in this viewer's zone, and posts it again
// untouched with every other edit. Re-deriving it unconditionally
// would move the expiry by the difference between two people's
// zones each time somebody merely renamed the file. Compared
// against the same string the form was given, so "unchanged" means
// what the editor actually saw.
$posted = $validated['expires_at'] ?? null;
if ($posted !== $this->expiry->asShown($file, $user)) {
$changes['expires_at'] = $this->expiry->instant($posted, $user);
}
// Only a user who can set expiration dates may change this file's
// own expiry — same "leave it alone if you lack the permission"
// rule as the upload_public gate below.
if ($request->user()?->can('set_file_expiration_date') === true) {
$attributes['expires_at'] = $this->expiryInstant($validated['expires_at'] ?? null, $request->user());
}
// Same rule again for the download cap, behind its own
// permission — the one that already gates a share link's
// max_downloads, since both are the same question asked about
// different objects.
if ($request->user()?->can('limit_downloads') === true) {
$attributes['download_limit'] = $validated['download_limit'] ?? null;
$attributes['download_limit_scope'] = $validated['download_limit_scope'] ?? DownloadLimitScope::Total->value;
}
$wasPublic = $file->public;
// Only a user who can manage public state may change it — a user
// who can edit a file but lacks upload_public leaves its public
// state exactly as it was, same rule as FoldersController::update.
if ($request->user()?->can('upload_public') === true) {
$attributes['public'] = $validated['public'] ?? $file->public;
// Omitting the field on an update leaves the current slug
// alone — it must not silently change just because the name
// did.
$attributes['slug'] = ($validated['slug'] ?? '') ?: ($file->slug ?: File::uniqueSlugFrom($validated['name'], $file->id));
}
$file->update($attributes);
// Categories are gated by their own permission; leave them untouched
// for a user who can edit the file but not set categories.
if ($request->user()?->can('set_file_categories') === true) {
$file->categories()->sync($validated['categories'] ?? []);
}
$this->activity->log(Action::FileUpdated, subject: $file);
if (! $wasPublic && $file->public) {
$this->activity->log(Action::FileMadePublic, subject: $file, context: ['slug' => $file->slug]);
} elseif ($wasPublic && ! $file->public) {
$this->activity->log(Action::FileMadePrivate, subject: $file);
}
$this->fileEdits->apply($user, $file, $changes);
return back()->with('success', __('File updated.'));
}
@@ -322,7 +338,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 +374,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'],
@@ -428,7 +444,7 @@ class FilesController extends Controller
// update()'s expires_at handling.
if ($validated['expiration_action'] !== 'no_change' && $canSetExpiration) {
$attributes['expires_at'] = $validated['expiration_action'] === 'set'
? $this->expiryInstant($validated['expires_at'], $user)
? $this->expiry->instant($validated['expires_at'], $user)
: null;
}
@@ -469,9 +485,23 @@ class FilesController extends Controller
});
$requested = count($validated['file_ids']);
$message = $updated < $requested
? __(':updated of :requested selected files were updated. The rest were skipped because you don\'t have permission to edit them.', ['updated' => $updated, 'requested' => $requested])
: trans_choice(':count file updated.|:count files updated.', $updated, ['count' => $updated]);
// Two different reasons a selected file can go unchanged, and they
// are not the same sentence. Files dropped by the Gate::allows
// filter above are ones this user may not edit at all. A file that
// survived the filter and still changed nothing was editable --
// every field they asked to change was one their role does not let
// them set, which is the case the single-file editor states
// separately too. Reporting the first reason for the second told a
// staff member with edit_files but without set_file_expiration_date
// that three files they own are not theirs to edit.
$unreachable = $requested - $files->count();
$message = match (true) {
$updated === $requested => trans_choice(':count file updated.|:count files updated.', $updated, ['count' => $updated]),
$updated + $unreachable === $requested => __(':updated of :requested selected files were updated. The rest were skipped because you don\'t have permission to edit them.', ['updated' => $updated, 'requested' => $requested]),
default => __(':updated of :requested selected files were updated. The rest were skipped because you don\'t have permission to make those changes.', ['updated' => $updated, 'requested' => $requested]),
};
return back()->with('success', $message);
}
@@ -481,28 +511,17 @@ class FilesController extends Controller
Gate::authorize('delete', $file);
$name = $file->name;
// Soft delete; the bytes stay on disk until a purge policy
// lands with the retention work.
// Soft delete of the row — but not of the bytes. File::booted()'s
// `deleted` hook runs FileDiskCleanup on commit, so the upload and
// every cached rendition of it are gone from disk by the time this
// returns. The row is kept because version chains, the activity
// log and the erasure grace period all still point at it; nothing
// serves it (route-model binding 404s), and nothing ever
// forceDelete()s it either.
$file->delete();
$this->activity->log(Action::FileDeleted, context: ['name' => $name]);
return redirect()->route('files.index')->with('success', __('File deleted.'));
}
/**
* The instant a `<input type="date">` expiry actually falls on.
*
* The form posts a bare `YYYY-MM-DD`, which Eloquent would otherwise
* store as midnight UTC — so "expires on the 12th" would cut the file
* off partway through the 11th for anyone in the Americas, and give
* anyone east of Greenwich most of a day they were not promised. It
* means the end of the 12th where the person setting it lives.
*/
private function expiryInstant(?string $date, ?User $setter): ?Carbon
{
return $date === null
? null
: LocalDay::end($date, $this->timezones->resolve($setter));
}
}
@@ -10,6 +10,7 @@ use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Comments\Access\VisibleCommentScope;
use App\Modules\Comments\CommentingRules;
use App\Modules\Files\Access\ClientIdentityScope;
use App\Modules\Files\Access\DownloadAllowance;
use App\Modules\Files\Access\ShareTargets;
use App\Modules\Files\Access\StaffLibraryScope;
@@ -54,6 +55,7 @@ class FoldersController extends Controller
private readonly ActivityLogger $activity,
private readonly PublicUrl $publicUrl,
private readonly ShareTargets $shareTargets,
private readonly ClientIdentityScope $identity,
private readonly BreadcrumbBuilder $breadcrumbs,
private readonly CommentingRules $commenting,
private readonly VisibleCommentScope $comments,
@@ -186,7 +188,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'),
@@ -236,7 +242,11 @@ class FoldersController extends Controller
'original_name' => $file->original_name,
'mime_type' => $file->mime_type,
'size' => $file->size,
'uploader' => $file->uploader ? [
// The whole block goes, not just the name: type and role
// describe the same person, and "a client uploaded this" on a
// row whose uploader is off this viewer's roster narrows who
// it could be just as effectively as naming them.
'uploader' => ($file->uploader !== null && $this->identity->permits($user, $file->uploader)) ? [
'name' => $file->uploader->name,
'type' => $file->uploader->type->value,
'role' => $file->uploader->role?->name,
@@ -305,7 +315,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 +399,7 @@ class FoldersController extends Controller
Gate::authorize('update', $folder);
$validated = $request->validate([
'parent_id' => ['nullable', 'integer', 'exists:folders,id'],
'parent_id' => Rules::folderId(),
]);
$newParent = $this->resolveParent($request->user(), $validated['parent_id'] ?? null);
@@ -401,10 +411,32 @@ class FoldersController extends Controller
return back();
}
public function destroy(Folder $folder): RedirectResponse
public function destroy(Request $request, Folder $folder): RedirectResponse
{
Gate::authorize('delete', $folder);
$viewer = $request->user();
assert($viewer !== null);
// Deleting a folder cascades to every file in its subtree, and a
// File's `deleted` hook removes the bytes from disk — there is no
// restore. Authorizing the folder is not authorizing its contents:
// FilePolicy::delete asks for `delete_others_files` on somebody
// else's upload, and for the library boundary on top of that, and
// neither question is asked anywhere on this path.
//
// MyFoldersController::destroy already refuses for the client half
// of the same cascade, in the same words. This is the staff half.
$blocked = $this->undeletableFileCount($viewer, $folder);
if ($blocked > 0) {
return back()->with('error', trans_choice(
'This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.',
$blocked,
['count' => (string) $blocked],
));
}
$name = $folder->name;
$parentId = $folder->parent_id;
@@ -415,6 +447,50 @@ class FoldersController extends Controller
return redirect()->route('files.index', $parentId !== null ? ['folder' => $parentId] : [])->with('success', __('Folder deleted.'));
}
/**
* How many files in this folder's subtree the viewer may not delete.
*
* Asked as one count rather than FilePolicy::delete per file: a folder
* can hold thousands, Gate resolves a fresh policy for every check, and
* a per-row policy check on a listing is the cost 0a8b609e went to
* some trouble to remove. The two halves of FilePolicy::delete are
* expressible in SQL — the permission half is constant for this
* viewer, and the library half is the query StaffLibraryScope already
* memoises per request.
*
* Somebody holding both delete permissions and no library scope can
* delete anything in the subtree by construction, so they never pay for
* the query at all.
*/
private function undeletableFileCount(User $viewer, Folder $folder): int
{
$mayDeleteOwn = $viewer->can('delete_files');
$mayDeleteOthers = $viewer->can('delete_others_files');
$scoped = $viewer->isClientScoped();
if ($mayDeleteOwn && $mayDeleteOthers && ! $scoped) {
return 0;
}
return File::query()
->whereIn('folder_id', $folder->subtreeFolderIds())
->where(function (Builder $outer) use ($viewer, $mayDeleteOwn, $mayDeleteOthers, $scoped): void {
if (! $mayDeleteOwn) {
$outer->orWhere('uploaded_by', $viewer->id);
}
if (! $mayDeleteOthers) {
$outer->orWhere(fn (Builder $others): Builder => $others
->whereNull('uploaded_by')->orWhere('uploaded_by', '!=', $viewer->id));
}
if ($scoped) {
$outer->orWhereNotIn('id', $this->scope->files($viewer)->select('id'));
}
})
->count();
}
private function resolveParent(?User $user, ?int $parentId): ?Folder
{
if ($user === null || $parentId === null) {
@@ -5,10 +5,16 @@ 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\Clients\ClientStorageUsage;
use App\Modules\Comments\Access\VisibleCommentScope;
use App\Modules\Comments\CommentingRules;
use App\Modules\Comments\CommentScope;
use App\Modules\Files\Access\DownloadAllowance;
use App\Modules\Files\DownloadLimitScope;
use App\Modules\Files\Editing\ApplyFileEdits;
use App\Modules\Files\Editing\FileExpiry;
use App\Modules\Files\Folders\BreadcrumbBuilder;
use App\Modules\Files\Models\Category;
use App\Modules\Files\Models\File;
@@ -22,6 +28,7 @@ use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Theming\PublicThemeRegistry;
use App\Support\ConcatenatedPagination;
use App\Support\Pagination;
use App\Support\Rules;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
@@ -66,6 +73,9 @@ class MyFilesController extends Controller
private readonly DownloadAllowance $allowance,
private readonly FileVersions $versions,
private readonly FileVersionLinks $versionLinks,
private readonly ApplyFileEdits $fileEdits,
private readonly FileExpiry $expiry,
private readonly ActivityLogger $activity,
) {}
public function index(Request $request): Response|RedirectResponse
@@ -122,15 +132,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
@@ -199,9 +217,11 @@ class MyFilesController extends Controller
$fileRows = $sliced['items']['files'];
$commentCounts = $this->comments->countsFor($client, $fileRows);
// Two queries for the page, not two per row. No URL resolver: the
// portal has no per-file page to link to, so a counterpart is named
// and not linked (see docs/theming-files-checklist.md).
// Two queries for the page, not two per row. Still no URL resolver:
// the portal's per-file page is an *editor* for a client's own
// uploads, and a version counterpart is frequently neither theirs
// nor editable — so a counterpart stays named and not linked (see
// docs/theming-files-checklist.md).
$versions = $this->versionLinks->forMany($fileRows, $client);
$unreadComments = $this->comments->unreadCountsFor($client, array_values(array_map(intval(...), $fileRows->pluck('id')->all())));
@@ -229,6 +249,14 @@ class MyFilesController extends Controller
'size' => $file->size,
'created_at' => $file->created_at?->toIso8601String(),
'is_mine' => $file->uploaded_by === $client->id,
// Decided per row by FilePolicy, exactly as the folder rows
// above are: a client's own uploads are theirs to manage
// and files shared with them are not, and both kinds sit in
// the same list. A theme reads these and never works them
// out from is_mine — holding the file is only half of it,
// the role's keys are the other half.
'can_update' => Gate::forUser($client)->allows('update', $file),
'can_delete' => Gate::forUser($client)->allows('delete', $file),
// Effective status (own flag or inherited from a public
// folder) — same "will visitors on the public site see
// this" badge as the staff library shows.
@@ -260,6 +288,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),
]);
}
@@ -291,6 +326,200 @@ class MyFilesController extends Controller
]);
}
/**
* The editor page for a file this client uploaded.
*
* One page for every theme, not one per theme — the same shape
* `upload()` uses, and for the same reason: this is a form, and a form
* rebuilt four times is four places for a field to go missing. The
* `theme` prop picks the shell (see portal/edit-file.tsx), which is the
* only part that differs.
*
* Every `can_*` prop below is the *same* question ApplyFileEdits will
* ask when the form posts. A control this page hides is not a control
* the server then trusts: hiding it is a courtesy so a client is not
* shown a switch that will silently do nothing, and the refusal is
* server-side either way.
*/
public function edit(Request $request, File $file): Response
{
$client = $request->user();
abort_unless($client !== null && $client->isClient(), 404);
Gate::authorize('update', $file);
$file->loadMissing('categories');
return Inertia::render('portal/edit-file', [
'theme' => $this->themeKey(),
'file' => [
'id' => $file->id,
'name' => $file->name,
'description' => $file->description,
'original_name' => $file->original_name,
'size' => $file->size,
'public' => $file->public,
'commentable' => $file->commentable,
// The stored instant as the calendar day this client's own
// zone shows — the value the form posts back untouched, and
// the one update() compares against to tell a real change
// from a date that merely came along with a rename.
'expires_at' => $this->expiry->asShown($file, $client),
'download_limit' => $file->download_limit,
'download_limit_scope' => ($file->download_limit_scope ?? DownloadLimitScope::Total)->value,
'folder_id' => $file->folder_id,
'categories' => $file->categories->pluck('id')->all(),
],
'can_delete' => Gate::forUser($client)->allows('delete', $file),
'can_publish' => $client->can('upload_public'),
'can_set_expiration' => $client->can('set_file_expiration_date'),
'can_set_categories' => $client->can('set_file_categories'),
'can_limit_downloads' => $client->can('limit_downloads'),
// Only while the installation asks per file; otherwise the
// setting decides and the switch would be a lie.
'can_set_commentable' => $this->commenting->scope() === CommentScope::SelectedFiles,
'categories' => Category::query()->orderBy('name')->get(['id', 'name', 'color'])
->map(fn (Category $category): array => [
'id' => $category->id, 'name' => $category->name, 'color' => $category->color,
])->all(),
// Somewhere this client could have uploaded it in the first
// place — the same rule update() enforces, so the picker cannot
// offer a destination the save would refuse.
'folders' => Folder::query()->visibleToClient($client)->orderBy('name')->get()
->filter(fn (Folder $folder): bool => Folder::uploadableBy($client, $folder))
->map(fn (Folder $folder): array => [
'id' => $folder->id,
'name' => $folder->name,
// A destination can publish the file without the public
// switch being touched: File::isEffectivelyPublic() is
// "my own flag OR my folder's", and a client holding
// upload_to_public_folders may move into a public
// folder without holding upload_public. That is the
// established meaning of the two keys, and it is what
// uploading there has always done — but in a picker of
// bare names it would be invisible, so the name carries
// the consequence with it.
'public' => $folder->isEffectivelyPublic(),
])
->values()->all(),
// Public files are reachable at the installation's one public
// slug; without it configured, publishing shows nowhere and the
// page says so rather than offering a switch that does nothing
// visible.
'public_listing_slug' => $this->settings->get(Setting::PublicListingSlug),
]);
}
/**
* Edit a file this client uploaded.
*
* The client portal's counterpart to the staff file editor, and
* deliberately a separate route rather than the staff one opened up:
* `files.*` renders assignments, share links, activity and download
* history, which are staff surfaces, and its folder guard asks
* StaffLibraryScope — which answers "allowed" for every client (see
* FilePolicy::update()).
*
* Who may edit at all is FilePolicy: the file must be this client's own
* upload and they must hold `edit_files`. Which *fields* they may
* write is ApplyFileEdits, the same decision the staff editor and the
* API get, so a client holding `set_file_categories` but not
* `upload_public` gets exactly what those keys say and nothing is
* decided twice.
*/
public function update(Request $request, File $file): RedirectResponse
{
$client = $request->user();
abort_unless($client !== null && $client->isClient(), 404);
Gate::authorize('update', $file);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'folder_id' => Rules::folderId(),
'public' => ['sometimes', 'boolean'],
'commentable' => ['sometimes', 'boolean'],
'categories' => ['array'],
'categories.*' => ['integer', 'exists:categories,id'],
'expires_at' => ['nullable', 'date'],
'download_limit' => ['nullable', 'integer', 'min:1'],
'download_limit_scope' => ['nullable', Rule::enum(DownloadLimitScope::class)],
]);
// No `slug`, on purpose, and its absence is what makes
// ApplyFileEdits derive one from the name. An installation-wide
// unique slug that a client picks is a name to squat and an
// existence oracle to probe against every file on the
// installation, for nothing a derived slug does not already give
// them.
$folderId = isset($validated['folder_id']) ? (int) $validated['folder_id'] : null;
// The client rule, not the staff one: somewhere they could have
// uploaded it in the first place. Same check the upload path makes,
// so moving a file cannot reach a folder that uploading it could
// not. Only when the folder actually changes, so re-saving a file
// that already sits somewhere unusual still works.
if ($folderId !== null && $folderId !== $file->folder_id) {
$folder = Folder::query()->visibleToClient($client)->find($folderId);
abort_unless($folder !== null && Folder::uploadableBy($client, $folder), 403);
}
$changes = [
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'folder_id' => $folderId,
'commentable' => $validated['commentable'] ?? $file->commentable,
'download_limit' => $validated['download_limit'] ?? null,
'download_limit_scope' => $validated['download_limit_scope'] ?? DownloadLimitScope::Total->value,
'public' => $validated['public'] ?? $file->public,
'categories' => $validated['categories'] ?? [],
];
// Only when the date actually moved — the form posts back what it
// was rendered with, and re-deriving it on every save would shift
// the expiry by a timezone difference each time somebody renamed
// the file. See FileExpiry.
$posted = $validated['expires_at'] ?? null;
if ($posted !== $this->expiry->asShown($file, $client)) {
$changes['expires_at'] = $this->expiry->instant($posted, $client);
}
$this->fileEdits->apply($client, $file, $changes);
return back()->with('success', __('File updated.'));
}
/**
* Delete a file this client uploaded.
*
* Their own upload and `delete_files`, both settled by
* FilePolicy::delete(). A file merely shared with them is not theirs to
* remove, and no permission changes that.
*
* The row is soft-deleted and the bytes are not: File::booted()'s
* `deleted` hook removes the upload and every cached rendition on
* commit, so the client's storage quota — which sums untrashed rows —
* frees up by exactly what the disk does.
*/
public function destroy(Request $request, File $file): RedirectResponse
{
$client = $request->user();
abort_unless($client !== null && $client->isClient(), 404);
Gate::authorize('delete', $file);
$name = $file->name;
$file->delete();
$this->activity->log(Action::FileDeleted, context: ['name' => $name]);
return redirect()->route('my-files.index')->with('success', __('File deleted.'));
}
/**
* Files this client may name as the previous version of what they are
* uploading — THEIR OWN UPLOADS ONLY.
@@ -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,14 +8,14 @@ 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;
use Inertia\Response as InertiaResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* The public, unauthenticated side of a share link: no Gate/policy is
@@ -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);
}
}
@@ -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\Delivery\FileDelivery;
use App\Modules\Files\Access\DownloadAllowance;
use App\Modules\Files\Access\ViewableFileScope;
use App\Modules\Files\Jobs\BuildZipDownloadJob;
@@ -15,12 +16,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;
use Symfony\Component\HttpFoundation\Response;
/**
* A folder's "Download as zip" button and the file listing's multi-select
@@ -40,6 +45,8 @@ class ZipDownloadsController extends Controller
private readonly ActivityLogger $activity,
private readonly ViewableFileScope $viewable,
private readonly DownloadAllowance $allowance,
private readonly Settings $settings,
private readonly FileDelivery $delivery,
) {}
public function store(Request $request): JsonResponse
@@ -47,6 +54,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 +121,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,29 +183,95 @@ 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);
return response('', 200, [
'X-Accel-Redirect' => '/protected-files/'.$path,
'Content-Type' => 'application/zip',
'Content-Disposition' => ContentDisposition::attachment($this->filenameFor($zipDownload)),
'Content-Length' => (string) $size,
]);
return $this->delivery->serve(
$path,
'application/zip',
ContentDisposition::attachment($this->filenameFor($zipDownload)),
$size,
);
}
/**
* 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 +289,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
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Modules\Files\Http\Resources\Api;
use App\Modules\Files\Access\ClientIdentityScope;
use App\Modules\Files\DownloadLimitScope;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\FileAssignment;
@@ -26,6 +27,23 @@ use Illuminate\Http\Resources\Json\JsonResource;
* - `checksum` is included deliberately, since verifying an integration's
* own download is a real use case, and it reveals nothing about
* location.
*
* Two fields are narrowed to the caller: the uploader and the assignment
* list both name clients, and a client-scoped account may hold a file whose
* uploader or co-recipients are clients off their own roster — the file is
* theirs to read, those names are not theirs to see. ClientIdentityScope is
* the rule; a name dropped here is dropped to null or out of the list, and
* an unscoped account is unaffected.
*
* That narrowing happens here rather than in the controllers, which is the opposite of how the version counterparts are
* handled a few files over — and deliberately so. Whether a counterpart may
* be named is a set-shaped question with a query to express it, so it is
* asked once in the caller's eager load. Whether a client may be named is a
* per-row check against the viewer's roster with no query to fold it into,
* and this resource is built at eight call sites across four controllers,
* two of them re-loading `assignments.assignable` after a write. Asking at
* the point of serialisation is the only version of this rule that cannot
* be forgotten by the ninth caller.
*/
class FileResource extends JsonResource
{
@@ -34,6 +52,15 @@ class FileResource extends JsonResource
*/
public function toArray(Request $request): array
{
$viewer = $request->user();
$identity = app(ClientIdentityScope::class);
// The morph class rather than ::class, matching ShareTargets: with
// a morph map registered the two disagree, and this line now
// decides which roster an entry is checked against, so getting it
// wrong would mean checking a group id against the client list.
$groupMorph = (new Group)->getMorphClass();
return [
'id' => $this->id,
'name' => $this->name,
@@ -96,11 +123,16 @@ class FileResource extends JsonResource
]),
// Name only. The uploader is a user record; their email address
// is not part of what "this file exists" needs to say.
'uploaded_by' => $this->whenLoaded('uploader', fn (): ?array => $this->uploader === null ? null : [
'id' => $this->uploader->id,
'name' => $this->uploader->name,
]),
// is not part of what "this file exists" needs to say. Null
// when the uploader is a client the token's owner is not
// scoped to; an unscoped account always gets the name.
'uploaded_by' => $this->whenLoaded(
'uploader',
fn (): ?array => $identity->permits($viewer, $this->uploader) && $this->uploader !== null ? [
'id' => $this->uploader->id,
'name' => $this->uploader->name,
] : null,
),
'categories' => $this->whenLoaded('categories', fn (): array => $this->categories
->map(fn ($category): array => [
@@ -109,15 +141,22 @@ class FileResource extends JsonResource
])
->all()),
// Who the file is shared with, as far as this caller is
// concerned: a recipient the token's owner is not scoped to is
// left out rather than returned without a name.
'assignments' => $this->whenLoaded('assignments', fn (): array => $this->assignments
->filter(fn (FileAssignment $assignment): bool => $assignment->assignable_type === $groupMorph
? $identity->permitsGroupId($viewer, (int) $assignment->assignable_id)
: $identity->permitsClientId($viewer, (int) $assignment->assignable_id))
->map(fn (FileAssignment $assignment): array => [
'type' => $assignment->assignable_type === Group::class ? 'group' : 'client',
'type' => $assignment->assignable_type === $groupMorph ? 'group' : 'client',
'id' => $assignment->assignable_id,
// getAttribute() rather than ->name: the relation is a
// MorphTo over User|Group, so the property is only
// knowable at runtime. Both targets carry a name.
'name' => $assignment->assignable?->getAttribute('name'),
])
->values()
->all()),
'links' => [
+248 -17
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,19 @@ class BuildZipDownloadJob implements ShouldQueue
$tempFiles = [];
$skipped = [];
// Counted rather than derived from $usedNames, which also
// holds the folder entry names.
$added = 0;
// Collected rather than derived from $usedNames, which also
// holds the folder entry names. Recording the ids, not just a
// count, is what lets the download action log exactly what it
// hands over instead of resolving the selection a second time
// against a scope that may have moved since.
//
// Keyed by id rather than appended to a list, because it is
// also what keeps a file out of the archive twice. The loose
// selection cannot repeat itself — one whereIn on the primary
// key — but a selected folder can hold a file that was also
// named loosely, and the cap is 10000 sources, so the check
// has to be a lookup rather than a scan.
$added = [];
foreach ((clone $visible)->whereIn('id', $zipDownload->file_ids)->get() as $file) {
// Re-checked here for the same reason visibility is: the
@@ -105,24 +157,87 @@ class BuildZipDownloadJob implements ShouldQueue
$entryName = $this->dedupeName($usedNames, $this->entrySegment($file->original_name));
$zip->addFile($this->localPathFor($file, $tempFiles), $entryName);
$totalSize += $file->size;
$added++;
$added[$file->id] = true;
}
foreach (Folder::query()->whereIn('id', $zipDownload->folder_ids)->get() as $folder) {
foreach ($this->outermostFolders($zipDownload->folder_ids) as $folder) {
$totalSize += $this->addFolder($zip, $folder, $requester, $usedNames, $tempFiles, $visible, $skipped, $added);
}
$zip->close();
// Re-checked here, not only in ZipDownloadsController: the
// selection is re-derived at build time, so a folder that grew
// while the job sat in the queue could otherwise fill the disk
// with an archive nobody is allowed to ask for. unchangeAll()
// drops every pending entry, so close() writes nothing rather
// than writing an archive we would delete a line later.
$maxBytes = (int) app(Settings::class)->get(Setting::MaxZipDownloadSizeMb) * 1024 * 1024;
if ($maxBytes > 0 && $totalSize > $maxBytes) {
$zip->unchangeAll();
@$zip->close();
foreach ($tempFiles as $tempFile) {
@unlink($tempFile);
}
$this->fail($zipDownload, $relativePath, 'The selection grew past the maximum zip download size before the archive could be built.', $skipped);
return;
}
// ZipArchive defers every write to close(): a source file
// deleted after its addFile() (a concurrent staff delete runs
// FileDiskCleanup at once) or a full disk only surfaces here,
// as a false return. Its low-level warning is silenced (as with
// the @unlink cleanup below) so the return value is the signal
// we act on, deterministically, rather than an exception whose
// firing depends on the error_reporting level. An archive that
// ended up with no entries is the same kind of non-result —
// libzip writes no file for one at all, even though close()
// still returns true. Either way there is nothing to serve, so
// the row must not be marked ready over a missing or empty
// archive: the download controller would X-Accel a file that
// isn't there.
$written = @$zip->close();
foreach ($tempFiles as $tempFile) {
@unlink($tempFile);
}
if ($written !== true || $added === []) {
if ($written !== true) {
// What the requester sees stays generic: a libzip
// string means nothing to them and can name a server
// path. An operator needs the opposite — "disk full"
// and "the source file vanished" are different
// problems — so the reason goes to the log instead.
Log::error('A zip download could not be written.', [
'zip_download_id' => $zipDownload->id,
'reason' => $zip->getStatusString(),
]);
}
// Nothing written is told apart from nothing added, and
// "every file had already been downloaded as often as it
// was meant to be" from "there was nothing left to send".
// They are different problems for the person who asked,
// and fail() carries the skipped list either way, so
// "which files?" stays answerable from the row.
$this->fail($zipDownload, $relativePath, match (true) {
$written !== true => 'The zip archive could not be written.',
$skipped !== [] => 'Every selected file had already reached its download limit.',
default => 'None of the selected files were available to add to the archive.',
}, $skipped);
return;
}
$zipDownload->update([
'status' => ZipDownload::STATUS_READY,
'path' => $relativePath,
'total_size' => $totalSize,
'file_count' => $added,
'file_count' => count($added),
'contained_file_ids' => array_keys($added),
'skipped_files' => $skipped === [] ? null : $skipped,
]);
} catch (Throwable $e) {
@@ -130,13 +245,64 @@ class BuildZipDownloadJob implements ShouldQueue
@unlink($tempFile);
}
// Same division as the write failure above: the reason is the
// operator's, the sentence is the requester's. An exception
// message here has already named a disk in practice — "Disk
// [x] does not have a configured driver." — and can name a
// server path, and this column is shown to whoever asked for
// the archive, including clients.
Log::error('A zip download could not be built.', [
'zip_download_id' => $zipDownload->id,
'exception' => $e::class,
'reason' => $e->getMessage(),
]);
$zipDownload->update([
'status' => ZipDownload::STATUS_FAILED,
'error' => $e->getMessage(),
'error' => 'The zip archive could not be built.',
]);
}
}
/**
* 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()
@@ -158,22 +324,51 @@ class BuildZipDownloadJob implements ShouldQueue
throw new \RuntimeException('Could not create a temp file for '.$file->original_name);
}
// Registered before anything else can fail. tempnam() has already
// created the file, and the caller's cleanup only knows the paths
// it was told about — so every throw between here and the end of
// the copy used to leave a zip-src- file behind for good.
$tempFiles[] = $tempPath;
$stream = Storage::disk($file->disk)->readStream($file->path);
$out = fopen($tempPath, 'wb');
if ($stream === null || $out === false) {
if (is_resource($stream)) {
fclose($stream);
}
if ($out !== false) {
fclose($out);
}
throw new \RuntimeException('Could not read '.$file->original_name.' from its storage disk.');
}
stream_copy_to_stream($stream, $out);
fclose($out);
try {
// A copy that stops early is a truncated member added to the
// archive as though it were the file: the build reports ready,
// and the recipient gets something that opens and is wrong.
// fclose is checked for the same reason it is in
// LocalPartStore: it flushes, so a volume that filled on the
// last buffer fails there rather than here.
$copied = stream_copy_to_stream($stream, $out);
$flushed = fclose($out);
$out = false;
if (is_resource($stream)) {
fclose($stream);
if ($copied === false || ! $flushed) {
throw new \RuntimeException('Could not copy '.$file->original_name.' from its storage disk.');
}
} finally {
if ($out !== false) {
fclose($out);
}
if (is_resource($stream)) {
fclose($stream);
}
}
$tempFiles[] = $tempPath;
return $tempPath;
}
@@ -182,8 +377,9 @@ class BuildZipDownloadJob implements ShouldQueue
* @param array<int, string> $tempFiles
* @param Builder<File> $visible every file the requester may read
* @param list<array{id: int, name: string}> $skipped
* @param array<int, true> $added every file really written into the archive, keyed by id
*/
private function addFolder(ZipArchive $zip, Folder $folder, User $requester, array &$usedNames, array &$tempFiles, Builder $visible, array &$skipped, int &$added): int
private function addFolder(ZipArchive $zip, Folder $folder, User $requester, array &$usedNames, array &$tempFiles, Builder $visible, array &$skipped, array &$added): int
{
$allowance = app(DownloadAllowance::class);
@@ -194,6 +390,15 @@ class BuildZipDownloadJob implements ShouldQueue
$totalSize = 0;
foreach ((clone $visible)->whereIn('folder_id', $subtreeIds)->get() as $file) {
// Already in the archive under another part of the selection —
// named loosely, or inside a folder selected before this one.
// Skipped rather than added again: a second entry is a second
// copy of the same bytes, and delivery charges one download
// however many copies went out.
if (isset($added[$file->id])) {
continue;
}
// Holding the folder does not entitle the requester to a file
// inside it whose own allowance is spent — same reason the
// per-file visibility filter is re-derived rather than
@@ -209,12 +414,38 @@ class BuildZipDownloadJob implements ShouldQueue
$entryPath = $this->dedupeName($usedNames, $entryPath);
$zip->addFile($this->localPathFor($file, $tempFiles), $entryPath);
$totalSize += $file->size;
$added++;
$added[$file->id] = true;
}
return $totalSize;
}
/**
* The selected folders with the redundant ones dropped: one that sits
* inside another selected folder is already covered by it.
*
* Zipping both would reach the same file twice, and which of the two
* paths the surviving entry ended up under would be decided by
* whatever order the database returned the rows in. Keeping the outer
* folder keeps the fuller path — Reports/Q1/report.pdf rather than
* Q1/report.pdf — and gives the same archive on every run.
*
* @param list<int> $folderIds
* @return Collection<int, Folder>
*/
private function outermostFolders(array $folderIds): Collection
{
/** @var Collection<int, Folder> $folders */
$folders = Folder::query()->whereIn('id', $folderIds)->orderBy('id')->get();
return $folders
->reject(fn (Folder $folder): bool => $folders->contains(
fn (Folder $other): bool => $other->id !== $folder->id
&& str_starts_with($folder->path, $other->subtreePathPrefix()),
))
->values();
}
/**
* @param Collection<int, Folder> $foldersById Every folder in the root's subtree, keyed by id.
*/
+32 -3
View File
@@ -105,7 +105,24 @@ 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 a
// row that is only trashed, and a scan will not offer those:
// OrphanFileScanner::knownPaths() counts a trashed row's path
// as claimed, on purpose, so nothing double-adopts a file still
// inside its erasure grace period. FileDiskCleanup's warning is
// therefore the only record that it happened.
//
// 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)
);
});
}
@@ -234,8 +251,20 @@ class File extends Model
/**
* A file's own expiration date — independent of any share link's.
* Null means never expires. Once past, the file is hidden from
* clients and the public site (see scopeNotExpired) but staff keep
* full access to view, download, and manage it.
* clients and the public site (see scopeNotExpired) and staff keep
* full access to view, download, and manage it — with one boundary
* this used to leave out.
*
* A client-scoped staff member's library is their own uploads ∪ what
* each assigned client may see (StaffLibraryScope::buildFiles), and
* that second half is scopeVisibleToClient, which ends in
* notExpired(). So an expired file they held only through a client
* leaves their library too, while their own expired upload stays.
* That is deliberate: c8078f65 weighed widening it and left the
* boundary where it is, because scopeVisibleToClient is the single
* source of truth for client file access, and relabelled the
* expired-files widget instead. ExpiredFileStaffAccessTest pins both
* halves so the sentence above cannot drift from the code again.
*/
public function isExpired(): bool
{
+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;
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Preview;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Models\File;
use Illuminate\Support\Facades\Cache;
/**
* One log row per viewer per file per five minutes, for both preview
* routes — FileThumbnailController::preview (signed in) and
* PublicGroupsController::preview (anonymous).
*
* Watching a video is a single deliberate act that the browser turns into
* dozens of Range requests, each arriving 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 person's playback never suppresses another's
* view of the same file. An anonymous visitor has no account to key on,
* so the request IP stands in — the same substitute the API's rate
* limiter makes for an unauthenticated caller. It is a cache key with a
* five-minute life and never reaches the log, which keeps its own
* decision about recording an IP (see ActivityLogger::shouldRecordIp and
* Setting::DownloadIpLogging).
*
* Shared rather than restated, because the window is the rule: two copies
* of "five minutes" are two things to change and one to forget.
*/
class PreviewLog
{
private const WINDOW_MINUTES = 5;
public function __construct(
private readonly ActivityLogger $activity,
) {}
public function record(Action $action, File $file, ?User $viewer): void
{
$viewerKey = $viewer !== null ? (string) $viewer->id : 'ip:'.request()->ip();
if (Cache::add('file-preview-logged:'.$file->id.':'.$viewerKey, true, now()->addMinutes(self::WINDOW_MINUTES))) {
$this->activity->log($action, subject: $file);
}
}
}
@@ -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();
}
}
@@ -14,9 +14,10 @@ use App\Modules\Files\Thumbnails\ImageRendition;
*
* A thumbnail never asks: it is a rendering by definition, nothing else
* would fit in a listing row. A preview is the case with two valid
* answers. Serving the stored file is far cheaper — an X-Accel-Redirect
* with no PHP in the path at all, or a redirect straight to external
* storage — and it is what this app has always done. Decoding and
* answers. Serving the stored file is far cheaper — handed to the web
* server with no PHP in the path at all where that is possible, or a
* redirect straight to external storage — and it is what this app has
* always done. Decoding and
* re-encoding a full-size photograph instead is only worth it when
* something actually intends to change what the viewer sees.
*
@@ -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);
}
}
}
}
@@ -134,6 +134,31 @@ class ThumbnailGenerator
// listening the image is written exactly as produced above.
Event::dispatch(new RenderingImage($image, $mimeType, $audience, $rendition));
$image->toFile($destinationPath, $mimeType);
// Written beside the destination and renamed into place, so the
// cached path never exists half-finished. Both callers test only
// that the path exists and then serve whatever is there
// (FileThumbnailController::render, PublicGroupsController::
// thumbnail), and nothing ever invalidates a rendition —
// RenderedImageCache::flush() runs on an event no core code raises.
// A render that died partway would therefore be served as the
// rendition from then on.
//
// It also settles the race: two requests rendering the same file at
// once used to encode into one path together. rename() within a
// directory is atomic and replaces what is there, so now the loser
// leaves a complete rendition behind rather than a mixture of two.
$temporaryPath = $destinationPath.'.'.bin2hex(random_bytes(8)).'.partial';
try {
$image->toFile($temporaryPath, $mimeType);
if (! rename($temporaryPath, $destinationPath)) {
throw new RuntimeException('Could not move the rendered image into place.');
}
} finally {
if (is_file($temporaryPath)) {
@unlink($temporaryPath);
}
}
}
}
+160 -46
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;
@@ -26,6 +27,12 @@ use Throwable;
*/
class LocalPartStore
{
/**
* Said twice, because a full temp volume can announce itself in the
* middle of the copy or only when the last buffer is flushed.
*/
private const WRITE_FAILED = 'Could not assemble the upload: writing to the temporary directory failed.';
/**
* The route name is a parameter because the same flow is mounted twice:
* once on the session-authenticated web routes for the browser, once on
@@ -139,9 +146,21 @@ class LocalPartStore
}
/**
* Stream-append parts in order onto the files disk, hashing as we
* go. Peak temp usage ≈ file size + one part (parts are unlinked
* as they are consumed).
* Stream-append parts in order onto the files disk, hashing as we go.
*
* The parts stay on disk until the assembled bytes are safely on the
* target disk. ChunkedUploadsController's completion lock promises that
* "a later retry still works", and everything that can fail after the
* concatenation — reopening the copy, a disk refusing the write, the
* File row itself — happens while the client has nothing but this
* session to retry with. Unlinking each part as it was consumed left
* listParts() empty, so every later complete() answered "Upload is
* incomplete: missing parts" for good.
*
* The cost is temp space: peak usage is the whole file twice over
* (every part, plus the assembled copy) rather than the file plus one
* part. Both are freed by the abort() below the moment the write lands,
* and by the failure path the moment it does not.
*
* @return array{path: string, disk: string, size: int, checksum: string}
*/
@@ -157,6 +176,93 @@ class LocalPartStore
}
$assembledPath = $this->directory($session).'/assembled';
try {
[$size, $checksum] = $this->concatenate($session, $parts, $assembledPath);
$readStream = fopen($assembledPath, 'rb');
if ($readStream === false) {
throw new RuntimeException('Could not reopen assembled file.');
}
$diskEvent = new ResolvingUploadDisk($session->user);
Event::dispatch($diskEvent);
$disk = $diskEvent->disk;
$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.'
);
}
} catch (Throwable $failure) {
// The half-written copy belongs to this attempt and the next one
// makes its own; the parts belong to the client, and they are
// what a retry needs. Deleting the copy here is also the only
// thing that removes it at all on this path — it used to sit in
// the session directory until the sweeper came round.
FileSystem::delete($assembledPath);
throw $failure;
}
$this->abort($session);
return [
'path' => $targetPath,
'disk' => $disk,
'size' => $size,
'checksum' => $checksum,
];
}
/**
* Concatenate the parts into $assembledPath, returning the byte count
* and the sha256 of what was written.
*
* Every read and every write is checked. They were not, and while a
* failing fwrite on a full volume is loud in practice — Laravel's
* error handler turns the warning into an ErrorException — loud there
* means a 500 carrying a PHP message, where the disk-refused-the-write
* case a few lines above becomes a sentence the person uploading can
* act on. A short write arriving without a warning would be worse
* still: $size and the hash describe the buffer that was read, so an
* unchecked one yields a truncated file with a checksum matching bytes
* that were never stored.
*
* @param list<array{PartNumber: int, Size: int, ETag: string}> $parts
* @return array{0: int, 1: string}
*/
private function concatenate(UploadSession $session, array $parts, string $assembledPath): array
{
$out = fopen($assembledPath, 'wb');
if ($out === false) {
@@ -166,57 +272,46 @@ class LocalPartStore
$hash = hash_init('sha256');
$size = 0;
foreach ($parts as $part) {
$partPath = $this->partPath($session, $part['PartNumber']);
$in = fopen($partPath, 'rb');
try {
foreach ($parts as $part) {
$in = fopen($this->partPath($session, $part['PartNumber']), 'rb');
if ($in === false) {
fclose($out);
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
}
while (! feof($in)) {
$buffer = fread($in, 1024 * 1024);
if ($buffer === false) {
break;
if ($in === false) {
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
}
fwrite($out, $buffer);
hash_update($hash, $buffer);
$size += strlen($buffer);
try {
while (! feof($in)) {
$buffer = fread($in, 1024 * 1024);
if ($buffer === false) {
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
}
if ($buffer !== '' && @fwrite($out, $buffer) !== strlen($buffer)) {
throw new RuntimeException(self::WRITE_FAILED);
}
hash_update($hash, $buffer);
$size += strlen($buffer);
}
} finally {
fclose($in);
}
}
} catch (Throwable $failure) {
fclose($out);
fclose($in);
unlink($partPath);
throw $failure;
}
fclose($out);
$readStream = fopen($assembledPath, 'rb');
if ($readStream === false) {
throw new RuntimeException('Could not reopen assembled file.');
// fclose flushes, so a volume that filled up on the last buffer
// fails here rather than in the loop.
if (! fclose($out)) {
throw new RuntimeException(self::WRITE_FAILED);
}
$diskEvent = new ResolvingUploadDisk($session->user);
Event::dispatch($diskEvent);
$disk = $diskEvent->disk;
Storage::disk($disk)->writeStream($targetPath, $readStream);
if (is_resource($readStream)) {
fclose($readStream);
}
$this->abort($session);
return [
'path' => $targetPath,
'disk' => $disk,
'size' => $size,
'checksum' => hash_final($hash),
];
return [$size, hash_final($hash)];
}
public function abort(UploadSession $session): void
@@ -226,7 +321,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
+24 -4
View File
@@ -96,8 +96,9 @@ class FileVersions
DB::transaction(function () use ($file, $previous, $root, $actor): void {
// Move, never drop: a revision holds no recipients of its
// own, but the people who already had this file must not
// lose it. Through FileSharing so each target still gets
// its activity entry, notification and digest.
// lose it. Through FileSharing, so a target the root does
// not hold yet still gets its activity entry, notification
// and digest — and only such a target, see below.
$this->moveAssignmentsToRoot($file, $root);
$file->update([
@@ -544,8 +545,27 @@ class FileVersions
continue;
}
// firstOrCreate inside, so a target the root already has is a
// no-op rather than a duplicate notification.
// A target the root already holds gains nothing here, so it
// is skipped rather than handed to FileSharing::assign().
// That method's firstOrCreate makes the assignment row
// idempotent but not the three side effects under it, so such
// a target was told a file had been shared with it about a
// file it already had — on top of the file_new_version it
// gets from sharedAudience(), which is exactly the two
// notifications for one action link() resolves that audience
// early to avoid. copyAssignmentsFrom() below states the rule
// outright for its own case: nobody is gaining access, so the
// notification would be a lie.
$alreadyOnRoot = FileAssignment::query()
->where('file_id', $root->id)
->where('assignable_type', $target->getMorphClass())
->where('assignable_id', $target->getKey())
->exists();
if ($alreadyOnRoot) {
continue;
}
$this->sharing->assign($root, $target, $target->name);
}
@@ -8,8 +8,10 @@ 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\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
@@ -17,6 +19,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,21 +40,61 @@ 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]);
$this->activity->log(Action::GroupMemberAdded, subject: $group, context: ['member' => $client->name]);
return new GroupResource($group->loadCount('members')->load('members'));
return $this->response($group, $actor);
}
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]);
return new GroupResource($group->loadCount('members')->load('members'));
return $this->response($group, $actor);
}
/**
* The group as this actor may see it.
*
* GroupResource carries a name and an email per member, and its own
* docblock puts the boundary here: "the controller loading this
* relation is where that narrowing is applied". Api\GroupsController
* ::show() applies it for the read of the same group; changing the
* membership is not a reason to be told more than reading it, so both
* halves narrow by the same query.
*
* The count is deliberately not narrowed. members_count is the size of
* the group, which is a fact about the group rather than about who is
* in it, and the web screen shows the same total.
*/
private function response(Group $group, User $actor): GroupResource
{
return new GroupResource($group->loadCount('members')->load([
'members' => fn (BelongsToMany $members) => $members
->whereIn('users.id', $this->scope->clients($actor)->select('id')),
]));
}
}
@@ -9,9 +9,11 @@ use App\Modules\Api\Support\PollingQuery;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Groups\Http\Resources\Api\GroupResource;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Groups\Models\Group;
use App\Support\Rules;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -29,6 +31,7 @@ class GroupsController extends Controller
public function __construct(
private readonly PollingQuery $polling,
private readonly ActivityLogger $activity,
private readonly StaffLibraryScope $scope,
) {}
public function index(Request $request): AnonymousResourceCollection
@@ -54,9 +57,20 @@ class GroupsController extends Controller
return GroupResource::collection($this->polling->paginate($request, $query, 'groups'));
}
public function show(Group $group): GroupResource
public function show(Request $request, Group $group): GroupResource
{
return new GroupResource($group->loadCount('members')->load('members'));
$viewer = $request->user();
assert($viewer !== null);
// The web edit screen's boundary, on its API twin: this is the read
// half of the group that update() and destroy() below already refuse
// to touch, and it hands back the membership with addresses.
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
return new GroupResource($group->loadCount('members')->load([
'members' => fn (BelongsToMany $members) => $members
->whereIn('users.id', $this->scope->clients($viewer)->select('id')),
]));
}
public function store(Request $request): JsonResponse
@@ -83,6 +97,14 @@ class GroupsController extends Controller
public function update(Request $request, Group $group): GroupResource
{
$viewer = $request->user();
assert($viewer !== null);
// Mirrors the web controller: a group reaching past this token
// owner's library is not theirs to change, and deleting one
// revokes its members' access to everything assigned to it.
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
$validated = $request->validate([
'name' => ['sometimes', 'string', 'max:255'],
'slug' => Rules::slug('groups', $group->id),
@@ -108,8 +130,16 @@ class GroupsController extends Controller
return new GroupResource($group->refresh()->loadCount('members'));
}
public function destroy(Group $group): JsonResponse
public function destroy(Request $request, Group $group): JsonResponse
{
$viewer = $request->user();
assert($viewer !== null);
// Mirrors the web controller: a group reaching past this token
// owner's library is not theirs to change, and deleting one
// revokes its members' access to everything assigned to it.
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
$name = $group->name;
$group->delete();
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Groups\Models\Group;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -17,6 +18,7 @@ class GroupMembersController extends Controller
{
public function __construct(
private readonly ActivityLogger $activity,
private readonly StaffLibraryScope $scope,
) {}
public function store(Request $request, Group $group): RedirectResponse
@@ -35,6 +37,17 @@ class GroupMembersController extends Controller
]);
}
$actor = $request->user();
assert($actor instanceof User);
// Membership is a library boundary, not just a list: joining a
// group hands the new member everything shared with it, and if
// that member is one of the actor's own clients,
// File::scopeVisibleToClient hands the same content back to the
// actor. `edit_groups` in front of the route is a permission,
// not a boundary. See StaffLibraryScope::allowsGroupMembership.
abort_unless($this->scope->allowsGroupMembership($actor, $group, $client), 403);
$group->members()->syncWithoutDetaching([$client->id]);
$this->activity->log(Action::GroupMemberAdded, subject: $group, context: ['member' => $client->name]);
@@ -42,8 +55,15 @@ class GroupMembersController extends Controller
return back();
}
public function destroy(Group $group, User $member): RedirectResponse
public function destroy(Request $request, Group $group, User $member): RedirectResponse
{
$actor = $request->user();
assert($actor instanceof User);
// The same boundary as store(): taking somebody out of a group
// is a decision about their access, and about a group.
abort_unless($this->scope->allowsGroupMembership($actor, $group, $member), 403);
$group->members()->detach($member->id);
$this->activity->log(Action::GroupMemberRemoved, subject: $group, context: ['member' => $member->name]);
@@ -8,8 +8,8 @@ use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\UserType;
use App\Support\Pagination;
use App\Support\PublicUrl;
use App\Support\Rules;
@@ -25,6 +25,7 @@ class GroupsController extends Controller
public function __construct(
private readonly ActivityLogger $activity,
private readonly PublicUrl $publicUrl,
private readonly StaffLibraryScope $scope,
) {}
public function index(Request $request): Response
@@ -92,11 +93,26 @@ class GroupsController extends Controller
$this->activity->log(Action::GroupMadePublic, subject: $group, context: ['slug' => $group->slug]);
}
return redirect()->route('groups.edit', $group)->with('success', __('Group created.'));
// Same create-without-edit rule as ClientsController::store().
$target = $request->user()?->can('edit_groups')
? redirect()->route('groups.edit', $group)
: redirect()->route('groups.create');
return $target->with('success', __('Group created.'));
}
public function edit(Group $group): Response
public function edit(Request $request, Group $group): Response
{
$viewer = $request->user();
assert($viewer !== null);
// The same reach question update() and destroy() ask, asked one
// step earlier. Without it this was the one group route holding no
// library boundary at all: a scoped staff member could open a group
// whose contents they cannot see, read its membership off the
// screen, and only be refused on save.
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
return Inertia::render('groups/edit', [
'group' => [
'id' => $group->id,
@@ -105,14 +121,24 @@ class GroupsController extends Controller
'description' => $group->description,
'public' => $group->public,
],
'members' => $group->members()->orderBy('name')->get()
// Both lists narrow through StaffLibraryScope::clients(), which
// is the listing half of the rule this screen's buttons are
// already guarded with: a member outside the roster cannot be
// removed here (allowsGroupMembership refuses it), and a client
// outside it cannot be added. Naming them anyway, with their
// address, was the same mistake the client list made before
// that method existed. An unscoped viewer sees everything,
// unchanged.
'members' => $group->members()
->whereIn('users.id', $this->scope->clients($viewer)->select('id'))
->orderBy('name')
->get()
->map(fn (User $member): array => [
'id' => $member->id,
'name' => $member->name,
'email' => $member->email,
])->all(),
'available_clients' => User::query()
->where('type', UserType::Client)
'available_clients' => $this->scope->clients($viewer)
->whereNotIn('id', $group->members()->pluck('users.id'))
->orderBy('name')
->get()
@@ -126,6 +152,19 @@ class GroupsController extends Controller
public function update(Request $request, Group $group): RedirectResponse
{
$viewer = $request->user();
assert($viewer !== null);
// A group whose reach extends past this staff member's library is
// not theirs to change. #1701 drew this line for membership; the
// object itself needs it for the same reason and more sharply —
// an assignment to a group is how its members reach a file, so
// deleting one revokes that access for every member, including
// clients outside this person's roster. Measured before this
// guard: a scoped role deleted a stranger's group and the
// stranger's client stopped seeing the file it carried.
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
// The slug only matters (and is only shown) once a group is
@@ -154,8 +193,21 @@ class GroupsController extends Controller
return back()->with('success', __('Group updated.'));
}
public function destroy(Group $group): RedirectResponse
public function destroy(Request $request, Group $group): RedirectResponse
{
$viewer = $request->user();
assert($viewer !== null);
// A group whose reach extends past this staff member's library is
// not theirs to change. #1701 drew this line for membership; the
// object itself needs it for the same reason and more sharply —
// an assignment to a group is how its members reach a file, so
// deleting one revokes that access for every member, including
// clients outside this person's roster. Measured before this
// guard: a scoped role deleted a stranger's group and the
// stranger's client stopped seeing the file it carried.
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
$name = $group->name;
$group->delete();
@@ -5,8 +5,11 @@ declare(strict_types=1);
namespace App\Modules\Groups\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Groups\Models\Group;
use App\Modules\Groups\Models\MembershipRequest;
use App\Modules\Groups\Notifications\GroupMembershipDeniedNotification;
use App\Modules\Notifications\Notifier;
@@ -30,6 +33,7 @@ class MembershipRequestsController extends Controller
private readonly ActivityLogger $activity,
private readonly Settings $settings,
private readonly Notifier $notifier,
private readonly StaffLibraryScope $scope,
) {}
public function index(Request $request): Response
@@ -40,12 +44,19 @@ class MembershipRequestsController extends Controller
$filters = ['search' => $validated['search'] ?? null];
$viewer = $request->user();
assert($viewer !== null);
$requests = MembershipRequest::query()
->pending()
// A request whose client or group vanished is dead weight; excluding
// it in SQL (not after fetching) keeps pagination counts honest.
->whereHas('user')
->whereHas('group')
// Narrowed the way the buttons on each row now are — see
// MembershipRequest::scopeApprovableBy, which the sidebar badge
// reads too so the number and this screen agree.
->approvableBy($viewer)
->with(['group', 'user'])
->when($filters['search'], fn (Builder $query, string $search) => $query->where(fn (Builder $q) => $q
->whereHas('user', fn (Builder $u) => $u->where('name', 'like', "%{$search}%")->orWhere('email', 'like', "%{$search}%"))
@@ -68,13 +79,15 @@ class MembershipRequestsController extends Controller
]);
}
public function approve(MembershipRequest $membershipRequest): RedirectResponse
public function approve(Request $request, MembershipRequest $membershipRequest): RedirectResponse
{
$group = $membershipRequest->group;
$client = $membershipRequest->user;
abort_unless($group !== null && $client !== null && $membershipRequest->status === MembershipRequest::STATUS_PENDING, 404);
$this->guardRequest($request, $group, $client);
$group->members()->syncWithoutDetaching([$client->id]);
$membershipRequest->delete();
@@ -85,11 +98,26 @@ class MembershipRequestsController extends Controller
return back()->with('success', __('Membership request approved.'));
}
public function deny(MembershipRequest $membershipRequest): RedirectResponse
public function deny(Request $request, MembershipRequest $membershipRequest): RedirectResponse
{
// The half of approve()'s guard that applies here. A request that
// has already been denied is not a decision left to make, and
// taking it again re-stamps denied_at -- which is what the
// client's re-request cooldown counts from, so the same request
// repeated keeps a client out of a group indefinitely -- while
// writing a second log entry and sending a second "your request
// was declined" mail for one decision. The queue only ever lists
// pending requests, so this is not reachable through the screen;
// it is reachable by asking for the route directly.
abort_unless($membershipRequest->status === MembershipRequest::STATUS_PENDING, 404);
$group = $membershipRequest->group;
$client = $membershipRequest->user;
if ($group !== null && $client !== null) {
$this->guardRequest($request, $group, $client);
}
// The denied row persists: the client sees the outcome, and it
// enforces the re-request cooldown.
$membershipRequest->forceFill([
@@ -107,4 +135,25 @@ class MembershipRequestsController extends Controller
return back()->with('success', __('Membership request denied.'));
}
/**
* Approving a request is GroupMembersController::store by another
* door: it joins a client to a group, with the same consequence for
* what that client -- and any staff member holding them -- can reach
* afterwards. Denying one is a decision about somebody's client, and
* emails them about it. Both belong inside the same boundary, and
* `approve_groups_memberships_requests` in front of the route is a
* permission, not one.
*
* 404 rather than 403, matching the guard immediately above it in
* approve(): a request this staff member may not act on should not
* be distinguishable from one that is not there.
*/
private function guardRequest(Request $request, Group $group, User $client): void
{
$viewer = $request->user();
assert($viewer !== null);
abort_unless($this->scope->allowsGroupMembership($viewer, $group, $client), 404);
}
}
@@ -9,11 +9,16 @@ 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\FileDelivery;
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\Preview\PreviewLog;
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;
@@ -29,12 +34,12 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia;
use Inertia\Response as InertiaResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* The guest-facing side of a public group: no Gate/policy involved (same
@@ -74,11 +79,15 @@ class PublicGroupsController extends Controller
public function __construct(
private readonly Settings $settings,
private readonly ActivityLogger $activity,
private readonly PreviewLog $previews,
private readonly DownloadAllowance $allowance,
private readonly ThumbnailGenerator $thumbnails,
private readonly PublicThemeRegistry $themes,
private readonly CapabilityRegistry $capabilities,
private readonly CommentingRules $commenting,
private readonly StoredFileResponse $bytes,
private readonly LocalSourceFile $source,
private readonly FileDelivery $delivery,
) {}
public function index(Request $request, string $publicSlug): InertiaResponse|RedirectResponse
@@ -208,6 +217,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
@@ -239,19 +255,93 @@ class PublicGroupsController extends Controller
$disk = Storage::disk('files');
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);
// An empty file is not a rendition — same rule as the signed-in
// twin in FileThumbnailController::render(), and the same reason:
// nothing invalidates one once it is cached.
if ($disk->exists($thumbnailPath) && $disk->size($thumbnailPath) === 0) {
$disk->delete($thumbnailPath);
}
return response('', 200, [
'X-Accel-Redirect' => '/protected-files/'.$thumbnailPath,
'Content-Type' => $file->mime_type,
'Content-Disposition' => ContentDisposition::inline($file->original_name),
]);
if (! $disk->exists($thumbnailPath)) {
$disk->makeDirectory(dirname($thumbnailPath));
// 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 $this->delivery->serve(
$thumbnailPath,
$file->mime_type,
ContentDisposition::inline($file->original_name),
);
}
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);
// Debounced exactly as the signed-in twin is, and for the same
// reason: a single visitor watching one video arrives here dozens
// of times. Without a viewer to key on, PreviewLog keys on the
// request IP.
$this->previews->record(Action::PublicFilePreviewed, $file, null);
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 +355,6 @@ class PublicGroupsController extends Controller
$this->activity->log(Action::PublicFileDownloaded, subject: $file);
return response('', 200, [
'X-Accel-Redirect' => '/protected-files/'.$file->path,
'Content-Type' => $file->mime_type,
'Content-Disposition' => ContentDisposition::attachment($file->original_name),
'Content-Length' => (string) $file->size,
]);
return $this->bytes->attachment($file);
}
}
@@ -13,9 +13,12 @@ use Illuminate\Http\Resources\Json\JsonResource;
* @mixin Group
*
* Members carry a name and an email, which is what the group edit screen
* already shows to anyone holding `edit_groups`. They are attached only
* when explicitly loaded, so a listing of groups does not become a bulk
* export of every client's address.
* shows the same viewer. That is a claim about the screen, so it holds
* only for as long as the screen does: both narrow the list to the
* clients the viewer may act on, and the controller loading this relation
* is where that narrowing is applied. They are attached only when
* explicitly loaded, so a listing of groups does not become a bulk export
* of every client's address.
*/
class GroupResource extends JsonResource
{
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Modules\Groups\Models;
use App\Models\User;
use App\Modules\Files\Access\StaffLibraryScope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -44,6 +45,37 @@ class MembershipRequest extends Model
return $query->where('status', self::STATUS_PENDING);
}
/**
* The requests a staff member may actually act on.
*
* MembershipRequestsController guards approve() and deny() with
* StaffLibraryScope::allowsGroupMembership, because joining a client
* to a group decides what that client -- and any staff member
* holding them -- can reach. This is the listing half of the same
* rule, and both the queue and the sidebar badge read it, so the
* number and the screen behind it cannot drift apart. That is why it
* lives here rather than in either caller, the same reasoning
* VisibleCommentScope::pendingTotal() gives for owning the comment
* badge instead of leaving the middleware to count for itself.
*
* Narrowed on the client only. Whether the *group* is reachable is
* the other half of allowsGroupMembership, and it depends on what is
* shared with that group -- not a question to ask row by row in a
* listing. So a scoped viewer may still be shown a request they
* would be refused on; it will be one of their own clients asking to
* join a group out of their reach, rather than a client they were
* never meant to hear about. The names are the part that leaks.
*
* @param Builder<MembershipRequest> $query
* @return Builder<MembershipRequest>
*/
public function scopeApprovableBy(Builder $query, User $viewer): Builder
{
$clientIds = app(StaffLibraryScope::class)->assignableClientIds($viewer);
return $clientIds === null ? $query : $query->whereIn('user_id', $clientIds);
}
/**
* @return BelongsTo<Group, $this>
*/
@@ -7,6 +7,7 @@ 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\Files\DeletedAccountContent;
use App\Modules\Identity\Models\Role;
use Illuminate\Database\Eloquent\Builder;
@@ -32,20 +33,39 @@ class AccountContentDeletion
public function __construct(
private readonly DeletedAccountContent $content,
private readonly ActivityLogger $activity,
private readonly StaffLibraryScope $scope,
) {}
/**
* Every other active account, for the reassignment-target picker.
* $excludeId is omitted on index pages, where one candidate list is
* shared across every row and each row's own id is filtered out
* client-side instead.
* Every other active account this viewer may be shown, for the
* reassignment-target picker. $excludeId is omitted on index pages,
* where one candidate list is shared across every row and each row's
* own id is filtered out client-side instead.
*
* The client half is narrowed by StaffLibraryScope, the same rule that
* narrows the list this picker sits next to: a client-scoped staff
* member is not shown the name of somebody they can reach nothing of,
* and a picker is no more a reason to hand one over than a listing is.
* Staff accounts are not narrowed anywhere in the application and are
* not narrowed here.
*
* An unscoped viewer's list is unchanged — StaffLibraryScope::clients()
* returns every client for them.
*
* $viewer is null only where the picker is about the installation
* rather than about a screen: the erasure default in privacy settings
* is stored once for everybody, behind edit_settings, so narrowing it
* by whoever happens to be editing would store the wrong answer.
*
* @return array<int, array{id: int, name: string, role: string}>
*/
public function candidates(?int $excludeId = null): array
public function candidates(?User $viewer, ?int $excludeId = null): array
{
return User::query()
->when($excludeId, fn (Builder $query, int $id) => $query->whereKeyNot($id))
->when($viewer, fn (Builder $query, User $for) => $query->where(fn (Builder $reachable) => $reachable
->where('type', UserType::Staff)
->orWhereIn('id', $this->scope->clients($for)->select('users.id'))))
->where('active', true)
->with('role')
->orderBy('name')
+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
{

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