66 Commits

Author SHA1 Message Date
ignacionelson 38400956bc Put the installation's own logo on the pages people sign in through
Requested by @Zodiac1978 in #1777.

The logo already replaced ours in the staff sidebar, on the public listing
and in the client portal. The sign-in screen still wore the ProjectSend
wordmark — and that is the first page of yours most people ever see, and
often the only one a client sees, because it is where the link in a
notification email lands them.

One layout serves every screen reached before signing in, so this covers
login, registration, both password-reset pages, the two-factor challenge,
first-run setup and the page a share link opens. That breadth is the reason
to change the layout rather than the login page: the same visitor moves
between several of them in one sitting, and a logo that appeared on one and
not the next would read as a different site.

Nothing needed gating. `branding.logo_url` is already shared on every
request and is already null wherever the Branding capability is absent, so
an installation that has withheld branding, or never uploaded anything,
renders exactly what it rendered before.

Three tests cover the server's half — the prop reaching a page nobody has
signed in to see, the null fallback, and the capability being taken away.
None of them can say whether the component mounted or the image resolved,
so that was checked in a real browser: headless Chrome against the dev
instance with a logo installed reports the <img> present, naturalWidth 360
(so it decoded rather than sitting broken) and a rendered height of 48px,
and with the logo removed reports no <img> and the fallback SVG in its
place. The branding row was snapshotted before and restored after.

One thing worth knowing, unchanged by this and not introduced by it: a logo
drawn for a white background is hard to read on the dark theme, here and on
every other surface that shows it, because none of them filter the artwork.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
2026-09-11 13:00:38 -03:00
ignacionelson eaba7ff633 Let an AWS-hosted install authenticate as its own IAM role
Requested by @ToMMy86 in #1773: an install running on ECS, EC2 or EKS
already has a role attached, and making it also create an IAM user with
a long-lived access key is both extra work and a worse security posture
than the one AWS offers.

The AWS SDK resolves credentials from its default provider chain
whenever none is supplied, and Laravel's FilesystemManager already omits
the `credentials` entry when the key and secret are empty — so the
upload path needed almost nothing. What blocked it was ours:

- `isConfigured()` demanded a key and a secret for S3, so a
  credential-less row was never "configured" and every upload silently
  stayed on the local disk.
- `access_key` was `required_if:provider,s3` on both the save and the
  connection test.
- `probeS3()` built an explicit `credentials` array, so Test connection
  would have failed even once uploads worked.

An explicit `use_instance_role` column rather than "the key was left
blank", because blank already means "keep the credential you have" on
this form — neither the secret nor the GCS key file is ever sent back to
the browser. Ticking it deletes the stored key and secret rather than
leaving them in the row for the next database dump.

Unchanged for everyone else: MinIO, Backblaze, Wasabi and any other
S3-compatible service still authenticate with a key and secret, and the
region is still required — the chain resolves credentials, not regions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QmyH342d8MuW3pDuE9mbtS
2026-09-10 16:16:31 -03:00
ignacionelson 6d7d80f62f Give the announcement band its own row, and put it on the dashboard too
All four themes had it as a flex child of the row holding the heading and
the buttons, so it was never full width: it took part of the line and
squeezed "My files" and "Upload a file" into a narrow column beside it. It
now sits above that row, which is where the staff dashboard has always put
it and where the comment claimed it was.

Worth noting why four themes shipped it wrong. The band renders nothing
for almost every viewer — it needs a hosted instance, a free plan, and a
client looking — so the broken layout was invisible to the suite, to the
build, and to anybody working on those pages. Seen only once somebody on
the real free tier looked at it.

Also adds it to the client's dashboard. That screen is about the account
rather than about the files, which is the more natural place for an offer
about the plan, and both read the same shared prop so they cannot disagree
about what is said or to whom.

Checked with real screenshots in all four themes and on the dashboard,
against a temporary listener standing in for cloud-modules, since this
install is community and would otherwise render nothing. The listener and
the theme setting were both put back.
2026-09-09 01:04:15 -03:00
ignacionelson df44c46a12 Say a reset link has expired before asking for the work
The page rendered the form without looking at the token, so somebody
opening a link an hour late typed a password, typed it again to confirm,
and was then told "this password reset token is invalid" — a word nobody
outside the code knows, at the end rather than the start. Links last an
hour and people open them late. That is ordinary, not an error to be
scolded for.

store() still validates and is still the rule; there is a test that a
spent token is refused there whatever the page drew. This is only the
screen being honest a minute earlier.

An address that is missing, or belongs to nobody, is drawn as the form was
before. Partly because an unanswerable question is not an expired link,
but mostly because a page that said "expired" for a real address and
something else for an unknown one would answer whether an account exists
here to anybody typing guesses — the exact property /forgot-password
protects by saying "a link will be sent if the account exists". Two tests
pin that.

