Commit Graph

239 Commits

Author SHA1 Message Date
ignacionelson 6560346280 Mark the first administrator's address verified, as intended
The last two paths that passed email_verified_at into User::create() and
lost it: the setup screen, and projectsend:admin for a container that
comes up from environment variables. It is deliberately absent from
$fillable, so mass assignment drops it without a word, and both meant to
set it.

The intent is plain in both cases — the first administrator typed their
own address into the form in front of them, and whoever provisioned the
container supplied it themselves. There is nobody to confirm it to.

Inert today, since MustVerifyEmail is not enabled on the model, but the
column is what a later switch would read: turning verification on would
have locked out the one account that cannot be helped by another
administrator.

Both are now pinned by a test that fails when the forceFill is removed.
StaffAccounts had already fixed this for staff and named the rest; with
client accounts done earlier today, that list is empty.

Also says on User::$fillable what absence from it buys and what it does
not. It stops a request smuggling a value in; it does not tell code that
meant to set the value that it failed. Four separate paths made the same
mistake against the same comment.
2026-09-08 17:07:35 -03:00
ignacionelson e187513cdd Stop a mistyped CAPTCHA flag from switching the CAPTCHA off
`env()` recognises the words "true" and "false" and returns everything
else as the string it was — and every non-empty string is truthy in PHP.
So `(bool) env('PROJECTSEND_CAPTCHA_DISABLED')` read all of these as "yes,
disabled":

    PROJECTSEND_CAPTCHA_DISABLED=no
    PROJECTSEND_CAPTCHA_DISABLED=off
    PROJECTSEND_CAPTCHA_DISABLED=fasle

An operator who meant to say no took the bot protection off their login
and registration forms and had nothing to tell them so — the setting
screen still shows the CAPTCHA configured, because this is the escape
hatch that runs ahead of it.

For most settings the cast is a shrug: somebody notices the feature is on
and fixes the line. It stops being a shrug when the wrong answer is the
unsafe one, and this is one of those. EnvFlag lists what counts as yes —
`true` and `1`, either case, either type — and reads everything else,
recognised or not, as no. A value typed as `disabled` turns nothing off:
a configuration mistake to be found rather than guessed at.

Found while fixing the same bug in a new cloud-modules flag, where the
unsafe direction was publishing a customer's files rather than dropping a
CAPTCHA. Two of the four remaining `(bool) env()` casts are left alone on
purpose: a wrong S3 path-style value breaks storage loudly, and the
migration tool's direct mode defaults to true anyway, so neither fails
into an unsafe state.
2026-09-08 17:05:30 -03:00
ignacionelson 896675d631 Tell a client whether their own file arrived
"Did it arrive?" is the question somebody asks about a file they sent, and
on a hosted free account — where a link is the whole of the sharing — the
count is the only evidence either way. Every file a client uploaded now
shows how often it has gone out and when it last did, in every render mode
of every theme.

Only their own. A download entry says somebody fetched the file, so a
count on a file shared with several clients tells each of them about the
others' activity, and nobody is entitled to that but the person who put
the file there. A file shared *with* this client carries null, not zero:
a zero would itself be a claim, and the two have to be distinguishable
because zero is an answer the owner came looking for and is shown as
words.

Counted from the activity log through the same three actions
DownloadAllowance uses, so a file leaving by the public site counts as
much as one leaving by its link. One query for a listing, none at all for
a client with no files of their own. Both filters have a test that fails
when only that filter is removed.

Two things a render check caught that types and a green build did not.
`t()` does no plural selection — the catalogues are flat key/value — so a
"one|many" string reached the screen with its pipe intact; the strings are
whole sentences now, with the singular spelled out. And the gallery card
was already laying its text out beside the action icons in a 200px column,
truncating the filename to "Q…" and the size to "75 …" on main today;
stacking them gives every line its full width.
2026-09-08 16:56:54 -03:00
ignacionelson 92bb807849 Show a client the public link to their own file
A client's portal lists two kinds of file side by side: what they
uploaded, and what somebody shared with them. Where a link exists on one
of their own, they can now copy it from the row — which is what makes the
hosted free plan a product rather than a place to put files, since a
customer there has no staff screen on which to make one.

