mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 00:55:07 +00:00
feature/gcs-storage
28 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
fbd6c3603d | Add tests for the connect redirect navigation | ||
|
|
2a82335e07 |
Move the shared public-listing helpers to tests/Helpers.php
publicListingFile() and publicListingImageFile() were defined in PublicGroupsTest.php and used from PublicFilePreviewTest.php too. Pest declares a test file's functions as ordinary globals, so that works only once the defining file has been loaded — which under --parallel depends on how the runner happens to distribute files across processes. Adding any unrelated test file anywhere in the suite reshuffles that and takes PublicFilePreviewTest.php down with "Call to undefined function", and running it on its own with --filter never worked at all. tests/Helpers.php exists for exactly this and its docblock describes this failure; these two had just been missed. publicPageProps() stays where it is, since only one file uses it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1aaab1bf66 |
Read TRUSTED_PROXIES late enough for it to be seen
The value was read with env() inside the withMiddleware closure in bootstrap/app.php. That closure runs when the HTTP kernel is resolved, which is before the dotenv bootstrapper reads .env — so on every web request env() returned null for anything set in .env, and the proxy was never trusted. It worked when the value came from a real environment variable, which is why the Docker compose path was fine and the manual install described in INSTALL.md, where we tell people to put it in .env, was not. Artisan bootstraps in the other order, so a check from the command line reported the setting as working the whole time. Behind a TLS-terminating proxy the consequence is not subtle. Laravel falls back to the connecting address and the plain scheme, builds every link and redirect with http:// while the browser is on https://, and marks the session cookie non-secure. The browser then declines to send that cookie to what it reads as a different, less secure origin, the session arrives empty, and the first write fails with a 419 that reads as "your session expired" — most often on the create-your-admin form, which is the first thing a new install submits. Afterwards each redirect leaves and re-enters over the wrong scheme, which is the random bounce back to the login screen people report as flakiness. Moved to config/trustedproxy.php, the key the framework's TrustProxies middleware already falls back to on its own. Config files load after dotenv, so the value is there whether it comes from .env or from the environment. This was also the only env() read outside config/, which means config:cache is no longer dangerous on this application. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
503676647f |
Let a split-user host serve downloads
A download is not served by PHP. PHP authorizes it and hands the web server the path with X-Accel-Redirect, so the web server has to open a file PHP wrote. Where those are different users — cPanel and Plesk commonly arrange it that way — it cannot: uploads land 0600 inside a 0700 directory, and traversing 0700 means being its owner. Nothing else on the site shows a symptom. Uploading works, the library lists everything, and only downloads fail, as ERR_INVALID_RESPONSE in the browser and `open() ... failed (13: Permission denied)` in the web server's log. FILES_WEB_SERVER_READABLE writes uploads 0644/0755 instead. Opt-in and spread into the disk configuration rather than switched by a ternary, so an install that does not set it keeps byte-for-byte the configuration it had: the relaxed modes are readable by every account on the machine, which is the wrong trade wherever the web server and PHP are one user, as in the image and on most self-administered servers. The two halves are not enforced alike, which is the part worth knowing. `visibility` has Flysystem chmod each file after writing it, so 0644 holds under any umask. A directory is created by mkdir(), which masks its mode argument, so 0755 is a ceiling: a pool at umask 0077 still produces 0700 and still cannot be traversed. That cannot be fixed from config, so INSTALL.md carries it — how to tell the two users apart, the one-time chmod for files already on disk, and the pool setting for the umask. FilePermissionsTest asserts all three modes, umask cases included, since the asymmetry is invisible from the configuration. Reported by @denkfabrik-li (#1668), who diagnosed it and verified the remedy on the affected host. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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. |