36 Commits

Author SHA1 Message Date
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
ignacionelson cab9291d29 Stop two tables from growing forever on an untended installation
Failed queue jobs and read notifications both grow with use, and neither
ever shrank on its own. The failed-jobs list waited for somebody to press
"Delete all failed" — a fine tool for a backlog you are looking at, and
the only thing that ever emptied it. Notifications had nothing at all: one
row per recipient per event, kept for the life of the installation, on
what is easily the fastest-growing table here.

Both now have a retention window, set together on the Scheduler screen
under Housekeeping, and a nightly purge that honours it. Thirty days for
failed jobs and ninety for read notifications, and zero means keep
everything — the explicit choice somebody makes when a failure is evidence
rather than debris.

Unread notifications are never deleted, whatever their age. A notification
nobody has looked at is the one row in that table still doing its job, and
somebody back from four months away should find their news rather than a
clean slate. The activity log is untouched by any of this: it is an audit
trail, and it is never pruned.

Two things came out of building it. The API request log purge has been
running nightly since it shipped without ever appearing on the Scheduler
screen — so a failure of it was invisible on the screen that exists to
make failures visible — and there is now a test asserting the screen's
list and the schedule are the same list, because they had already drifted
once and would again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:44:06 -03:00
ignacionelson b671d0d74a Put the update script's useful options one click away
The screen that tells somebody how to update names one command and stops,
which is right — the command is the procedure. But two of the options
behind it are the ones an administrator wants at exactly that moment:
whether it can take the backup for them, and whether they can just look
without changing anything. Both were documented only in UPDATE.md and in
--help, neither of which is open on the screen they are reading.

They are behind an "Other options" link rather than printed, so the page
in its resting state is unchanged and the one command stays the thing you
see. Four lines, the two above plus the two for a run nobody is sitting in
front of.

It opens from the dashboard card and from the update dialog both, which
means a dialog on top of a dialog in the second case. That is the right
shape here: the reader asked for a footnote to what they are already
reading, and Escape puts them back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:37:01 -03:00
ignacionelson e87ceb60ba Say what the update check found, not just that it ran
The Scheduler screen printed "Check for updates · Succeeded · —" and left
it there. What it found — the whole reason that job exists — was in the
settings, which that screen never read. Somebody opening it to ask "is
there a new version?" got the answer to "did the job run?"

The Message column now carries "Up to date" or the version that is
waiting. A failure's own message still wins: what the last successful run
found is not the answer to why this one broke.

Joined at render time rather than recorded by the command, because
Laravel's scheduler fires its finished event after the command returns and
overwrites whatever the command wrote — which is exactly why that column
was empty in the first place. Reading the settings instead also keeps the
line true when the new Check now button did the work rather than the
nightly run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:36:01 -03:00
ignacionelson d888145b21 Say when this installation was last updated
The update command has been recording the version it applied and the
moment it did since it shipped, and exactly one thing read it: the notice
that appears when the running code and the applied version disagree. So
the fact was written down and then only ever mentioned when something was
wrong.

About now answers the ordinary version of that question — "Updated to
2.1.0 on 17 Aug 2026" — beside the version it already showed. It is the
answer to "when did this change?", asked after something looks different,
and by whoever inherited a server from the person who set it up.

Absent rather than approximated on an installation that has never been
updated through the command: a fresh install has no update to date, and
"unknown" is noise. Same gate as the rest of that block, so a managed
installation — where the version is not the reader's concern — is
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:36:01 -03:00
ignacionelson 997debc6a3 Let somebody ask for an update instead of waiting for tonight
The check ran daily and there was no other way to run it. An administrator
who has just read that a release fixes the thing bothering them had to
reach a terminal — or wait until tomorrow to be told what the project
announced this morning.

There is now a Check now button beside the setting that schedules it. It
says what came back: the version waiting, or that this installation is
already on the newest. The time of the last check sits next to it, because
the notice on the dashboard is only as good as when it was last refreshed
and nothing said when that was.

Deliberately not gated on the daily-check setting. Switching that off says
"do not have my server phone out unattended", which is not the same
sentence as "refuse to answer when I ask" — so the button works either way
and the setting keeps governing only the schedule.

The work moved out of the command into CheckForUpdates, because the part
that must not drift between the two callers is the part with consequences:
which staff get notified, and the guard that stops them being notified
again for a release they already know about. A second copy of that in a
controller would have been found wrong six months later by somebody
receiving the same notification every time a colleague pressed a button.

