projectsend/community-modules is published and tagged now, so the
dependency can name a version. It could not before: with nothing to point
at, composer.json carried "*" against a vcs entry, which meant every
install resolved the default branch and got whatever had been pushed to it.
`composer validate` has been saying so on every run — "unbound version
constraints (*) should be avoided" — and it is right, because a dependency
that tracks a branch cannot be reasoned about and cannot be rolled back.
So: "^1.0", and the vcs entry goes. It has to go rather than merely stop
being needed, for the same reason the migration tool's did — a repository
declared in composer.json is canonical and outranks packagist.org, so
leaving it would keep serving dev-main off the default branch and shadow
every release ever tagged. Silently, which is the part that matters.
The lock moves with it: dev-main 311883a becomes v1.0.0, sourced from
Packagist rather than a git clone of a moving branch. Same code — the tag
was cut from that commit — but now it is a code somebody can name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
projectsend/v1-migration-tool is published now, and tagged, so
`composer require projectsend/v1-migration-tool` resolves on its own —
on any installation, new or existing, with nothing added to composer.json.
That is what the guide always claimed and what a customer's Debian install
proved it was not.
The vcs entry has to go, not just stop being necessary. Repositories
declared in composer.json are canonical and outrank packagist.org, so
leaving it would keep serving dev-main off the default branch and silently
shadow every release ever tagged. No error, no warning, just the wrong code
indefinitely — the failure mode you find months later.
So the manual fallback goes too, from the guide and from the teardown note
that told people to keep the entry. Neither describes anything that exists
any more, and instructions for a state the reader is not in are how the
last round of this went wrong: the note about a private repository outlived
the repository being private, and got skipped for looking inapplicable
while still being required.
The changelog entry is rewritten rather than added to. It described
shipping a pointer, which was the previous fix and lasted a day; what
landed is that the tool is published and versioned, and an installation can
finally say which version of it ran.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Delete a folder called Test and you could never have a folder called Test
again. The deletion worked, the folder left the screen, and the name went
with it — permanently, with an error that named a collision against a row
the interface will not show you and offered nothing to do about it.
Files and groups had it too. All three carry a unique index on slug and
all three soft-delete, so the trashed row sat in the index holding a name
nothing could reach. A public one failed outright at the validator, which
checks the table and therefore sees rows the screen does not. A private
one failed more quietly: the derived slug stepped around the trashed row
into report-2, then report-3, once per deletion, climbing forever.
The reservation was deliberate — a trashed row's slug was kept so that
restoring it could not land on somebody else's URL. But nothing in this
application restores anything. There is no restore() call, no route, no
screen; File's own comment says as much. Soft deletes are here so rows can
outlive their delete for foreign keys, the activity log and the erasure
grace period, never so they can come back. The slug was being held for a
page that could not return, and route binding already 404s the trashed row
in the meantime.
So deleting now hands the slug back, and the database is what makes that a
rewrite rather than a gentler lookup: teaching the collision checks to skip
trashed rows would leave two rows holding "report", which the unique index
rejects whatever the application thinks. The slug moves to report__deleted-42
instead. Underscores are the whole trick — Str::slug() turns them into
hyphens and Rules::slug() refuses them outright, so no derived slug and no
hand-typed one can ever land on a vacated one. That is a guarantee about
the character class rather than a hope about collisions.
The format lives in VacatedSlug rather than on the trait because the
migration needs it too and a trait constant cannot be reached through the
trait's own name — the first version of this was a fatal error waiting for
whoever ran migrations. The migration matters as much as the hook: without
it the fix only helps installations that have never deleted anything, and
every name already buried stays buried.
The collision checks still count trashed rows. It costs nothing and keeps
them honest about what the index will accept if a row is ever soft-deleted
by something that bypasses model events.
previous_file_id had this same bug and was fixed this same way, in
File::detachOnDelete — a trashed row holding its predecessor's unique slot
so the chain could never be re-linked. This is that fix, for the other four
unique indexes' worth of the same mistake. users.email is the one left, and
is deliberately not in here: an email address is a login identity rather
than a URL handle, and freeing it silently is the wrong answer.
Fixes#1645
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`composer require projectsend/v1-migration-tool` — the first command in the
migration guide, and the first thing anybody moving from Legacy types —
fails on a fresh installation with "Could not find a matching version of
package". The tool is not on Packagist, and nothing in composer.json said
where else to look, so Composer had no way to find it and said so.
The manifest already had the answer four lines up: community-modules, also
unpublished, resolves through a vcs entry. The migration tool now has the
same one, so the documented command works as documented.
The guide said to add that entry by hand, but behind "while the repository
is private" — which stopped being true when the repository went public,
and a note that reads as inapplicable gets skipped. The step it described
was still mandatory, because public and published are different things.
That paragraph is now a fallback for installations predating this commit,
which do still need it, keyed on the error text rather than on a condition
the reader cannot check.
The teardown step is inverted to match: it used to suggest removing the
repositories entry after the migration, which would now break a second
attempt. It says to leave it.
Changing repositories invalidates composer.lock's content-hash, hence the
one-line lock update.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
"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>
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>
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>
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>
Four unrelated one-liners, each already written down and none of them
worth a branch of its own.
The lock was still pinned to the community package's previous commit,
which is the one before it started shipping its own sixteen catalogues.
The mechanism that carries a package's translations to the browser landed
here last week; without this bump the release would have shipped that
mechanism with nothing to carry, and the Custom Assets screen would have
stayed half-English in every language.
The stock `local` disk had `serve` left on. Nothing in this application
writes to it, so the framework's /storage route was a door with nothing
behind it — but it was still a door, and closing it costs one word.
nginx evaluated `\.php$` before `/protected-files/`, so a protected path
ending in .php would have reached the PHP handler instead of streaming
under the sandbox headers that block sets. Not reachable on a default
install — the upload allowlist refuses php and X-Accel paths are UUIDs —
but the guarantee read stronger than it was. `^~` makes it true.
And `.release-build` is now ignored by eslint, so linting after building a
zip stops walking the vendored minified JS inside it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The frontend's catalogue was read straight out of lang/{locale}.json, so
it held exactly the strings this repository owns. That was true for as
long as this repository owned every screen — but the companion packages
own several: Branding, Custom Assets, the whole v1 import. Their strings
have been rendering in English in all sixteen languages, in silence,
because a package catalogue registered through loadJsonTranslationsFrom()
never got as far as the browser.
Asked of the framework's own loader now, which is where that registration
already lands. Same answer as before for this installation — no package
registers a path today, and the merged result is byte-identical to the
file — and the right answer the moment one does.
Precedence comes free and is the useful way round: the loader merges the
application's own catalogue last, so an installation can override a
package's wording without editing the package. There is a test for that,
because it is the kind of ordering that gets reversed by accident.
One thing the test needed and is worth knowing: SetLocale honours an
account's chosen language only while that language is enabled for the
installation, and the Settings cache outlives RefreshDatabase. A test that
sets users.locale and assumes it takes effect gets English and a very
confusing failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing was missing this pass — the new strings were translated as they
landed — so this is step 3, the orphan review that has been deferred for
weeks with the count sitting at 277.
Of those 277, **188 are laravel-lang's own JSON keys** (HTTP status names,
"Whoops!", "Verify Email Address", the pagination labels). Upstream and
human-maintained; the skill says to leave them alone and a lang:update
would put them straight back. Identified by diffing against
vendor/laravel-lang/*/locales/es/*.json rather than by eye.
Another **60 are strings the scanner cannot see**: prose held as data and
rendered through t(VAR) — the dashboard widget labels, the portal's sort
options, EmailTemplateSlot's descriptions, CommentingRules' refusals,
convert.tsx's plural pairs — plus three that belong to the private
packages, which the scanner does not read at all.
That leaves **29 that are genuinely ours and genuinely gone**, and this
removes them. Each was checked as a quoted literal across app/, routes/,
database/, resources/ and all three package repos, then re-checked against
every data-driven translation source. The three that looked alive
("Assigned", "Result", "System settings") turned out to be substrings of
longer live strings — "Assigned the file :subject to :target", the
LdapProbeResult class name, "System settings were updated".
Two were reworded rather than removed, which is the case that matters:
the comment-visibility description and the public-listing paragraph both
say something slightly different in the code today, so their old
translations had silently stopped applying in all sixteen languages while
the new ones are already in place.
Orphans: 277 -> 248, and every one of the 248 is now accounted for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
Twenty-four strings each: the thank-yous, the eight quick-start steps and
their one-line descriptions, the Essential label, and the line from the
website at the foot of both pages. 336 entries in, 48 out.
The three removed are the what's-new hero sentences reworded earlier
today — a translation that silently stopped applying the moment the
English changed, which is exactly the orphan the skill says to clear. All
three were checked against resources/ and app/ first; none survives as a
literal anywhere.
Register follows each catalogue rather than the language's default, and
terminology follows the screen each step links to: the scheduler step
says "programador de tareas" in Spanish because that is what the sidebar
calls it, "Planer" in German, "Harmonogram" in Polish.
"Made with care in Argentina" is translated rather than left in English —
it is a sentence about the people who made this, and the people reading
it in Japanese deserve to read it too.
Checked in the browser with the interface in Spanish: "Gracias por
instalar ProjectSend", "Estás usando la versión 2.0.0, y es tuya",
IMPRESCINDIBLE on the mail server and the scheduler, and "Hecho con
cariño en Argentina. Compartido con el mundo." under the fold line. Scan
reports 0 missing in all sixteen; the Locale tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"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>
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>
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>
The eleven strings the welcome page introduced, now in every catalogue:
176 entries across sixteen files, appended rather than merged in, so the
diff is only what was added and no existing line moves.
Two of them are deliberately identical to the English. "Discord" is a
product name, and "ProjectSend :version" is a name and a placeholder with
nothing between them to translate — the scan counts both as untranslated
and that is the right answer.
Register follows each catalogue rather than the language's default: Sie
in German, vous in French, je in Dutch, Ty in Polish, tú in Spanish. The
thank-you sentence was written to sound like somebody meant it, since a
literal translation of a warm line is a worse translation than a free one.
Checked in the browser with the interface in Spanish, not only scanned:
"ProjectSend ahora está en la versión 2.0.0", "Ven a saludarnos", "Únete
a Discord", "Novedades" — every line fitting its space. Scan reports 0
missing in all sixteen; the Locale tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
The last thing an operator sees after a successful update is now where the
people are. Printed rather than asked: the answer to "would you like to
join?" is a browser, and this runs over SSH on a server that has none, so
a y/n could only print the URL its "no" branch prints anyway — and --yes
would answer it on behalf of a cron job that cannot join anything.
Only on success. --check is a report somebody may be scripting, and a
failed run's last line should stay the recovery instructions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Somebody followed the README's Docker quickstart, which starts the
development stack, and got three failures in a row with nothing to search
for (#1627): the worker died once a second on a missing autoloader, the
site answered a bare 500, and once dependencies were installed by hand the
setup screen threw ViteManifestNotFoundException.
None of that is wrong behaviour for a clone — vendor/ and public/build/
are deliberately not in git — but every one of those failures kept its
cause to itself.
The preflight guard exists to turn "this was never set up" into a
sentence, and it runs before the autoloader precisely so it can. It now
answers two more questions: dependencies not installed, and frontend not
built. The dependency check goes first, before the .env one, because the
fix that branch prints — php artisan key:generate — cannot itself run
without the autoloader, so reporting the key first hands somebody a second
and more confusing error. A running vite dev server counts as built:
public/hot means the assets come from there, and blocking a developer
mid-session would be worse than the exception this replaces.
The worker and scheduler exec straight into artisan, so before composer
install they died instantly and restarted forever, filling the log that
had to be read to fix it. They now print what is missing and exit slowly,
and recover on their own once it is there. The scheduler gains the restart
policy the worker already had — without one it exits during that window
and stays exited, and scheduled work then silently never happens.
Rehearsed on a genuine clone of the public repository, following the
reporter's exact path: worker prints instructions instead of fatals (2
restarts in 30s, not 30), the browser gets "ProjectSend is not installed
yet" naming composer install, then "not configured yet", then "not built
yet" naming npm run build, then the setup screen.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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>
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>
Recent Debian and Ubuntu nginx packages set HTTP_HOST to $host in
fastcgi_params, on purpose — it keeps a client-supplied Host header away
from the application — and $host carries no port. On 80 and 443 nobody
notices. Serve the site on 8080 and every absolute URL the application
builds points at port 80 instead: the login redirect lands somewhere that
does not answer, which reads as ProjectSend being broken rather than as a
web server default having changed.
Found on a manual install of our own, serving on a non-default port,
where the site answered 200 and the browser still got a 404.
The official image is unaffected — Alpine's fastcgi_params has no such
override, so nginx passes the Host header with its port.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`chown -R` on the install directory also tries to change the ownership of
public/storage, which is a symlink — and some filesystems refuse that even
for root. It turned up on a bind-mounted install (a `fakeowner` mount
refuses lchown outright) and would equally turn up on an NFS export with
root_squash. The update stopped there: files replaced, database not yet
migrated, which is the worst place to stop.
A symlink's own ownership decides nothing, since access is governed by
whatever it points at, so those are now skipped rather than tolerated by
ignoring errors that might have mattered elsewhere.
Found by running the thing on a manual install rather than by reading it.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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>
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>
Same frame, same data and the same 1360x850 at 2x as dashboard.png,
captured with the appearance in dark. Not referenced from the README —
it is here for anywhere the dark one suits better.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The retake was 2720x2664 — near square, because it fitted both columns
of widgets end to end. Next to files.png and portal.png, both 16:10, it
read as a different kind of picture and took three times the vertical
room in the README for it.
Same 1360x850 at 2x the other two use. The point was never that every
widget be legible in one frame — only that they all be switched on, so
the installation looks like one somebody uses rather than one nobody has
configured.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The old one photographed a development database: 58 files totalling
7.3 MB, "template-business" and "new-editor" among the largest, two
clients in the storage table, and eight identical "Logged in" rows as
the recent activity. It showed the layout and none of the point.
This one is a plausible installation — real-sounding client
organisations against their quotas, files with names and sizes somebody
would recognise, a month of uploads and downloads with a working week's
rhythm in it, and activity that says what people did rather than that
they arrived. Every widget is on, in the two-column layout, so the
picture also answers "what does this thing actually track".
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The image creates a fixed-uid www-data for php-fpm but left nginx on the
alpine package's own `user nginx;` (uid 100), while nginx.conf's header
claimed the two already matched. They did not, and the claim is why
nobody looked.
It matters because php-fpm hands nginx files to serve: protected
downloads and thumbnails go out by X-Accel-Redirect. Directories the
application creates on demand come out at Flysystem's private mode, 0700,
owned by www-data — and traversing one of those means being its owner,
since there are no group or other bits to fall back on. So the first
thumbnail an installation ever renders answers 403, from nginx, with
nothing in the application's log to show for it (#1614).
Pointing the directive at www-data is half the fix. /var/lib/nginx and
its tmp/ arrive from the package owned by the old user, and nginx
recreates only the leaf temp directories at boot — as the new user, so
they look correct while their parent stays untraversable. Every request
nginx buffers to disk then fails with a bare 500 that never reaches
php-fpm, which is every upload chunk, which is every upload (#1618).
Fixing one without the other trades a broken thumbnail for a broken
upload, so both land together.
Verified by building the image three ways and driving a real upload and
thumbnail through each: unchanged, the part PUT succeeds and the
thumbnail 403s; with the user directive alone, the thumbnail works and
the part PUT is a bare nginx 500 with "open()
/var/lib/nginx/tmp/client_body/0000000001 failed (13: Permission
denied)"; with both, the chunked upload completes, the thumbnail renders,
and the file downloads back byte for byte.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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>
The base php:*-fpm image creates /var/www/html owned by its own www-data
(uid 82) and mode 1777, so that an image can run as an arbitrary user.
This image replaces www-data with a fixed uid 1000 and copies the
release in with COPY --chown — which re-owns what it copies into the
directory, never the directory itself. It was left world-writable,
sticky, and owned by a uid the container no longer has.
fs.protected_symlinks — on by default on Ubuntu, Debian and most current
distributions — then refuses to let a non-root process follow a symlink
in such a directory, and .env is exactly that: the entrypoint keeps it
on the storage volume so a generated APP_KEY survives container
replacement, and links it into place. So every request 503'd with
"ProjectSend is not configured yet" while `docker exec ... cat .env`,
run as root, printed the file back perfectly (#1615).
Three changes, each independently sufficient for the reported case, and
deliberately so — this failure is silent and its symptom points away
from its cause:
- the image owns /var/www/html as the runtime user, at mode 755;
- the entrypoint owns the symlink it creates, so it stays followable
even if that directory's mode ever drifts back;
- preflight distinguishes "no .env" from ".env is there and cannot be
read", instead of reporting the second as the first and sending the
operator off to create a file they already have.
Verified by building the production image before and after: every
request 503s beforehand, with /var/www/html at uid 82 mode 1777 and
www-data denied on the symlink while root reads it; afterwards /up
answers 200, the container reports healthy, and / redirects to /setup.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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>
The lock pinned a commit that no longer exists: that repository's
history was replaced with a single commit, so an install resolving from
the lock would have failed to fetch it.
Nothing about the package's contents changed — same code, same version
constraint, new commit id.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.