The rule is narrow, and both halves are load-bearing: a link this client
created, on a file this client uploaded.

Not "a link on a file shared with them" — that link is the sharer's
decision about who may reach the file, and handing the recipient the URL
would quietly turn "you may download this" into "you may pass this on to
anyone".

And not "any link on their own file" either — a link staff minted on a
file a client uploaded exists for a reason the client may be no part of,
and on the shared instance it would sit beside the one link they were
promised. Ordering is by id, so an unfiltered lookup would hand them
whichever was minted first.

Both halves have a test that fails when only that half is removed. The
first draft did not: every case was carried by the ownership filter
alone, so the creator check was green for the wrong reason.

Links that no longer work are left out rather than shown greyed. The only
thing a client can do here is copy it, and a URL that answers "this link
has expired" is worse than no URL at all.

One query per listing, not one per row, and none at all for a client with
no files of their own.
2026-09-08 16:47:57 -03:00
ignacionelson 0a28e239d6 One home for what a client account is
Three surfaces create client accounts now: the staff screens,
/api/v1/clients, and the platform control plane in the private package.
Two of them held their own copy of the type, the role, the active flag,
the "0 means inherit the site default" quota, the verified stamp, the
activity entry and the seat guard — and the third could not have a copy
at all, because a package cannot import a host class.

ClientAccounts is that one definition, reached by name from outside.
What stays with each caller is what genuinely differs: its validation,
its response, its custom fields, and who is asking.

Two things changed rather than moved:

The seat cap is now checked inside create(), before anything is written,
instead of at the top of each controller. That is what makes a leaked
platform token an incident rather than an unbounded one — a guard that
ran only where somebody remembered it is not a guard.

email_verified_at is written with forceFill. It is deliberately absent
from User::$fillable, so every client-creation path passed it into a mass
assignment and lost it in silence. StaffAccounts already noted this and
named the other paths; this closes the client half.
2026-09-08 16:23:34 -03:00
ignacionelson b128b114b5 Make an announcement say who it is for
The first version refused clients outright. That was right for the only
message that existed — a hosted instance telling its administrator about
their plan — and it stopped being right the moment a message needed to
reach the *clients* of a shared instance, where the administrator is the
operator and the customers are client accounts.

The unsafe fix would have been to drop the guard and let each listener
check `isStaff`. The safe one is to make every caller say who it is
talking to and have core enforce it: `show()` now takes a required
`audience` with no default, and a message aimed elsewhere is dropped
before it reaches the props. A listener that forgets therefore reaches
nobody rather than everybody, which is the direction a mistake should
fall.

An unrecognised audience reaches nobody either, and is ignored rather
than thrown — a listener aimed at the wrong people should show nothing,
not break the page it was decorating.

The old "a client is never shown one" test became "a message for staff
reaches no client, even from a listener that never checks", which is the
property that actually matters and the one the enforcement provides. Two
more pin the other directions: a client message reaches clients and no
staff, and an unknown audience reaches neither.

cloud-modules declares `staff` for the free-plan band, and its test fake
enforces the same rule, so a listener aimed at the wrong audience fails
in the package's own suite rather than passing there and misbehaving in
the host.
2026-09-08 15:48:29 -03:00
ignacionelson 757fba19ca Give uploads a seam, and link-minting one home
Two pieces of groundwork, no behaviour change.

FileWasStored is dispatched from StoreUploadedFile, which every upload
path converges on — the chunked flow staff and clients share, and the
synchronous POST beside it. A listener therefore sees each upload once
without knowing which route produced it, which is the property that makes
it usable from outside this repository. A notification, not a filter:
nothing on it is mutable, and anything that needs to influence an upload
has to do so before the bytes land, which is what ResolvingUploadDisk is
already for.

CreateShareLink is the other half. Minting a link was a ShareLinksController
private concern, and the controller is an HTTP handler behind `staff`
middleware — so a link now needs making from outside a request as well.
Two copies of "make a token, write the row, log it" would drift, and the
half most likely to drift is the token, which is the entire authorization
for /s/{token}: there is no session behind it and no second factor, so
being unguessable is its only defence. Anything minted through the action
gets Str::random(32) — about 190 bits, more than a UUID's 122 — and never
a chosen value. The chosen-token path stays in the controller, where a
person is typing one into a form and its minimum length can be argued
about in a validation rule.