Two throttles, and the second is not redundant. The route's bucket is per
user; GitHub's limit is per server address, so two administrators each
within their own allowance can still exhaust the installation's. The
cooldown is installation-wide and costs no new setting — it reads the
timestamp every check already writes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:35:48 -03:00
ignacionelson a8a7f3f340 Send each edition to its own front door, and stop asking payers for money
Two things on the About screen were written when there was only one
edition. "Website" pointed at projectsend.org for everybody, and the
donation link was offered to hosted customers who are already paying for
this — on the same screen that thanks them for choosing it.

projectsend.org is the way in for the software you run yourself and
projectsend.cloud is the way in for the hosted service, so `links.website`
now resolves to whichever one the reader is actually using. That reaches
further than About by design: the "Powered by ProjectSend" line at the
foot of every outgoing email and on every client-facing page is where a
recipient meets this product for the first time, and sending a hosted
customer's recipients to self-hosting instructions is the wrong door.

The donation link is *omitted* rather than hidden by the page, so a
surface added later cannot ask a paying customer for money by forgetting
to check. Its TypeScript type is optional now, which makes the compiler
enforce the same thing.

Also fixed on the way past: the settings footer hardcoded the text
"projectsend.org" next to that link, so on the hosted service it named a
site it did not link to. It reads the host off the resolved URL now.

Verified in a browser against both editions, not only in tests. Cloud:
projectsend.cloud, no donation link, on both screens. Community:
projectsend.org and Open Collective, exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 00:55:41 -03:00
ignacionelson 29f1eaaa1b Do not thank a hosted customer for installing anything
Nobody on a managed installation installed ProjectSend, and the version
they are running is not theirs to keep or to change — it is looked after
for them. "Thank you for installing ProjectSend. You are running version
2.0.0, and it is yours to keep" thanked them for somebody else's work, on
the one screen whose entire job is to sound like a person wrote it.

Cloud now reads "Thank you for choosing ProjectSend" over "Your site is
ready, and keeping it running is our job." The version comes out of the
sentence there: it is true, and it is none of their concern, which is
exactly what makes it noise.

Community is unchanged, and so is the wording somebody sees revisiting
the page later — that one is neutral already.

Branched on the shared `edition` prop rather than a capability, because
the question being asked really is which edition this is: SystemUpdates
happens to line up today, but it is about who may update, not about who
installed.

Both new strings are in all sixteen catalogues in the same commit, since
this is a two-string change and splitting it would leave the hosted
greeting English-only for however long the next pass takes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 00:43:23 -03:00
ignacionelson b72d30d89e Sign the two greeting pages the way the website signs itself
"Made with care in Argentina. Shared with the world." — projectsend.org's
own line, at the foot of the two screens where somebody is being greeted
rather than getting on with their work.

Drawn rather than set as the flag emoji the website uses. Regional
indicator pairs have no glyphs on Windows or on most Linux desktops, and
both fall back to a pair of small letters — the first render of this
component read "AR Made with care in Argentina" on the machine that
screenshotted it, which is what a good half of the people self-hosting
this would have seen. Eleven lines of SVG look the same everywhere.

The Sun of May is a plain disc: at sixteen pixels its rays are a smudge,
and a smudge reads as a rendering fault rather than as a flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 20:17:17 -03:00
ignacionelson 046567fdfc Say thank you, and make the list scannable at a glance
Both greeting pages read like status reports. The install page opened
":name is installed and yours" without saying which version, the update
page opened "The update finished, and everything came back up", and the
quick-start list was eight full-width rows of two-line descriptions —
about 1400px, with the last two steps and the invitation below the fold
on a laptop.

The install page now thanks somebody for installing ProjectSend and names
the version they are on. The update page thanks them for updating and for
continuing to trust it with their file sharing. Both revert to plain
wording when the page is opened later from a link: thanking a reader
again for something they did months ago is the cold thing, not the warm
one.

The list becomes a two-column grid of icon cards — four rows instead of
eight, 1000px against 1490px, which is one screen. Icons come from the
sidebar's own vocabulary, so the chip on a card is the icon on the screen
it opens. Descriptions are one short clause each; the screen at the other
end explains itself.

And the steps stop pretending to be equally urgent. QuickStart now says
which are essential — the two that make this application do anything at
all, the mail server, the scheduler — and those carry an amber chip and
a label, against the brand colour for everything else and green for the
done ones. Amber is not invented here: it is the warning Alert variant's
palette, reused verbatim so dark mode is somebody else's solved problem.

The Discord card was two identical copies within an hour of each other,
so it is one component now, before the pair could drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 20:05:33 -03:00
ignacionelson 4ce6793da9 Show a new installation's administrator around, once
Setup ended by handing somebody a login form and an empty dashboard.
Everything this application can do was one menu away, and which menu was
theirs to discover.

