Commit Graph

28 Commits

Author SHA1 Message Date
ignacionelson 55e17498a2 Log which bucket an upload could not be written to
The failure message names the disk, which reads as a credentials problem
even when the real cause is a bucket name that was never changed — the
exact confusion produced by switching an existing S3 configuration over
to Google and leaving the old bucket in the field.

Logged rather than shown, because 'throw' => false means the reason is
already gone by the time this code runs, and because the message goes to
whoever was uploading. That can be a client, and a bucket name is not
theirs to see.
2026-08-24 19:56:10 -03:00
ignacionelson a459d45c87 Store the bytes, or say you did not
Two bugs a green suite could not find, both from pointing the
application at a real Google Cloud Storage bucket.

The adapter attaches a legacy per-object ACL to every write, and a
bucket with uniform bucket-level access — which our own setup
instructions require, and which Google recommends — refuses it:
"Cannot insert legacy ACL for an object when uniform bucket-level access
is enabled". So the default configuration could not write to the
recommended bucket. The library ships
UniformBucketLevelAccessVisibility for exactly this, and nothing is
lost by never setting an ACL: every object here is private and every
read is a signed URL.

The second is worse and was never about Google. Both file disks are
configured 'throw' => false, so a refused write returns false rather
than raising, and LocalPartStore ignored the return. The upload reported
success, the File row was written, and the bytes were nowhere — the
listing showed a file whose download could never work. An expired S3
credential did the same thing. It now checks, and the controller already
turns that into a validation error rather than a 500, so the person
uploading is told.

Verified against a live bucket with a key scoped to
roles/storage.objectAdmin: the probe lists, writes land, reads
round-trip byte for byte, and a signed URL comes back 200 carrying
"Informe año.pdf" intact through both the ASCII and RFC 8187 forms of
Content-Disposition.
2026-08-24 19:52:05 -03:00
ignacionelson 23b7dc0d11 Declare the capability a managed installation's storage hangs off
Cloud instances are given a bucket rather than configuring one, which is
the counterpart of StorageConfigure above it rather than a contradiction
of it: one edition points itself at storage, the other is pointed.

Only the declaration lives here. The behaviour is in the private
cloud-modules package, the same division Branding already uses, and
without that package the capability is inert and files stay on local
disk — so a self-hosted installation that somehow holds it is unchanged.
2026-08-24 18:35:44 -03:00
ignacionelson daec0a877e Offer Google Cloud Storage as a storage backend
External storage meant S3 and nothing else, which is an odd hole for a
product whose users are as likely to be standing on Google Cloud as on
AWS — and paying to move bytes between two clouds to use this. The
Storage screen now asks which provider first, and the answer decides
which fields it shows, which it validates, and which driver the
files_external disk resolves to.

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

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

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

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

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

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

Verified in a browser as well as in tests — which is how the null
provider on an unmigrated row was found, since the suite migrates and
never sees that state.
2026-08-24 16:38:13 -03:00
ignacionelson 57540164fa Read a file from the disk it is actually on, everywhere
Two routes still assumed every file sits on local disk, which stopped
being true the moment external storage was switched on. A share link
answered with X-Accel-Redirect whatever the file's disk said, pointing
nginx at a path it has nothing behind; a public listing built a
thumbnail from Storage::disk('files')->path(), which for an externally
stored file is a path nobody ever wrote. Both fail only for installs
using S3, and only on those two routes, so the same file downloading
correctly from the file manager made the share link look like the
broken thing rather than where the file lives.

Neither is a new rule. FileDownloadController and
FileThumbnailController already did it right, which is the actual
finding: the knowledge was sitting in a private method on one class and
inline in another, so the next caller could not inherit it and did not.
Both are now objects with one job.

StoredFileResponse replaces InlineFileResponse and grows an
attachment() alongside inline(), since the two differ only by
disposition. LocalSourceFile takes a closure rather than returning a
path: the version that returned one also left the caller to unlink it,
and both of those are exactly the mistakes made here.

The regression tests fail against the previous controllers — checked in
both directions rather than assumed.
2026-08-24 16:24:39 -03:00
denkfabrik-li 5bfc5a0883 Send the browser to the provider, not the XHR 2026-08-23 22:13:36 +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 cca3d9c314 Group only the downloads, which are the actions that need it
The grouped filter had a second member, "All previews", built on a
public-preview action that does not exist: previewing is recorded one
way today, so its own option already answers "who previewed this?" in
full. Static analysis caught the reference; the group would have been
unreachable even if it had compiled, since a group with a single
present member is deliberately not offered.

Previews get a group here the day a second way to preview a file is
recorded separately, and the test now pins the single-member case on a
file whose log holds one flavour of download.
2026-08-21 13:12:26 -03:00
ignacionelson 7d1903f9db Let the download history be searched
The installation-wide download history listed every download newest
first and offered nothing else, so "did that client ever actually
download the contract?" meant paging through everything that had
happened since.

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

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

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

