mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 08:35:07 +00:00
main
10 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f06a3c7ab3 |
Put a new client account in front of the staff who administer clients
Invitations produced no in-app notification at all, and neither did self-registration: the whole Clients module raised none. The only admin-facing signal when an account appeared was an email to whatever raw addresses an operator typed into a setting -- addresses that need not correspond to any account in this installation, and that plenty of installations never fill in. An invitation could be accepted and nobody signed in would ever be told. So: one new type, client_registered, reaching the bell and /notifications. One type for both doors on purpose. A client arriving through the public form and one arriving through an invitation are the same event to the person being told -- an account now exists that did not -- and a second type would buy nothing, because preferences here govern email only, so it could not have been switched off separately anyway. Which door it came through is one click away in the activity log and on the invitations screen. In-app only, the reasoning client_uploaded already states: email for this event is sent separately to that address list, and routing it through Notifier's mail dispatch too would risk double-emailing any staff member who is also on it. Two things worth stating about who gets it. Recipients are resolved at the call site, because Notifier authorizes nothing by design -- its security contract is explicit that a broad query must never be handed to it. And a client-scoped staff member is deliberately not told: their whole view is the clients assigned to them, and a brand-new account is assigned to nobody, so it would link them to a screen they are refused. Which is also why the notification links to the clients list filtered to the address, and not to clients.edit: that route is gated by edit_clients while these recipients are chosen by manage_clients. A notification that refuses the person it was sent to is worse than one that lands a click short. Translated in all sixteen locales, and the redemption was driven through a real browser to see the row arrive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe |
||
|
|
35d68a792b |
Stop a rejected settings form flashing the credential it carried
When validation fails, Laravel flashes the request's input into the
session so the form can be repopulated. Its exclusion list is
current_password, password and password_confirmation -- written for the
login and password screens, and covering none of the credentials the
system settings screens take. `dontFlash` did not appear anywhere in this
repository.
So every one of these went into the session in clear the moment its form
was rejected:
secret ExternalStorageSettingsController (S3 secret access key)
key_file ExternalStorageSettingsController (GCS service account JSON)
bind_password LdapSettingsController
client_secret SocialLoginSettingsController, EmailSettingsController
secret_key CaptchaSettingsController
Each is stored with an `encrypted` cast, and config/session.php puts
sessions in the database with `encrypt => false` -- so the rejected save
wrote in clear into the same database the cast exists to protect.
The sharpest one is key_file. serviceAccountKeyRule() exists to catch a
paste that lost its last line, which makes "the request carrying a
service account private key" and "the request that fails validation" the
same request more often than not.
dontFlash() merges rather than replaces, so the framework's three stay.
The cost is that these five come back blank after a failed save. That is
already what they do after a successful one -- every screen here treats
them as write-only, and a blank means "keep what is stored" -- so the
behaviour is now the same either way instead of only on success.
Tests: one per field, each submitting a form that fails validation while
carrying a secret, then reading the old input back the way the form
would. All five fail against the unmodified bootstrap/app.php. A sixth
pins that the framework's own three are still excluded, and each
assertion checks a neighbouring non-secret field still comes back, so
this cannot pass by flashing nothing at all.
Note for the record: this is testable in the existing harness after all.
phpunit.xml sets SESSION_DRIVER=array, but old input is written to the
session whatever the driver backs it, so getOldInput() sees exactly what
a database session would have stored.
|
||
|
|
479dc61d2d |
Move branding into core, and leave white-labelling behind
Logo and watermark belonged in the private package for one reason: that is where they were written. Nothing about them needs a hosted platform, and an installation wanting its own mark on the pages it serves is the ordinary case rather than the exotic one. They are core's now, and every installation has them. Hiding "Powered by ProjectSend" did not come. That is what a hosted customer pays for, and its gate is not a capability key but the absence of the code: cloud-modules keeps the listener, so an installation without that package holds the column and has nothing able to read it. Flipping an edition variable buys nothing, which was true before and stays true. Core renders the switch where Capability::AttributionHide is held and has no route that can save it -- there is a test asserting exactly that, which fails the day white-labelling quietly becomes free. The migrations move with their original filenames on purpose. A Cloud tenant already ran them under those names, so Laravel skips them there and the table and its data are untouched; a fresh install or a community one runs them from here for the first time. What got better on the way rather than merely moving: The watermark listeners take core's real RenderingImage and ResolvingImageRendering instead of duck-typed `object` payloads, and the tests construct the genuine events rather than anonymous stand-ins that imitated their shape. The package had to do it that way -- it builds with no host present -- so three PHPStan ignore entries existed to describe what the type system could not see. They are gone. ModuleBoundaryTest asserted "branding is cloud-only, and the suite runs as community", which was never what it was testing. It now reads the capability off the route and subtracts it, so the invariant holds for whichever module is installed. The 43 branding strings arrived in all sixteen locales from the package's own catalogues rather than being retranslated, and the package's are pruned to the one string it still uses. A hosted plan without branding subtracts branding.customize and attribution.hide from the instance's environment. The row is never deleted by that: a downgrade is usually an expired card rather than a decision, and wiping somebody's artwork over a billing event is a loss they would find weeks later with no way to know what it used to be. Hiding reverses; deleting does not. |
||
|
|
6086821d6c |
Close the other three doors that answered a write with a 302
#1680 fixed the redirect rendered from an exception and said plainly what it did not cover: EnsureSetupIsComplete, EnsureAccountIsActive and EnforceTwoFactor answer before HandleInertiaRequests is ever entered, so a response they return never unwinds through Inertia's 302 to 303 upgrade either. Same 405, reached a different way — an account deactivated while its owner was part-way through a form, or one being made to enrol in two-factor. The rule now lives in one place rather than four. Three copies of "if the method is PUT, PATCH or DELETE" is how the fourth caller gets it wrong, and WriteSafeRedirect can carry the explanation of why 303 — which is worth more than the three lines it replaces, because nothing about a bare setStatusCode call says what a browser does with a 302. PUT /timezone is the route the setup test uses: it is one of only two writes a guest can reach and the only one that middleware does not exempt, so the case is real rather than defensive. All three new tests were run against the unfixed middleware and fail there. Extends the work of @denkfabrik-li, who found the gap and wrote it down. |
||
|
|
4737849ec5 |
Answer redirected writes with 303 so browsers follow with GET
A redirect born in exception handling - the guest redirect after an expired login, above all - never travels back through the middleware stack, so Inertia's usual 302-to-303 upgrade cannot reach it. Browsers follow a 302 by replaying the request method on the redirect target (only POST is downgraded to GET), so a widget save whose session just died replays as PUT /login and fails with a 405 that hides the real "please sign in again" (#1673). Repeat the upgrade in the exception pipeline: any 302 answered to a PUT, PATCH or DELETE becomes a 303. Reads keep their 302, POST needs nothing - browsers already downgrade it. |
||
|
|
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> |
||
|
|
5a7c9938dd |
Work properly behind a reverse proxy
Three findings from one report of intermittent 502s behind Nginx Proxy Manager, all of them ours. Stop sending the Link: preload header. AddLinkHeadersForPreloadedAssets copied every Vite preload into a response header, duplicating tags the document already carried in its head — twenty on the login page. nginx buffers a response's headers into a single block defaulting to 4 KB, so /files, at 6060 bytes of headers, was refused with "upstream sent too big header" and the proxy answered 502. Which pages went over depended on how many assets they loaded, which is why it read as intermittent rather than as a header that is always too big: the login screen fitted, the application did not. Removing it takes /files to 1247 bytes and /dashboard from 4544 to 1247. Nothing is lost — the browser reads the tags in the document, and we send no 103 Early Hints. Send nginx's logs to the container's streams. supervisord captures what each program writes to its own stdout, but nginx opens the files named in the package's nginx.conf as soon as it reads its config, so access and error logs went to /var/log/nginx/ inside the container. That is where the reason for every 502 and every 403 was written, and docker logs never showed it — so a proxy problem presented as no logs on either side, which is exactly how it was reported. Document the thing neither guide covered. DOCKER.md had no reverse-proxy section at all: no mention of proxies, of 502s, or of TRUSTED_PROXIES, which until now was explained only in a comment in the compose example. It gains one, including that TRUSTED_PROXIES cannot cause a 502 and is the wrong place to dig. INSTALL.md's nginx-in-front-of-Apache path gains the proxy_* buffer settings its fastcgi_* equivalents already had. Reported by @denkfabrik-li (#1664), who traced it to the middleware independently, and separately by a user running Nginx Proxy Manager who found the too-big-header line in the proxy's own log. |
||
|
|
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> |
||
|
|
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. |