The permission questions stay in the controller too. Whether somebody may
set an expiry or a download cap is a fact about them, and the action has
no viewer to ask; it takes both already resolved, including the expiry,
because "the end of the 12th" depends on whose timezone you are in.

Five tests, including that the file a listener receives is complete and
readable rather than half-built, and that the staff form still refuses an
expiry to somebody without the permission after the extraction.
2026-09-08 15:39:55 -03:00
ignacionelson 763e7b0e2e Render one image once, however many requests ask at the same time
Renditions are generated on demand and cached by existence, and nothing
between the callers stopped two requests decoding the same image at once.
The atomic rename settled which file survived; it never stopped both from
doing the work. So N concurrent requests for one cold rendition were N
full-size decodes, each holding four bytes per source pixel — up to 160 MB
at the 40-megapixel ceiling.

That is not an attack. A public listing emits a thumbnail URL per file, a
browser opens six or more connections at once, and the first visit to a
gallery of ordinary camera images was six simultaneous decodes on a
container sized for one. PublicGroupsController reaches the generator with
no account at all, so nothing about it required a customer to be signed
in, and the 240/min throttle bounds rate rather than concurrency.

Worse than a crash, it did not resolve itself: a render killed mid-flight
renames nothing, so the cache warmed only by whatever finished before the
kill and the page died again on the next visit.

A lock keyed on the destination path — which already encodes the file, the
audience and the rendition, so two requests collide exactly when they
would have written the same path. The waiter re-reads after acquiring,
which is what turns a wait into a cache hit rather than a second decode of
the same image.

Waiting rather than refusing, because the arithmetic says so: a waiting
request holds an idle worker at about 35 MB, a rendering one holds that
plus the whole source bitmap. Six waiters cost what one renderer costs.

On timeout it refuses instead of rendering anyway. Falling through would
reinstate the pile-on at the moment the system is already struggling, and
one failed thumbnail is a better outcome than a container that dies and
takes the warm cache with it.

The wait is configurable because the right number is a property of the
machine — a small VPS reading a large source off a slow disk wants longer
— and clamped to at least a second, since a stray empty variable would
otherwise make every concurrent request fail instantly, which is the
opposite of the point.

Eight tests. Two go red without the lock, and the clamp is asserted on the
resolved value rather than the clock, because block() measures in whole
seconds and a timing assertion there would be flaky rather than wrong.

Found by the session sizing free-tier containers, from the outside.
2026-09-08 15:29:20 -03:00
ignacionelson a5496d24cd Stop describe() vouching for a detection it could not make
`FileDelivery::describe()` from a console returned
`{"method":"php","detected":true}` on every installation, whatever its web
server. detect() reads SERVER_SOFTWARE, which only exists inside a
request, so a console process has nothing to look at and falls to the
`php` default — and `detected: true` then vouched for it.

The value is right for that process and wrong as a statement about the
installation, which is how anybody running it from `artisan tinker` will
read it. Somebody verifying a healthy nginx tenant hit exactly that, spent
an afternoon on it, and only recognised it as an artefact of *where* the
question was asked after reading `nginx -T` in the container.

There is now a third field. `observed` is false only outside a request,
where `method` is a default rather than a finding. Both screens that read
this run in a request and always see true; it exists for whoever asks from
a shell, which is the one place the answer could mislead.

The two web paths are unchanged and were never wrong — `projectsend:status`
does not report delivery at all, so no fleet ever reported this
incorrectly. What was wrong was a confident answer to a question that
could not be answered from where it was asked.

Three tests: a console reading says not observed and still says php,
because php is what that process would actually do; a reading during a
request observes nginx; and a stated method is observed wherever it is
read, since a decision needs nothing detected to be true.

Found by the session verifying the 2.4.0 canary, not by me.
2026-09-08 10:21:02 -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 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 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 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
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 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 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
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
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
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 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 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 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 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