That full history is now filterable, which is the point of sending
somebody to it. The action list is built from the file's own log rather
than from the eighty-odd actions the software can record — all but a
handful of which can never apply to a file — and each option carries its
count. Downloads are three separate actions on purpose (a signed-in
recipient, a public link, the public group listing), so "All downloads"
asks that question once instead of three times; the group only appears
when the file's log actually holds more than one of its members.
Narrowing by who acted and by date range works the same as it does on
the main activity log, the reader's own calendar day included.
2026-08-21 12:39:16 -03:00
ignacionelson 1b6513f0fb Stop container detection from taking the dashboard down on shared hosting
Deciding which update instructions to print starts with asking whether we
are running in a container, and that question is asked by looking for the
file a container runtime leaves in the root of the filesystem. Shared
hosting confines PHP to the webspace with open_basedir, where looking
outside it is a warning rather than a false — and the framework's error
handler turns warnings into exceptions, so the probe threw instead of
answering. The dashboard is the one page that asks, so it returned a 500
while everything else worked (#1663).

Suppress both probes. A host that keeps PHP inside a single directory is
not our published image, so false is the right answer as well as the
surviving one, and it lands on the manual instructions that shared
hosting wants anyway. Checking ini_get('open_basedir') instead would get
a hardened container wrong in the other direction, handing the manual
sequence to someone whose files are inside an image.

The dashboard was only the first symptom. updateNotice() reaches the same
call on every Inertia response once a newer release exists, and
RunningCodeState reaches it whenever the applied and running versions
disagree — so the next release, or the host's next update attempt, would
have taken every page rather than one.
2026-08-21 12:03:23 -03:00
ignacionelson dd779fafbe Let the scheduled task names be translated
Ten task names sat in a private const as bare strings — 'Purge erased
accounts' and its nine siblings — so the Scheduler screen listed ten
English rows in the middle of an otherwise fully translated page. Found by
looking at the Spanish screen while checking the translation pass, not by
any check, because nothing could have reported it: the scan only sees
literals inside __(), and prose held as data under a key is invisible to
it. Nine of the ten had never been translatable in any language. The tenth,
'Check for updates', looked translated purely by coincidence — a button
elsewhere uses the same words, so the catalogue happened to have it.

A const cannot call __(), so the map becomes a method. That is the whole
change in substance.

The keys are untouched and stay untouched: they are the command names, they
are what the run history, the detail map, the frontend and the test
asserting this list matches the schedule all match on, and they are what
somebody types to run the thing by hand. Only the values were ever
language. The screen still prints the command name verbatim under each
translated label, which is the half a reader would copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:49:27 -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 44f015c066 Let the one essential step nobody could finish finish itself
"Check the scheduler is running" was marked essential and hardcoded
unticked, so the getting-started list could never be completed — the two
steps that tick themselves sat above one that never would, which reads as
a checklist that has quietly stopped working.

It is answerable, and the screen it links to was already answering it: a
scheduled-run row exists once the scheduler has run on this server at all.
That is precisely what the step asks. A run that failed counts, because a
failure still proves cron reaches this installation; why it failed is the
Scheduler screen's job and the step links there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:36:07 -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 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 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 dad8d21dc8 Translate the update strings into all sixteen locales (#1632)
The English for the updater, the stale-code banner and the activity-log
entry had settled, so this is their pass: 208 lines across sixteen
catalogues, every :placeholder intact, no existing entry reordered.

The activity row and its filter label were modelled on each catalogue's
own "Installed ProjectSend" / "ProjectSend was installed" pair, so the
new rows read like the ones beside them rather than like a different
translator wrote them.

One code change belongs to this pass rather than to the feature: the
update entry recorded "an unrecorded version" when the previous version
was unknown, and context values are substituted into the template
verbatim — never translated — so that sentence fragment would have sat
inside an otherwise Japanese or Polish row forever. It is a dash now.

Verified on a real install with the interface switched to Spanish:
"Sistema — Actualizó ProjectSend a 2.0.3, desde 2.0.2", and the action
filter reading "ProjectSend fue actualizado a una versión nueva".
Scan reports 0 missing in every locale; the Locale tests pass.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:13:17 -03:00
Ignacio Nelson 0b994aebe2 Put the update in the activity log (#1631)
The activity log is where an administrator goes to answer "what changed
on this installation, and when" — and the largest change of all was not
in it. A new version arrived, the schema moved, behaviour changed, and
the log said nothing.

`projectsend:update` now records it as a system action, naming both
versions: "Updated ProjectSend to 2.1.0, from 2.0.1". It appears in the
log's own action filter without further work, since that list is built
from the enum.

Only a real version change is written. The container entrypoint runs this
command on every boot, so logging unconditionally would bury the log
under an entry per restart, and a first boot is an installation rather
than an update — SetupCompleted already covers that. "First boot" is
decided by whether any migration had run before this one, not by whether
a version was recorded: the first update of any installation older than
this command finds no recorded version, and that update is exactly the
one worth logging. It says "from an unrecorded version", once, ever.

Writing the entry cannot fail the update: an update that worked must not
report failure because its own paperwork did.

Verified on a real manual install — the row renders as
"Updated ProjectSend to 2.0.3, from 2.0.2", attributed to the system.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:00:23 -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 9b265e3b87 Say what a stored upload part's receipt actually is (#1617)
An empty response gets Symfony's default Content-Type, text/html, and a
CDN in front of the app takes that at its word: Cloudflare's Email
Obfuscation and Automatic HTTPS Rewrites both rewrite HTML bodies, so
they drop the origin's ETag from the response — a rewritten body would
no longer match it.

That ETag is the client's only signal that a part landed. Nothing on
this side notices its loss, because LocalPartStore keeps its own record
of every part and complete() never reads a client-supplied one; the
upload simply reaches 100% and stops, with no error at either end.
Reported from a Cloudflare-fronted install (#1616), where the visible
symptom was Uppy's "Could not read the ETag header" — which names CORS,
and sends you chasing a preflight that same-origin requests never make.

Naming the content type accurately keeps the response out of every
HTML-rewriting path there is, rather than asking each CDN-fronted
install to discover this one for itself.

The rationale sits inside the header array rather than above the return:
Scramble reads a comment attached to a return statement as that
response's description in the published OpenAPI document, and this
controller is mounted on the API routes too.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:51:46 -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