mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 16:45:07 +00:00
docs/readme-projectsend-cloud
11 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f4fd194991 |
Merge pull request #1756 from denkfabrik-li/fix/provider-link-password-confirm
Make linking a provider re-prove the password |
||
|
|
bde86c10e4 |
Make linking a provider re-prove the password
Connecting a provider needed nothing but the session. Anyone holding one
could POST /settings/connected-accounts/google, follow the returned
Inertia::location(), sign in at the provider as *themselves*, and
completeLink() would bind their identity to the victim's account.
SocialAccount says what that row is:
This row *is* the authorization to sign in as that account.
So it is not a preference -- it is a credential, and one that outlives
every way the victim has of ending the session that created it. It
survives a password change, it survives Auth::logoutOtherDevices(), it
survives invalidating every session. Where a stolen session gives an
attacker access until it is noticed, this gives them an account.
routes/settings.php already makes exactly this argument, twenty lines
down, for the two-factor block and the API token routes:
a token outlives the session that minted it, so a stolen session must
not be enough to mint one
The link has that property too, and was the one thing on this screen
without the gate. Now it has it.
The gate goes on `connect`, not on the callback: starting the flow is what
writes the intent the callback completes, and the callback deliberately
sits outside every group so a provider sign-in works without a session.
Not changed, deliberately: `connected-accounts.destroy`. Disconnecting
removes a way in rather than adding one, and destroy() already refuses to
remove the last one ("This is the only way you can sign in. Set a password
first"). Putting it behind password.confirm would fall hardest on the
accounts a provider provisioned -- they hold a Str::password(64) nobody
has ever seen -- and leave them unable to disconnect anything at all.
There is a test pinning that it stays reachable.
Also not changed: the account owner still is not told. SocialLoginController
writes an activity log entry, and that sits behind `staff` +
can:view_actions_log, so a client never sees it. Notifying them is a real
gap and a separate change; this one closes the door rather than adding a
bell to it.
Tests: two that fail against the ungated route -- the redirect, and the
whole attack end to end with a stranger identity never binding. The
existing connect() helper now confirms the password, the way
enableTwoFactor() already did, so the rest of the file keeps exercising
the real gate rather than asserting around it.
|
||
|
|
c72adadc44 |
Give every password check in front of an account its own bucket
POST /confirm-password verified the account's password and counted nothing. Forty wrong guesses, forty identical refusals, no lockout, no Retry-After, no log line. routes/auth.php opens by requiring the opposite: **Every `throttle:` below names its own bucket, and must.** and every other route in the file has one. POST login is the deliberate exception, and the file says why -- LoginRequest limits it per email *and* IP, which is a stronger boundary than a per-IP count. confirm-password had neither of those things. It is the wrong door to leave unlatched. Re-proving the password is what stands between a stolen session and disabling two-factor, regenerating recovery codes, or minting an API token -- credentials that outlive the session, which is the reason routes/settings.php gives for putting those routes behind it. An attacker who already holds the session can sit on this endpoint until the password falls out of it, and then has the password for everything else too. Two more with the same shape, in routes/settings.php: - PUT /settings/password -- update() validates `current_password`. - DELETE /settings/profile -- destroy() validates `current_password`. Both were equally uncounted, and both answer the same question in the same way, so an attacker refused at one door simply used the next. Fixing one of three would have been cosmetic. All three get named buckets at 6/1, matching the credential-facing routes already in auth.php. Named rather than bare: a bare `throttle:` keys on sha1(domain|ip) or sha1(user_id) with no route in it, which is how six share links once locked a visitor out of the two-factor challenge. Not changed: POST /logout has no bucket either and does not need one -- it checks no credential and reveals nothing by being repeated. PATCH /settings/profile likewise. Tests: three that fail against the unthrottled routes, and two that pin what the buckets must not do -- exhausting one must not spend another's, and one account's guesses must not lock a different account out. |
||
|
|
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. |
||
|
|
073101d184 |
Put a ceiling on a zip download, and clean up after the ones that fail
Follow-up to #1687, which made a zip build report failure honestly. Four things it passed near, none of them regressions it introduced. A zip has never had a size limit — only a cap of 10,000 files, which bounds nothing that costs anything. Ten thousand spreadsheets zip in seconds; two hundred videos is an hour of stream-copying and an archive that fills the disk. Bytes are what a build actually costs, so the new Settings → Downloads screen caps the total size instead, at 2 GB out of the box. It is a setting rather than a constant because the safe figure depends on free disk, on whether sources live on a remote disk, and on the plan a hosted tenant is on — the file count stays fixed, since it is a foot-gun rail and not a knob anybody needs. The controller measures the selection at request time and names both numbers when it refuses; the job measures again, because it re-derives the selection at run time and a folder can grow while the job waits in the queue. Every shipped topology runs exactly one queue worker, and everything shares the default queue, so raising the job timeout to an hour handed any signed-in person an hour of everyone else's notification mail. There is now one build in progress per requester and a named throttle bucket on the endpoint, which had neither. A pending row older than an hour is treated as abandoned rather than in progress, so a worker killed hard enough to skip failed() cannot lock somebody out for good. Giving zip builds their own queue is the structural fix and wants its own change: it touches compose, supervisord and the systemd unit in INSTALL.md, and an install that upgrades without changing its worker command would stop building zips silently. zip_downloads.requested_by cascades on delete, so removing a user takes their rows with it and strands every archive they built — invisible to a purge that walks rows, and to OrphanFileScanner, which skips zips/ on purpose. The purge now also sweeps files in zips/ that no row explains, after a day's grace so a build in progress is never taken out from under itself. Two smaller things while in here. A build that failed because every file had already hit its download limit said only that nothing was available, and dropped the skipped list — the same distinction the store guard goes out of its way to draw at request time. And a failed close() now logs libzip's reason, which the @ silencing had been discarding: "the disk is full" and "the source vanished" are different problems for whoever has to fix one, while the requester still sees a message with no server paths in it. |
||
|
|
933eaa2ba4 |
Send mail through Microsoft Graph as an admin-connected mailbox
Adds "Microsoft 365 (OAuth)" to the Email settings provider dropdown. Selecting it swaps the SMTP form for an app registration (client id, secret, optional tenant) and a "Connect mailbox" flow: the admin signs into the mailbox the installation should send as, and outgoing email goes through Graph sendMail as that mailbox — no password, no app password, no SMTP AUTH, which Microsoft is winding down. Delegated flow on purpose: it needs no admin consent and works for work/school and personal accounts alike. Its one weakness — a grant can die silently behind a password reset or a Conditional Access change — is answered by a daily scheduled refresh that keeps the token alive and, on a dead grant, warns the settings admins once in-app and on the settings page instead of letting mail stop quietly. Tokens and the client secret live encrypted in their own row and are read fresh at send time, never through the boot-config cache. The stored SMTP transport survives a provider switch untouched. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |