mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-19 10:05:11 +00:00
main
34 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
51035994ae |
Give a client the public link the switch already promised
Reported by Ricardo Cazati. A client with every file permission could mark their own file public — and was then shown nothing. The screen said "anyone with the link will be able to open and download it" while the only route that makes a link was staff-only, so the link existed for nobody. Version 1 could do this. Making one now asks `upload_public`, the same key that lets them mark the file public, and `update` on the file, which for a client means one they uploaded and nothing else. Staff are not asked for the key, as they never have been: `update` is their boundary and asking now would be a new refusal on every installation that upgrades. Revoking deliberately does not ask for the publishing key. It takes access away, and somebody whose permission to publish was withdrawn must still be able to undo what they published. The portal's file editor grows the section the staff screen has, minus what a client has no business setting: the link, a copy button, and revoke. |
||
|
|
616a355d54 |
Give each client a folder of their own, standing in for the root
A client who may create folders creates them at the top of the library, beside the ones staff made, and their uploads land at the root too. An administrator opening /files gets one flat pile with nothing saying which parts belong to whom. With the new "Give each client a folder of their own" setting, every new client gets a folder named after them and it acts as their root: what they upload and any folder they create goes inside it. /files becomes a list of clients rather than a pile. The sentence this feature has to keep true: **the home is a default location, not a boundary.** Folder::scopeVisibleToClient is untouched, so a folder staff shared with a client still reaches them and sits beside their own. Making the home a jail would have silently revoked every share that already exists -- a data-access change wearing the clothes of a tidying-up feature. There is a test named after that rule. What the client sees is the *inside* of their folder, not a folder wearing their own name, which is not information to them. The breadcrumb is trimmed of it for the same reason: "Invoices", not "Acme Ltd / Invoices". Some decisions worth naming: - **A column, not a convention.** `folders.home_for_user_id`, unique. Matching on the name breaks the moment two clients share one, and `created_by` plus a null parent catches every root folder a client ever made themselves. The question is asked on each upload and each portal listing and the answer has to be exact. - **created_by is the client**, because that is how scopeVisibleToClient already grants somebody their own folder -- no assignment row to keep in step with it. That is also why this writes the row rather than calling FolderService::create(), which takes created_by from auth()->id(). - **On model events**, not in the services that make and rename clients. There are nine of those (ClientAccounts, ClientProvisioning, the profile screen, two update endpoints, AccountConversion, invitations, LDAP, social) and a rule repeated in nine places is missing from the tenth. - **Turning the setting on creates nothing.** Existing clients get a folder when an administrator presses a button that says how many are waiting, and it reports created/total/already-had afterwards. Somebody should be able to switch this on, look, and switch it off without having reorganised a library. It moves no files either. - **Nobody deletes a home from a folder screen**, staff included, and the client cannot rename theirs -- they own it, so ownership alone would have let them, and its name follows the account anyway. - **The name always follows the client**, over a hand-typed one. A folder still called "Acme Ltd" under an account now called something else misleads the administrator the feature exists for. Verified in a real browser as well as in tests: the screen mounts, the panel reads "24 of your existing clients have no folder yet", and pressing the button answers "24 of 24 clients got a folder. 0 already had one." |
||
|
|
a255a883a8 |
Merge remote-tracking branch 'origin/main' into virus-scanning
# Conflicts: # tests/Feature/Platform/SchedulerMonitoringTest.php |
||
|
|
f937b4398d |
Tell a missing file apart from a missing scanner, and do something about it
A row whose bytes are gone was recorded as "the scanner could not be
reached". Wrong on screen, and wrong underneath: that is the one reason
the hourly sweep re-queues, so every orphaned row would have been
rescanned hourly forever.
It is its own state now, `missing`, and withheld rather than offered:
a client who sees a file listed and gets an error on the download is
worse off than one who never saw it. Staff still see it, marked, which
is the point — somebody has to decide what to do about it. The refusal
says what it is ("no longer on the server") instead of sending somebody
looking for a permission that would let them through.
A daily `projectsend:check-missing-files` finds them, whether or not
this installation scans for viruses: it is not a virus question, and an
installation with no scanner has exactly the same problem. It compares
one disk listing against the rows rather than asking "does this exist?"
per file, which on object storage would be a request per file per day.
Files that come back — a remount, a restored backup — are picked up on
the next run and re-checked rather than left for dead.
They are listed beside the orphans, which is the same fault seen from
the other end: bytes with no row, rows with no bytes. The tab carries
the count, each row says where the file should be, and removing one
takes the record with it through the deletion that already exists.
The dashboard says how many there are, and so does
`projectsend:status`, because a fleet-wide jump in this is a storage
fault nothing else in that document would show.
|
||
|
|
dc0937fda1 |
Add an Activity tab that shows a scan as it happens
A backfill runs for minutes or hours inside a queue worker, where none of it is visible. The third tab polls every four seconds and says what is happening: whether anything is running, how many uploads are held, how deep the queue is, how many files were checked in the last hour, and the last twenty verdicts with what each one was. When nothing is running, that same list is the record of the last run, which is what somebody opening the tab after the fact came for. Two things the live screen found that the tests had not: **A backfill read as "nothing is being scanned."** Re-scanning a file that already went out unchecked deliberately leaves it available, so it is never "pending" — and the screen counted only pending files. It counts the scans queue too, and the two are shown separately, because "an upload nobody can download yet" and "work the scanner has not reached" are different facts. **A file whose bytes are missing was recorded as "the scanner could not be reached."** Wrong on screen, and worse than wrong in behaviour: that is the one reason the hourly sweep re-queues, so every orphaned row would have been rescanned every hour forever. It has its own reason now, and goes through the same policy as a file the scanner could not open. Both tabs also gained the header shortcut to Quarantine, and Quarantine one back to the settings, each shown only to somebody the destination will actually let in. |
||
|
|
b6b777e42f |
Add the virus scanning settings screen, with a button that proves it works
Settings → Virus scanning: switch it on, point it at a ClamAV daemon, choose the two policies, and see how many files are waiting, in quarantine, or were let through unscanned. Switching it on with no address is refused rather than saved and left inert. The Test button is three answers, not one. Unreachable is obvious. Reachable but detecting nothing is the failure that looks like success — empty or broken virus definitions — so the test sends the EICAR string and reports "it found the test file", never "it did not complain". The string is assembled at runtime so no checkout contains it: antivirus software on a developer's machine quarantines files that do. "Scan existing files" queues the library that predates scanning, through the hourly command so no request is held open, paced by a setting so it does not starve today's uploads. Where the environment names a scanner, the connection and the on/off switch leave the screen and scanning cannot be turned off — the same managed shape the CAPTCHA screen has. The two policies stay editable, because what to do with a file nobody could scan is a decision about somebody's own files. |
||
|
|
e9496dc357 |
Give quarantined files a screen, an owner, and somebody to tell
An infected file now goes somewhere rather than nowhere. Staff holding the new release_quarantined_files permission get a Quarantine screen listing what was refused, who uploaded it, and what the scanner called it. They can delete it as they always could, or release it — which needs a written reason, a password confirmation on top of the permission, and lands in the activity log under their name. Only the administrator role holds that permission by default. Deciding a threat report is wrong is a different judgement from deciding a file is no longer needed, which is why it is not delete_files. Two notifications, two audiences: staff who can act on it, and the person who uploaded it — for whom this is how they learn their own machine has something on it. The people the file was shared with are deliberately not told about a file they never received. `projectsend:scan-files` runs hourly: it re-queues files still waiting, and re-scans the ones that went out unscanned while the scanner was unreachable, since it may be back. With --existing it also works through a library uploaded before scanning was switched on, paced by a setting so it does not starve today's uploads. A file that was downloadable before it was caught says so on the screen, with its download count, because that is the case where somebody may already have a copy. |
||
|
|
495f3ae471 |
Let each role, and each person, choose where they land after signing in
A role now has a start page: the dashboard, files, upload, groups, clients or the activity log (the last two for staff only). Anyone can override their role's choice in their profile. The administrator role takes a start page too, while everything else about it stays locked. A choice is only used if the account can open that page now. Otherwise the next one down is tried, ending at the dashboard, so a permission removed later never lands somebody on a 403. A role cannot be saved with a start page its own permissions block. A link followed before signing in still wins, and a waiting getting-started or what's-new page still goes first. Applies to password, two-factor and provider sign-ins, and to the site root for someone already signed in. StartPageTest opens every page for real, with and without its permission, so the enum cannot drift from the routes. Requested by @Zodiac1978 in #1777. |
||
|
|
c21658f6f7 |
Let a client account expire on a date
Staff can give a client an expiry date on the create and edit screens, and through /api/v1/clients. When the date passes, the client is refused at sign-in and on their next request, and their API access ends too. Files and history stay, and a later date (or none) brings them back. Access is checked through one predicate, User::maySignIn(), at every door: sign-in, the web session, API tokens and the two-factor challenge. An hourly sweep also switches `active` off, so the list, its filter and seat counts agree. The sweep is not what enforces it, so a scheduler that is not running cannot keep an account open. An account cannot be active with a date that has passed. Reactivating an expired client needs a new date in the same save. The day-means-end-of-day-where-you-are rule moved out of FileExpiry into a shared DateInput, so file and account expiry read dates the same way. Requested by @Drardollan in #1310. |
||
|
|
123ae68972 |
Give invitations their own place in the navigation
The "Invite client" button led to a history, which is not what it says. The tabs were a way of housing two things that had nowhere else to live, and now they do: Invitations is a sidebar entry between Custom fields and Groups, and the button goes to the form. That is also the shape every other list in this application already has -- Clients, Groups, Categories, Roles all sit in the sidebar with a "New X" button leading to their own create screen -- so the tabs were the odd one out rather than the pattern. Two URLs, each meaning one thing: /clients/invitations is the history, /clients/invitations/create is the form. Sending now returns to the history, where the invitation just sent is the first row. No badge on the sidebar entry, deliberately, unlike the two queues below it. Account requests and Membership requests count things waiting on somebody here; an outstanding invitation is waiting on the person who was invited. A number there would say "you have three things to do" about three things nobody in this installation can act on. Translations move with it: "History (:count pending)" was the tab label and is gone from all sixteen, and "Invite a client to share files with" comes back -- it was the form's description before the tabs took the heading, and had never been translated because it left the code in the same commit that would have reported it missing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe |
||
|
|
a502a26075 |
Let staff cancel an invitation nobody has used
An invitation could be sent and never taken back. There was no list of outstanding ones and no revoke, so the only way to withdraw a link sent to the wrong address was to let it expire -- and the expired page's own "send me a new one" button undoes exactly that, silently, for anybody still holding the link. The one cancel the feature had could be reversed by the person it was aimed at. So: a new STATUS_REVOKED, outside the pending() scope that both the redemption and the resend doors look through. A revoked link is dead to all three things a live one can do -- opening the form, redeeming it, and asking for a replacement -- and nothing but sending a fresh invitation brings it back. The list sits under the invite form rather than on a screen of its own, because the person who wants to cancel an invitation is the person who just sent one. It shows outstanding invitations only: pending, expired ones included. An expired invitation is not inert until it is revoked, so hiding it would hide the rows most worth a decision -- which is why they sort to the top, soonest expiry first. Revoking is gated by create_clients, the same authority as sending: whoever may invite somebody may take it back. It is logged, like sending and redeeming already were. The row is kept rather than deleted, for the reason a superseded one is kept -- the activity log names who invited this address and when, and that trail should still lead somewhere. Verified in a browser, not only in tests: the screen mounts, both rows render, and the expired one carries its badge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe |
||
|
|
856c13b09c |
Invite a client to register instead of handing them a password (#1780)
Staff can now invite a specific address to register instead of typing a
password for somebody and finding a way to get it to them. The invited
person sets their own, the link is locked to the address it was sent to,
and an invitation always activates the account regardless of the
auto-approve setting -- naming an address is already the decision the
approval queue exists to make for one nobody named.
Two fixes ride along: outgoing mail now reads the installation's own site
name in its title, header and signature rather than the one baked into
config('app.name') at install time, and the CSRF cookie name is read per
request rather than captured once at load.
Follow-up work, tracked separately: an invitation cannot be cancelled --
there is no pending-invitations screen and no revoke, so letting one expire
is the only way to take it back, which the self-service resend button then
undoes. Redemption also needs the address-availability check every other
non-form caller of ClientProvisioning makes.
Thanks @mash2k3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
|
||
|
|
6ad26bb61e |
Hold an upload to the size it said it was sending
Reported by @ry2811 as GHSA-6jh6-gvj5-pv8v. A resumable upload declares its size, and that declaration is what store() weighs against the maximum file size and the client's storage quota. Only the assembled file was ever held to it. The parts in between were bounded one request at a time and never added up, so a client could declare one byte and then stream parts: ten thousand part numbers at twice a 20 MB part is about 400 GB, per session, and the number of sessions was not bounded either. None of it counted against anything, because nothing becomes a File row until the upload completes and ClientStorageUsage sums File rows. A client with a 1 MB quota could fill the volume and repeat. putPart()'s own comment described this defect and treated the per-part cap as the answer to it: "without a cap here the exposure is a day's worth of disk". A cap on one request bounds one request. The exposure was a day's worth of disk multiplied by however many requests somebody cared to make. Three limits, and each one exists because the other two do not cover it. A session may not stage more than it declared. The room for a part is claimed before the body is read — a body's length is not known until it has arrived, and by then it is on the disk being protected — and the write is then capped at exactly what was claimed, so an over-long body is cut off mid-stream as it always was, against a smaller number. The claim is a read and a conditional update under a per-session lock, the same shape complete() already uses: the protocol sends parts in parallel and how many is the client's choice, so an unlocked read lets every part in flight claim the same room, while an atomic claim alone refuses the honest parallel upload instead. Whatever the part really weighs is settled back afterwards, in a finally, or a client's own retries would exhaust a session with room to spare. Open sessions count against the quota at the size they declared. A quota measured against finished files alone is spent twice by opening sessions one after another — each is told there is room, because the ones before it have not finished. The cost is that an abandoned transfer holds its share until it is cancelled or swept, so the sweeper now runs hourly rather than daily: that gap is now somebody unable to upload, which it was not before. And a cap on open sessions, because for anyone with no quota to spend — staff, and clients on an installation that sets none — the session count is the only thing between a declared size and any multiple of it. Four tests fail on the unfixed code, and three existing ones had to change: they declared a tiny size and sent a large part deliberately, to reach the re-checks at complete(). That route is now closed at putPart(), so they reach those re-checks the way a real install would instead — the file-size limit or the quota moving while a long transfer is running, which is the reason complete() re-asks rather than trusting what store() decided. The staged-byte total is BIGINT UNSIGNED, and the suite runs SQLite, which has no unsigned integers. The first version of the bounds read `staged_bytes + :delta BETWEEN 0 AND size` and raised SQLSTATE 22003 on MySQL for any refund — in the comparison, so the bound written to prevent the underflow was the statement that underflowed. Every SQLite test passed on it. Both bounds are now arranged so the column is never inside a subtraction, and UploadSessionStagedBytesMysqlTest skips loudly unless the connection is MySQL. Verified against 8.4, as was the report itself: three sessions declaring one byte each put 6 MB on the volume of a client with a 1 MB quota before, and nothing at all after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx |
||
|
|
ea214fc27e |
Give the client portal a file editor
The authorization landed last commit; this is the way in. A client with edit_files now gets an Edit action on the files they uploaded, opening a form with every field their role actually grants, and a Delete beside it. One page for every theme, not one per theme. portal/edit-file.tsx picks its shell from the `theme` prop exactly as portal/upload.tsx does, because a form with eight fields behind five separate permissions, rebuilt four times, is four places for a field to go quietly missing. What *is* per-theme is only the entry point: one <FileRowActions /> in each theme's row actions group, the file twin of the FolderRowActions that was already there. Row actions gate on can_update/can_delete, sent per file by MyFilesController and answered by FilePolicy — never on is_mine, which is half the question. Holding the file is one half and the role's keys are the other, and a theme that reads is_mine offers an Edit button that 403s. Written into docs/theming-files-checklist.md so the next theme does not have to rediscover it. The folder picker offers only folders the client could have uploaded to, so it cannot present a destination the save would refuse. Publishing says in plain words that anyone with the link will be able to open the file without signing in, and says so differently when the installation has no public page configured, because there the switch would do nothing visible. Hiding a control is a courtesy, never the enforcement. Every can_* prop here is the same question ApplyFileEdits asks when the form posts, and the tests assert both ends. Verified in a real browser over CDP rather than only by types and tests, which say nothing about whether a page mounts: 23 edit actions on the client's 23 own files and none on the file shared with them, the editor mounting with its real values, every gated field present, no console errors. The dev instance's Client role was snapshotted before the run and restored to exactly what it was. Refs #1771 |
||
|
|
922be7226c |
Let a client edit and delete the files they uploaded
A client could upload a file and then never touch it again. No rename, no description, no expiry, no categories, no delete — the portal has three file routes and all three are GET. Meanwhile the Roles screen happily grants the Client role edit_files, delete_files, set_file_categories, set_file_expiration_date and upload_public, and every one of them was inert, because the routes that honour them are `staff`-gated rather than permission-gated. That is what #1771 hit: a permission granted, saved, and silently doing nothing. A client owns what they uploaded. Ownership is now what lets them edit and delete it, subject to the same per-field keys staff are subject to. The obvious implementation is a trap, and it is worth writing down. Both policy methods began `if (! $user->isStaff()) return false;` and both end in StaffLibraryScope, whose allowsFile() reads `if (! isClientScoped()) return true` — and isClientScoped() is `isStaff() && role->client_scoped`, so it is false for every client. Delete the early return and a client falls into the branch meaning "this staff member is unrestricted" and is handed the whole library. Same for folders(), which returns an unfiltered query: a client could move their file into any folder on the installation. So clients get their own branch, reaching neither. The portal asks Folder::uploadableBy() instead — a file cannot be moved somewhere it could not have been uploaded. edit_others_files and delete_others_files stay inert for clients by construction. A client has no others' files, only files somebody showed them, and being shown a file is not being given it. Which fields an editor may write moved into ApplyFileEdits, shared by the staff editor, /api/v1 and the portal. There were two copies of the same eight permission checks and this would have been the third; the checks are easy, which is exactly why the drift would have been invisible. Callers normalise their own request shape, this gates and writes and logs. Expiry reading and writing came along too, as FileExpiry — three copies, of which only the API's could read a timestamp. Clients do not choose the public slug. It is derived from the name they already picked, because an installation-wide unique slug a client sets is a name to squat and an existence oracle to probe with. One consequence for later, written up in docs/api-todo.md: the policy now says yes to a client for file writes, so `staff-token` is the only thing holding the API boundary where there used to be two independent refusals. ActorBoundaryTest pins it, and asserts the policy passes first so the test cannot quietly stop testing the middleware. Also corrects a stale comment that claimed a deleted file's bytes stay on disk. They have not since File::booted() grew a `deleted` hook; nothing ever forceDelete()s a File row, so "until a purge lands" would have meant never — which is why a client's delete frees their quota by exactly what it frees on disk. The UI comes next; this is the authorization, the routes and the tests. Fixes #1771 |
||
|
|
d32788e4a1 |
Put the CAPTCHA settings screen behind a capability
The screen is open in both editions and stays that way by default, so a self-hosted installation loses nothing: nobody else supplies its keys, and nobody else is affected by what it decides. What the key buys is the ability to take it away. A hosted fleet puts every tenant on one parent domain and one sending reputation, so an administrator who turns their own CAPTCHA off is spending everybody else's deliverability rather than only their own. That is not the shape LDAP and social login have, which is why those two stay ungated and this one does not. Gated all-or-nothing on the route, read included, exactly as Storage and Branding are. Per-field gating in the controller would not have closed it: switching the CAPTCHA off needs none of the gated fields — `provider: none` does it, and so does unticking the four per-form switches while leaving good keys in place — so the PATCH had to be closed too, and the middleware closes both verbs at once. Which keys the screen may offer is still the separate, narrower question Capability::CaptchaManagedKeys answers per field. An operator withdraws it by naming captcha.configure in PROJECTSEND_CAPABILITIES_DISABLED. Note that the key also joins the list `projectsend:status` and GET /api/v1/me report, which is additive — the OpenAPI document types capabilities as an untyped array, so nothing there needed regenerating. |
||
|
|
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. |
||
|
|
602c7bed94 |
Merge pull request #1708 from denkfabrik-li/fix/confirm-password-under-enforcement
EnforceTwoFactor exempts by route name, and only the GET half of the confirm-password screen had one -- Route::named() answers false for a null name, so the submission was never exempt. Enrolling requires password confirmation, so with enforcement on nobody could enrol at all: the form rendered, its POST was redirected to two-factor.show, auth.password_confirmed_at was never written, and every account on the installation was left with logout as its only working route. Including the administrator who turned the setting on. Reproduced on main before merging: POST /confirm-password redirects to /settings/two-factor and the session flag stays unset. The widened pattern was checked against the route table -- password.confirm* reaches password.confirm and the newly named password.confirm.store and nothing else; password.reset, password.store and the rest are not under that prefix. Exempting the submission grants nothing further, since every other route stays bounced and store() still validates the password. Reported and fixed by @denkfabrik-li. |
||
|
|
2eb23dbc07 |
Stop four comments saying user management is Community-only
It stopped being true in
|
||
|
|
1dc274e896 |
Let an enforced user reach the far side of the confirm-password screen
EnforceTwoFactor exempts by route name, and only the GET half of confirm-password has one. routes/auth.php:95 names the form `password.confirm`; :98 registers its submission with no name at all, and Route::named() answers false for a null name. So the loop the exemption exists to prevent is still there, one step further along. With Setting::TwoFactorEnforcement set to staff, clients or all, an un-enrolled account walks: GET /dashboard -> two-factor.show GET /system/settings/security -> two-factor.show PATCH /system/settings/security -> two-factor.show POST /settings/two-factor -> /confirm-password (RequirePassword) GET /confirm-password -> 200, the form renders POST /confirm-password -> two-factor.show <- not exempt `auth.password_confirmed_at` is never written, so enrolling can never start, and every route that is not on the exemption list stays shut -- including Settings -> Security, the one screen that could turn enforcement back off. Logout is the only door left; recovery is CLI or database access. It takes one administrator turning the setting on to reach it, and it reaches every account on the installation at once, including their own. The fix is the name. `password.confirm*` then covers both halves of one screen, matching `two-factor.*` in the same expression; the namespace belongs entirely to a flow enrolment already depends on being reachable, and the route table has nothing else under it -- `password.confirm` (GET) and `password.confirm.store` (POST) are the two it reaches. Exempting the submission grants nothing further. store() validates the password, writes a session flag and redirects; the redirect it issues enters this middleware like any other request, so Settings -> Security is still answered with two-factor.show after confirming. What changes is that enrolment can now be started. Two tests, both measured red against the unfixed middleware: the password confirmation sticks, and enrolment can be started afterwards (the secret is written and the screen reports `pending`). Also named the redirect the existing test settles for. `->assertRedirect()` with no target passes on this middleware bouncing the request back to two-factor.show, which is the shape that file exists to refuse. It is a clarification rather than a guard -- that assertion is green either way, since the redirect it sees comes from RequirePassword. Full suite passes (2050 passed / 2 skipped), PHPStan level 8 clean. |
||
|
|
623ad686da |
Open user management on the cloud edition
A managed installation's staff accounts were expected to arrive from outside it, so users.manage was Community-only and /users, /roles and their API twins answered 404 there. The platform side spent a long document designing its way around that gate; opening it is cheaper than routing around it, and more honest about where the knowledge sits. The division that settles it is the one managed storage already uses. We do not manage a tenant's files from outside — a bucket is provisioned, a scoped credential handed over, and what goes in it is the tenant's business. Seats are the same kind of thing. A platform knows how many staff accounts it sold; it does not know whether Alice should be an Account Manager, and it certainly does not know where her files go when she leaves. Capacity is the platform's, occupancy is the tenant's, and the cap belongs in an environment variable rather than in a closed screen. The capability stays in front of the routes rather than being deleted. It is currently true in both editions, but it is the seam an edition difference has to travel through, and removing it would mean inventing one again later. Seven test files asserted the old rule, which is the tests doing their job. Most flip. Two needed a different example instead: EnsureCapability and AbilityCapability were both using users.manage to stand for "Community-only", so they now use storage.configure and manage_updates — keys that still are. Two rationales half-expired and say so rather than being quietly rewritten. CommentAuthors gave two reasons for being a setting rather than a permission; the first was that roles are uneditable on cloud, which stopped being true here, and the second — that `Everyone` includes anonymous visitors, who have no role to hold a key — was always the stronger and is now the whole of it. The seat cap this makes necessary is the next commit, not this one. On its own this change lets a managed tenant create staff accounts without limit, which is why the two belong in the same release. |
||
|
|
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. |
||
|
|
7646e99f33 |
Add an activity endpoint, so an integration can react rather than poll for shape
Every list in /api/v1 answers "what is there now". Nothing answered "what happened", and for the two events people most want to act on there was nowhere to look at all. Sharing a file writes an assignment row and never touches the file, so no amount of polling /files?updated_since= will ever show a share. A download is recorded only in the activity log. So the most requested automations for a file-sharing product — tell me when a client gets a file, tell me when they open it — were not possible to build. GET /api/v1/activity is one feed rather than one endpoint per event, because the log already records every one of them and a caller filtering by action gets whatever the application grows later without waiting for us to expose it. It reuses what already exists: view_actions_log is the permission the activity screen uses, and ActivityLogScope narrows the rows the same way, so a staff member limited to their assigned clients cannot read the whole installation's log through a token when the screen would not show it. Two deliberate limits. Class names never reach the wire — subject.type is a stable public string, or moving a model between namespaces would be a breaking change to a frozen contract. And no ip_address, though the column exists and the screen shows it: a person looking at a log has decided to look, where an integration streams every row to somebody else's servers by default. PollingQuery grew an optional column so it can walk a table that is appended to rather than edited. The parameter stays updated_since everywhere, because on an append-only log the two timestamps are the same thing and one shape learned once is worth more than a second name. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |