mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-18 01:25:09 +00:00
2567d9f193565232ebbfb60a67ecd2fe173f8a0d
17 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
98597d462d |
Give a deleted folder's name back
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
928173e8be |
Let a package's translations reach the screen it wrote
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
f446398dfd |
Say which step is missing instead of failing blankly (#1633)
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
d53bb9a2f7 |
Own the application directory in the official image (#1620)
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> |
||
|
|
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. |