The first time the administrator signs in to a new installation they now
land on a short ordered list of what is worth doing first — add a client,
upload a file, group the people who get the same things, choose how the
file lists and the email look, point it at a mail server, add the team,
check the scheduler — each a link straight to the screen that does it.

The list is filtered twice, and both filters matter. By permission,
because a link that answers 403 is worse than no link. And by edition:
a managed installation is not sent off to configure a mail server
somebody else runs, to create staff accounts that are not its to create,
or to check a scheduler it does not host. Those three drop out on Cloud
and the other five remain.

Two steps tick themselves, because the database can answer them: a client
exists, a file exists. Nothing else is checkable without guessing — a
theme that was never changed looks exactly like one chosen deliberately —
and a tick meaning "we assume so" is worse than no tick.

The invitation to the Discord is at the very bottom, after the list.
Somebody who has just installed this came with a job in mind, and opening
with a social invitation is the fastest way to lose them.

The marker is raised where a first administrator comes into existence —
the setup screen and `projectsend:admin`, so a container provisioned from
environment variables is welcomed too — and it is false by default, so an
installation that updates into this feature is not congratulated on an
install it finished a year ago.

RedirectToWhatsNew becomes RedirectToGreeting and answers for both: they
are the same interruption, and a second middleware on the same route
would have to know about the first to avoid arguing with it. Installing
wins; release notes for a version you never ran are the wrong greeting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 19:44:22 -03:00
ignacionelson e3c58818e2 Give the invitation the brand colour, so it reads as the feature
On `muted` it was a grey box between two things people actually look at.
It now sits on `accent` — the brand colour at surface strength, which
already carries a dark-mode counterpart, so this is legible in both
without a hardcoded purple anywhere and follows the palette on an
installation whose branding replaces ours.

Its button is filled and the one below it is not. Outline was wrong here
twice over: its hover state is this exact background, so the button
vanished under the cursor, and "continue to the dashboard" is leaving —
not the thing this page is encouraging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 14:25:36 -03:00
ignacionelson 6ddfc1aa5d Greet the administrator once, on the first visit after an update
An update finished and nothing said so. The dashboard looked identical to
yesterday's, and whatever the release brought was in a file nobody opens.

The first time the installation's administrator opens ProjectSend after
an update, they now land on a page that names the version they are on,
invites them to the Discord — the same invitation update.sh prints, made
again where they are actually looking — and then lays out what the
release brought.

The notes come from CHANGELOG.md inside the release, not from GitHub: the
one moment this page exists for is the moment after an update, possibly
on a server with no outbound access, describing code already on disk.
Parsed rather than rendered, so nothing in it can become HTML.

Once, and to one person. The update happened to the installation, so
greeting five staff members — each having to dismiss a page they did not
ask for — would turn a pleasant moment into a support question. It goes
to the oldest active administrator, which on any installation that went
through setup is whoever set it up. No owner flag was invented for this:
administrators are equal in authority, and changing that for a greeting
is not a trade worth making.

Only forwards, and only for a real update. A fresh install has nothing to
catch up on, a container reboot has not updated anything, and somebody
restoring an older release is dealing with a problem rather than
celebrating. Managed installations never see it at all — nobody signed in
there performed the update it thanks them for, which is the same gate the
System card and About's environment block already carry.