Worth having now rather than later: the advisories publishing with this
release will send more people than usual through this screen, in a hurry
and some of them frightened.

Found by the portal session's user, who opened a real link an hour and
forty minutes after it was sent.
2026-09-08 20:08:28 -03:00
ignacionelson ab5fa2da8b Make Entra prove the address, not just the directory
Reported by Dickson Massawe as GHSA-2rfh-v3j2-2jg7.

Pinning the tenant was half an answer. It defeats the classic
cross-tenant nOAuth, where a stranger's own directory asserts your
address, because a foreign tenant carries a different tid. It does
nothing about the same attack from inside the pinned tenant: Entra's
email claim is user-mutable — a B2B guest's otherMails among its sources
— so a colleague or an invited guest could present an administrator's
address and have their subject bound to that account.

Tenant-pinning answers "which directory said this". It never answered
"does this person own that address". xms_edov is Microsoft's own answer
to the second, and their guidance says to require it wherever email
identifies an account. Absent counts as unverified, which is the only
safe reading given it is absent by default.

Nobody is locked out by this, which is worth saying because it looked
like a breaking change until I read SocialAuthenticator::resolve in
order. An account already linked resolves by subject at step 3, before
trust is consulted at all — those keep working untouched. A first-time
link to an existing account is refused with the message that already
exists for exactly this case, which names the way through: sign in with
your password and connect the provider from your settings. A brand-new
account is still created; it goes to the approval queue rather than
auto-approving.

The settings screen and docs/testing-social-login.md now tell an
operator to add the claim, and there is an upgrade note.

The tests exercise fromSocialite() on raw claims, which nothing did
before: tests/Feature/Auth/SocialLoginTest.php builds a SocialIdentity by
hand and so never reaches this mapping. That is how the branch could
trust a tenant match alone with a full suite passing.
2026-09-08 19:00:46 -03:00
ignacionelson 3244be6bac Ask for the password before changing the address a reset goes to
Reported by Nooraldden Khalel as GHSA-f32x-fgmp-q353.

The profile screen let a signed-in session change its own email address
with nothing else, and that address is where a password reset is sent. So
a stolen session was enough: point the account at your own inbox, ask for
a reset, set a password, and temporary access is permanent ownership.
Clearing email_verified_at did not stand in the way, because the model
does not implement MustVerifyEmail and the reset broker never asks.

destroy(), thirty lines further down the same controller, has always
required the current password, and its comment says why: "the rule every
other door into this already asks". This door leads to the same place and
was not asking.

Only a *different* address asks. A name, a timezone or a custom field is
not a credential, so the rest of the screen saves with nothing extra —
which is why the rule is excluded rather than flat, and why the comparison
is trimmed and lowercased: re-saving a profile with the address typed in a
different case must not demand a password for nothing.

An account whose credentials live in a directory or at an identity
provider is refused outright and told why, rather than being asked for a
password it does not have. LdapProvisioner stores Str::password(64)
exactly so that local password can never be used, so asking would be a
dead end dressed as a form error — and the address is not theirs to change
here anyway: it is what the directory says it is.

The test walks the whole chain rather than checking the field is
validated, because the chain is what made this high: change the address,
ask for a reset there, and confirm nothing is sent and no such account
exists.
2026-09-08 18:57:01 -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 3d923188d9 Show an announcement on the client's own file portal
The band existed only on the staff dashboard, which is a screen a client
never opens. On a shared instance the customer *is* a client account —
they sign in, upload, and share by link, and the administrator is the
operator — so a message for them has to reach the page they actually use.

ViewerAnnouncement reads the shared prop itself rather than taking one,
so each theme adds it in a single line without threading a prop through a
page that has no other reason to know about it. All four portal themes
render it, because a message that only appears in the theme somebody
remembered to wire is a message that quietly does not exist.

Above the heading, not inside the list: it is not one of the things the
client came to do, and burying it under the files would defeat the point
of having it at all.

No theme decides who sees it. Core drops anything not aimed at the
viewer, so a theme renders whatever it is handed and cannot leak a staff
message to a client by being careless.
2026-09-08 15:55:47 -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 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 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 2ebadf0793 Mark the downloads settings link the way the dashboard marks it
Same amber, same underline, same warning triangle as the System widget
row. The two say the same thing about the same installation, so looking
different made the settings one read as an ordinary footnote rather than
the thing to go and read.
2026-08-31 22:49:06 -03:00
ignacionelson 25e4f77b63 Make the whole downloads row open the explanation
The warning triangle alone did not read as clickable. The label is now
an underlined button and the value and icon are a second one, so either
half opens the dialog and the row looks like the one thing on this card
you are meant to act on.

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

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

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

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

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

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

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

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

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

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

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

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

The suite pins itself to nginx. Left at auto it would detect no server
at all, fall back to php, and quietly retire the coverage of the
mechanism most installations actually use.
2026-08-31 22:31:27 -03:00
ignacionelson 41b22d003e Merge pull request #1764 from denkfabrik-li/fix/placeholder-case-convention
Honour Laravel's placeholder case convention in t()
2026-08-29 16:00:17 -03:00
ignacionelson 78d5067c6b Merge pull request #1763 from denkfabrik-li/fix/zip-poll-stops-with-its-page
Stop the zip poll when its page goes away
2026-08-29 15:58:11 -03:00
denkfabrik-li ed82d748ea Run the auth and settings screens through the translator
use-translation.ts states the rule: every user-facing string in a
component must go through t(). Five screens never called it at all --
forgot-password, reset-password, confirm-password, verify-email and
settings/password had zero occurrences of useTranslation -- so a client
who had chosen Spanish reset their password in English, from the browser
tab down to the submit button. settings/profile had the hook but used it
for two strings, leaving its heading, labels and the whole
email-verification notice hardcoded around them.

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

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

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

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

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

Three holes, one leak:

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

No test accompanies this: there is no JavaScript test runner in the
project, and the PHP suite never mounts a component. Verified with
tsc, eslint and prettier, and by reading the two consumers —
files/index.tsx and use-portal-files.ts — both of which only ever
clear the interval through the dialog's onClose today.
2026-08-29 08:57:49 +02:00
denkfabrik-li 7264c44fd7 Serve the interface font from the installation, not from a font CDN
app.blade.php is the root template for all three interfaces, and it opened
with two lines pointing at a third party:

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

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

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

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

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

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

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

Verified: `npm run build` succeeds and emits the faces; no reference to
the CDN survives anywhere in public/build; `tsc --noEmit` and prettier are
clean. No test asserts on the font, before or after.
2026-08-29 00:50:02 +02:00
ignacionelson 479dc61d2d Move branding into core, and leave white-labelling behind
Logo and watermark belonged in the private package for one reason: that
is where they were written. Nothing about them needs a hosted platform,
and an installation wanting its own mark on the pages it serves is the
ordinary case rather than the exotic one. They are core's now, and every
installation has them.

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

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

What got better on the way rather than merely moving:

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

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

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

A hosted plan without branding subtracts branding.customize and
attribution.hide from the instance's environment. The row is never
deleted by that: a downgrade is usually an expired card rather than a
decision, and wiping somebody's artwork over a billing event is a loss
they would find weeks later with no way to know what it used to be.
Hiding reverses; deleting does not.
2026-08-28 13:27:10 -03:00
ignacionelson 13b56186f4 Say the seat limit before the form, not after it
On a managed installation with its staff seats full, /users/create opened
as though there were room. You typed a name, an address and a password
you had to invent, pressed Save, and the plan limit came back as a
validation error under the email field -- which reads as a complaint
about the address rather than a fact about the plan.

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

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

Clients get the same treatment: the cap exists there too, and reached it
the same way. Self-hosted installations have no limit, so they are shown
nothing about one.
2026-08-27 21:02:54 -03:00
ignacionelson 351da21e8d Stop the text half of an email printing its link twice in brackets
Laravel's notification view writes the subcopy URL as [$url]($url).
The HTML half parses that into an anchor; the text half parses nothing,
so it arrives as literal brackets around a duplicated address. With the
button line above it the URL appeared three times in one message.

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

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

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

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

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

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

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

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

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

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

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

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

Orphans left alone at 250. The ten that touch today's subjects were
checked one by one and every one is a false positive of the kind the
skill warns about: `Expired files` is WIDGET_LABELS data, `Uploader` is a
role name from the database, `Page Expired` is laravel-lang's.
2026-08-26 22:55:18 -03:00
ignacionelson c8078f65c5 Say whose expired files the dashboard is listing
Closing the one thing 4b8220a left open, and the reason it was left: the
expired-files widget reads StaffLibraryScope::files(), and
File::scopeVisibleToClient ends in notExpired(), so a client-scoped
viewer sees only their own expired uploads and never a client's.

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

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

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

