Commit Graph

157 Commits

Author SHA1 Message Date
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 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
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 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 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 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
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