The redirect is attached to the dashboard alone, not the web group: it
catches a login and the sidebar logo both, without ever interrupting a
download to congratulate somebody. Reading the page clears the marker,
but the address keeps working — closing it by accident should not be
unrecoverable — and About now links to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 14:22:17 -03:00
Ignacio Nelson ed0d36de25 Reduce a manual update to one command that asks first (#1628)
Updating a server install cost nine artisan invocations plus a PHP-FPM
reload, written out in three places that had already drifted apart. One
of those steps is silently fatal to skip: with opcache.validate_timestamps
off — what production guides recommend and what our own image ships — the
database moves to the new version while every visitor keeps being served
the old code, and artisan reports the new version throughout.

`sudo ./update.sh` is now the whole procedure. It asks whether to check
GitHub, asks whether to download the release and verifies the checksum
published beside it, and asks whether there is a backup — offering to dump
the database when the answer is no. Then it takes the site down, replaces
the files, runs the update, reloads PHP-FPM, restarts the worker and
brings the site back. The application still has no self-updater: nothing
is fetched or applied unless somebody runs this and answers yes.

Underneath it is `php artisan projectsend:update`, which is everything an
update does that needs no root — and now the only definition of it. Both
container entrypoints call it instead of carrying their own copy of the
sequence, so the two paths cannot drift again.

Three findings worth keeping in the record, all from rehearsing rather
than reasoning:

  - queue:restart has to come last. It writes its signal into the cache,
    so clearing the cache afterwards deletes it and the worker runs old
    code forever.
  - optimize:clear is not safe to recommend. It runs cache:clear, which
    on Redis is FLUSHDB — harmless on the default two-database layout,
    but on a single-database Redis it takes the sessions and the queue
    with it. The compiled caches are cleared individually instead.
  - update.sh overwrites itself mid-run, because the zip contains it and
    bash reads its own script lazily by byte offset. It re-execs from a
    temporary copy before touching anything.

And when the reload is skipped anyway, the application now says so:
projectsend:update records the version it applied, and any staff page
compares that with what the running process actually compiled. The same
check catches the mirror image — new files in place, update never run.

Rehearsed end to end against real installs: a container upgrade (69 to 73
migrations, key and data intact, healthy), a scripted update on a real
nginx + php-fpm install with OPcache pinned (web process moved 2.1.0 to
2.1.1), the skipped-reload case (banner appears naming both versions, and
clears on reload), the refusals (downgrade, non-release zip, truncated
zip, URL passed to --zip, non-root), a database taken down mid-update
(site comes back out of maintenance mode by itself), and a real download
of the published 2.0.0 zip with its checksum verified.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:29:20 -03:00
Ignacio Nelson 72749a9070 Document updating, and the OPcache step nobody documents (#1626)
Updating had a numbered list inside INSTALL.md and a code block in the
dashboard, and between them they were missing the step that decides
whether an update works at all.

Rehearsed both paths end to end against a real 2.0.0 install before
writing any of this down. On Docker the whole update is `docker compose
pull && docker compose up -d`: the entrypoint waits for the database,
migrates, ensures the roles, and starts the workers, and it does not
start nginx until that finishes — so a failed migration is a container
that stays down rather than a half-updated site taking traffic. The
generated APP_KEY on the storage volume is left alone. Verified: 69
migrations to 73, key unchanged, data intact, healthcheck healthy.

The manual path is where the gap was. Replace the files, run the four
documented commands, and the site still serves the old version: with
`opcache.validate_timestamps=0` — what every production guide recommends,
and what our own image ships — PHP never re-reads a file it has already
compiled. The database is on the new version, every visitor is on the
old code, and `php artisan` cheerfully reports the new version the whole
time you are trying to work out why. Reproduced exactly that, then
watched a php-fpm reload fix it.

UPDATE.md is now the whole procedure for both, INSTALL.md keeps the short
sequence with the reload added, and the in-app instructions gained the
same line — in the code block, which is not translated, so no locale is
left saying something different.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 18:42:49 -03:00
Ignacio Nelson 0e3f7d5c68 Take the browser tab's name from the site, not from the build (#1621)
The tab title suffix came from `import.meta.env.VITE_APP_NAME`, with the
starter kit's own `|| 'Laravel'` behind it. Vite resolves that at build
time, and a release ships public/build/ already compiled — precisely so
nobody downstream needs npm — so whatever the build machine had is
frozen for every install that artifact produces. The 2.0.0 zip has
`const s2="Laravel"` in app-BoPagLMd.js, and no APP_KEY, APP_NAME or
VITE_APP_NAME an operator sets afterwards can reach it (#1619).

Swapping the fallback to 'ProjectSend' would fix the reported symptom
and leave the mechanism: a name baked at build time, wrong for anyone
who renamed their site. The name is already on every page — the shared
props carry the site_name setting — so read it from there and drop the
build-time variable entirely, .env.example's dead VITE_APP_NAME line
included.

The root view's own <title> now reads the same shared prop, so the tab
does not show one name before hydration and another after on any
installation whose administrator renamed the site.

Verified in a real browser, which is the only place this is visible —
the server-rendered title was always right, and curl never saw the bug.
Titles read "Log in - ProjectSend" and, after renaming the site with no
rebuild of any kind, "Log in - Acme Files".

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:42:06 -03:00
ignacionelson 6e47d76ba6 ProjectSend 2.0.0
Client file sharing, rebuilt from the ground up: a private area per
client, resumable uploads, folders, groups and categories, sharing with
expiry dates and download limits, comments, file versions, an activity
log, a REST API, and sixteen languages.

This repository begins here. ProjectSend 2 was developed privately, and
that development history is not published — the previous generation
remains available, with its own history, at projectsend/legacy.

Free software under the GNU General Public License v2, or (at your
option) any later version.
2026-08-14 01:38:12 -03:00