Checked in a browser for both, not just in the assertions: the scoped
dashboard renders "Your expired files / Files you uploaded. Your
clients' files are not listed here. / None of your uploads have
expired.", with no console errors, and an unscoped administrator's is
unchanged.
2026-08-26 18:12:57 -03:00
ignacionelson 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
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 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 daec0a877e Offer Google Cloud Storage as a storage backend
External storage meant S3 and nothing else, which is an odd hole for a
product whose users are as likely to be standing on Google Cloud as on
AWS — and paying to move bytes between two clouds to use this. The
Storage screen now asks which provider first, and the answer decides
which fields it shows, which it validates, and which driver the
files_external disk resolves to.

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

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

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

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

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

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

Verified in a browser as well as in tests — which is how the null
provider on an unmigrated row was found, since the suite migrates and
never sees that state.
2026-08-24 16:38:13 -03:00
denkfabrik-li 6a8a287984 Name the tab Outgoing email, not Sending
Discussion feedback: in ProjectSend "sending" can just as well mean
files. Outgoing email is unambiguous and the established name for this
screen elsewhere (GitLab, Jira, Moodle all call it that).
2026-08-24 06:58:28 +02:00
ignacionelson 4f38c9adee Show one confirmation toast, not two
Every page wraps itself in AppLayout, so a flashed redirect that lands on
a different page component tears the layout down and builds it again --
Toaster with it. The fresh Toaster then reads the flash at mount *and*
catches the router success event for the same visit, and every "Client
created." arrived twice. Saves that stay on the same component never
remount, which is why this survived unnoticed.

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

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

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

The Sending tab links to the guide right where an admin picks an
OAuth provider, via the shared links.source origin.
2026-08-23 22:46:25 +02:00
denkfabrik-li 933eaa2ba4 Send mail through Microsoft Graph as an admin-connected mailbox
Adds "Microsoft 365 (OAuth)" to the Email settings provider dropdown.
Selecting it swaps the SMTP form for an app registration (client id,
secret, optional tenant) and a "Connect mailbox" flow: the admin signs
into the mailbox the installation should send as, and outgoing email
goes through Graph sendMail as that mailbox — no password, no app
password, no SMTP AUTH, which Microsoft is winding down.

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

Tokens and the client secret live encrypted in their own row and are
read fresh at send time, never through the boot-config cache. The
stored SMTP transport survives a provider switch untouched.
2026-08-23 22:46:24 +02:00
ignacionelson 51eea30dda Answer "did they ever actually get it?" from the file itself
The two things staff most often want to know about a file — who
downloaded it, who looked at it — were answerable only by reading the
whole activity log past everything else that had happened to it, or by
going back to the library list for the details panel.

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

Which filter value stands for "every download" is decided server-side
and travels with the payload, because it is a fact about the log's
vocabulary: downloads are three actions and share a group, previews are
one action and are filtered by name. The history page now also keeps
whatever filter it was sent with visible in its dropdown even at a
count of zero, so a button cannot land somebody on an empty table above
a select that has gone blank.
2026-08-21 15:04:11 -03:00
ignacionelson 8f12c83d21 Tell a clone-and-build install to rebuild, not to pull
ProjectSend prints the update instructions for the way this server was
installed, and it knew two answers where it needed three: anything inside
a container was handed `docker compose pull && docker compose up -d`. On
the Compose stack that builds from a checkout there is no image behind
those containers, so `pull` skips every ProjectSend service and `up -d`
then finds them all current — the update reports success, changes
nothing, and the dashboard goes on offering the same release. Reported by
@mueller7382, who stayed on 2.0.0 that way while 2.1.0 was out (#1661).

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

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

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

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

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

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

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

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

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

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

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

Also fixes a layout bug the tests could never catch. A portal file row was
flex justify-between with three children — name, comment trigger, download
— so the middle one settled wherever the name happened to end and the
comment icon sat at a different place on every row. The name block now
takes the slack and every action lives in one trailing group, with the
comment trigger in a fixed-width slot so the icons form a column. And
because half the previewable files have no thumbnail to click — a PDF, an
mp3 and an mp4 all render as a generic icon — every row gains an explicit
PreviewAction beside DownloadAction, matching whatever style that theme
gives its download control.
2026-08-21 14:14:23 -03:00
ignacionelson 7d1903f9db Let the download history be searched
The installation-wide download history listed every download newest
first and offered nothing else, so "did that client ever actually
download the contract?" meant paging through everything that had
happened since.

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

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

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

That full history is now filterable, which is the point of sending
somebody to it. The action list is built from the file's own log rather
than from the eighty-odd actions the software can record — all but a
handful of which can never apply to a file — and each option carries its
count. Downloads are three separate actions on purpose (a signed-in
recipient, a public link, the public group listing), so "All downloads"
asks that question once instead of three times; the group only appears
when the file's log actually holds more than one of its members.
Narrowing by who acted and by date range works the same as it does on
the main activity log, the reader's own calendar day included.
2026-08-21 12:39:16 -03:00