mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-18 17:45:09 +00:00
c21658f6f7dc29ec2e588564b7c870ea30194c73
434 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
7c7ba7cd53 |
Stop a typed-in storage quota from 500ing when a client is created
Filling the "Storage quota (MB)" field on the new-client form raised a TypeError and the request died with a 500. Leaving it blank worked, which is why it reached a release: that path goes through `null ?? 0`, and the 0 is an int. The `integer` validation rule checks that a value looks like an integer. It does not convert it. `$request->validate()` returns the raw input, so the form field arrives as the string "2048" -- and the create form types that field as a string in React, so it is a string even over JSON. Both controllers declare strict_types, so handing it to `ClientAccounts::create()`'s `int $storageQuotaMb` is a TypeError. Fixed on both surfaces that call create(): the staff screen and /api/v1/clients. The API twin had the same defect, reachable by sending the quota as a quoted JSON value or a form-encoded body -- its own create test only ever sent a JSON number. Two more call sites had the same shape and are cast too, though nothing sends them a string today: the share-link download cap and a comment's reply_to. Both are safe only because a frontend file happens to call Number() first, which is a fact about that file rather than anything the signature guarantees. The null in each is preserved rather than collapsed to 0 -- "no cap" is not a cap of zero. `storage_quota_mb` is also cast on User and Invitation. The column is an unsignedInteger and both docblocks already promise int; it is read straight into provision()'s typed parameter when an invitation is redeemed, and which type a driver hands back is not something that call site should depend on. Found on the new files-test rehearsal instance, on its first real use, against the same build the whole fleet is running. |
||
|
|
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 |
||
|
|
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 |
||
|
|
1577862099 |
Translate the forty-one strings the invitation work added
Sixteen locales, forty-one strings each: the invite screen and its history, the redemption and expired-link pages, the invitation email, the expiry setting, and the four new activity-log entries. Two English strings were fixed before translating rather than after. "Waiting" was a second word for what this application has always called Pending -- account requests and membership requests both use it -- and a synonym is much harder to take back once it exists in sixteen files. And the submit button said "Send invitation" while the tab beside it said "Send an invitation", which is a distinction a translator has to stop and puzzle over to discover there isn't one. Both now reuse what was already there, which is also why this pass is forty-one strings rather than forty-three. Terminology was read off each catalogue rather than chosen. Every locale already had its own words for client, group, expired, revoke and status, and these strings reuse them exactly, so a badge in the new table reads in the same vocabulary as the filter above it. Formality follows each file too -- informal in ca, es, it, nl, pl and zh_CN, formal in cs, de, fr, id, pt_BR, ru and tr -- which for Polish meant following the catalogue rather than the note in the skill, since its existing strings say "Cześć" and "Twoje konto". The numeral trap caught one for real, and only because the screen was looked at: "Historial (1 pendientes)". Spanish, Catalan and Portuguese inflect that adjective with the number, so those three now use the "label: :count" shape the Slavic locales already use, and Swahili follows for the same reason. French, German, Dutch, Italian, Turkish, Indonesian and Vietnamese keep the natural word order because their word does not move. Verified by rendering the screen in Spanish, German, Japanese and Polish against real rows in all five states -- nothing overflows, and the German column headers wrap rather than collide. Enabling every locale to do that meant changing a live setting: it was ["en","es"] and is ["en","es"] again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe |
||
|
|
fb3fd1766c |
Turn the second tab into a history of every invitation, and open on it
The tab listed only what was still live, which cannot answer the question somebody actually arrives with: did we ever invite this person, and what happened? An invitation that was accepted, revoked or replaced by a newer one simply vanished from the screen, and the activity log was the only place left to look. So the tab is a history now -- every invitation ever sent, newest first, each carrying the state it ended in -- with a status filter for reading one slice of it. And it opens first, because arriving here the question is usually about what has already been sent, including to the person you were about to invite again. ?tab=send still goes straight to the form. Five states, and two of them needed deciding: - "Expired" is not a stored status and deliberately is not one: nothing writes it, a row becomes expired by the clock passing rather than by anybody acting, and storing it would need a scheduled task to stay true. Invitation::state() derives it, once, and both the badge and the filter read that -- two copies of the rule is how they start disagreeing about a row whose expiry passed a second ago. - "Replaced" is what superseded says to somebody who is not reading the source. It is a different fact from expired, and worth telling apart: one ran out, the other was retired by a newer invitation to the same address. The count in the tab label stays a count of live invitations rather than of the rows below. The history is mostly settled, and the number worth carrying in a label is the one that says whether anybody is still waiting -- which is also why it is counted over the table rather than the filtered page, so narrowing the list cannot change it. Revoke appears only on a row that still has something to revoke, and the expiry column is blank on a settled one: the date is still stored and still true, and printing it invites somebody to wonder what expires about an invitation that was accepted. The screen is called Invitations now, in the heading and the breadcrumb. With the history first it is no longer a form with a list under it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe |
||
|
|
073bf8b853 |
Put the invite form and the pending list on their own tabs
They were stacked: the form, then the list under it. Two different jobs on one scroll, and the list -- the half somebody opens this screen to act on rather than to fill in -- sat below the fold on any installation with a few invitations out. Two tabs now, the same plain border-bottom nav the staff account screen and the theming settings already use. The count rides in the tab label, because the reason to open that half is that something is waiting in it. Three details worth stating: - The form is hidden rather than unmounted, exactly as the staff account form is, so switching to the list and back does not throw away a half-typed invitation. - Revoking passes preserveState, so the page comes back on the tab the person was working in. Acting on a row and landing on the other half reads as having lost the list. - ?tab=pending opens on the list, so something elsewhere can link at the half it means rather than at the screen plus a sentence telling the reader which tab to find. Verified in a browser, since none of this is visible to the suite: both tabs render, the form is present on one and absent on the other, the query parameter opens the right one, and revoking leaves the page on the pending tab with the count down by one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe |
||
|
|
38187dcf1a |
Stop an expired invitation being renewed for ever
The expired page offers a "send me a new one" button that re-issues the invitation with no staff member involved. On its own that is reasonable -- the new link goes to the address on the invitation, never to whoever clicked, so holding a leaked URL gets nobody a working one, and it is the same shape as a password reset. What it spent was the operator's expiry window. A link could be renewed from a dead link, indefinitely, so a window set to 72 hours was only ever as short as the longest anybody bothered to wait. That matters in the case expiry is actually for: a link sitting somewhere it should not be -- a forwarded thread, a shared inbox, a mailbox that changed hands. So the chain gets a limit: three renewals, then a staff member has to send a new invitation. The count is carried forward on each renewal rather than stored per row, which is what makes it apply to the chain; a staff-sent invitation starts at zero, because sending one is somebody deciding to. A renewal beyond the limit answers in exactly the same words as a spent, unknown or revoked token, and sends nothing. Four situations, one sentence: telling them apart is how this door would become a way to learn which addresses an installation has invited. Renewals are now logged, which they were not -- sending and redeeming already were, and renewing was the one step that moved an invitation along with nobody behind it and left no trace. The limit bounds how many rows an anonymous door can write. 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 |
||
|
|
74d1de2d6e |
Say why an invitation was refused when the installation is full
Two halves of the same gap. Redemption always provisions with autoApprove: true, so it always meets SeatAllowance::guardClient(), which refuses on the `email` field -- and the redemption form's email input is read-only, never submitted, and had nowhere to render an error. The account was correctly not created and the person was told nothing at all: the form simply came back. The field now renders errors.email, which is where every other account form's refusal already lands. The other half is the button. "New client" has been seat-limited since the limit existed -- it goes dead with the reason beside it, rather than offering a form that cannot be submitted. "Invite client" sat next to it, live, on a full installation. Worse than the original complaint, because the refusal is met by the invited person rather than by the staff member who caused it. So the invite button is seat-limited too, and sending guards as well as redeeming. An outstanding invitation is still not a client and is still not counted as one -- the rule a pending account request follows, for the reason SeatAllowance spells out -- so this reserves nothing. It refuses to send a link a full installation could not honour, and redemption keeps its own guard, because the seat can be taken by somebody else in the days between. SeatLimitedAction's usage caption is now optional, and the invite button omits it. Two buttons governed by one limit, each captioned with the same sentence, reads as two limits. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe |
||
|
|
cd2ce960d3 |
Refuse an invitation whose address was taken while the link was live
An invitation stays live for days -- 72 hours by default -- and the address it names can be claimed in that window: staff got impatient and created the account by hand, or the person used the public registration form instead. Redemption never asked, so User::create() met the unique index on users.email and raised a QueryException. A 500, on the screen of somebody who had just chosen a password, having done nothing wrong. ClientProvisioning::addressIsFree() exists for exactly this, and its docblock says who must call it: the paths with no form to validate. LDAP asks. Redemption is the third such path and did not. It now refuses with a message that says what happened, rather than the generic "this invitation is no longer valid" the expired case uses. There is nothing to withhold here -- whoever holds the link already knows the address, because it is the one the invitation was sent to -- and being told to sign in instead is the only useful thing to say. The invitation stays pending rather than being retired. It is the account that resolved the situation, not the link, and a retired row would only make the second attempt read as expired. The address rule spans soft-deleted accounts, the same as it does everywhere else, so a deleted account still holds its address until erasure takes the row away. Both cases are tested. 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
|
||
|
|
b8050b36ca |
Release 2.4.1
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRxv2.4.1 |
||
|
|
da5aadd1f3 |
Cut the 2.4.1 entry down to what changed, and add what was missing
Three entries were absent. A sweep of every commit since v2.4.0 for code
changes with no changelog line returned thirteen; ten were correctly absent
— the announcement work is a seam with no content on a self-hosted install,
the client share link is always null unless a platform module mints one, the
quota floor is a platform environment variable, and the email_verified_at
change is inert while MustVerifyEmail is off. The other three were real:
- the IAM-role feature, a visible control on the storage settings screen
that every self-hosted administrator can reach, with no entry at all;
- #1770, a Docker upgrade that fails outright when external storage is
already configured, which is exactly what a changelog is for;
- download counts on a client's own files, which OwnFileDownloads gates
on nothing, so it is live everywhere.
Every entry is now one line. The explanatory paragraph, the "who this
affected" note and the upgrade advice are gone from the change list; what an
operator must actually do was already collected at the top and stays there,
because a title alone cannot be acted on.
Reordered so the account takeovers lead rather than sitting ninth and
eleventh behind a thumbnail-rendering fix, and the two public-folder entries
sit together — they are one boundary reported in two halves, and had six
entries between them.
All seven reporters keep their credit, moved inline.
Version is the user's call, recorded here rather than argued: 2.4.1. Note
that the file's own rule above says the last number moves when there are
only fixes, and this entry has an Added section.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
|
||
|
|
386cb32ebb |
Translate the four strings that accumulated since 2.4.0
Sixteen locales, four strings each, which is the whole of what had drifted:
three from the IAM-role work (the toggle, its explainer, and the warning
that saving clears a stored key and secret) and one from this week's upload
limits ("Too many uploads are already in progress").
Terminology was taken from each catalogue rather than chosen: every locale
already had "Access key" and "Secret key", and the new strings reuse those
words exactly, so the explainer reads in the same vocabulary as the two
fields directly beneath it. Product nouns stay as they are — AWS, IAM, ECS,
EC2, EKS/IRSA, MinIO, Backblaze, Wasabi, ProjectSend.
Formality was read off each file instead of assumed: informal in ca, es, it,
nl and zh_CN, formal in cs, de, fr, id, pl, pt_BR, ru and tr, matching what
the neighbouring sentences already do. Spanish has three voseo entries among
thirty-three tuteo ones — they arrived with the password-reset work on
2026-09-08 (
|
||
|
|
38400956bc |
Put the installation's own logo on the pages people sign in through
Requested by @Zodiac1978 in #1777. The logo already replaced ours in the staff sidebar, on the public listing and in the client portal. The sign-in screen still wore the ProjectSend wordmark — and that is the first page of yours most people ever see, and often the only one a client sees, because it is where the link in a notification email lands them. One layout serves every screen reached before signing in, so this covers login, registration, both password-reset pages, the two-factor challenge, first-run setup and the page a share link opens. That breadth is the reason to change the layout rather than the login page: the same visitor moves between several of them in one sitting, and a logo that appeared on one and not the next would read as a different site. Nothing needed gating. `branding.logo_url` is already shared on every request and is already null wherever the Branding capability is absent, so an installation that has withheld branding, or never uploaded anything, renders exactly what it rendered before. Three tests cover the server's half — the prop reaching a page nobody has signed in to see, the null fallback, and the capability being taken away. None of them can say whether the component mounted or the image resolved, so that was checked in a real browser: headless Chrome against the dev instance with a logo installed reports the <img> present, naturalWidth 360 (so it decoded rather than sitting broken) and a rendered height of 48px, and with the logo removed reports no <img> and the fallback SVG in its place. The branding row was snapshotted before and restored after. One thing worth knowing, unchanged by this and not introduced by it: a logo drawn for a white background is hard to read on the dark theme, here and on every other surface that shows it, because none of them filter the artwork. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx |
||
|
|
8aef6e5b5a |
Tell an Apache install what it needs, and where its 500 is written
Reported by @Zodiac1978 in #1778, on IONOS. Step 6 was nginx and only nginx, while "What you need" says Apache is fine. It is fine, but not without being told two things — document root at public/, and AllowOverride All with mod_rewrite on, or the .htaccess we ship does nothing and every address but the home page is a 404. There is now an Apache vhost beside the nginx one. The 500 in the report is its own troubleshooting entry, because the entry we had sends people to storage/logs/ and for this class of failure that directory is empty — Apache never reached PHP, so ProjectSend had nothing to write, and an empty log reads as a dead end rather than as the clue it is. The error is in Apache's log. Two causes cover nearly all of them: Options refused by AllowOverride, and the internal-redirect loop this reporter hit, where Apache cannot derive the per-directory base and the front-controller rule rewrites to a path that is not there, repeatedly. public/.htaccess now carries a commented-out RewriteBase with the explanation next to it, which is where somebody debugging a 500 is already looking. Only RewriteBase is documented, not the report's second change — making the substitution absolute (`/index.php`). With the base set correctly the relative form resolves to the same place, and the absolute one would send a subdirectory install to the domain root's index.php instead. The trap underneath all of this is worth its own paragraph, and nothing said it before: update.sh merge-copies the release over the install, so an edit to public/.htaccess is reverted on the next update and the site 500s again. Put the directives in the vhost if it is yours to edit, since an update cannot reach there — and on shared hosting, where it is not, keep a note. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx |
||
|
|
8372f42525 |
Ask the picker's own question of what comes back from it
Reported by @skeletonsec as GHSA-w29w-pj29-x7ww. Deleting an account that owns files makes the admin choose who inherits them. The picker narrows that list for a client-scoped staff member to their own roster, and says why two methods up: "a client-scoped staff member is not shown the name of somebody they can reach nothing of, and a picker is no more a reason to hand one over than a listing is." The write asked something else entirely — exists, active, and not the account being deleted. All three are true of every account on the installation. So a scoped staffer could name an id the picker had deliberately kept off the list, and a roster client's files and folders landed with a client on somebody else's roster: readable, editable and deletable there, because a client owns what they uploaded and visibleToClient() includes uploaded_by. The entry doors scope the source account and always did — guardTarget goes through canAssignClient. It is the destination nobody scoped. candidates() and validate() now run one predicate, reachableTargets(), rather than two that happened to agree. Two that agree by inspection is what this was: the narrowing existed, was correct, and was only ever applied to the list. The refusal deliberately reads as "no such account". An out-of-roster id and an id belonging to nobody now produce the same message, because a refusal that distinguishes them lets a scoped staffer walk the id space and learn which accounts exist outside their roster. That is why Rule::exists is gone rather than kept alongside: one code path, one answer. A test pins the two messages as identical instead of naming either. Both the web screen and the API twin come through this one validate(), so both are fixed by it — and the test file proves each separately rather than assuming the sharing holds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx |
||
|
|
50f8b578df |
Ask the publication question wherever content lands, not just on upload
Reported by @skeletonsec as GHSA-rxf8-wh8v-jm9j. A file in a public folder is public: isEffectivelyPublic() is "my own flag, or my folder's", read up the whole ancestry. GHSA-237r-jx85-j3hr settled that three days ago, put the rule in Folder::uploadableBy(), and wired it into the upload paths. Content arrives in a folder four other ways. move() drags one file in, bulkUpdate() moves a selection, update() reparents through the edit form, and FoldersController::move() drags a whole folder — every file in its subtree — under a public parent. Each of them asked whether the destination was *visible* to the mover and then wrote folder_id. Visible is not the same question as publishable, and the difference is the entire permission: a staff member given editing rights and deliberately not given upload_public could publish confidential files to the anonymous site by choosing where they landed. The API twin of update() had the same gap. Both earlier advisories named these paths in their own "suggested fix" sections. Neither demonstrated them, so neither was followed. The fix to a report wants the scrutiny the report got, and this one did not get it. The predicate did not need changing — it needed calling. Four sinks now ask it, plus the API twin. The check stays split in two deliberately: the destination is resolved through StaffLibraryScope as before, so a folder somebody cannot see is still a 404 and not an existence oracle, and the publication clause is a separate 403 on top. They agree by construction — allowsFolder() is folders()->whereKey()->exists() — so nothing that used to resolve can now fail the first half. On the file paths the check fires only when folder_id actually changes, which is the convention already there: re-saving a file that sits in a folder out of the saver's scope must keep working. bulkUpdate() checks its destination once instead, before the loop, because there is one destination for the batch and if it publishes then no file in the batch may go. Folder::uploadableBy()'s docblock now says to read the name as "may place into", with why: the name is what made this easy to miss, and the next folder_id or parent_id write will be written by somebody reading it. Ten tests, one per sink with a private-destination control beside it, plus an editor who *can* publish to show the boundary is about publishing and not about moving. The last one follows the advisory's own chain to the end and asserts the thing actually claimed — a stranger with no session, no token and no assignment fetching the anonymous download URL. It returns 200 on the code before this commit and 404 after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx |
||
|
|
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 |
||
|
|
83a8fe2288 |
Claim the installation instead of checking whether it is free
Reported by @ry2811 as GHSA-w3w9-prpw-qx77, with a working two-worker reproducer. Setup asked the database whether any staff user existed, and created one some time later, in a separate statement with nothing joining the two. So two POSTs arriving together both read "no staff" and both inserted a System Administrator. Different addresses do not collide; `users.email` is the only unique key and it has nothing to say about there being one first administrator. The gap is not narrow. Between the check and the insert sits password hashing at BCRYPT_ROUNDS=12, which is slow on purpose, so the window is hundreds of milliseconds wide and observable without trying. What makes this worth fixing is not that a stranger can set up an unconfigured installation — first-run setup is open to whoever reaches it first, and always was. It is that racing the operator is *quiet*. The operator's own request also succeeds, also redirects to /setup/success, and the installation they get looks exactly like the one they expected. The second administrator is discovered later or not at all, and closing setup afterwards does not revoke it. FirstAdministrator::claim() makes it one operation. The row it locks is the System Administrator role, because the obvious candidate cannot work: there are no staff rows on a fresh install and a lock over an empty result serialises nothing. That role row is written by the roles migration and rewritten on every boot, so it is always there to be locked. The second caller waits on it, and by the time it has the lock the first caller's user is committed and visible to the re-check it then makes. Everything the request writes moved inside the claim, including the site name. A request that loses now writes nothing at all, rather than renaming the installation on its way to the login screen. `projectsend:admin --if-none` had the same shape and is fixed the same way — two containers coming up against one database is the version of this that needs no attacker. The early check stays where it is so an unattended boot does not prompt for a password it is about to discard; it is simply asked again under the lock. Both tests fail on the unfixed code. They stage the interleaving rather than attempting real concurrency, creating the winning administrator from a query listener after the request has made its first check — which is exactly the window, and the re-check is the only thing that closes it. The lock itself is invisible to them: the suite runs SQLite, where lockForUpdate() compiles to nothing. That half was verified against MySQL 8.4 by running the reporter's race for real, two processes through the full HTTP kernel: two administrators before, one after, repeatably. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx |
||
|
|
62c763d04e |
Put a floor under a client quota nobody set
Setting::DefaultClientStorageQuotaMb defaults to 0, and 0 means
unlimited. That is the right default for somebody setting up their own
installation and the wrong one for an installation a platform operates
on other people's behalf: an account that arrived without an explicit
quota has no ceiling at all, and it does not have to be an account the
platform created.
So a platform may set a floor in the environment
(PROJECTSEND_PLATFORM_DEFAULT_CLIENT_QUOTA_MB), exactly as it sets the
seat caps, and for the same reason those are not settings: it is the
shape of what was sold rather than a preference the installation's
administrator is expressing. It applies only where the setting says
nothing, so an administrator who chose a number keeps it, and an install
with no platform behind it is unaffected.
ClientStorageUsage::defaultQuotaMb() is where the three sources resolve,
and every screen that presents the answer now reads it there:
- The client create and edit screens. The edit screen mirrors that
resolution client-side to draw the usage bar, so handed the raw
setting on a floored installation it computed an effective quota of
zero, printed "unlimited" and hid the bar entirely -- for a client
whose next upload was about to be rejected for exceeding a limit the
screen said did not exist.
- projectsend:status, which gains clients_can_register and
default_client_storage_quota_mb. Both defaults are the permissive
ones, both are invisible from outside, and a document reporting the
setting while uploads obeyed the floor would say the ceiling was
missing on an installation that has one.
The Client settings form deliberately still reads the raw setting: that
field is read and written back on save, so prefilling it with the floor
would write the platform's number into the setting as the
administrator's own choice, where it would outlive the floor.
|
||
|
|
0671848bfa |
Read settings written before the columns they name existed
Reported by @apps3000 in #1770. Upgrading a container from 2.0 or 2.1 with external storage configured restart-loops, and says the database is unreachable while the database is fine. A row hydrated from the database does not get the model's column defaults — only a new model does. So a row written before external_storage_settings.provider existed reads that column as null, and the enum match in isConfigured() throws UnhandledMatchError. That would be a small bug anywhere else. It is not here, because PlatformServiceProvider::boot() reads these settings on every process boot, and boot happens before `artisan migrate` runs. During an upgrade the code is new and the schema is still old, so every artisan command in that window dies — including `projectsend:update`, the one that would have added the column. Reordering the entrypoint or using a lighter readiness probe does not help for that reason; the crash is in the bootstrap, not in the probe. current() now applies the model's declared defaults to any column the hydrated row does not have. That closes the window for every column with a default rather than for the one where it was found, and goes inert the moment the schema is current. The match in isConfigured() is left total on purpose: a default arm would swallow a real unhandled case, and the invariant it needs now holds at the one place the row is read. The probe's message is the other half. It boots the whole application, so it fails both when the database is absent and when the application cannot start, and it reported the second as the first — sending an operator off checking credentials that were never wrong. It now prints the error it actually hit and says which of the two it looks like. Verified end to end against a 2.1-shaped database: `artisan migrate` dies with UnhandledMatchError before the change and completes after it, leaving the row reading as S3 with its bucket intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QmyH342d8MuW3pDuE9mbtS |
||
|
|
469297893b |
Merge pull request #1775 from projectsend/s3-instance-role
Support AWS IAM roles for S3 storage |
||
|
|
eaba7ff633 |
Let an AWS-hosted install authenticate as its own IAM role
Requested by @ToMMy86 in #1773: an install running on ECS, EC2 or EKS already has a role attached, and making it also create an IAM user with a long-lived access key is both extra work and a worse security posture than the one AWS offers. The AWS SDK resolves credentials from its default provider chain whenever none is supplied, and Laravel's FilesystemManager already omits the `credentials` entry when the key and secret are empty — so the upload path needed almost nothing. What blocked it was ours: - `isConfigured()` demanded a key and a secret for S3, so a credential-less row was never "configured" and every upload silently stayed on the local disk. - `access_key` was `required_if:provider,s3` on both the save and the connection test. - `probeS3()` built an explicit `credentials` array, so Test connection would have failed even once uploads worked. An explicit `use_instance_role` column rather than "the key was left blank", because blank already means "keep the credential you have" on this form — neither the secret nor the GCS key file is ever sent back to the browser. Ticking it deletes the stored key and secret rather than leaving them in the row for the next database dump. Unchanged for everyone else: MinIO, Backblaze, Wasabi and any other S3-compatible service still authenticate with a key and secret, and the region is still required — the chain resolves credentials, not regions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QmyH342d8MuW3pDuE9mbtS |
||
|
|
6339ae1514 |
Answer a failed reset the same way whatever failed
The screen leaked account existence a second way, through the write, and this one is older than last night's: it is the scaffolding. Laravel answers a failed reset with passwords.user for an address it cannot find and passwords.token for a real one whose token is dead, and the controller surfaced __($status) straight through. Two sentences, one difference, and the difference is whether the account is here. passwords.throttled is the third and the sharpest. The broker throttles per user, so an address nobody holds can never be throttled — being told to wait is being told the account exists. All of them collapse to one sentence now. Nothing is lost: the action is the same in every case, and /forgot-password one step earlier already refuses to say whether an address has an account. Keeping three messages was only ever more precise about a thing we had decided not to say. Found because the portal session went looking for the GET oracle I had just fixed, found theirs, and also found a POST variant I had not thought to check. I had it too. The test asserts the two refusals are identical rather than naming the sentence, so it survives the wording changing. |
||
|
|
7c4b582d25 |
Stop the expired-link notice saying whether an account exists
I built the oracle in the commit whose docblock describes preventing it. The comment said a page answering "expired" for a real address and something else for an unknown one would tell anybody who typed a guess whether an account is here — and then the method returned false for an unknown address and true for a known one. Two branches, two answers, and the difference was the account. /forgot-password deliberately says "a link will be sent if the account exists". This undid that on the next screen along. Both branches answer the same now: anything that will not validate reads as expired, whether the address is known, unknown or absent. The message stays right in every case somebody real will meet — a mistyped address gets "ask for a new link", which is what they should do anyway — and the page reveals nothing. The test is written as "these two are the same answer" rather than "both are false", so it keeps holding if somebody later changes which answer it is. The two tests that encoded the oracle asserted `expired` was false for an unknown address; they were pinning the bug. Found by asking my own question of my own code. The portal session had checked whether their collation folded addresses, which sent me back to the reset screen to see what it does with an address it cannot place. |
||
|
|
b96d060ad8 |
Compare an address ourselves, instead of asking the collation
Reported by @choewonwoo1817 as GHSA-wgxf-v8cr-37mj, with a working
end-to-end reproducer against Keycloak.
`where('email', $address)` is not an exact match. It is whatever the
database says equality means, and the collation INSTALL.md tells people to
create — utf8mb4_unicode_ci — folds accents:
administrator@example.com = administrator@éxample.com -> 1
Those are two different domains. The second is xn--xample-9ua.com, which
somebody else can register and honestly verify at an OIDC provider. So an
attacker with no account here could sign in as themselves and be handed
the first account: SocialAuthenticator found it, linked their subject to
it permanently, and started a session. No password, no interaction from
the owner, an administrator session where that account was one.
Comparison now happens in PHP, in one place, on every driver. Case is
still folded because that is a real requirement — addresses are stored
lowercased and a provider may send any case — and mb_strtolower folds case
without folding accents, which is exactly the line to draw.
Three call sites move to it and two deliberately do not. Loose matching is
right when *refusing* and wrong when *selecting*: AvailableEmailRule and
ClientProvisioning ask "is this address free", where a collation that says
no to a near-miss refuses more registrations, which is the safe direction.
The three that ask "which account is this" are the social path, the login
form (where a password still gated it, so it was confusion rather than
takeover) and the erasure command (irreversible, and the wrong row is the
wrong person).
The test story is the part worth reading. The suite runs on SQLite, whose
`=` is byte-exact, so this defect does not exist there and never did —
which is how it survived six releases with everything green. A test
written the obvious way passes on unfixed code. So the comparison is
pinned by driver-independent tests that always run, and the chain is
proved by AccountLookupCollationTest, which skips unless the connection is
MySQL and carries the command to run it. Run against real MySQL with the
real collation: it fails on the old code and passes on the new.
|
||
|
|
6d7d80f62f |
Give the announcement band its own row, and put it on the dashboard too
All four themes had it as a flex child of the row holding the heading and the buttons, so it was never full width: it took part of the line and squeezed "My files" and "Upload a file" into a narrow column beside it. It now sits above that row, which is where the staff dashboard has always put it and where the comment claimed it was. Worth noting why four themes shipped it wrong. The band renders nothing for almost every viewer — it needs a hosted instance, a free plan, and a client looking — so the broken layout was invisible to the suite, to the build, and to anybody working on those pages. Seen only once somebody on the real free tier looked at it. Also adds it to the client's dashboard. That screen is about the account rather than about the files, which is the more natural place for an offer about the plan, and both read the same shared prop so they cannot disagree about what is said or to whom. Checked with real screenshots in all four themes and on the dashboard, against a temporary listener standing in for cloud-modules, since this install is community and would otherwise render nothing. The listener and the theme setting were both put back. |
||
|
|
bc559ade3f |
Prove a client's upload announces itself, not just a staff one
The test above this said "whichever path stored it" and only exercised the plain staff POST. The path the hosted free tier hangs on is the other one: a client, through the resumable flow, whose upload is what cloud-modules listens for to mint the public link. Worth its own test rather than trusting the shared StoreUploadedFile, because the package's suite structurally cannot tell us. It fakes both the event and the link-minting, so a chunked path that stopped dispatching would leave all 140 of its tests green and the free tier silently inert on a real instance. That gap is why the listener was checked against sim-cloud by hand rather than believed; this is the half of it that belongs in core and runs on every commit. The counter-check is worth a note. The first attempt at it changed nothing — `\$file` inside a sed pattern is a literal, so the substitution never matched and all six tests passed, which reads exactly like a fix that is not load-bearing. Confirmed the mutation landed by counting the line before re-running: three tests fail without the dispatch, the new one among them. |
||
|
|
df44c46a12 |
Say a reset link has expired before asking for the work
The page rendered the form without looking at the token, so somebody opening a link an hour late typed a password, typed it again to confirm, and was then told "this password reset token is invalid" — a word nobody outside the code knows, at the end rather than the start. Links last an hour and people open them late. That is ordinary, not an error to be scolded for. store() still validates and is still the rule; there is a test that a spent token is refused there whatever the page drew. This is only the screen being honest a minute earlier. An address that is missing, or belongs to nobody, is drawn as the form was before. Partly because an unanswerable question is not an expired link, but mostly because a page that said "expired" for a real address and something else for an unknown one would answer whether an account exists here to anybody typing guesses — the exact property /forgot-password protects by saying "a link will be sent if the account exists". Two tests pin that. Worth having now rather than later: the advisories publishing with this release will send more people than usual through this screen, in a hurry and some of them frightened. Found by the portal session's user, who opened a real link an hour and forty minutes after it was sent. |
||
|
|
a1f59e133b |
Translate the strings the security fixes added
Three, sixteen locales: the Entra optional-claim instruction on the social-login screen, and the two the profile screen gained when changing an address started asking for a password. The catalogues gate the release build, so these had to land before the version could be stamped. |
||
|
|
0a3410140d |
Keep an erased staff member's library away from a client
The reassignment target is one installation-wide id used for every erasure, and the picker offers clients deliberately: erasing a client and handing their files to another client is what the setting is for. Applied to a staff account the same id means something else. A staff library is usually the whole installation's, so a client named there inherits all of it — through an unattended scheduled job, with no per-account confirmation, because this is the default rather than a choice somebody makes at the moment of deleting. So a staff account's content may only go to staff. With nobody valid to hand it to, handleContent() already cascades, which keeps the existing promise that content is never orphaned — it now also never becomes a disclosure. Not in the settings validation, which is where it looks like it belongs. That runs when the target is chosen, and whose account will be erased later is not knowable then. Both halves are only in hand here. Found while checking a list from the portal session, who had it as one where() on `type`. That would have been too broad: it would also have stopped a client's files reaching another client, which is the case the setting exists to serve. The condition is on the account being erased, not on the target alone. |
||
|
|
80cf99d80e |
Put what the operator must do at the top, and mark it
The upgrade notes sat at the bottom of a release entry, after every list of what changed. That is the wrong end of the page: somebody deciding whether to upgrade reads the first screen and stops, and that is exactly the reader who needs to know a permission stopped working or a value changed meaning. So the section moves to the top of the entry, gets a heading nobody skims past, and opens by saying what these items have in common — everything else in a release happens on its own, and these do not. It also says plainly that none of them stops the upgrade, because an operator who cannot tell "the installation will not start" from "one role gets a 403" plans the wrong maintenance window. Three items for this release: the CAPTCHA flag that now means the opposite for anybody who typed something other than true or 1, the public folder permission that is now asked of staff, and the Entra claim. Releases before this keep the old "Upgrade notes" heading — rewriting published entries would change what people were told at the time. The file's own header names both, and the release skill now carries the new shape so the next one does not drift back. |
||
|
|
cbc6760a93 |
Warn that a mistyped CAPTCHA flag now means the opposite
The fix reads only true and 1 as "disabled". An operator who wrote "off" and has been running without a CAPTCHA gets it back on this upgrade, which is correct and is still a surprise if nobody says so. |
||
|
|
d8ca41ae0c |
Credit the reporters by their GitHub handles
One of the four said 'the same researcher as the group finding above', which was wrong: @skeletonsec reported the public-folder and upload-target findings, @Drescargot the group one. The other two were credited by name rather than by handle, which is not how anybody finds them. |
||
|
|
1149df277b |
Ask for the public-folder key before publishing through a folder
Reported as GHSA-237r-jx85-j3hr.
A file is public if its own flag is on or its folder's is, so the upload
destination reaches the property `upload_public` guards without touching
the switch. A staff member allowed to upload but deliberately not allowed
to publish could publish to the anonymous public site by choosing where
the file landed.
No new key. `upload_to_public_folders` already exists, already appears on
every role's checkboxes, and already means exactly this on the client
branch of the same method — MyFilesController's picker calls it the
established meaning of the two keys. It was never asked of staff, so on a
staff role that checkbox did nothing at all: an unenforced permission, the
class this project audited and closed once already.
Effectively public rather than the folder's own flag, because the flag is
inherited down a subtree: a private folder inside a public one publishes
just the same, and a check on the folder's own column walks past it. There
is a test for that case specifically.
One place, because every upload path — the plain POST, the chunked flow,
the API and the client portal — already asks Folder::uploadableBy(). The
sibling report about the target folder not being scope-checked at all
(GHSA-56qr-cq56-qg66) was fixed in
|
||
|
|
ab5fa2da8b |
Make Entra prove the address, not just the directory
Reported by Dickson Massawe as GHSA-2rfh-v3j2-2jg7. Pinning the tenant was half an answer. It defeats the classic cross-tenant nOAuth, where a stranger's own directory asserts your address, because a foreign tenant carries a different tid. It does nothing about the same attack from inside the pinned tenant: Entra's email claim is user-mutable — a B2B guest's otherMails among its sources — so a colleague or an invited guest could present an administrator's address and have their subject bound to that account. Tenant-pinning answers "which directory said this". It never answered "does this person own that address". xms_edov is Microsoft's own answer to the second, and their guidance says to require it wherever email identifies an account. Absent counts as unverified, which is the only safe reading given it is absent by default. Nobody is locked out by this, which is worth saying because it looked like a breaking change until I read SocialAuthenticator::resolve in order. An account already linked resolves by subject at step 3, before trust is consulted at all — those keep working untouched. A first-time link to an existing account is refused with the message that already exists for exactly this case, which names the way through: sign in with your password and connect the provider from your settings. A brand-new account is still created; it goes to the approval queue rather than auto-approving. The settings screen and docs/testing-social-login.md now tell an operator to add the claim, and there is an upgrade note. The tests exercise fromSocialite() on raw claims, which nothing did before: tests/Feature/Auth/SocialLoginTest.php builds a SocialIdentity by hand and so never reaches this mapping. That is how the branch could trust a tenant match alone with a full suite passing. |
||
|
|
3244be6bac |
Ask for the password before changing the address a reset goes to
Reported by Nooraldden Khalel as GHSA-f32x-fgmp-q353. The profile screen let a signed-in session change its own email address with nothing else, and that address is where a password reset is sent. So a stolen session was enough: point the account at your own inbox, ask for a reset, set a password, and temporary access is permanent ownership. Clearing email_verified_at did not stand in the way, because the model does not implement MustVerifyEmail and the reset broker never asks. destroy(), thirty lines further down the same controller, has always required the current password, and its comment says why: "the rule every other door into this already asks". This door leads to the same place and was not asking. Only a *different* address asks. A name, a timezone or a custom field is not a credential, so the rest of the screen saves with nothing extra — which is why the rule is excluded rather than flat, and why the comparison is trimmed and lowercased: re-saving a profile with the address typed in a different case must not demand a password for nothing. An account whose credentials live in a directory or at an identity provider is refused outright and told why, rather than being asked for a password it does not have. LdapProvisioner stores Str::password(64) exactly so that local password can never be used, so asking would be a dead end dressed as a form error — and the address is not theirs to change here anyway: it is what the directory says it is. The test walks the whole chain rather than checking the field is validated, because the chain is what made this high: change the address, ask for a reset there, and confirm nothing is sent and no such account exists. |
||
|
|
5fb17388cd |
Stop scoped staff reaching groups that are not theirs
Reported by @Drescargot as GHSA-r3hg-3fxw-rcmr, in two halves. The groups listing never narrowed at all. Every other action in that controller is guarded with allowsGroupChange(), and index() — web and API alike — built a bare Group::query(), so a client-scoped staff member was shown every group on the installation with its name, description and member count. StaffLibraryScope::groups() is that narrowing, and assignableGroupIds() now reads from it rather than restating the same rule a second time, which is how the two drifted apart to begin with. The second half is the one that mattered. allowsGroupChange() asked only groupReachesNoFurther() — "is anything shared with this group outside my library" — which a group with nothing shared with it yet passes vacuously. So a scoped staff member could rename, delete or publish a group whose every member was somebody else's client. Publishing is the sharp end: whatever is shared with the group afterwards is reachable without signing in. The reporter suggested putting the membership check inside groupReachesNoFurther(). Tried, and it breaks two things. That predicate is shared with allowsGroupMembership(), where a group nobody has joined must stay usable so its creator can add the first member. And "every member must be mine" is the obvious reading of the rule and is wrong: it turns GHSA-whmp-p9hv-r7j7's narrowing — a mixed group's edit screen loads and simply does not name the stranger — back into a 404, undoing that fix. Four tests from it fail that way. So the check sits in allowsGroupChange() alone, and asks whether the group is wholly somebody else's rather than whether it is wholly theirs. A mixed group stays workable and is still covered by the reach check; an empty one stays nameable by whoever just made it; a group with members and none of them theirs is refused. |
||
|
|
2be423d685 |
Translate the download counts and the copy-link control
Four strings, sixteen locales. Everything else was already complete, so this is the whole of the debt from the client-portal work. Czech, Polish and Russian do not get "downloaded :count times": those languages inflect the noun according to the number in front of it, so no single string can be right for every value. They read as a labelled count instead — "Pobrania: :count — ostatnie :date" — which is the shape the other numeric strings in those catalogues already use. German and Turkish stay formal, Spanish, Dutch, Polish and Chinese stay informal, as the rest of each catalogue does. |
||
|
|
6560346280 |
Mark the first administrator's address verified, as intended
The last two paths that passed email_verified_at into User::create() and lost it: the setup screen, and projectsend:admin for a container that comes up from environment variables. It is deliberately absent from $fillable, so mass assignment drops it without a word, and both meant to set it. The intent is plain in both cases — the first administrator typed their own address into the form in front of them, and whoever provisioned the container supplied it themselves. There is nobody to confirm it to. Inert today, since MustVerifyEmail is not enabled on the model, but the column is what a later switch would read: turning verification on would have locked out the one account that cannot be helped by another administrator. Both are now pinned by a test that fails when the forceFill is removed. StaffAccounts had already fixed this for staff and named the rest; with client accounts done earlier today, that list is empty. Also says on User::$fillable what absence from it buys and what it does not. It stops a request smuggling a value in; it does not tell code that meant to set the value that it failed. Four separate paths made the same mistake against the same comment. |
||
|
|
e187513cdd |
Stop a mistyped CAPTCHA flag from switching the CAPTCHA off
`env()` recognises the words "true" and "false" and returns everything
else as the string it was — and every non-empty string is truthy in PHP.
So `(bool) env('PROJECTSEND_CAPTCHA_DISABLED')` read all of these as "yes,
disabled":
PROJECTSEND_CAPTCHA_DISABLED=no
PROJECTSEND_CAPTCHA_DISABLED=off
PROJECTSEND_CAPTCHA_DISABLED=fasle
An operator who meant to say no took the bot protection off their login
and registration forms and had nothing to tell them so — the setting
screen still shows the CAPTCHA configured, because this is the escape
hatch that runs ahead of it.
For most settings the cast is a shrug: somebody notices the feature is on
and fixes the line. It stops being a shrug when the wrong answer is the
unsafe one, and this is one of those. EnvFlag lists what counts as yes —
`true` and `1`, either case, either type — and reads everything else,
recognised or not, as no. A value typed as `disabled` turns nothing off:
a configuration mistake to be found rather than guessed at.
Found while fixing the same bug in a new cloud-modules flag, where the
unsafe direction was publishing a customer's files rather than dropping a
CAPTCHA. Two of the four remaining `(bool) env()` casts are left alone on
purpose: a wrong S3 path-style value breaks storage loudly, and the
migration tool's direct mode defaults to true anyway, so neither fails
into an unsafe state.
|
||
|
|
896675d631 |
Tell a client whether their own file arrived
"Did it arrive?" is the question somebody asks about a file they sent, and on a hosted free account — where a link is the whole of the sharing — the count is the only evidence either way. Every file a client uploaded now shows how often it has gone out and when it last did, in every render mode of every theme. Only their own. A download entry says somebody fetched the file, so a count on a file shared with several clients tells each of them about the others' activity, and nobody is entitled to that but the person who put the file there. A file shared *with* this client carries null, not zero: a zero would itself be a claim, and the two have to be distinguishable because zero is an answer the owner came looking for and is shown as words. Counted from the activity log through the same three actions DownloadAllowance uses, so a file leaving by the public site counts as much as one leaving by its link. One query for a listing, none at all for a client with no files of their own. Both filters have a test that fails when only that filter is removed. Two things a render check caught that types and a green build did not. `t()` does no plural selection — the catalogues are flat key/value — so a "one|many" string reached the screen with its pipe intact; the strings are whole sentences now, with the singular spelled out. And the gallery card was already laying its text out beside the action icons in a 200px column, truncating the filename to "Q…" and the size to "75 …" on main today; stacking them gives every line its full width. |
||
|
|
92bb807849 |
Show a client the public link to their own file
A client's portal lists two kinds of file side by side: what they uploaded, and what somebody shared with them. Where a link exists on one of their own, they can now copy it from the row — which is what makes the hosted free plan a product rather than a place to put files, since a customer there has no staff screen on which to make one. The rule is narrow, and both halves are load-bearing: a link this client created, on a file this client uploaded. Not "a link on a file shared with them" — that link is the sharer's decision about who may reach the file, and handing the recipient the URL would quietly turn "you may download this" into "you may pass this on to anyone". And not "any link on their own file" either — a link staff minted on a file a client uploaded exists for a reason the client may be no part of, and on the shared instance it would sit beside the one link they were promised. Ordering is by id, so an unfiltered lookup would hand them whichever was minted first. Both halves have a test that fails when only that half is removed. The first draft did not: every case was carried by the ownership filter alone, so the creator check was green for the wrong reason. Links that no longer work are left out rather than shown greyed. The only thing a client can do here is copy it, and a URL that answers "this link has expired" is worse than no URL at all. One query per listing, not one per row, and none at all for a client with no files of their own. |
||
|
|
0a28e239d6 |
One home for what a client account is
Three surfaces create client accounts now: the staff screens, /api/v1/clients, and the platform control plane in the private package. Two of them held their own copy of the type, the role, the active flag, the "0 means inherit the site default" quota, the verified stamp, the activity entry and the seat guard — and the third could not have a copy at all, because a package cannot import a host class. ClientAccounts is that one definition, reached by name from outside. What stays with each caller is what genuinely differs: its validation, its response, its custom fields, and who is asking. Two things changed rather than moved: The seat cap is now checked inside create(), before anything is written, instead of at the top of each controller. That is what makes a leaked platform token an incident rather than an unbounded one — a guard that ran only where somebody remembered it is not a guard. email_verified_at is written with forceFill. It is deliberately absent from User::$fillable, so every client-creation path passed it into a mass assignment and lost it in silence. StaffAccounts already noted this and named the other paths; this closes the client half. |
||
|
|
3d923188d9 |
Show an announcement on the client's own file portal
The band existed only on the staff dashboard, which is a screen a client never opens. On a shared instance the customer *is* a client account — they sign in, upload, and share by link, and the administrator is the operator — so a message for them has to reach the page they actually use. ViewerAnnouncement reads the shared prop itself rather than taking one, so each theme adds it in a single line without threading a prop through a page that has no other reason to know about it. All four portal themes render it, because a message that only appears in the theme somebody remembered to wire is a message that quietly does not exist. Above the heading, not inside the list: it is not one of the things the client came to do, and burying it under the files would defeat the point of having it at all. No theme decides who sees it. Core drops anything not aimed at the viewer, so a theme renders whatever it is handed and cannot leak a staff message to a client by being careless. |
||
|
|
b128b114b5 |
Make an announcement say who it is for
The first version refused clients outright. That was right for the only message that existed — a hosted instance telling its administrator about their plan — and it stopped being right the moment a message needed to reach the *clients* of a shared instance, where the administrator is the operator and the customers are client accounts. The unsafe fix would have been to drop the guard and let each listener check `isStaff`. The safe one is to make every caller say who it is talking to and have core enforce it: `show()` now takes a required `audience` with no default, and a message aimed elsewhere is dropped before it reaches the props. A listener that forgets therefore reaches nobody rather than everybody, which is the direction a mistake should fall. An unrecognised audience reaches nobody either, and is ignored rather than thrown — a listener aimed at the wrong people should show nothing, not break the page it was decorating. The old "a client is never shown one" test became "a message for staff reaches no client, even from a listener that never checks", which is the property that actually matters and the one the enforcement provides. Two more pin the other directions: a client message reaches clients and no staff, and an unknown audience reaches neither. cloud-modules declares `staff` for the free-plan band, and its test fake enforces the same rule, so a listener aimed at the wrong audience fails in the package's own suite rather than passing there and misbehaving in the host. |
||
|
|
757fba19ca |
Give uploads a seam, and link-minting one home
Two pieces of groundwork, no behaviour change.
FileWasStored is dispatched from StoreUploadedFile, which every upload
path converges on — the chunked flow staff and clients share, and the
synchronous POST beside it. A listener therefore sees each upload once
without knowing which route produced it, which is the property that makes
it usable from outside this repository. A notification, not a filter:
nothing on it is mutable, and anything that needs to influence an upload
has to do so before the bytes land, which is what ResolvingUploadDisk is
already for.
CreateShareLink is the other half. Minting a link was a ShareLinksController
private concern, and the controller is an HTTP handler behind `staff`
middleware — so a link now needs making from outside a request as well.
Two copies of "make a token, write the row, log it" would drift, and the
half most likely to drift is the token, which is the entire authorization
for /s/{token}: there is no session behind it and no second factor, so
being unguessable is its only defence. Anything minted through the action
gets Str::random(32) — about 190 bits, more than a UUID's 122 — and never
a chosen value. The chosen-token path stays in the controller, where a
person is typing one into a form and its minimum length can be argued
about in a validation rule.
The permission questions stay in the controller too. Whether somebody may
set an expiry or a download cap is a fact about them, and the action has
no viewer to ask; it takes both already resolved, including the expiry,
because "the end of the 12th" depends on whose timezone you are in.
Five tests, including that the file a listener receives is complete and
readable rather than half-built, and that the staff form still refuses an
expiry to somebody without the permission after the extraction.
|
||
|
|
763e7b0e2e |
Render one image once, however many requests ask at the same time
Renditions are generated on demand and cached by existence, and nothing between the callers stopped two requests decoding the same image at once. The atomic rename settled which file survived; it never stopped both from doing the work. So N concurrent requests for one cold rendition were N full-size decodes, each holding four bytes per source pixel — up to 160 MB at the 40-megapixel ceiling. That is not an attack. A public listing emits a thumbnail URL per file, a browser opens six or more connections at once, and the first visit to a gallery of ordinary camera images was six simultaneous decodes on a container sized for one. PublicGroupsController reaches the generator with no account at all, so nothing about it required a customer to be signed in, and the 240/min throttle bounds rate rather than concurrency. Worse than a crash, it did not resolve itself: a render killed mid-flight renames nothing, so the cache warmed only by whatever finished before the kill and the page died again on the next visit. A lock keyed on the destination path — which already encodes the file, the audience and the rendition, so two requests collide exactly when they would have written the same path. The waiter re-reads after acquiring, which is what turns a wait into a cache hit rather than a second decode of the same image. Waiting rather than refusing, because the arithmetic says so: a waiting request holds an idle worker at about 35 MB, a rendering one holds that plus the whole source bitmap. Six waiters cost what one renderer costs. On timeout it refuses instead of rendering anyway. Falling through would reinstate the pile-on at the moment the system is already struggling, and one failed thumbnail is a better outcome than a container that dies and takes the warm cache with it. The wait is configurable because the right number is a property of the machine — a small VPS reading a large source off a slow disk wants longer — and clamped to at least a second, since a stray empty variable would otherwise make every concurrent request fail instantly, which is the opposite of the point. Eight tests. Two go red without the lock, and the clamp is asserted on the resolved value rather than the clock, because block() measures in whole seconds and a timing assertion there would be flaky rather than wrong. Found by the session sizing free-tier containers, from the outside. |