Compare commits

...
129 Commits
Author SHA1 Message Date
Jeremy Daer 6c5ceca6b5 Merge pull request #367 from basecamp/backport/security-1.5.3
Security fixes for 1.5.3
2026-10-19 14:08:49 -07:00
Jeremy Daer bf2b9cc61b Look the endpoint up past the query cache, and keep a subscription a race refused
The lookup under the write lock repeated the one made before
validation, so the query cache could answer it with that earlier miss
even after another connection had registered the endpoint. It runs
uncached now.

Enabling notifications unsubscribed the browser on any refusal, so a
session signed out between authenticating and saving lost the browser's
subscription too, and the next session had nothing to post. Only a
refused endpoint unsubscribes it now.

From #371.

(cherry picked from commit aa93269ceb)
2026-10-19 13:34:37 -07:00
Jeremy Daer fadf6f1859 Post the browser's push subscription from every page, and register it once
Only room pages posted the subscription again, so a browser that
changed its password and closed the profile page went without
notifications until it next opened a room. A small controller in the
layout does it now, still once per session, and the bell is back to how
it was.

Two tabs posting at once after a password change could both miss the
row and insert two, so the endpoint is looked up again under the same
write lock as the session check.

From #371.

(cherry picked from commit 3786ee5244)
2026-10-19 13:15:14 -07:00
Jeremy Daer 931f6b2c38 Remove the fake push subscription after its system test
The browser is shared across system tests, and a script added to every
new document stays until it's removed, so later tests would have seen
the granted permission and the fake subscription too.

From #371.

(cherry picked from commit 1af62a5bff)
2026-10-19 12:55:16 -07:00
Jeremy Daer 93718f7408 Remove push subscriptions on a password change, and post them again
A push subscription belongs to the user, not a session, so a device
signed out by a password change kept getting notifications. Changing
the password now removes every subscription along with the sessions,
in the same transaction.

A browser still signed in keeps its own subscription in its service
worker, so the notifications controller posts it once per session: the
bell names the session by a digest of its id, never its token, and the
browser remembers the last one it posted for. That post never
unsubscribes the browser when the server refuses it.

A request that authenticated just before the change could put a
subscription back after it. The create action now checks the session
still exists and saves in one immediate transaction, after validating
the endpoint outside it.

From #371.

(cherry picked from commit 6cab924097)
2026-10-19 12:39:14 -07:00
Jeremy Daer 417f087964 Ask for the current password to change it
Changing the password now ends every other session, so whoever holds
one could shut the owner out of their own account with a new password.
The profile asks for the current password whenever the password
changes; without it, or with a wrong one, nothing is saved and the page
says why. Other profile edits don't need it, and the admin
reset-password script still works without it.

Attempts are rate limited like signing in, 10 in 3 minutes per address,
so the current password can't be guessed here instead.

From #371. The cached-response test it touches isn't in 1.5.2.

(cherry picked from commit 44a92ff6c6)
2026-10-19 12:17:27 -07:00
Jeremy Daer 58a25c6281 End sign-in links made before a password change
A transfer link signed only the user id, so one copied before the
password changed still started a session for up to four hours after.
Bind it to the password salt with generates_token_for, so a new
password ends those links too. Links made before this upgrade stop
working; they last four hours at most.

From #371.

(cherry picked from commit 160ba295b5)
2026-10-19 11:37:05 -07:00
Jeremy Daer 00abff4088 Close live connections on the transaction that changed the password
An after_commit condition reads the record's last save, so another save
of the user in the same transaction skipped the disconnect even though
the sessions were gone. The callback that deletes them now registers
the disconnect on the transaction itself, which also leaves a rolled
back change alone. A password changed along with the avatar is covered
too: the attachment's touch doesn't hide the change.

From #371.

(cherry picked from commit e158388f3d)
2026-10-19 10:57:49 -07:00
Jeremy Daer b1122c7d6d Give the changing browser a new session rather than keeping its own
Keeping the session the password was changed from also kept any copy of
its cookie working, and a copied cookie is one of the ways someone else
gets into an account. Every session ends now, and the profile page
signs the browser that changed the password back in with a new one.

From #371.

(cherry picked from commit b01bb7a0a7)
2026-10-19 10:46:24 -07:00
Jeremy Daer 2fb599e21c Close live connections from the model on any password change
The sessions end wherever the password changes, but only the profile
page closed the live connections, so a change from a console left a
signed-out device receiving messages until it dropped. Close them after
commit from User, logging rather than failing if the realtime service
can't be reached, since the password is saved by then.

From #371.

(cherry picked from commit c6e0105491)
2026-10-19 10:33:48 -07:00
Jeremy Daer c78f03fda7 Sign out the account's other sessions when its password changes
Changing the password is how someone shuts out whoever else is signed
in to their account, but every session opened with the old password
stayed valid. Now the others end and their live connections close, as
on sign-out. The session the password was changed from stays signed in.

Changing the password outside a request, as from a console, ends every
session.

From #371. 1.5.2 has no record snapshot or response cache, so the test
that a cached session lookup doesn't outlive the password change is
left out; the rest applies as it is.

(cherry picked from commit c78c3fd7ef)
2026-10-19 10:28:55 -07:00
Rosa Gutierrez 9474e70f5a Clear no_proxy in the link preview proxy test
(cherry picked from commit 2d4b05a74a)
2026-10-17 01:11:38 -07:00
Rosa Gutierrez a1dd6e1d8a Don't let an http_proxy environment variable bypass the pinned address in link previews
Opengraph::Fetch connects to the IP it checked, not to a fresh lookup of
the host. With http_proxy set, Net::HTTP would instead send the hostname
to the proxy, which looks it up again, so the connection could end up
somewhere other than the checked address. Pass nil as the proxy address,
as web push delivery already does.

(cherry picked from commit a7e9ea53dd)
2026-10-17 01:11:38 -07:00
Jeremy Daer d3e6602b88 Run CI on pull requests to release branches
Patch releases are prepared on a release-* branch cut from their tag,
and CI only ran on pull requests to main. Pushes to a release branch
stay untriggered, as zizmor treats a push trigger on release branches as
a publishing workflow whose setup-ruby cache could be poisoned.
2026-10-17 00:48:24 -07:00
Jeremy Daer 7a5931fe2a Allow an asset host only up to its own asset directory
An asset host's path can contain the asset directory too, as in
https://cdn.example.com/assets, and cutting each URL at the first one would
allow everything the host serves under it. Cut at the last one instead.

(cherry picked from commit 2ba940c683)
2026-10-17 00:47:41 -07:00
Jeremy Daer 26b3d2abb0 Leave the scheme off this origin's script sources
Behind a proxy that terminates TLS without X-Forwarded-Proto, Rails sees
http while the page is https, and an http source on a non-default port
doesn't match. A source without a scheme takes the page's own.

(cherry picked from commit 0a494a10cb)
2026-10-17 00:47:41 -07:00
Jeremy Daer 681bd78ea2 Allow scripts only from the asset directory, not the whole origin
Uploads are served from this origin too, so script-src 'self' would run one
that a cache still holds under a script type from before the upload guard.
Application pages now allow each importmap URL up to its asset directory
(relative root and asset host included), the inline importmap by hash, and
the service worker by worker-src.

(cherry picked from commit 2be2227f40)
2026-10-17 00:47:41 -07:00
Jeremy Daer 8f249cbde4 Leave a request without a policy when the policy is turned off
With DISABLE_CONTENT_SECURITY_POLICY=true there's no application policy,
and the hash callback started an empty one, so pages sent an empty
Content-Security-Policy header. Skip the callback then.

(cherry picked from commit 2a449401ec)
2026-10-17 00:47:32 -07:00
Jeremy Daer 27f7b8a9b0 Keep a multi-range upload response's multipart type
A request for several byte ranges gets a 206 multipart/byteranges whose
boundary the client needs to split the body. Rewriting it to
octet-stream lost the boundary. No browser runs that type as a script,
so it passes through.

(cherry picked from commit 9c37f41ff6)
2026-10-17 00:47:32 -07:00
Jeremy Daer 05ba551cef Say how long a pre-upgrade cached upload can last
Disk URLs are cached for an hour, but blob proxy URLs are cached
publicly and as immutable for a hundred years, so a copy taken that way
before the upgrade doesn't expire.

(cherry picked from commit d713f2631d)
2026-10-17 00:47:32 -07:00
Jeremy Daer c3616f81b4 Enforce a script policy that allows the importmap by hash
script-src 'self', object-src 'none' and base-uri 'self', enforced. The
two inline scripts the importmap helper emits are allowed by their
SHA-256, and the origins its URLs point to (a configured asset host, in
any form Rails accepts) are allowed by name. There is no per-request
nonce, so cached pages stay valid.

DISABLE_CONTENT_SECURITY_POLICY=true leaves the policy off, for an
install whose proxy or customization injects scripts.

(cherry picked from commit 562888251e)

Backport: 1.5.2 has no response cache, so the cached-page test is left out.
2026-10-17 00:47:32 -07:00
Jeremy Daer 3a140f65b2 Serve uploads that aren't media as downloads
Uploaded files come from this origin, so one served with a JavaScript
type could run as one of the app's own scripts. Browsers accept sixteen
spellings of that type, and Disk URLs sign the type they were minted
with, so refusing a list of types at upload can't cover links already
out there. Instead, a middleware rewrites the type of anything Active
Storage reads out that isn't plainly media, a PDF or plain text to
application/octet-stream, whatever the URL says.

(cherry picked from commit cad2d7211d)
2026-10-17 00:47:22 -07:00
Jeremy Daer 049fc920d3 Quote a mention as text when replying
Replying swaps each mention in the quoted copy for its text. Assigning
that text to outerHTML parsed it as html; replace the mention with a
text node instead.

(cherry picked from commit 6052b123d5)
2026-10-17 00:46:37 -07:00
Jeremy Daer 6ba5d2ece3 Link URLs only in a message's text
auto_link finds URLs and email addresses with regular expressions over
the rendered html, which can't tell an attribute value from text, so it
could put a link inside an attribute. Hand it each text node on its own,
escaped the way the serializer writes text, after the final sanitize, so
links only ever land between tags. Write the result with
preserve_newline so a code block keeps its leading blank lines.

Bump the presentation cache version so nothing rendered the old way is
served again.

Backport of 7ea05e1, 4e29c6e and 9b271d0 from #364. 1.5.2 has no
plain-text fragment cache, so only the message partial's version moves.
2026-10-17 00:46:37 -07:00
Jeremy Daer a67ecde390 Compare a table message's filtered html as markup
The filters can write the same table with or without newlines between
its rows depending on which tests ran first in the process, so the exact
string comparison failed under some seeds. main compares it as markup.
2026-10-17 00:46:37 -07:00
Jeremy Daer e4ef1785e8 Update Brakeman to 8.1.0
(cherry picked from commit b2140cf2aa)
2026-10-17 00:37:47 -07:00
Jeremy DaerandMarcello Costagliola 6e312c6028 Stop sending push notifications to banned users (#338)
Banning a user deletes their sessions and closes their connections but
keeps their push subscriptions, and Room::MessagePusher chose recipients
by membership alone. A banned user's browser or phone therefore went on
receiving the room name, sender and text of new direct messages,
mentions, and messages in rooms they had set to everything.

Choose subscriptions from active users only. The subscriptions are kept,
so unbanning brings notifications back without the user having to
subscribe again (the client doesn't resubscribe while the browser still
holds a subscription).

Co-authored-by: Marcello Costagliola <176920116+namespaceMarcello@users.noreply.github.com>
2026-10-07 11:25:16 -07:00
GPT on behalf of DHH ee5fe3717a Update benchmarks from the shared response-verified comparison 2026-10-07 17:53:35 +02:00
GPT on behalf of DHH 27f5461067 Render a complete auto-submit transfer form 2026-10-07 17:08:48 +02:00
GPT on behalf of DHH 8bbe129030 Preserve active ping editors during sidebar refreshes 2026-10-07 16:55:08 +02:00
GPT on behalf of DHH 765711f27d Preserve request ports in message copy links 2026-10-07 16:37:53 +02:00
GPT on behalf of DHH 3c022a1641 Preserve timestamp ordering of Rails unread notices 2026-10-07 16:19:10 +02:00
GPT on behalf of DHH 2b9685c35c Wait for sidebar frames before reloading on Cable connections 2026-10-07 16:18:39 +02:00
GPT on behalf of DHH dbc7620a76 Bound accessible search probes and reject invalid benchmark responses 2026-10-07 14:22:07 +02:00
GPT on behalf of DHH c49f53d8f8 Refresh Go measurements after final sidebar correction 2026-10-07 12:22:17 +02:00
GPT on behalf of DHH 6288430f37 Update shared benchmarks after independent performance review 2026-10-07 12:17:01 +02:00
GPT on behalf of DHH 7df98ac883 Merge current Rails main during performance review
# Conflicts:
#	app/views/messages/_message.html.erb
2026-10-07 11:34:17 +02:00
GPT on behalf of DHH 27065ef489 Keep same-second unread events and bound idle push connections 2026-10-07 11:00:47 +02:00
Donal McBreenandJeremy Daer 8a6e4290d8 Merge commit from fork
* Derive message DOM ids from the server id, not client_message_id

A message's DOM id was derived from the browser-chosen client_message_id
via a Message#to_key override, so dom_id(message) was
"message_<client_message_id>". Turbo's append de-dups by DOM id, so a room
member who posted a message reusing a victim's client_message_id displaced
the victim's message element in every connected member's live view; editing
the attacker's own message then broadcast onto the victim's presentation id.

Drop the to_key override so every message DOM id and broadcast target derives
from the record's primary key. Two distinct records can no longer share a DOM
id regardless of stored client_message_id, so the collision is impossible with
no data migration and no uniqueness constraint. to_param and the fragment
cache key already used the primary key, so message URLs and per-message cache
entries are unchanged.

The composer's optimistic pending message still uses client_message_id as its
placeholder DOM id, which no longer matches the server broadcast's PK-based id.
Reconcile instead by rendering data-client-message-id on the real message and
having the messages controller drop the matching pending placeholder on
connect. Only client-side placeholders (data-pending-message) are removed, so a
message another member posts reusing the same client_message_id can never
displace a real one through the reconciliation path either.

Also point the boost broadcast target at the PK-based dom_id(message, :boosts)
to match the rebuilt container id.

GHSA-3v99-4vxh-xg84

* Bust cached message fragments rendered with client_message_id DOM ids

The message fragment cache keys on the record and the template digest, and
removing the to_key override changes neither. Fragments cached by an earlier
release would keep their message_<client_message_id> ids, so edit, delete and
boost broadcasts, which now target primary-key ids, would miss those messages
in other members' live views until the cache entry expired.

* Locate messages by record id in the client_message_id collision tests

Assert on data-message-id rather than the new primary-key DOM ids, so the tests
describe the behavior instead of the fix and fail on the vulnerable code for the
real reason. Edit the attacker's message to new text and wait for it to arrive,
so the edit path is exercised rather than passing vacuously. Add a request test
that two messages sharing a client_message_id render as distinct elements.

---------

Co-authored-by: Jeremy Daer <jeremy@37signals.com>
2026-10-07 01:41:12 -07:00
GPT on behalf of DHH 14a2f8f550 Merge pull request #329: Delete a room's messages in a job, one transaction each
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:33:57 +02:00
GPT on behalf of DHH 4ceb92ea04 Merge pull request #326: Reuse push connections pinned to the address the guard just approved
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:33:57 +02:00
GPT on behalf of DHH 72648a8f41 Merge pull request #296: Fan the unread room notice out from a job
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:33:57 +02:00
GPT on behalf of DHH b409b9998f Merge pull request #328: Bound how long link unfurling can hold a request
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:33:57 +02:00
GPT on behalf of DHH 3005154114 Merge pull request #331: avoid preview generation for rich-text file embeds
Reviewed and merged by GPT on behalf of DHH. Retain all media regressions and invalidate presentation caches again.
2026-10-07 10:33:57 +02:00
GPT on behalf of DHH a991adb19d Merge pull request #330: bound attachment preview work
Reviewed and merged by GPT on behalf of DHH. Keep both boost-cache and preview-loading regressions.
2026-10-07 10:33:02 +02:00
GPT on behalf of DHH cbf52805bd Merge pull request #311: Post a message whose attachment can't be previewed instead of failing
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
GPT on behalf of DHH 0f2a1da560 Merge pull request #316: List one page of account members at a time
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
GPT on behalf of DHH 6ad4735b19 Merge pull request #324: Rewrite a message's search entry only when its body or attachment changes
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
GPT on behalf of DHH 819b3896fb Merge pull request #323: Count unread rooms for push badges once per batch
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
GPT on behalf of DHH b6d307537e Merge pull request #325: Render the boosts a message has preloaded instead of querying them again
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
GPT on behalf of DHH fdec7dfc9f Merge pull request #322: Cache boosts with their message instead of one by one
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
GPT on behalf of DHH 0d5998f057 Merge pull request #318: Query sidebar directs and shared rooms separately
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
GPT on behalf of DHH 87eafc025f Merge pull request #310: Find a direct room with one query instead of checking every one
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
GPT on behalf of DHH 1e0d353c60 Merge pull request #312: Find the messages a refresh replaces through an index
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
GPT on behalf of DHH cf63b61361 Merge pull request #334: Create the room and creation time index only where it is missing
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
Marcello CostagliolaandClaude Opus 5.5 bf5dc0d74d Create the room and creation time index only where it is missing
The Rust port creates index_messages_on_room_id_and_created_at on boot,
under the same name and on the same columns, with CREATE INDEX IF NOT
EXISTS. On a database the port opened before this migration ran,
db:prepare stopped with "index ... already exists" and the app didn't
start. With if_not_exists the migration skips an index that is already
there and creates it everywhere else.

Databases that already ran the migration don't run it again, and the
dumped schema is the same, so db/schema.rb doesn't change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwoX7uRy5vFZSNKJhSqMSG
2026-10-06 22:00:50 +02:00
Marcello CostagliolaandClaude Opus 5.5 839ea34c29 Create the room and update time index only where it is missing
The Rust port creates index_messages_on_room_id_and_updated_at on boot,
under the same name, with CREATE INDEX IF NOT EXISTS
(basecamp/once-campfire-rust#45). On a database the port has opened,
this migration stopped db:prepare with "index ... already exists" and
the app didn't start. With if_not_exists it skips an index that is
already there and creates it everywhere else, and the dumped schema
stays the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwoX7uRy5vFZSNKJhSqMSG
2026-10-06 21:55:54 +02:00
Marcello CostagliolaandClaude Opus 5.5 c48083dcfe Show a file in a message's rich text by its name, whatever its variants
Showing an image's preview when its variant had already been made tied the cached message to state
that changes without touching it: a variant made after the message was cached would never show. It also
cost a query per embedded image, since Action Text loads the embeds without their variant records.
Nothing in the app makes those variants, so the partial no longer looks at them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj8KnxpTf9sj2Ysa8aLAVa
2026-10-06 16:02:35 +02:00
Marcello CostagliolaandClaude Opus 5.5 15f56134e7 Show a file in a message's rich text without making its preview on view
Files are posted as a message's own attachment, and the composer puts none in the rich text: it permits
only mentions and link embeds. The server still keeps any attachment whose signed id resolves, a blob
included, and Action Text's blob partial links to a representation URL that makes the preview on the
first request, without the limits the POST has, and on every request while it fails.

The app's own partial shows an image's preview only if it was already made, and otherwise the file's
name and size, as it does for a file that isn't an image. The cached presentation's version goes up,
since Action Text renders the partial by name and the template digest doesn't see it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj8KnxpTf9sj2Ysa8aLAVa
2026-10-06 15:41:34 +02:00
Marcello CostagliolaandClaude Opus 5.5 5f198146e8 Give a video a poster only once its poster variant is made
ActiveStorage::Preview#processed? is true as soon as ffmpeg's frame is attached, but the poster is a
variant of that frame and can fail on its own: the POST rescues a Vips::Error and leaves the frame
attached. The view then emitted the poster's URL, and every view retried the resize. It now checks the
variant too, from the variant records with_attached_attachment already preloads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj8KnxpTf9sj2Ysa8aLAVa
2026-10-06 15:34:46 +02:00
Marcello CostagliolaandClaude Opus 5.5 9912e63d69 Bound the work of previewing an attachment
A video's preview and a picture's thumbnail are made inside the request that posts the message, and
nothing bounded how long either could take.

- The video preview filter also selects any frame from 5 seconds on. Rails' filter takes the second
  frame it selects, which a video with a single keyframe and no scene change only gives at its end, so
  ffmpeg decoded all of it.
- TimeLimitedVideoPreviewer gives ffmpeg 10 seconds of wall-clock time, kills it past that, and reports
  a failed preview, so the message is posted without one.
- Pictures and videos above 250 megapixels, or whose size couldn't be read, get no preview: decoding
  costs in proportion to the pixels, however small the file.
- The view shows a preview only if it was made when the message was posted. Its URL used to make it on
  view, so a preview that failed or was skipped would be attempted again on every view. The cached
  presentation's version goes up, so cached messages pick this up.
- A video's poster is made, when the message is posted, at the size the view shows it. The full-size WebP
  made until now wasn't shown anywhere, and encoding it costs in proportion to the frame's pixels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj8KnxpTf9sj2Ysa8aLAVa
2026-10-06 15:20:57 +02:00
Marcello CostagliolaandClaude Opus 5.5 91036c5085 Merge branch 'post-messages-with-unreadable-attachments' into bound-attachment-previews
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj8KnxpTf9sj2Ysa8aLAVa
2026-10-06 15:20:56 +02:00
Marcello CostagliolaandClaude Opus 5.5 462eff10df Reset former members' connections and grant open rooms in one statement
Deleting the memberships with delete_all skips Membership's after_destroy_commit, which resets a member's
connections when one membership is revoked. Until the job ran, a member who had the room open kept its
streams, and a message that still landed in the room (a request already past the membership check, a bot's
reply) reached them. The request now reads the members' ids in the transaction that deletes their
memberships, and the job resets their connections before it destroys the messages. It costs a Redis round
trip per member, about 1.5 s for 10,000, so it's done in the job rather than in the request.

User#grant_membership_to_open_rooms read the open rooms and inserted in a separate statement, so a user
created while a room was being closed could read it as open and be granted it after the close. It's now a
single insert ... select, which SQLite runs under the write lock, skipping duplicates as insert_all did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj8KnxpTf9sj2Ysa8aLAVa
2026-10-06 14:45:18 +02:00
Marcello CostagliolaandClaude Opus 5.5 414ff3376e Put the whole unfurl, lookups included, under one deadline
The deadline in Opengraph::Fetch started after the lookup of the pasted
host, and an unfurl looks up more hosts outside any fetch: the canonical
URL's, and the image's, once to check its content type and again to
validate it. Each of those waits as long as the resolver takes to give
up. Move the deadline up to UnfurlLinksController, around everything the
link leads to; Opengraph::Fetch keeps its per-operation timeouts and
makes no retries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj8KnxpTf9sj2Ysa8aLAVa
2026-10-06 14:03:26 +02:00
Marcello CostagliolaandClaude Opus 5.5 7b16014f01 Delete a room's messages in a job, one transaction each
Room#destroy destroyed every message inside the room's own
transaction, which holds SQLite's write lock until the last one: on a
room with many messages, every other write in the app waited and
failed. The request now takes the room away from its members and
leaves the rest to Room::DestroyJob, which destroys the messages one
at a time, each in its own short transaction, and then the room.

An open room is closed in the request, so that someone who joins the
account before the job ends isn't given it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj8KnxpTf9sj2Ysa8aLAVa
2026-10-06 13:57:05 +02:00
Marcello CostagliolaandClaude Opus 5.5 a196a93ed2 Bound how long link unfurling can hold a request
Opengraph::Fetch runs inside POST /unfurl_link, against a host the member
picked, with Net::HTTP's defaults: 60 s to connect, 60 s for each read, and
one retry of the GET. A per-read timeout doesn't bound a host that sends a
byte at a time, in its headers or its body, so nothing limited how long a
fetch could hold the request thread.

Give each operation 7 s, as Webhook does, turn off the retry, and put the
whole fetch, redirects included, under one 10 s deadline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj8KnxpTf9sj2Ysa8aLAVa
2026-10-06 13:43:34 +02:00
Marcello CostagliolaandClaude Opus 5.5 7e5dd695c9 Merge upstream main into reuse-pinned-push-connections
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj8KnxpTf9sj2Ysa8aLAVa
2026-10-06 13:33:38 +02:00
Rosa Gutierrez 32b4144b52 Merge pull request #314 from rubys/update-rails-main
Update Rails to main
2026-10-06 13:03:59 +02:00
Cursor AgentandThomas Klemm d2241f16db Merge remote-tracking branch 'upstream/main' into cursor/split-sidebar-membership-queries-8545
Co-authored-by: Thomas Klemm <github@tklemm.eu>
2026-10-05 18:30:41 +00:00
Marcello CostagliolaandClaude Opus 5.5 ff1a742f27 Tell a failed reconnect from a dead idle connection
Only a failure while Net::HTTP checks a reused idle socket means the
push service closed it, and a new connection can take the push. A
failure while connecting again, such as a certificate for another
name, or before the request is written on a new connection, is raised
as it is, so the subscription is invalidated as before. After the
request is written, a dropped connection raises ConnectionLost on new
connections too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9
2026-10-05 19:21:59 +02:00
Marcello CostagliolaandClaude Opus 5.5 53520d4444 Reuse push connections pinned to the address the guard just approved
Since push delivery was pinned to the IP resolved and guarded for it,
every push opens a new TCP and TLS connection: Net::HTTP::Persistent
looks the host up itself and can't be pinned. The handshake is one or
two extra round trips for every push.

WebPush::Connections keeps the pinned connections open for 30 seconds
and hands one out again only to a delivery whose own, fresh resolution
returned the same address for the same host. Net::HTTP only ever
reconnects to that address, so no request goes to an address the guard
didn't just approve. A connection the push service closed while idle is
replaced before the push is written; once it's written, a dropped
connection raises ConnectionLost instead of sending the push twice or
invalidating the subscription. A delivery without a resolved IP is no
longer sent at all, and net-http-persistent goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9
2026-10-05 18:24:41 +02:00
Sam RubyandClaude Opus 5.5 e0c0a1dc79 Check templates with bin/rails herb:check in CI
Rails 8.2 compiles HTML templates through Herb in strict mode, so a
template that doesn't compile fails to render. herb:check boots the app,
which loads ruby-vips, hence libvips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:45:37 -04:00
Sam Ruby 9d75c8b7eb Update Rails to main
Rails main (e3d5c569) moves Campfire's pin forward ten months. Because
Campfire loads the 8.2 framework defaults, the ones added since then take
effect too, among them header-only forgery protection, Herb as the HTML
template engine, strict Accept headers and immediate blob analysis.

Changes it needed:

- Minitest 6 has no minitest/unit; the test helper no longer requires it.
- Rack::Sendfile is no longer in the middleware stack; DebugLocks goes
  before ActionDispatch::Executor, as the Rails guide now says.
- Time::DATE_FORMATS is deprecated; :epoch is registered through
  ActiveSupport::TimeFormats.
- Channel test subscriptions expose stream_names; streams is private.
- sentry-rails declares its Action Cable handle_open and handle_close
  wrappers private, which Rails 8.2 calls from outside the connection, so
  no /cable connection succeeded. An initializer makes them public until
  getsentry/sentry-ruby#2972 ships (issue #2975).
- Lexxy renders editor content through Rails' editor adapter when Rails
  has one, which asks a mention for its editor partial. It is the same
  users/mention partial the mention prompt already inserts.
- redis-client moves to 0.30.1: Rails main's Redis cache store, which
  production uses, requires 0.28.0 or later, and assets:precompile
  (the Docker build) aborted on 0.25.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit b4d3880a3d88059dd5b0b884f1f5f1a6f82aa95c)
2026-10-05 11:45:37 -04:00
Sam Ruby 1cb2f69786 Compile the PWA help and account settings through Herb
Rails main compiles HTML templates through Herb under the 8.2 framework
defaults, which Campfire loads. Herb rejects `case` and its first `when`
in a single ERB tag, so the three pwa/ partials failed to compile, and
`herb:check` rejects ERB output in attribute names, which the account
settings' switch used for `checked`. Give `case` its own tag and build
the switch with tag.input; both render the same under Erubi.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 244e77241b)
2026-10-05 11:45:06 -04:00
Marcello CostagliolaandClaude Opus 5.5 e07da58668 Rewrite the search entry when the attachment is replaced too
An attachment message is indexed by its file name, and the update
action still accepts a new attachment. Active Storage clears
attachment_changes in its own after_commit, which runs before this one,
so the replacement is noted in before_update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9
2026-10-05 17:34:06 +02:00
Marcello CostagliolaandClaude Opus 5.5 20275e1fa7 Render the boosts a message has preloaded instead of querying them again
Pages of messages load them with_presentation, which preloads each
message's boosts with their boosters and avatars, but the boosts partial
asked for message.boosts.ordered: a new query for every message, and the
preloaded boosts went unused. Sort the preloaded boosts in Ruby. The
boosts frame, whose message comes without them, still queries them in
order with their boosters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9
2026-10-05 17:17:29 +02:00
Stanko Krtalić d2155e85a0 Merge pull request #306 from namespaceMarcello/post-webhook-attachments-only-on-success
Post a webhook reply as an attachment only when the bot answered 200
2026-10-05 17:13:57 +02:00
Stanko Krtalić 5c72c35775 Merge pull request #291 from basecamp/dependabot/bundler/selenium-webdriver-4.49.0
build(deps-dev): bump selenium-webdriver from 4.35.0 to 4.49.0
2026-10-05 17:10:16 +02:00
Marcello CostagliolaandClaude Opus 5.5 78a84a6ab7 Rewrite a message's search entry only when its body is saved
Boosting and unboosting touch the message, so after_update_commit
rewrote its row in the full-text index with the same text, loading the
rich text again to rebuild it, on every boost. The index only needs a
new row when the rich text body was saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9
2026-10-05 17:09:46 +02:00
Stanko Krtalić 21c49b3ccc Merge pull request #289 from basecamp/dependabot/bundler/faker-3.8.0
build(deps-dev): bump faker from 3.5.2 to 3.8.0
2026-10-05 17:06:49 +02:00
Stanko Krtalić c09c464e9a Merge pull request #288 from basecamp/dependabot/github_actions/github-actions-e631a66382
build(deps): bump ruby/setup-ruby from 1.324.0 to 1.327.0 in the github-actions group
2026-10-05 17:06:28 +02:00
Stanko Krtalić b39766c8d6 Merge pull request #287 from basecamp/dependabot/bundler/propshaft-dc979db
build(deps): bump propshaft from `e49a9de` to `dc979db`
2026-10-05 17:06:15 +02:00
Stanko Krtalić b67a0fb706 Merge pull request #285 from cattekin/test-performance
Hash fixture passwords at minimum `bcrypt` cost
2026-10-05 17:05:57 +02:00
Stanko Krtalić 4690de5057 Merge pull request #301 from rubys/fix-edge-install-icon-path
Find the Edge install icon under images/external
2026-10-05 17:04:04 +02:00
Stanko Krtalić 77c5234cb6 Merge pull request #304 from namespaceMarcello/search-newest-matches-by-rowid
Read the newest search matches off the index instead of sorting them all
2026-10-05 16:59:44 +02:00
Marcello CostagliolaandClaude Opus 5.5 d485db6038 Count unread rooms for push badges once per batch
Each push notification carries the subscriber's unread room count as its
badge. The pool built it per subscription, loading the user and counting
their unread memberships: two queries for every subscriber, all in the job
before the deliveries reach the threads. With 1,000 subscribed members the
job spent ~180 ms and 2,000 queries there; with 5,000, a second.

The pool now counts the unread rooms of a whole batch with one grouped query
and hands each subscription its badge; nothing else in the notification needs
the user. The queries still run before the work is posted to the threads,
which run outside the Rails executor. Push::Subscription#notification still
counts by itself when no badge is given, as for the test notification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9
2026-10-05 16:49:59 +02:00
Thomas Klemm 77d4eb0d3a Merge branch 'main' into cursor/split-sidebar-membership-queries-8545 2026-10-05 17:47:46 +03:00
Stanko Krtalić 2393f01ba2 Merge pull request #302 from rubys/fix-layout-stray-spans
Drop two unmatched closing spans from the flash
2026-10-05 16:46:27 +02:00
Stanko Krtalić d1f09b4276 Merge pull request #297 from namespaceMarcello/check-paging-without-counting
Check for a second page of messages without counting the room
2026-10-05 16:45:50 +02:00
Marcello CostagliolaandClaude Opus 5.5 c761763c8c Cache boosts with their message instead of one by one
Every boost had its own fragment, nested inside the message fragment that
already holds it. With a cold cache, a read and a write per boost plus a
read_multi and a write_multi per message: a room page of 40 messages with
120 boosts takes ~410 ms instead of ~290 ms. Redis runs without persistence
(config/redis.conf), so every page is cold after a restart.

A boost touches its message, so adding or removing one rewrites the message
fragment anyway. The inner fragments only paid off when a message with many
boosts was drawn again, and there they hid a lookup per booster. The boosters
are now loaded with the boosts, one query per message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9
2026-10-05 16:40:49 +02:00
Stanko K.R. 259c06bea1 Remove trailing whitespace 2026-10-05 16:30:18 +02:00
Stanko Krtalić 6913afa864 Merge branch 'main' into check-paging-without-counting 2026-10-05 16:18:32 +02:00
Stanko Krtalić c73ea37e0c Merge pull request #300 from rubys/fix-autocomplete-json-accept
Ask for JSON when autocompleting people to ping
2026-10-05 16:17:10 +02:00
Stanko Krtalić e0e31846c9 Merge pull request #299 from rubys/fix-boost-soft-keyboard-action
Wire the inline boost link to the soft keyboard
2026-10-05 16:16:25 +02:00
Stanko Krtalić f2bdfde610 Merge pull request #298 from namespaceMarcello/search-operator-words
Search for operator words instead of parsing them
2026-10-05 16:15:23 +02:00
Marcello CostagliolaandClaude Opus 5.5 a740581f20 Keep an unread notice older than the latest read from marking the room
The job picks the room's members once and then publishes to them one at a
time, as the request did before it. A member who opens the room during that
loop gets the read event in their other tabs and then the older notice,
which marked the room unread again. Both events now carry the server time,
and the sidebar ignores a notice dated before the room's latest read. The
notice still moves a direct room to the top.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9
2026-10-05 16:11:37 +02:00
Marcello CostagliolaandClaude Opus 5.5 288ee9dc5c Merge main, now with the room and creation time index from #295
Both migrations stay; db/schema.rb keeps both indexes and the later version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9
2026-10-05 15:57:15 +02:00
Stanko Krtalić acef0c71cf Merge pull request #295 from namespaceMarcello/messages-room-created-at-index
Index messages by room and creation time
2026-10-05 15:53:19 +02:00
Marcello CostagliolaandClaude Opus 5.5 ce4c05da81 Apply the same user filter to every page of account members
Administrators see banned users in the account settings, but only the
first page counted them: the next pages listed active users alone. A
banned member before the page boundary shifted the offset, so one active
member was never listed. Both pages now share User.visible_to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9
2026-10-05 15:19:21 +02:00
Marcello CostagliolaandClaude Opus 5.5 f864403c2c List one page of account members at a time
Since administrators were grouped apart from members (b52c318), the account
settings page loads every user to split them and renders all of them. The
lazy next page still starts at the 501st user, so on an account with more
than 500 people, scrolling down lists those users a second time.

Load administrators on their own and page only members, both on the settings
page and on the pages that follow it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9
2026-10-05 14:56:22 +02:00
Cursor AgentandThomas Klemm d6b3dfd64d Compose sidebar lists from existing room scopes
Directs merge Room.directs and order by recency. Shared rooms reuse
with_ordered_room and without_direct_rooms, with the STI filter coming
from Room.without_directs so the join alias stays rooms.

Co-authored-by: Thomas Klemm <github@tklemm.eu>
2026-10-05 12:48:29 +00:00
Cursor AgentandThomas Klemm 88fbeedc75 Avoid a double join alias when loading shared sidebar rooms
Chaining without_direct_rooms with with_ordered_room joined rooms as
`room` while still ordering on `rooms.name`. Load shared rooms in one
scope instead.

Co-authored-by: Thomas Klemm <github@tklemm.eu>
2026-10-05 12:36:42 +00:00
Cursor AgentandThomas Klemm fbeb1ae993 Query sidebar directs and shared rooms separately
Load visible direct memberships ordered by room recency and other
rooms ordered by name, instead of hydrating every membership and
splitting them in Ruby.

Fixes #307.

Co-authored-by: Thomas Klemm <github@tklemm.eu>
2026-10-05 12:35:35 +00:00
Marcello CostagliolaandClaude Opus 5.5 ad541b1c6a Drop the query plan test
Keep the test of what page_updated_since returns, which covers the
rewritten scope; pinning SQLite's plan locks in more than the change
needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9
2026-10-05 13:59:49 +02:00
David Heinemeier Hansson 345e63718f Update README with latest Campfire benchmark comparison (#315) 2026-10-05 07:52:23 -04:00
Marcello CostagliolaandClaude Opus 5.5 e12651240c Drop the query plan test
The index is a schema change; a test that pins SQLite's plan for each
pagination scope locks in more than the change needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9
2026-10-05 13:51:56 +02:00
Marcello CostagliolaandClaude Opus 5.5 ca626ea7e2 Find the messages a refresh replaces through an index
Every room visit and every reconnection asks for the messages updated
since the page was rendered. That query filtered the room by updated_at
and sorted it by created_at, so SQLite walked every message in the room:
about 220-360 ms in a room of a million messages. An index on
(room_id, updated_at) finds the few updated messages directly.

Sorting on +created_at keeps SQLite on that index once messages are also
indexed by (room_id, created_at): with both, the planner otherwise walks
the room in creation order looking for updated rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVFo3Lt9T8M5NR7KxVvsZ2
2026-10-05 04:48:53 +02:00
Marcello CostagliolaandClaude Opus 5.5 e6de359872 Post a message whose attachment can't be previewed instead of failing
The thumbnail or video preview is generated while the message is posted.
When ffmpeg or libvips can't decode the file (a truncated upload, an .mp4
holding only audio), the error escaped after the message had been saved:
the request failed with a 500, the message was never broadcast, and the
sender's upload stayed at 100% while the rest of the files in that drop
were never sent. Posting the message without a preview keeps the file and
lets everyone see it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVFo3Lt9T8M5NR7KxVvsZ2
2026-10-05 04:12:44 +02:00
Marcello CostagliolaandClaude Opus 5.5 33c4b726e7 Find a direct room with one query instead of checking every one
Opening a direct room looked for an existing one by loading every direct
room on the account and comparing its member ids in Ruby, two queries per
room, on each click. The cost grows with the account: about 0.3 s at 1,000
direct rooms and 3 s at 10,000. Asking SQL for the room among the first
user's memberships whose member set is exactly the given users finds the
same room in one query, however many direct rooms there are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVFo3Lt9T8M5NR7KxVvsZ2
2026-10-05 04:06:17 +02:00
Marcello CostagliolaandClaude Opus 5.5 6288a10633 Post a webhook reply as an attachment only when the bot answered 200
A bot's reply becomes a message when the status is 200 and the type is text,
and an attachment otherwise, but the attachment branch never looked at the
status. Whenever a bot's endpoint failed, its error page landed in the room as
a file: a proxy's 502 page as attachment.html, a 404 as attachment.text.

Apply the text branch's 200 check to attachments too. The error reply test
answered without a content type, which skipped the attachment branch, so it
now answers with an HTML error page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVFo3Lt9T8M5NR7KxVvsZ2
2026-10-05 02:58:11 +02:00
Marcello CostagliolaandClaude Opus 5.5 edaab3e5d1 Read the newest search matches off the index instead of sorting them all
Search showed the last 100 matches by created_at, so SQLite collected every
message containing the words and sorted them before keeping a page. A common
word in a large account meant sorting most of its history on every search.

Ordering by the full-text index's rowid, which is the message id, lets SQLite
walk the index from the newest match and stop once the page is full.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVFo3Lt9T8M5NR7KxVvsZ2
2026-10-05 02:50:48 +02:00
Marcello CostagliolaandClaude Opus 5.5 506c2771fe Skip the unread notice for members who have caught up
Now that the unread fanout runs in a job, it can run after a member has
already opened the room and moved on to another one. Their sidebar would
then mark the room unread for a message they have seen.

The job now notifies only members who still have the room unread or are
in it now. Room#receive marks members who aren't in the room unread, and
opening the room clears it, so a member who caught up in the meantime is
skipped. When the
job runs right away this is the same set of people as before, except
members who have hidden the room, whose sidebar doesn't list it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVFo3Lt9T8M5NR7KxVvsZ2
2026-10-05 02:08:01 +02:00
Sam RubyandClaude Opus 5.5 955d799d11 Find the Edge install icon under images/external
install-edge.svg lives in app/assets/images/external/, alongside the
other install icons, but the Edge branch of pwa/_install_instructions
asked for it at the top level. Propshaft raises MissingAssetError, so a
browser the useragent gem reports as Edge got a 500 on the profile page
and anywhere else the partial renders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:06:53 -04:00
Sam RubyandClaude Opus 5.5 1d39b8cbe1 Drop two unmatched closing spans from the flash
The flash icons were followed by `</span>` with no opening tag.
Browsers discard them, so nothing renders differently, but HTML-aware
tools such as Herb report them as errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:06:53 -04:00
Sam RubyandClaude Opus 5.5 b8be941b99 Ask for JSON when autocompleting people to ping
`as: "json"` is a @rails/request.js option; plain fetch ignores it and
sends `Accept: */*`, so Autocompletable::UsersController answered with
its HTML format and response.json() threw. The new ping form never
showed any suggestions. Send an explicit Accept header instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:06:53 -04:00
Sam RubyandClaude Opus 5.5 c057484dfb Wire the inline boost link to the soft keyboard
link_to passed `action: "soft-keyboard#open"` as a plain option, so it
rendered as an `action` attribute, which Stimulus never reads. The link
in messages/_actions.html.erb already passes it under `data:`; this one
now does too, so tapping "Add a boost" on a touch device opens the
keyboard for the boost form as the menu's link does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:06:53 -04:00
Marcello CostagliolaandClaude Opus 5.5 74bdbb0764 Keep the room_id index and date the migration in UTC
The composite index doesn't serve everything the room_id one did. In
SQLite that index is (room_id, rowid), so it also reads a room's messages
in id order, and it is the narrower one to count a room with. Keeping both,
as memberships already does, leaves those lookups as they were.

The migration was named after local time, two hours ahead of the UTC
timestamp Rails generates; it now carries a UTC one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NuTtwJKhb77Dv7EQqv2C3C
2026-10-05 01:39:36 +02:00
Marcello CostagliolaandClaude Opus 5.5 3212a683ee Fan the unread room notice out from a job
Since the unread rooms stream was scoped per user, posting a message
publishes the room id once to each member of the room. Each publish is a
round trip to Redis, made one after another inside the request, so the time
to post a message grows with the size of the room and in a big room is far
more than the rest of the request.

The fanout now runs in Message::BroadcastUnreadRoomJob, so the poster no
longer waits for it. Who gets the notice and what it says are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NuTtwJKhb77Dv7EQqv2C3C
2026-10-05 01:17:59 +02:00
Marcello CostagliolaandClaude Opus 5.5 6cdcb459b9 Search for operator words instead of parsing them
The search box keeps only word characters, but FTS5 still reads AND, OR,
NOT and NEAR in the query as operators, so searching for "AND" or for
"salt AND" raised "fts5: syntax error" and answered with a 500. Quoting
each word makes FTS5 look for it as text, which is how it already treats
the same words in lower case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NuTtwJKhb77Dv7EQqv2C3C
2026-10-05 01:08:53 +02:00
Marcello CostagliolaandClaude Opus 5.5 cf0ba58d8d Check for a second page of messages without counting the room
The original room shows its welcome box until it holds more than a page of
messages, and paged? answered that with a COUNT(*) over every message in
the room on each visit. Asking whether there is a row past the first page
answers the same question while reading at most a page of index entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NuTtwJKhb77Dv7EQqv2C3C
2026-10-05 01:03:39 +02:00
Marcello CostagliolaandClaude Opus 5.5 5cb4316133 Index messages by room and creation time
Every page of a room's messages (opening the room, scrolling back, the
refresh after reconnecting, the bot API) selects WHERE room_id = ? ORDER BY
created_at LIMIT 40. With only the room_id index, SQLite reads every
message in the room and sorts them in a temporary B-tree to return 40, so
these requests grow with the room's history.

A composite index on (room_id, created_at) lets SQLite read the 40 rows in
order straight from the index. It covers every lookup the room_id index
served, so that one goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NuTtwJKhb77Dv7EQqv2C3C
2026-10-05 00:55:24 +02:00
David Heinemeier Hansson 254dd1d46f Add Django and Laravel benchmarks and label the baseline Rails (#294) 2026-10-04 18:18:31 -04:00
David Heinemeier Hansson 6c7f8fa15f Document other Campfire implementations and benchmarks (#293)
* Document other Campfire implementations and benchmarks

* Remeasure README benchmarks with the latest Ruby optimizations

* Shorten the README benchmark context
2026-10-04 14:10:38 -04:00
David Heinemeier Hansson 659f95748a Preload only uncached messages and reduce rendering overhead (#292)
* Preload only uncached messages and reduce rendering overhead

* Keep benchmark summaries without raw JSON results

* Use Ruby benchmark drivers and keep generated results out of the repo
2026-10-04 12:36:36 -04:00
dependabot[bot] 67b6df3847 build(deps-dev): bump selenium-webdriver from 4.35.0 to 4.49.0
Bumps [selenium-webdriver](https://github.com/SeleniumHQ/selenium) from 4.35.0 to 4.49.0.
- [Release notes](https://github.com/SeleniumHQ/selenium/releases)
- [Changelog](https://github.com/SeleniumHQ/selenium/blob/trunk/rb/CHANGES)
- [Commits](https://github.com/SeleniumHQ/selenium/compare/selenium-4.35.0...selenium-4.49.0)

---
updated-dependencies:
- dependency-name: selenium-webdriver
  dependency-version: 4.49.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-02 19:36:16 +00:00
dependabot[bot] 957c9da9c4 build(deps-dev): bump faker from 3.5.2 to 3.8.0
Bumps [faker](https://github.com/faker-ruby/faker) from 3.5.2 to 3.8.0.
- [Release notes](https://github.com/faker-ruby/faker/releases)
- [Changelog](https://github.com/faker-ruby/faker/blob/main/CHANGELOG.md)
- [Commits](https://github.com/faker-ruby/faker/compare/v3.5.2...v3.8.0)

---
updated-dependencies:
- dependency-name: faker
  dependency-version: 3.8.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-02 19:34:50 +00:00
dependabot[bot] 862e128722 build(deps): bump ruby/setup-ruby
Bumps the github-actions group with 1 update: [ruby/setup-ruby](https://github.com/ruby/setup-ruby).


Updates `ruby/setup-ruby` from 1.324.0 to 1.327.0
- [Release notes](https://github.com/ruby/setup-ruby/releases)
- [Changelog](https://github.com/ruby/setup-ruby/blob/master/release.rb)
- [Commits](https://github.com/ruby/setup-ruby/compare/a0102e0972be65f351c307e2d64b9314a57c8073...14594264cd68ce8a2345dd349bc3d138a4ef85c8)

---
updated-dependencies:
- dependency-name: ruby/setup-ruby
  dependency-version: 1.327.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-02 19:34:48 +00:00
dependabot[bot] 26c70b3254 build(deps): bump propshaft from e49a9de to dc979db
Bumps [propshaft](https://github.com/rails/propshaft) from `e49a9de` to `dc979db`.
- [Release notes](https://github.com/rails/propshaft/releases)
- [Commits](https://github.com/rails/propshaft/compare/e49a9de659ff27462015e54dd832e86e762a6ddc...dc979db89cd07c72ee4d11d415ae1cb4fd072623)

---
updated-dependencies:
- dependency-name: propshaft
  dependency-version: dc979db89cd07c72ee4d11d415ae1cb4fd072623
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-02 19:34:42 +00:00
Edward Tippett ae149fca92 Hash fixture passwords at minimum bcrypt cost
The fixture calls BCrypt directly, bypassing Rails' test configuration.
2026-09-29 09:53:39 +07:00
127 changed files with 3823 additions and 269 deletions

No files matched your search

+33 -7
View File
@@ -4,7 +4,7 @@ on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
branches: [ main, "release-*" ]
permissions: {}
@@ -21,7 +21,7 @@ jobs:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
with:
ruby-version: .ruby-version
bundler-cache: true
@@ -40,7 +40,7 @@ jobs:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
with:
ruby-version: .ruby-version
bundler-cache: true
@@ -48,6 +48,29 @@ jobs:
- name: Lint code for consistent style
run: bin/rubocop
herb:
name: Herb check
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Install packages
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libvips
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Check that every HTML template compiles through Herb
run: bin/rails herb:check
lint-actions:
name: GitHub Actions audit
runs-on: ubuntu-latest
@@ -79,7 +102,7 @@ jobs:
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Install packages
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 sqlite3 libvips curl ffmpeg
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -87,7 +110,7 @@ jobs:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
env:
REDIS_URL: redis://localhost:6379/0
with:
@@ -97,6 +120,9 @@ jobs:
- name: Run tests
run: bin/rails db:setup test
- name: Test benchmark response validation
run: bundle exec ruby bench/test_http_client.rb
test_system:
runs-on: ubuntu-latest
permissions:
@@ -109,7 +135,7 @@ jobs:
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Install packages
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 sqlite3 libvips curl ffmpeg
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -117,7 +143,7 @@ jobs:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
env:
REDIS_URL: redis://localhost:6379/0
with:
-1
View File
@@ -43,7 +43,6 @@ gem "rqrcode"
gem "rails_autolink"
gem "geared_pagination"
gem "jbuilder"
gem "net-http-persistent"
gem "surfguard", github: "basecamp/surfguard" # The SSRF address policy behind RestrictedHTTP
gem "kredis"
gem "platform_agent"
+53 -40
View File
@@ -23,16 +23,16 @@ GIT
GIT
remote: https://github.com/rails/propshaft.git
revision: e49a9de659ff27462015e54dd832e86e762a6ddc
revision: dc979db89cd07c72ee4d11d415ae1cb4fd072623
specs:
propshaft (1.2.1)
propshaft (1.3.2)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
GIT
remote: https://github.com/rails/rails.git
revision: 1a02651ac37fb64b4de2a2b73461d86acf9c98fb
revision: e3d5c569d217c56c022b04acb127a8c24dbe9685
branch: main
specs:
actioncable (8.2.0.alpha)
@@ -63,10 +63,10 @@ GIT
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
rails-html-sanitizer (~> 1.7)
useragent (~> 0.16)
actiontext (8.2.0.alpha)
action_text-trix (~> 2.1.15)
action_text-trix (~> 2.1.16)
actionpack (= 8.2.0.alpha)
activerecord (= 8.2.0.alpha)
activestorage (= 8.2.0.alpha)
@@ -77,11 +77,12 @@ GIT
activesupport (= 8.2.0.alpha)
builder (~> 3.1)
erubi (~> 1.11)
herb (>= 0.10)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
rails-html-sanitizer (~> 1.7)
activejob (8.2.0.alpha)
activesupport (= 8.2.0.alpha)
globalid (>= 0.3.6)
globalid (>= 1.4.0)
activemodel (8.2.0.alpha)
activesupport (= 8.2.0.alpha)
activerecord (8.2.0.alpha)
@@ -93,7 +94,7 @@ GIT
activejob (= 8.2.0.alpha)
activerecord (= 8.2.0.alpha)
activesupport (= 8.2.0.alpha)
marcel (~> 1.0)
marcel (~> 2.0)
activesupport (8.2.0.alpha)
base64
bigdecimal
@@ -104,6 +105,8 @@ GIT
json
logger (>= 1.4.2)
minitest (>= 5.1)
psych (>= 4)
ractor-dispatch (>= 0.3.0)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
@@ -142,8 +145,8 @@ GEM
base64 (0.3.0)
bcrypt (3.1.22)
benchmark (0.5.0)
bigdecimal (3.3.1)
brakeman (8.0.6)
bigdecimal (4.1.3)
brakeman (8.1.0)
racc
builder (3.3.0)
bundler-audit (0.9.3)
@@ -159,7 +162,7 @@ GEM
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
chunky_png (1.4.0)
concurrent-ruby (1.3.7)
concurrent-ruby (1.3.8)
connection_pool (2.5.5)
crack (1.0.0)
bigdecimal
@@ -170,9 +173,9 @@ GEM
irb (~> 1.10)
reline (>= 0.3.8)
drb (2.2.3)
erb (6.0.4)
erb (6.0.7)
erubi (1.13.1)
faker (3.5.2)
faker (3.8.0)
i18n (>= 1.8.11, < 2)
ffi (1.17.2)
ffi (1.17.2-aarch64-linux-gnu)
@@ -182,17 +185,22 @@ GEM
geared_pagination (1.2.0)
activesupport (>= 5.0)
addressable (>= 2.5.0)
globalid (1.3.0)
globalid (1.4.0)
activesupport (>= 6.1)
hashdiff (1.2.0)
i18n (1.14.7)
herb (0.11.0-aarch64-linux-gnu)
herb (0.11.0-arm64-darwin)
herb (0.11.0-x86_64-darwin)
herb (0.11.0-x86_64-linux-gnu)
i18n (1.15.2)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
io-console (0.8.1)
irb (1.15.3)
io-console (0.9.4)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jbuilder (2.14.1)
@@ -219,28 +227,28 @@ GEM
net-imap
net-pop
net-smtp
marcel (1.1.0)
marcel (2.1.0)
matrix (0.4.3)
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
minitest (5.26.2)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
mocha (3.1.0)
ruby2_keywords (>= 0.0.5)
mono_logger (1.1.2)
multi_json (1.17.0)
mustermann (3.0.4)
ruby2_keywords (~> 0.0.1)
net-http-persistent (4.0.6)
connection_pool (~> 2.2, >= 2.2.4)
net-imap (0.6.4.1)
net-imap (0.6.7)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
net-protocol (0.4.0)
timeout
net-smtp (0.5.1)
net-smtp (0.5.2)
net-protocol
nio4r (2.7.5)
nokogiri (1.19.4-aarch64-linux-gnu)
@@ -260,18 +268,18 @@ GEM
platform_agent (1.0.1)
activesupport (>= 5.2.0)
useragent (~> 0.16.3)
pp (0.6.3)
pp (0.6.4)
prettyprint
prettyprint (0.2.0)
prism (1.4.0)
psych (5.2.6)
prism (1.9.0)
psych (5.5.0)
date
stringio
public_suffix (6.0.2)
puma (7.2.1)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.6)
rack (3.2.7)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
@@ -281,8 +289,9 @@ GEM
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.2.1)
rackup (2.3.1)
rack (>= 3)
ractor-dispatch (0.3.0)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
@@ -295,19 +304,24 @@ GEM
activesupport (> 3.1)
railties (> 3.1)
rainbow (3.1.1)
rake (13.3.1)
rdoc (6.16.1)
rake (13.4.2)
rbs (4.2.0)
logger
prism (>= 1.6.0)
tsort
rdoc (8.1.0)
erb
psych (>= 4.0.0)
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
redis (5.4.1)
redis-client (>= 0.22.0)
redis-client (0.25.2)
redis-client (0.30.1)
connection_pool
redis-namespace (1.11.0)
redis (>= 4)
regexp_parser (2.11.2)
reline (0.6.3)
reline (0.7.0)
io-console (~> 0.5)
resque (2.7.0)
mono_logger (~> 1)
@@ -355,9 +369,9 @@ GEM
ffi (~> 1.12)
logger
ruby2_keywords (0.0.5)
rubyzip (3.4.0)
rubyzip (3.7.0)
securerandom (0.4.1)
selenium-webdriver (4.35.0)
selenium-webdriver (4.49.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
@@ -382,8 +396,8 @@ GEM
sqlite3 (2.9.6-x86_64-linux-gnu)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.8)
thor (1.4.0)
stringio (3.2.0)
thor (1.5.0)
thruster (0.1.23-aarch64-linux)
thruster (0.1.23-arm64-darwin)
thruster (0.1.23-x86_64-darwin)
@@ -412,7 +426,7 @@ GEM
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.7.3)
zeitwerk (2.8.3)
PLATFORMS
aarch64-linux
@@ -435,7 +449,6 @@ DEPENDENCIES
kredis
lexxy (~> 0.9.24)
mocha
net-http-persistent
ostruct
platform_agent
propshaft!
+17
View File
@@ -49,6 +49,23 @@ If you'd rather run the Docker image yourself, you can read more about that in t
> that people have someone to contact if they need help with their account. If that bothers you, put in any
> email address you want and create yourself a new admin account.
## Other implementations
Campfire also has implementations in Django, Laravel, Express, Elixir, Go, Rust and C:
| HTTP workload (requests/sec) | Rails | [Django](https://github.com/basecamp/once-campfire-django) | [Laravel](https://github.com/basecamp/once-campfire-laravel) | [Express](https://github.com/basecamp/once-campfire-express) | [Elixir](https://github.com/basecamp/once-campfire-elixir) | [Go](https://github.com/basecamp/once-campfire-go) | [Rust](https://github.com/basecamp/once-campfire-rust) | [C](https://github.com/basecamp/once-campfire-c) |
|---|---:|---:|---:|---:|---:|---:|---:|---:|
| Room page | 230 | 62 | 760 | 2,622 | 942 | 31,673 | 35,484 | 141,834 |
| Messages page | 402 | 70 | 924 | 3,245 | 1,267 | 30,746 | 40,674 | 151,564 |
| Sidebar | 468 | 229 | 1,383 | 34,938 | 2,515 | 18,586 | 34,479 | 159,850 |
| Search | 399 | 118 | 1,135 | 6,613 | 1,814 | 29,765 | 34,432 | 155,456 |
| Post a message | 248 | 112 | 498 | 2,088 | 1,400 | 9,073 | 8,998 | 7,460 |
[Shared verification](https://github.com/basecamp/once-campfire-verification) · [Detailed results](https://github.com/basecamp/once-campfire-verification/blob/main/docs/performance-review.md).
Measured with 16 concurrent clients on an AMD Ryzen AI MAX+ 395 with 32 GB RAM,
with four hardware cores allocated to each app.
## Development
You are welcome - and encouraged - to modify Campfire to your liking.
+1 -1
View File
@@ -22,6 +22,6 @@ class PresenceChannel < RoomChannel
end
def broadcast_read_room
ActionCable.server.broadcast "user_#{current_user.id}_reads", { room_id: membership.room_id }
ActionCable.server.broadcast "user_#{current_user.id}_reads", { room_id: membership.room_id, at: Time.current.to_f }
end
end
+1 -1
View File
@@ -2,7 +2,7 @@ class Accounts::UsersController < ApplicationController
before_action :ensure_can_administer, :set_user, only: %i[ update destroy ]
def index
set_page_and_extract_portion_from User.active.ordered.without_bots, per_page: 500
set_page_and_extract_portion_from User.visible_to(Current.user).ordered.member, per_page: 500
end
def update
+3 -11
View File
@@ -3,9 +3,9 @@ class AccountsController < ApplicationController
before_action :set_account
def edit
users = account_users.ordered.without_bots
@administrators, @members = users.partition(&:administrator?)
set_page_and_extract_portion_from users, per_page: 500
users = User.visible_to(Current.user).ordered
@administrators = users.administrator
set_page_and_extract_portion_from users.member, per_page: 500
end
def update
@@ -21,12 +21,4 @@ class AccountsController < ApplicationController
def account_params
params.require(:account).permit(:name, :logo, settings: {})
end
def account_users
if Current.user.can_administer?
User.where(status: [ :active, :banned ])
else
User.active
end
end
end
+1 -1
View File
@@ -1,4 +1,4 @@
class ApplicationController < ActionController::Base
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders, InlineScriptHashes
include Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
end
@@ -0,0 +1,50 @@
# Allows the scripts the importmap helper emits and nothing else: the inline
# importmap and its entry-point module by content hash, and the asset
# directory its URLs point into. Not the whole origin, because uploads are
# served from this origin too. The hashes change only when the importmap does
# (a deploy), so cached HTML stays valid, unlike a per-request nonce.
module InlineScriptHashes
extend ActiveSupport::Concern
ENTRY_POINT_SCRIPT = %(import "application")
included do
# With the policy turned off there's nothing to add to, so leave the
# request without one rather than starting an empty policy
content_security_policy if: -> { request.content_security_policy } do |policy|
if sources = policy.directives["script-src"]
importmap = Rails.application.importmap.to_json(resolver: helpers)
policy.directives["script-src"] = (sources - [ "'self'" ]) | script_locations(importmap) | script_hashes(importmap, ENTRY_POINT_SCRIPT)
policy.worker_src "#{request.host_with_port}#{service_worker_path(format: :js)}"
end
end
end
private
# The importmap is resolved by Rails' asset helpers, so its URLs already
# carry any relative root and asset host, however the host is written:
# with or without a scheme, protocol-relative, a %d set, or a proc. Each
# is allowed up to its asset directory, the last one in its path, since an
# asset host's own path may contain one too; a URL outside it is allowed
# exactly.
#
# This origin's own sources leave out the scheme, so they match the page's
# scheme even behind a proxy that terminates TLS without saying so.
#
# A path only limits the first request: browsers check just the origin
# after a redirect. Nothing under the asset directory redirects.
def script_locations(importmap)
assets = File.join(config.relative_url_root.to_s, Rails.application.config.assets.prefix, "")
JSON.parse(importmap)["imports"].values.map do |url|
url = URI.join(request.base_url, url)
origin = url.origin == request.base_url ? request.host_with_port : url.origin
directory = url.path[0, url.path.rindex(assets) + assets.length] if url.path.include?(assets)
"#{origin}#{directory || url.path}"
end.uniq
end
def script_hashes(*scripts)
scripts.map { |script| "'sha256-#{Digest::SHA256.base64digest(script)}'" }
end
end
@@ -8,6 +8,6 @@ module SetCurrentRequest
end
def default_url_options
{ host: Current.request_host, protocol: Current.request_protocol }.compact_blank
{ host: Current.request_host, port: Current.request&.optional_port, protocol: Current.request_protocol }.compact_blank
end
end
@@ -36,7 +36,7 @@ class Messages::BoostsController < ApplicationController
def broadcast_create
@boost.broadcast_append_to @boost.message.room, :messages,
target: "boosts_message_#{@boost.message.client_message_id}", partial: "messages/boosts/boost", attributes: { maintain_scroll: true }
target: helpers.dom_id(@boost.message, :boosts), partial: "messages/boosts/boost", attributes: { maintain_scroll: true }
end
def broadcast_remove
+3 -3
View File
@@ -62,11 +62,11 @@ class MessagesController < ApplicationController
def find_paged_messages
case
when params[:before].present?
@room.messages.with_creator.page_before(@room.messages.find(params[:before]))
@room.messages.with_presentation.page_before(@room.messages.find(params[:before]))
when params[:after].present?
@room.messages.with_creator.page_after(@room.messages.find(params[:after]))
@room.messages.with_presentation.page_after(@room.messages.find(params[:after]))
else
@room.messages.with_creator.last_page
@room.messages.with_presentation.last_page
end
end
@@ -4,8 +4,8 @@ class Rooms::RefreshesController < ApplicationController
before_action :set_last_updated_at
def show
@new_messages = @room.messages.with_creator.page_created_since(@last_updated_at)
@updated_messages = @room.messages.without(@new_messages).with_creator.page_updated_since(@last_updated_at)
@new_messages = @room.messages.with_presentation.page_created_since(@last_updated_at)
@updated_messages = @room.messages.without(@new_messages).with_presentation.page_updated_since(@last_updated_at)
end
private
+3 -3
View File
@@ -12,7 +12,7 @@ class RoomsController < ApplicationController
end
def destroy
@room.destroy
@room.destroy_later
broadcast_remove_room
redirect_to root_url
@@ -44,9 +44,9 @@ class RoomsController < ApplicationController
end
def find_messages
messages = @room.messages.with_creator.with_attachment_details.with_boosts
messages = @room.messages.with_presentation
if show_first_message = messages.find_by(id: params[:message_id])
if params[:message_id].present? && (show_first_message = messages.find_by(id: params[:message_id]))
@messages = messages.page_around(show_first_message)
else
@messages = messages.last_page
+1 -1
View File
@@ -20,7 +20,7 @@ class SearchesController < ApplicationController
private
def set_messages
if query.present?
@messages = Current.user.reachable_messages.search(query).last(100)
@messages = Message.search_reachable(Current.user, query)
else
@messages = Message.none
end
+14 -3
View File
@@ -1,8 +1,9 @@
class UnfurlLinksController < ApplicationController
def create
opengraph = Opengraph::Metadata.from_url(url_param)
# The pasted link decides which hosts an unfurl looks up and fetches from, and how quickly they answer.
DEADLINE = 10.seconds
if opengraph.valid?
def create
if opengraph = unfurl(url_param)
render json: opengraph
else
head :no_content
@@ -13,4 +14,14 @@ class UnfurlLinksController < ApplicationController
def url_param
params.require(:url)
end
# One deadline for everything the link leads to: DNS lookups, redirects, the page, the image check.
def unfurl(url)
Timeout.timeout(DEADLINE) do
Opengraph::Metadata.from_url(url).then { |opengraph| opengraph if opengraph.valid? }
end
rescue Timeout::Error
Rails.logger.warn "Gave up unfurling #{url} after #{DEADLINE.inspect}"
nil
end
end
+33 -3
View File
@@ -1,14 +1,23 @@
class Users::ProfilesController < ApplicationController
before_action :set_user
# Limited like signing in, so the current password can't be guessed here instead.
rate_limit to: 10, within: 3.minutes, only: :update, if: :changing_password?,
with: -> { redirect_to user_profile_url, alert: "Too many attempts. Try again in a few minutes." }
def show
@direct_memberships, @shared_memberships =
Current.user.memberships.with_ordered_room.partition { |m| m.room.direct? }
end
def update
@user.update user_params
redirect_to user_profile_url, notice: update_notice
if @user.update user_params
start_new_session_for @user if @user.saved_change_to_password_digest?
redirect_to user_profile_url, notice: update_notice
else
redirect_to user_profile_url, alert: update_alert
end
end
private
@@ -17,10 +26,31 @@ class Users::ProfilesController < ApplicationController
end
def user_params
params.require(:user).permit(:name, :avatar, :email_address, :password, :bio).compact
permitted = params.require(:user).permit(:name, :avatar, :email_address, :password, :bio).compact
if changing_password?
permitted.merge(password_challenge: params[:user][:password_challenge].to_s)
else
permitted
end
end
# As has_secure_password reads it: an empty password leaves the current one alone.
def changing_password?
!params.dig(:user, :password).to_s.empty?
end
def update_notice
params[:user][:avatar] ? "It may take up to 30 minutes to change everywhere." : "✓"
end
def update_alert
if !@user.errors.include?(:password_challenge)
@user.errors.full_messages.to_sentence
elsif params[:user][:password_challenge].blank?
"Enter your current password to change it."
else
"That isn't your current password."
end
end
end
@@ -5,17 +5,14 @@ class Users::PushSubscriptionsController < ApplicationController
end
def create
if subscription = @push_subscriptions.find_by(push_subscription_params)
# Existing endpoints must pass current validations
if subscription.valid?
subscription.touch
head :ok
else
head :unprocessable_entity
end
subscription = @push_subscriptions.find_or_initialize_by(push_subscription_params)
subscription.user_agent = request.user_agent if subscription.new_record?
# Existing endpoints must pass current validations too
if subscription.valid?
head register_while_signed_in(subscription) ? :ok : :unauthorized
else
subscription = @push_subscriptions.create push_subscription_params.merge(user_agent: request.user_agent)
head subscription.persisted? ? :ok : :unprocessable_entity
head :unprocessable_entity
end
end
@@ -29,6 +26,24 @@ class Users::PushSubscriptionsController < ApplicationController
@push_subscriptions = Current.user.push_subscriptions
end
# A password change ends every session and subscription. A request that authenticated just before it mustn't
# put one back, so the session is checked under the same write lock as the save, and the endpoint is looked up
# again there, past the query cache, so two tabs posting at once register it once. Validation, which resolves
# the endpoint's address, runs before taking the lock.
def register_while_signed_in(subscription)
Push::Subscription.uncached do
Push::Subscription.transaction do
if Session.exists?(Current.session.id)
if existing = @push_subscriptions.find_by(push_subscription_params)
existing.touch
else
subscription.save!(validate: false)
end
end
end
end
end
def push_subscription_params
params.require(:push_subscription).permit(:endpoint, :p256dh_key, :auth_key)
end
+3 -7
View File
@@ -2,18 +2,14 @@ class Users::SidebarsController < ApplicationController
DIRECT_PLACEHOLDERS = 20
def show
all_memberships = Current.user.memberships.visible.with_ordered_room
@direct_memberships = extract_direct_memberships(all_memberships)
@other_memberships = all_memberships.without(@direct_memberships)
visible_memberships = Current.user.memberships.visible
@direct_memberships = visible_memberships.with_direct_rooms
@other_memberships = visible_memberships.with_ordered_room.without_direct_rooms
@direct_placeholder_users = find_direct_placeholder_users
end
private
def extract_direct_memberships(all_memberships)
all_memberships.select { |m| m.room.direct? }.sort_by { |m| m.room.updated_at }.reverse
end
def find_direct_placeholder_users
exclude_user_ids = user_ids_already_in_direct_rooms_with_current_user.including(Current.user.id)
User.active.where.not(id: exclude_user_ids).order(:created_at).limit([ DIRECT_PLACEHOLDERS - exclude_user_ids.count, 0 ].max)
+1 -1
View File
@@ -5,7 +5,7 @@ module ContentFilters
#
# * SanitizeTags strips disallowed markup from the message body
# * Action Text sanitizes the rendered content (lib/rails_ext/action_text_allowed_tags.rb)
# * auto_link re-sanitizes the final html (MessagesHelper#message_presentation)
# * the final html is re-sanitized before its text is linked (MessagesHelper#auto_link_text)
EDITOR_FORMATTING_TAGS = %w[ s u mark table thead tbody tfoot tr th td ]
EDITOR_FORMATTING_ATTRIBUTES = %w[ data-language ]
@@ -17,11 +17,16 @@ class Messages::AttachmentPresentation
attr_reader :message, :context
delegate :tag, :link_to, :broadcast_image_tag, :rails_blob_path, :url_for, to: :context
# Previews are made when the message is posted, within limits. One that wasn't, because the file was too large or
# the preview failed, isn't attempted again from here: its URL would make it on every view.
def render_preview
if message.attachment.video?
case
when message.attachment.video?
video_preview_tag
else
when message.attachment.representation(:thumb).processed?
lightboxed_image_preview_tag
else
render_link
end
end
@@ -30,11 +35,17 @@ class Messages::AttachmentPresentation
inline_media_dimension_constraints(width, height) do
tag.video \
src: rails_blob_path(message.attachment), poster: url_for(message.attachment.preview(format: :webp, resize_to_limit: [ Message::THUMBNAIL_MAX_WIDTH, Message::THUMBNAIL_MAX_HEIGHT ])),
src: rails_blob_path(message.attachment), poster: video_poster_url,
controls: true, preload: :none, width: "100%", height: "100%", class: "message__attachment"
end
end
# A preview is processed once the frame is drawn, but the poster is a variant of that frame, which can fail on its own.
def video_poster_url
poster = message.attachment.preview(:poster)
url_for(poster) if poster.processed? && poster.image.variant(poster.variation).processed?
end
def lightboxed_image_preview_tag
width, height = preview_dimensions
+25 -4
View File
@@ -1,9 +1,12 @@
module MessagesHelper
# auto_link re-sanitizes with Rails' default safe list, which lacks the
# formatting the rich text editor produces
# Rendered text is re-sanitized before linking. Rails' default safe list
# lacks the formatting the rich text editor produces
AUTO_LINK_ALLOWED_TAGS = Rails::HTML5::SafeListSanitizer.allowed_tags + ContentFilters::EDITOR_FORMATTING_TAGS
AUTO_LINK_ALLOWED_ATTRIBUTES = Rails::HTML5::SafeListSanitizer.allowed_attributes + ContentFilters::EDITOR_FORMATTING_ATTRIBUTES
# Text auto_link could turn into a link: a URL has a scheme or www., an email address an @
LINKABLE_TEXT = /:|@|www\./i
def message_area_tag(room, &)
tag.div id: "message-area", class: "message-area", contents: true, data: {
controller: "messages presence drop-target",
@@ -36,6 +39,7 @@ module MessagesHelper
controller: "reply",
user_id: message.creator_id,
message_id: message.id,
client_message_id: message.client_message_id,
message_timestamp: message_timestamp_milliseconds,
message_updated_at: message.updated_at.to_fs(:epoch),
sort_value: message_timestamp_milliseconds,
@@ -62,8 +66,7 @@ module MessagesHelper
when "sound"
message_sound_presentation(message)
else
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)),
html: { target: "_blank" }, sanitize_options: { tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES }
auto_link_text h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body))
end
rescue Exception => e
Sentry.capture_exception(e, extra: { message: message })
@@ -73,6 +76,24 @@ module MessagesHelper
end
private
# auto_link finds URLs and email addresses with regular expressions, which
# can't tell an attribute value from text. Hand it one text node at a time,
# escaped as the serializer writes text, so a link can only land between tags.
def auto_link_text(html)
fragment = Nokogiri::HTML5.fragment(sanitize(html, tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES))
fragment.xpath(".//text()[not(ancestor::a)]").each do |node|
if node.text.match?(LINKABLE_TEXT)
text = node.to_html
linked = auto_link(text, html: { target: "_blank" })
node.replace Nokogiri::HTML5.fragment(linked) unless linked == text
end
end
# A <pre> starting with a newline needs an extra one written out to survive the next parse
fragment.to_html(preserve_newline: true).html_safe
end
def messages_actions
"turbo:before-stream-render@document->messages#beforeStreamRender keydown.up@document->messages#editMyLastMessage"
end
+1
View File
@@ -2,6 +2,7 @@ module TranslationsHelper
TRANSLATIONS = {
email_address: { "🇺🇸": "Enter your email address", "🇪🇸": "Introduce tu correo electrónico", "🇫🇷": "Entrez votre adresse courriel", "🇮🇳": "अपना ईमेल पता दर्ज करें", "🇩🇪": "Geben Sie Ihre E-Mail-Adresse ein", "🇧🇷": "Insira seu endereço de email", "🇯🇵": "メールアドレスを入力してください" },
password: { "🇺🇸": "Enter your password", "🇪🇸": "Introduce tu contraseña", "🇫🇷": "Saisissez votre mot de passe", "🇮🇳": "अपना पासवर्ड दर्ज करें", "🇩🇪": "Geben Sie Ihr Passwort ein", "🇧🇷": "Insira sua senha", "🇯🇵": "パスワードを入力してください" },
current_password: { "🇺🇸": "Enter your current password", "🇪🇸": "Introduce tu contraseña actual", "🇫🇷": "Saisissez votre mot de passe actuel", "🇮🇳": "अपना वर्तमान पासवर्ड दर्ज करें", "🇩🇪": "Geben Sie Ihr aktuelles Passwort ein", "🇧🇷": "Insira sua senha atual", "🇯🇵": "現在のパスワードを入力してください" },
update_password: { "🇺🇸": "Change password", "🇪🇸": "Cambiar contraseña", "🇫🇷": "Changer le mot de passe", "🇮🇳": "पासवर्ड बदलें", "🇩🇪": "Passwort ändern", "🇧🇷": "Alterar senha", "🇯🇵": "パスワードを変更" },
user_name: { "🇺🇸": "Enter your name", "🇪🇸": "Introduce tu nombre", "🇫🇷": "Entrez votre nom", "🇮🇳": "अपना नाम दर्ज करें", "🇩🇪": "Geben Sie Ihren Namen ein", "🇧🇷": "Insira seu nome", "🇯🇵": "お名前を入力してください" },
account_name: { "🇺🇸": "Name this account", "🇪🇸": "Nombre de esta cuenta", "🇫🇷": "Nommez ce compte", "🇮🇳": "इस खाते का नाम दें", "🇩🇪": "Benennen Sie dieses Konto", "🇧🇷": "Dê um nome a essa conta", "🇯🇵": "アカウントに名前を付ける" },
@@ -0,0 +1,7 @@
module Users::PushSubscriptionsHelper
# Names the session without revealing its token, so the browser can tell when it's in a new one. The creation
# time keeps an id reused by a new database from looking like the same session.
def push_subscription_session_marker
Digest::SHA256.hexdigest("push-subscription-session-#{Current.session.id}-#{Current.session.created_at.to_f}") if Current.session
end
end
@@ -46,6 +46,7 @@ export default class extends Controller {
}
messageTargetConnected(target) {
this.#reconcilePendingMessage(target)
this.#formatter.format(target, ThreadStyle.thread)
}
@@ -125,6 +126,25 @@ export default class extends Controller {
// Internal
// The composer inserts a pending message client-side using the client_message_id
// as its DOM id. The server broadcast now renders the real message with a DOM id
// derived from the record's primary key, so it no longer replaces the pending
// element by id. Reconcile by matching the server message's client_message_id and
// dropping our own pending placeholder. Only elements flagged data-pending-message
// (client-side placeholders, never broadcast) are removed, so a message another
// member posted reusing this client_message_id can never displace a real one.
#reconcilePendingMessage(target) {
const clientMessageId = target.dataset.clientMessageId
if (target.dataset.messageId && clientMessageId) {
const selector = `[data-pending-message][data-client-message-id="${CSS.escape(clientMessageId)}"]`
this.messagesTarget.querySelectorAll(selector).forEach((pending) => {
if (pending !== target) pending.remove()
})
}
}
async #ensureUpToDate() {
if (!this.#paginator.upToDate) {
await this.#paginator.resetToLastPage()
@@ -106,7 +106,8 @@ export default class extends Controller {
async #syncPushSubscription(subscription) {
const response = await post(this.subscriptionsUrlValue, { body: this.#extractJsonPayloadAsString(subscription), responseKind: "turbo-stream" })
if (!response.ok) subscription.unsubscribe()
// Only a refused endpoint is for good. A session signed out mid-request posts it again after signing in.
if (response.unprocessableEntity) subscription.unsubscribe()
}
async #requestPermissionAndSubscribe(registration) {
@@ -0,0 +1,32 @@
import { Controller } from "@hotwired/stimulus"
import { post } from "@rails/request.js"
const SYNCED_SESSION_KEY = "push-subscription-synced-session"
// A password change removes the account's push subscriptions, the one this browser holds included. Post it again
// once per session, so a browser that's still signed in keeps getting notifications. A failure here leaves the
// browser's own subscription alone: the next session tries again.
export default class extends Controller {
static values = { url: String, session: String }
async connect() {
if (this.#allowed && this.sessionValue && localStorage.getItem(SYNCED_SESSION_KEY) !== this.sessionValue) {
const registration = await navigator.serviceWorker.getRegistration(window.location.origin)
const subscription = await registration?.pushManager?.getSubscription()
if (Notification.permission === "granted" && subscription) {
await post(this.urlValue, { body: this.#payload(subscription), responseKind: "turbo-stream" })
localStorage.setItem(SYNCED_SESSION_KEY, this.sessionValue)
}
}
}
get #allowed() {
return navigator.serviceWorker && window.Notification
}
#payload(subscription) {
const { endpoint, keys: { p256dh, auth } } = subscription.toJSON()
return JSON.stringify({ push_subscription: { endpoint, p256dh_key: p256dh, auth_key: auth } })
}
}
@@ -16,7 +16,7 @@ export default class extends Controller {
})
}
#read = ({ room_id }) => {
this.dispatch("read", { detail: { roomId: room_id } })
#read = ({ room_id, at }) => {
this.dispatch("read", { detail: { roomId: room_id, at } })
}
}
@@ -28,7 +28,7 @@ export default class extends Controller {
}
#stripMentionAttachments(node) {
node.querySelectorAll(".mention").forEach(mention => mention.outerHTML = mention.textContent.trim())
node.querySelectorAll(".mention").forEach(mention => mention.replaceWith(mention.textContent.trim()))
return node
}
@@ -7,8 +7,13 @@ export default class extends Controller {
static classes = [ "unread" ]
#disconnected = true
#connection = 0
#discardDirectEditor = false
#readAt = new Map()
async connect() {
this.element.addEventListener("turbo:click", this.#directNavigation)
this.element.addEventListener("turbo:before-frame-render", this.#preserveDirectEditor)
this.channel ??= await cable.subscribeTo({ channel: "UnreadRoomsChannel" }, {
connected: this.#channelConnected.bind(this),
disconnected: this.#channelDisconnected.bind(this),
@@ -17,7 +22,11 @@ export default class extends Controller {
}
disconnect() {
this.element.removeEventListener("turbo:click", this.#directNavigation)
this.element.removeEventListener("turbo:before-frame-render", this.#preserveDirectEditor)
this.#discardDirectEditor = false
ignoringBriefDisconnects(this.element, () => {
this.#channelDisconnected()
this.channel?.unsubscribe()
this.channel = null
})
@@ -27,19 +36,58 @@ export default class extends Controller {
this.read({ detail: { roomId: Current.room.id } })
}
read({ detail: { roomId } }) {
read({ detail: { roomId, at } }) {
const room = this.#findRoomTarget(roomId)
if (at) {
this.#readAt.set(Number(roomId), Math.max(Number(at), this.#readAt.get(Number(roomId)) ?? 0))
}
if (room) {
room.classList.remove(this.unreadClass)
this.dispatch("read", { detail: { targetId: roomId } })
}
}
#channelConnected() {
// Preserve the live picker through background refreshes, but allow deliberate navigation.
#directNavigation = event => {
if (event.target.closest("a")?.dataset.turboFrame === this.element.id) {
this.#discardDirectEditor = true
}
}
#preserveDirectEditor = event => {
if (event.target !== this.element) return
const discard = this.#discardDirectEditor
this.#discardDirectEditor = false
if (discard) return
const current = this.element.querySelector("#direct_rooms_control")
const incoming = event.detail.newFrame.querySelector("#direct_rooms_control")
if (!current?.querySelector('[data-autocomplete-target="input"]') || !incoming) return
// Turbo's Bardo moves the actual element, keeping input, selections and controllers.
const controls = [ current, incoming ].map(element => [ element, element.hasAttribute("data-turbo-permanent") ])
for (const [ element ] of controls) element.setAttribute("data-turbo-permanent", "")
const render = event.detail.render
event.detail.render = (...args) => {
try {
return render(...args)
} finally {
for (const [ element, permanent ] of controls) element.toggleAttribute("data-turbo-permanent", permanent)
}
}
}
async #channelConnected() {
if (this.#disconnected) {
this.#disconnected = false
this.element.reload()
const connection = ++this.#connection
// Reloading an unfinished frame aborts its response body reader.
await this.element.loaded
if (this.element.isConnected && !this.#disconnected && connection === this.#connection) {
this.element.reload()
}
}
}
@@ -47,11 +95,11 @@ export default class extends Controller {
this.#disconnected = true
}
#unread({ roomId }) {
#unread({ roomId, at }) {
const unreadRoom = this.#findRoomTarget(roomId)
if (unreadRoom) {
if (Current.room.id != roomId) {
if (Current.room.id != roomId && !this.#readSince(roomId, at)) {
unreadRoom.classList.add(this.unreadClass)
}
@@ -59,6 +107,12 @@ export default class extends Controller {
}
}
// Notices fan out one member at a time, so one can arrive after the member has already
// read the room in another tab. It still reorders the room, but doesn't mark it unread.
#readSince(roomId, at) {
return Number(at) <= this.#readAt.get(Number(roomId))
}
#findRoomTarget(roomId) {
return this.roomTargets.find(roomTarget => roomTarget.dataset.roomId == roomId)
}
@@ -120,7 +120,7 @@ export default class BaseAutocompleteHandler {
#fetchAutocompletables(url) {
if (url) {
return fetch(url, { as: "json" }).then(response => response.json())
return fetch(url, { headers: { Accept: "application/json" } }).then(response => response.json())
} else {
return Promise.resolve()
}
@@ -0,0 +1,5 @@
class Message::BroadcastUnreadRoomJob < ApplicationJob
def perform(message)
message.broadcast_unread_room
end
end
+8
View File
@@ -0,0 +1,8 @@
class Room::DestroyJob < ApplicationJob
discard_on ActiveJob::DeserializationError
def perform(room, former_member_ids)
room.reset_remote_connections_of(former_member_ids)
room.destroy_one_message_at_a_time
end
end
+2 -1
View File
@@ -9,7 +9,8 @@ class Membership < ApplicationRecord
enum :involvement, %w[ invisible nothing mentions everything ].index_by(&:itself), prefix: :involved_in
scope :with_ordered_room, -> { includes(:room).joins(:room).order("LOWER(rooms.name)") }
scope :without_direct_rooms, -> { joins(:room).where.not(room: { type: "Rooms::Direct" }) }
scope :with_direct_rooms, -> { includes(:room).joins(:room).merge(Room.directs).order(rooms: { updated_at: :desc }) }
scope :without_direct_rooms, -> { joins(:room).merge(Room.without_directs) }
scope :visible, -> { where.not(involvement: :invisible) }
scope :unread, -> { where.not(unread_at: nil) }
+3 -7
View File
@@ -14,20 +14,16 @@ class Message < ApplicationRecord
scope :ordered, -> { order(:created_at) }
scope :with_creator, -> { preload(creator: :avatar_attachment) }
scope :with_attachment_details, -> {
with_rich_text_body_and_embeds
with_attached_attachment
with_rich_text_body_and_embeds.with_attached_attachment
.includes(attachment_blob: :variant_records)
}
scope :with_boosts, -> { includes(boosts: :booster) }
scope :with_boosts, -> { includes(boosts: { booster: :avatar_attachment }) }
scope :with_presentation, -> { with_creator.with_attachment_details.with_boosts.preload(:room) }
def plain_text_body
body.to_plain_text.presence || attachment&.filename&.to_s || ""
end
def to_key
[ client_message_id ]
end
def content_type
case
when attachment? then "attachment"
+19 -1
View File
@@ -4,9 +4,14 @@ module Message::Attachment
THUMBNAIL_MAX_WIDTH = 1200
THUMBNAIL_MAX_HEIGHT = 800
# Decoding a picture, or a video's frame, costs in proportion to its pixels, however small the file. The largest
# phone photos have 200 million.
THUMBNAIL_MAX_PIXELS = 250_000_000
included do
has_one_attached :attachment do |attachable|
attachable.variant :thumb, resize_to_limit: [ THUMBNAIL_MAX_WIDTH, THUMBNAIL_MAX_HEIGHT ]
attachable.variant :poster, format: :webp, resize_to_limit: [ THUMBNAIL_MAX_WIDTH, THUMBNAIL_MAX_HEIGHT ]
end
end
@@ -30,12 +35,25 @@ module Message::Attachment
attachment&.analyze
end
# A file that ffmpeg or libvips can't decode is still the message: post it without a preview.
def process_attachment_thumbnail
return if too_many_pixels_to_preview?
case
when attachment.video?
attachment.preview(format: :webp).processed
attachment.preview(:poster).processed
when attachment.representable?
attachment.representation(:thumb).processed
end
rescue ActiveStorage::PreviewError, Vips::Error => error
Rails.logger.warn "Posted #{attachment.filename} without a preview: #{error.class}: #{error.message.lines.first&.chomp}"
end
# Without a size, the analyzer couldn't read the file's header, and the previewer wouldn't either.
def too_many_pixels_to_preview?
if attachment.image? || attachment.video?
width, height = attachment.metadata.values_at(:width, :height)
width.nil? || height.nil? || width * height > THUMBNAIL_MAX_PIXELS
end
end
end
+17 -7
View File
@@ -1,19 +1,29 @@
module Message::Broadcasts
def broadcast_create
broadcast_append_to room, :messages, target: [ room, :messages ]
broadcast_unread_room
broadcast_unread_room_later
end
def broadcast_remove
broadcast_remove_to room, :messages
end
# Fanned out to the room's members rather than published on one global stream, so
# that the timing of activity in a room only reaches people who are in it. Of those,
# only the ones who still have it unread or are in it now: by the time this runs,
# someone may have opened the room and moved on, and would see it marked unread again.
def broadcast_unread_room
payload = ActiveSupport::JSON.encode(roomId: room.id, at: created_at.to_f)
room.memberships.unread.or(room.memberships.connected).pluck(:user_id).each do |user_id|
ActionCable.server.broadcast UnreadRoomsChannel.stream_name_for(user_id), payload, coder: nil
end
end
private
# Fanned out to the room's members rather than published on one global stream, so
# that the timing of activity in a room only reaches people who are in it.
def broadcast_unread_room
room.memberships.pluck(:user_id).each do |user_id|
ActionCable.server.broadcast UnreadRoomsChannel.stream_name_for(user_id), { roomId: room.id }
end
# The fanout is one publish per member, which in a big room takes far longer than the
# rest of posting a message, so it runs in a job rather than while the poster waits.
def broadcast_unread_room_later
Message::BroadcastUnreadRoomJob.perform_later(self)
end
end
+34 -5
View File
@@ -3,9 +3,31 @@ module Message::Pagination
PAGE_SIZE = 40
# Expose Rails' collection-preloading hook while preserving Array pagination and
# validators. The renderer passes only cache misses to preload_associations.
class Page < Array
def self.load(relation, direction, size)
new(relation.skip_preloading!.public_send(direction, size), relation)
end
def initialize(records, relation)
super(records)
@relation = relation
end
def loaded?
true
end
def preload_associations(records)
@relation.preload_associations(records)
end
end
included do
scope :last_page, -> { ordered.last(PAGE_SIZE) }
scope :first_page, -> { ordered.first(PAGE_SIZE) }
# Keep presentation data lazy until the collection cache knows which messages missed.
scope :last_page, -> { last_page_of(PAGE_SIZE) }
scope :first_page, -> { Page.load(ordered, :first, PAGE_SIZE) }
scope :before, ->(message) { where("created_at < ?", message.created_at) }
scope :after, ->(message) { where("created_at > ?", message.created_at) }
@@ -14,16 +36,23 @@ module Message::Pagination
scope :page_after, ->(message) { after(message).first_page }
scope :page_created_since, ->(time) { where("created_at > ?", time).first_page }
scope :page_updated_since, ->(time) { where("updated_at > ?", time).last_page }
# Sorting on +created_at, which no index covers, has SQLite find the few messages updated since
# through (room_id, updated_at) and sort just those, instead of walking the whole room by
# (room_id, created_at) looking for them.
scope :page_updated_since, ->(time) { Page.load(where("updated_at > ?", time).reorder(Arel.sql("+messages.created_at")), :last, PAGE_SIZE) }
end
class_methods do
def last_page_of(size)
Page.load(ordered, :last, size)
end
def page_around(message)
page_before(message) + [ message ] + page_after(message)
Page.new(page_before(message) + [ message ] + page_after(message), ordered)
end
def paged?
count > PAGE_SIZE
offset(PAGE_SIZE).exists?
end
end
end
+43 -2
View File
@@ -3,10 +3,45 @@ module Message::Searchable
included do
after_create_commit :create_in_index
after_update_commit :update_in_index
before_update -> { @attachment_replaced = attachment_changes.key?("attachment") }
after_update_commit :update_in_index, if: :indexed_text_changed?
after_destroy_commit :remove_from_index
scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", query).ordered }
scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", match_terms(query)).ordered }
end
class_methods do
# Quotes each word, so that FTS5 searches for AND, OR, NOT and NEAR rather than
# parsing them as operators, which fails on a query like "AND" or "salt AND".
def match_terms(query)
query.split.map { |word| %("#{word.gsub('"', '""')}") }.join(" ")
end
end
class_methods do
def search_reachable(user, query, size: 100)
relation = user.reachable_messages.search(query).with_presentation
probe = connection.select_rows(sanitize_sql([ <<~SQL, user.id, match_terms(query) ]))
SELECT m.id, mm.user_id IS NOT NULL FROM message_search_index idx
JOIN messages m ON m.id = idx.rowid
LEFT JOIN memberships mm ON mm.room_id = m.room_id AND mm.user_id = ?
WHERE idx.body MATCH ? ORDER BY idx.rowid DESC LIMIT 1000
SQL
ids = probe.filter_map { |id, reachable| id if reachable == 1 }.first(size)
# A scoped fallback preserves older reachable hits behind a large private history.
if ids.size < size && probe.size == 1000
relation.last_page_of_matches(size)
else
Message::Pagination::Page.load(relation.where(id: ids).reorder(:id), :last, size)
end
end
# Orders by the index's rowid, which is the message id, so SQLite walks the full-text index
# newest first and stops at the page. Ordering by created_at sorted every match before paging.
def last_page_of_matches(size)
Message::Pagination::Page.load(reorder("idx.rowid"), :last, size)
end
end
private
@@ -14,6 +49,12 @@ module Message::Searchable
execute_sql_with_binds "insert into message_search_index(rowid, body) values (?, ?)", id, plain_text_body
end
# Boosts touch their message too: rewrite the index only when the body was saved or the attachment replaced.
# Active Storage clears attachment_changes in its own after_commit, before this one runs.
def indexed_text_changed?
association(:rich_text_body).target&.saved_change_to_body? || @attachment_replaced
end
def update_in_index
execute_sql_with_binds "update message_search_index set body = ? where rowid = ?", plain_text_body, id
end
+12 -1
View File
@@ -5,6 +5,7 @@ class Opengraph::Fetch
ALLOWED_DOCUMENT_CONTENT_TYPE = "text/html"
MAX_BODY_SIZE = 5.megabytes
MAX_REDIRECTS = 10
TIMEOUT = 7.seconds # to connect (TLS included), and for each read and write, as Webhook does
class TooManyRedirectsError < StandardError; end
class RedirectDeniedError < StandardError; end
@@ -22,9 +23,14 @@ class Opengraph::Fetch
end
private
# The nil proxy address ignores http_proxy: a proxy would look the host up again, and the connection
# would no longer go to the checked IP.
#
# The timeouts bound each operation. A host can still send its headers or body a byte at a time,
# in as many reads as it likes: UnfurlLinksController puts the whole unfurl under one deadline.
def request(url, request_class, ip:)
MAX_REDIRECTS.times do
Net::HTTP.start(url.host, url.port, ipaddr: ip, use_ssl: url.scheme == "https") do |http|
Net::HTTP.start(url.host, url.port, nil, ipaddr: ip, use_ssl: url.scheme == "https", **timeouts) do |http|
http.request request_class.new(url) do |response|
if response.is_a?(Net::HTTPRedirection)
url, ip = resolve_redirect(response["location"])
@@ -38,6 +44,11 @@ class Opengraph::Fetch
raise TooManyRedirectsError
end
# Without max_retries: 0, Net::HTTP sends a GET or HEAD that timed out a second time.
def timeouts
{ open_timeout: TIMEOUT, read_timeout: TIMEOUT, write_timeout: TIMEOUT, max_retries: 0 }
end
def resolve_redirect(location)
url = URI.parse(location)
raise RedirectDeniedError unless url.is_a?(URI::HTTP)
+2 -2
View File
@@ -14,9 +14,9 @@ class Push::Subscription < ApplicationRecord
validates :endpoint, presence: true
validate :validate_endpoint_url
def notification(**params)
def notification(badge: user.memberships.unread.count, **params)
# Defer DNS lookup to the delivery worker to prevent rebinding
WebPush::Notification.new(**params, badge: user.memberships.unread.count, endpoint: endpoint, endpoint_ip_resolver: method(:resolved_endpoint_ip), p256dh_key: p256dh_key, auth_key: auth_key)
WebPush::Notification.new(**params, badge: badge, endpoint: endpoint, endpoint_ip_resolver: method(:resolved_endpoint_ip), p256dh_key: p256dh_key, auth_key: auth_key)
end
# Validate at point of use, not just when saved.
+28
View File
@@ -45,6 +45,34 @@ class Room < ApplicationRecord
end
end
# Takes the room away from its members at once, and leaves its messages to Room::DestroyJob. Destroying them in
# the room's own transaction held the database's write lock, and stopped every other write, until the last one.
# An open room is closed first, so that no one who joins the account before the job runs is let in.
def destroy_later
room = open? ? becomes!(Rooms::Closed) : self
former_member_ids = transaction do
room.save!
room.memberships.pluck(:user_id).tap { room.memberships.delete_all }
end
Room::DestroyJob.perform_later(room, former_member_ids)
end
# Deleting the memberships skips the reset that revoking one does, so the former members get it here, all at once,
# before the messages go: their connections stop receiving the room's streams. It's done in the job, not the
# request, because it costs a Redis round trip per member.
def reset_remote_connections_of(user_ids)
User.where(id: user_ids).find_each(&:reset_remote_connections)
end
# Each message is destroyed in its own transaction, so other writes get through in between. Any message posted
# meanwhile goes with the room.
def destroy_one_message_at_a_time
messages.find_each(&:destroy)
destroy
end
def receive(message)
unread_memberships(message)
push_later(message)
+3
View File
@@ -53,9 +53,12 @@ class Room::MessagePusher
relevant_subscriptions.merge(Membership.involved_in_mentions).where(user_id: mentionees.ids)
end
# Banning keeps the user's subscriptions, so that unbanning brings their notifications back,
# but nobody who can't sign in should be sent what's said in their rooms meanwhile.
def relevant_subscriptions
Push::Subscription
.joins(user: :memberships)
.merge(User.active)
.merge(Membership.visible.disconnected.where(room: room).where.not(user: message.creator))
end
+8 -5
View File
@@ -7,12 +7,15 @@ class Rooms::Direct < Room
end
private
# FIXME: Find a more performant algorithm that won't be a problem on accounts with 10K+ direct rooms,
# which could be to store the membership id list as a hash on the room, and use that for lookup.
# Among the first user's rooms, the one whose members are exactly these users: as many
# memberships as users, and all of them theirs.
def find_for(users)
all.joins(:users).detect do |room|
Set.new(room.user_ids) == Set.new(users.pluck(:id))
end
user_ids = users.pluck(:id).uniq
where(id: Membership.where(user_id: user_ids.first).select(:room_id))
.joins(:memberships).group(:id)
.having("COUNT(*) = :size AND COUNT(CASE WHEN memberships.user_id IN (:user_ids) THEN 1 END) = :size", size: user_ids.size, user_ids: user_ids)
.first
end
end
+37 -1
View File
@@ -18,8 +18,10 @@ class User < ApplicationRecord
enum :status, %i[ active deactivated banned ], default: :active
has_secure_password validations: false
validate :password_challenge_matches, unless: -> { password_challenge.nil? }
after_create_commit :grant_membership_to_open_rooms
after_update :sign_out_all_sessions, if: :saved_change_to_password_digest?
scope :ordered, -> { order("LOWER(name)") }
scope :filtered_by, ->(query) { where("name like ?", "%#{query}%") }
@@ -50,8 +52,42 @@ class User < ApplicationRecord
end
private
# The profile sets password_challenge whenever it changes the password; scripts that reset one don't.
def password_challenge_matches
unless password_digest_was.present? && BCrypt::Password.new(password_digest_was).is_password?(password_challenge)
errors.add :password_challenge, :invalid
end
end
# A new password is how someone shuts out whoever else got into their account, so it ends every session, copies
# of the changing browser's cookie included, and every push subscription; browsers still signed in post theirs
# again. Connections close once that commits, registered on the transaction because a later save in it would
# replace the saved change an after_commit condition reads.
def sign_out_all_sessions
sessions.delete_all
push_subscriptions.delete_all
self.class.current_transaction.after_commit { disconnect_signed_out_sessions }
end
# The password is saved by now, so a realtime service that can't be reached mustn't fail the change.
def disconnect_signed_out_sessions
reset_remote_connections
rescue => error
Rails.logger.warn "Could not disconnect remote connections after a password change: #{error.class}"
end
# One statement, which SQLite runs under the write lock, so no room is closed between reading the open ones and
# inserting. A room that Room#destroy_later closes is either closed before, and skipped, or after, and takes this
# membership away with the others. A room that granted itself to every user when it was created is skipped too,
# as insert_all did.
def grant_membership_to_open_rooms
Membership.insert_all(Rooms::Open.pluck(:id).collect { |room_id| { room_id: room_id, user_id: id } })
now = Time.current
Membership.connection.execute Membership.sanitize_sql([ <<~SQL, id, now, now ])
insert into memberships (room_id, user_id, created_at, updated_at)
select id, ?, ?, ? from rooms where type = 'Rooms::Open'
on conflict do nothing
SQL
end
def deactived_email_address
+5
View File
@@ -1,6 +1,11 @@
module User::Bannable
extend ActiveSupport::Concern
included do
# Administrators still see banned users, so they can lift the ban.
scope :visible_to, ->(user) { user.can_administer? ? where(status: %i[ active banned ]) : active }
end
def ban
transaction do
create_bans_from_sessions
+5
View File
@@ -11,6 +11,11 @@ module User::Mentionable
"users/mention"
end
# How a mention appears inside the editor, matching the prompt's editor template.
def to_editor_content_attachment_partial_path
"users/mention"
end
def attachable_plain_text_representation(caption)
"@#{name}"
end
+9 -2
View File
@@ -3,13 +3,20 @@ module User::Transferable
TRANSFER_LINK_EXPIRY_DURATION = 4.hours
included do
# Bound to the password, so changing it also ends sign-in links made before.
generates_token_for :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION do
password_salt&.last(10)
end
end
class_methods do
def find_by_transfer_id(id)
find_signed(id, purpose: :transfer)
find_by_token_for(:transfer, id)
end
end
def transfer_id
signed_id(purpose: :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION)
generate_token_for(:transfer)
end
end
+1 -1
View File
@@ -63,7 +63,7 @@ class Webhook < ApplicationRecord
end
def extract_attachment_from(response)
if response.content_type && mime_type = Mime::Type.lookup(response.content_type)
if response.code == "200" && response.content_type && mime_type = Mime::Type.lookup(response.content_type)
ActiveStorage::Blob.create_and_upload! \
io: StringIO.new(response.body), filename: "attachment.#{mime_type.symbol}", content_type: mime_type.to_s
end
+5 -6
View File
@@ -76,10 +76,9 @@
<% end %>
<label class="switch">
<input type="checkbox"
class="switch__input"
<%= "checked" if Current.account.settings.restrict_room_creation_to_administrators? %>
data-action="change->form#submit">
<%= tag.input type: "checkbox", class: "switch__input",
checked: Current.account.settings.restrict_room_creation_to_administrators?,
data: { action: "change->form#submit" } %>
<span class="switch__btn round"></span>
<span class="for-screen-reader">
Must be admin to create new rooms
@@ -102,11 +101,11 @@
<turbo-frame id="account_users">
<%= render partial: "accounts/users/user", collection: @administrators, as: :user %>
<% if @administrators.any? && @members.any? %>
<% if @administrators.any? && @page.records.any? %>
<hr class="separator full-width" style="--border-style: solid">
<% end %>
<%= render partial: "accounts/users/user", collection: @members, as: :user %>
<%= render partial: "accounts/users/user", collection: @page.records, as: :user %>
<%= render "accounts/users/next_page_container", page: @page.next_param unless @page.last? %>
</turbo-frame>
</menu>
@@ -0,0 +1,13 @@
<%# Files are posted as a message's own attachment, which gets its preview when it's posted; the composer adds none to
the rich text. One that's there anyway is shown by its name, with no preview: Action Text's partial links to a URL
that makes one on view, and nothing else in the app would make it. %>
<figure class="attachment attachment--file attachment--<%= blob.filename.extension %>">
<figcaption class="attachment__caption">
<% if caption = blob.try(:caption) %>
<%= caption %>
<% else %>
<span class="attachment__name"><%= blob.filename %></span>
<span class="attachment__size"><%= number_to_human_size blob.byte_size %></span>
<% end %>
</figcaption>
</figure>
+7 -2
View File
@@ -40,9 +40,9 @@
<div class="flash" data-controller="element-removal" data-action="animationend->element-removal#remove">
<div class="flash__inner shadow" style="<%= "--flash-background: var(--color-negative)" if flash[:alert] %>">
<% if flash[:alert] %>
<%= image_tag "alert.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %></span>
<%= image_tag "alert.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %>
<% else %>
<%= image_tag "check.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %></span>
<%= image_tag "check.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %>
<% end %>
</div>
<span class="for-screen-reader" role="alert" aria-atomic="true"><%= notice %></span>
@@ -63,6 +63,11 @@
<%= render "layouts/lightbox" %>
<% if Current.session %>
<%= tag.div hidden: true, data: { controller: "push-subscription-sync",
push_subscription_sync_url_value: user_push_subscriptions_path, push_subscription_sync_session_value: push_subscription_session_marker } %>
<% end %>
<a href="https://once.com" id="app-logo" target="_blank" aria-label="Once software from 37signals home page">
<%= image_tag "campfire-icon.png", alt: "Campfire logo", width: 256, height: 216 %>
</a>
+1 -1
View File
@@ -1,7 +1,7 @@
<%# Be sure to check/update messages/_template.html.erb when changing this file %>
<%# Bump this version when the message presentation filters change what they emit. Editing this line changes the template digest, which busts BOTH this fragment cache and the collection cache that keys on this partial's digest (helper Ruby changes alone don't). %>
<% cache [ message, "presentation-v3" ] do %>
<% cache [ message, "presentation-v7" ] do %>
<%= message_tag message do %>
<h2 class="message__day-separator"><%= local_datetime_tag message.created_at, style: :date %></h2>
+2
View File
@@ -2,6 +2,8 @@
<div class="message message--me $messageClasses$"
id="message_$clientMessageId$"
data-format-message-target="message"
data-pending-message="true"
data-client-message-id="$clientMessageId$"
data-user-id="<%= Current.user.id %>"
data-message-timestamp="$messageTimestamp$"
data-messages-target="message">
+16 -18
View File
@@ -1,20 +1,18 @@
<% cache boost do %>
<div id="<%= dom_id(boost) %>"
class="boost boost-item flex-inline postion--relative max-width align-center fill-white gap"
data-controller="boost-delete" data-boost-delete-perform-class="boost--deleting" data-boost-delete-reveal-class="expanded" data-boost-delete-booster-id-value="<%= boost.booster.id %>">
<figure class="avatar boost__avatar flex-item-no-shrink">
<%= avatar_tag boost.booster, aria: { label: "#{boost.booster.name} boosted #{boost.content}" } %>
</figure>
<div id="<%= dom_id(boost) %>"
class="boost boost-item flex-inline postion--relative max-width align-center fill-white gap"
data-controller="boost-delete" data-boost-delete-perform-class="boost--deleting" data-boost-delete-reveal-class="expanded" data-boost-delete-booster-id-value="<%= boost.booster.id %>">
<figure class="avatar boost__avatar flex-item-no-shrink">
<%= avatar_tag boost.booster, aria: { label: "#{boost.booster.name} boosted #{boost.content}" } %>
</figure>
<%= tag.span boost.content, role: "button",
class: [ "txt-small", { "txt-medium": boost.content.all_emoji? } ],
data: { action: "click->boost-delete#reveal keydown.enter->boost-delete#reveal:prevent", boost_delete_target: "content" } %>
<%= tag.span boost.content, role: "button",
class: [ "txt-small", { "txt-medium": boost.content.all_emoji? } ],
data: { action: "click->boost-delete#reveal keydown.enter->boost-delete#reveal:prevent", boost_delete_target: "content" } %>
<%= button_to message_boost_path(boost.message, boost), method: :delete, data: { action: "boost-delete#perform", boost_delete_target: "button" },
class: "btn btn--negative flex-item-justify-end boost__delete" do %>
<%= image_tag "minus.svg", size: 20, aria: { hidden: "true" } %>
<span class="for-screen-reader">Delete this boost</span>
<% end %>
</div>
<span id="delete_boost_accessible_label" class="for-screen-reader">Press enter to delete this boost</span>
<% end %>
<%= button_to message_boost_path(boost.message, boost), method: :delete, data: { action: "boost-delete#perform", boost_delete_target: "button" },
class: "btn btn--negative flex-item-justify-end boost__delete" do %>
<%= image_tag "minus.svg", size: 20, aria: { hidden: "true" } %>
<span class="for-screen-reader">Delete this boost</span>
<% end %>
</div>
<span id="delete_boost_accessible_label" class="for-screen-reader">Press enter to delete this boost</span>
+2 -2
View File
@@ -2,12 +2,12 @@
<div class="boosts flex flex-wrap align-center gap full-width" style="--column-gap: 0.4ch; --row-gap: 0"
data-controller="turbo-streaming" data-action="turbo:submit-start->turbo-streaming#unsubscribe">
<div class="flex-inline flex-wrap gap" id="<%= dom_id(message, :boosts) %>" data-turbo-streaming-target="container">
<%= render partial: "messages/boosts/boost", collection: message.boosts.ordered, cached: true %>
<%= render partial: "messages/boosts/boost", collection: message.boosts.loaded? ? message.boosts.sort_by(&:created_at) : message.boosts.ordered.includes(:booster) %>
</div>
<%= turbo_frame_tag message, :new_boost do %>
<div class="flex-inline message__boost-inline" data-controller="soft-keyboard">
<%= link_to new_message_boost_path(message), class: "boost__action txt-small btn", action: "soft-keyboard#open" do %>
<%= link_to new_message_boost_path(message), class: "boost__action txt-small btn", data: { action: "soft-keyboard#open" } do %>
<%= image_tag "boost.svg", size: 20, aria: { hidden: "true" } %>
<span class="for-screen-reader">Add a boost</span>
<% end %>
+2 -2
View File
@@ -6,8 +6,8 @@
<%= image_tag "disclosure.svg", aria: { hidden: "true" }, size: 10, class: "disclosure" %>
</summary>
<% case
when platform.firefox? && platform.android? %>
<% case %>
<% when platform.firefox? && platform.android? %>
<ol>
<li>Tap <em><%= image_tag "lock.svg", alt: "the View site information button", size: 20 %></em> in the address bar.</li>
<li>Tap <em>Notification</em> to change to <em>Allowed</em>.</li>
+3 -3
View File
@@ -6,10 +6,10 @@
<%= image_tag "disclosure.svg", aria: { hidden: "true" }, size: 10, class: "disclosure" %>
</summary>
<% case
when platform.edge? %>
<% case %>
<% when platform.edge? %>
<ol>
<li>Click <em><%= image_tag "install-edge.svg", alt: "the app available - install Campfire chat button", size: 16 %></em>in the address bar.</li>
<li>Click <em><%= image_tag "external/install-edge.svg", alt: "the app available - install Campfire chat button", size: 16 %></em>in the address bar.</li>
<li>Click <em>Install</em>.</li>
</ol>
<% when platform.chrome? && platform.android? %>
+2 -2
View File
@@ -5,8 +5,8 @@
<%= image_tag "disclosure.svg", aria: { hidden: "true" }, size: 10, class: "disclosure" %>
</summary>
<% case
when platform.firefox? && platform.android? %>
<% case %>
<% when platform.firefox? && platform.android? %>
<ol>
<li>Tap the <em><%= image_tag "menu-dots-vertical.svg", alt: "More options", size: 16 %></em> menu button.</li>
<li>Tap <em>Settings</em>.</li>
+1 -1
View File
@@ -54,7 +54,7 @@
</div>
<%= search_results_tag do %>
<%= render @messages %>
<%= render partial: "messages/message", collection: @messages, cached: true %>
<% end %>
</div>
+2 -1
View File
@@ -1 +1,2 @@
<%= auto_submit_form_with method: :put %>
<%= auto_submit_form_with method: :put do %>
<% end %>
+10
View File
@@ -69,6 +69,16 @@
</label>
</div>
<div class="flex align-center gap">
<%= translation_button(:current_password) %>
<label class="flex align-center gap flex-item-grow input input--actor">
<%= form.password_field :password_challenge, class: "input txt-large", autocomplete: "current-password",
placeholder: "Current password", required: false, maxlength: 72 %>
<%= image_tag "password.svg", aria: { hidden: "true" }, size: 24, class: "colorize--black" %>
</label>
</div>
<div class="flex align-center gap">
<%= translation_button(:update_password) %>
+27
View File
@@ -0,0 +1,27 @@
# Message benchmarks
The drivers use Ruby's standard library and Docker; no separate load generator is required.
Provide a frozen baseline source directory, an isolated seed containing `db/`, `storage/`
and `labels.json`, and a matching application image with compiled assets.
```sh
ruby bench/compare_message_hot_paths.rb --baseline PATH --baseline-ref SHA --seed SEED
ruby bench/compare_http.rb --baseline PATH --seed SEED
ruby bench/compare_http.rb --baseline PATH --seed SEED --paths sidebar,search --concurrencies 16 --duration 10
```
Both drivers alternate before/after order and reset fixture storage for each run.
Results default to ignored `tmp/rails-optimization/results/`; override with `--output PATH`.
Use `--image` to override `campfire-reference:app`, and `--cpus` to override server CPUs
`8-11`. HTTP clients use CPUs `12-15` by default (`--client-cpus`). `--help` lists options.
The rendering probe checks exact response bodies, selected headers and unread payloads,
and records timing, queries and allocations with MemoryStore, frozen time and fixture-only
CSRF disabling. Unread fanout excludes adapter I/O.
The HTTP driver uses production Puma/Redis with one worker and five threads. Ruby threads
each maintain a keep-alive connection, request uncompressed responses, and consume the
whole body. Login uses normal CSRF protection; all warmup and measured responses must be
HTTP 200 without transport errors, complete HTML, exact independently seeded message windows
and the visible sidebar rooms; this applies to every warmup and measured response. Measurements exclude Thruster, TLS and gzip. Client
CPU, JIT warmup and GC can affect throughput; repeat runs and check client saturation.
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env ruby
# Compare warm production Puma/Redis requests using isolated seeded containers.
require "socket"
require_relative "support"
require_relative "http_client"
require_relative "response_contract"
include BenchmarkSupport
options = parse_options("Compare HTTP throughput with Ruby keep-alive clients; every response must be HTTP 200.",
duration: 3.0, paths: "room,messages,sidebar,search", concurrencies: "1,16", client_cpus: "12-15",
output: File.join(WORK, "results/http"))
labels = JSON.parse(File.read(File.join(options[:seed], "labels.json")))
paths = {
"room" => "/rooms/#{labels.fetch('rooms.watercooler')}",
"messages" => "/rooms/#{labels.fetch('rooms.watercooler')}/messages?before=#{labels.fetch('messages.busy_060')}",
"sidebar" => "/users/me/sidebar", "search" => "/searches?q=coffee"
}.slice(*options[:paths].split(","))
abort "--paths must select room,messages,sidebar,search" unless paths.size == options[:paths].split(",").size && !paths.empty?
concurrencies = options[:concurrencies].split(",").map { |value| Integer(value) }
abort "--duration and --concurrencies must be positive" unless options[:duration].positive? && !concurrencies.empty? && concurrencies.all?(&:positive?)
run("taskset", "-pc", options[:client_cpus], Process.pid.to_s)
work = File.join(WORK, "http")
assets = prepare_assets(options[:image])
network = "cf-ruby-bench-#{Process.pid}"
redis = "#{network}-redis"
app = "#{network}-app"
begin
run("docker", "network", "create", network)
run("docker", "run", "-d", "--name", redis, "--network", network, "redis:7-alpine")
options[:rounds].times do |iteration|
sides = iteration.even? ? %w[before after] : %w[after before]
sides.each do |side|
remove_container(app)
data = File.join(work, "data")
prepare_storage(options[:seed], File.join(data, "storage"))
FileUtils.mkdir_p([ File.join(data, "tmp/pids"), File.join(data, "log") ])
run("docker", "exec", redis, "redis-cli", "FLUSHALL")
port = TCPServer.open("127.0.0.1", 0) { |socket| socket.addr[1] }
client = BenchmarkHTTPClient.new("http://127.0.0.1:#{port}")
source = side == "before" ? options[:baseline] : ROOT
command = [ "docker", "run", "-d", "--name", app, "--entrypoint", "", "--network", network,
"--cpuset-cpus", options[:cpus], "-p", "127.0.0.1:#{port}:3000" ]
command.concat mounts(source => "/rails", File.join(data, "storage") => "/rails/storage",
File.join(data, "tmp") => "/rails/tmp", File.join(data, "log") => "/rails/log", assets => "/rails/public/assets")
command.concat environment(RAILS_ENV: "production", SECRET_KEY_BASE: "isolated-benchmark-fixture-key", DISABLE_SSL: true,
SKIP_TELEMETRY: true, RAILS_LOG_LEVEL: "fatal", WEB_CONCURRENCY: 1, JOB_CONCURRENCY: 1, RAILS_MAX_THREADS: 5,
REDIS_URL: "redis://#{redis}:6379/0")
command.concat [ options[:image], "bundle", "exec", "puma", "-C", "config/puma.rb" ]
run(*command)
deadline = clock + 45
until client.ready?
if clock > deadline
File.write(File.join(work, "server.log"), run("docker", "logs", app))
raise "server did not become ready; see #{work}/server.log"
end
sleep 0.1
end
cookie = client.login(labels)
results = {}
paths.each do |name, path|
contract = BenchmarkResponseContract.new(name, File.join(data, "storage/db/production.sqlite3"), labels)
client.measure(path, cookie, concurrency: 1, duration: 3, contract: contract)
concurrencies.each do |concurrency|
results["#{name}_#{concurrency}"] = client.measure(path, cookie, concurrency: concurrency, duration: options[:duration], contract: contract)
end
end
write_json(File.join(options[:output], "#{side}-#{iteration + 1}.json"), results)
puts "#{iteration + 1}/#{options[:rounds]}: #{side}"
end
end
ensure
remove_container(app)
remove_container(redis)
Open3.capture3("docker", "network", "rm", network)
end
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env ruby
# Compare frozen Rails sources with isolated fixtures and an existing Ruby image.
require "digest"
require_relative "support"
include BenchmarkSupport
options = parse_options("Compare rendering queries, allocations, timing and response parity.",
baseline_ref: nil, rounds: 4, output: File.join(WORK, "results/requests"))
work = File.join(WORK, "runtime")
FileUtils.mkdir_p([ File.join(work, "tmp"), File.join(work, "log") ])
assets = prepare_assets(options[:image])
runs = { "before" => [], "after" => [] }
order = []
options[:rounds].times do |iteration|
sides = iteration.even? ? %w[before after] : %w[after before]
order << sides
sides.each do |side|
storage = File.join(work, "storage")
prepare_storage(options[:seed], storage)
source = side == "before" ? options[:baseline] : ROOT
command = [ "docker", "run", "--rm", "--entrypoint", "", "--cpuset-cpus", options[:cpus] ]
command.concat mounts(source => "/rails", storage => "/rails/storage", File.join(work, "tmp") => "/rails/tmp",
File.join(work, "log") => "/rails/log", assets => "/rails/public/assets", File.join(ROOT, "bench") => "/bench",
File.join(options[:seed], "labels.json") => "/bench-labels.json")
command.concat environment(RAILS_ENV: "production", SECRET_KEY_BASE: "isolated-benchmark-fixture-key", DISABLE_SSL: true,
SKIP_TELEMETRY: true, RAILS_LOG_LEVEL: "fatal", BENCH_LABELS: "/bench-labels.json")
command.concat [ options[:image], "bundle", "exec", "ruby", "-r", "/rails/config/environment.rb", "/bench/message_hot_paths.rb" ]
data = JSON.parse(run(*command))
runs[side] << data
write_json(File.join(options[:output], "#{side}-#{iteration + 1}.json"), data)
puts "#{iteration + 1}/#{options[:rounds]}: #{side}"
end
end
summary = {}
runs["before"].first.fetch("results").each_key do |name|
values = runs.transform_values { |rows| rows.map { |row| row.fetch("results").fetch(name) } }
%w[body_sha256 headers payload_sha256].each do |field|
expected = values["before"].first[field]
raise "#{name}: #{field} differs; no winning summary written" unless values.values.flatten.all? { |row| row[field] == expected }
end
summary[name] = values.transform_values do |rows|
fields = %w[milliseconds allocations queries].select { |field| rows.first.key?(field) }
fields.to_h { |field| [ field, median(rows.map { |row| median(row.fetch(field)) }) ] }
.merge("round_medians_ms" => rows.map { |row| median(row.fetch("milliseconds")) })
end
summary[name]["speedup"] = summary[name]["before"]["milliseconds"] / summary[name]["after"]["milliseconds"]
end
metadata = {
baseline_sha: options[:baseline_ref], candidate_head: run("git", "-C", ROOT, "rev-parse", "HEAD").strip,
candidate_app_diff_sha256: Digest::SHA256.hexdigest(run("git", "-C", ROOT, "diff", "--", "app")),
platform: RUBY_PLATFORM, cpus: options[:cpus], image: options[:image],
image_id: run("docker", "image", "inspect", "-f", "{{.Id}}", options[:image]).strip,
body_and_selected_header_parity: "exact across all measured baseline/candidate requests",
order: order, ruby: runs["before"].first.fetch("ruby"), rails: runs["before"].first.fetch("rails"),
limits: "In-process Rails requests, MemoryStore, frozen clock and fixture-controller CSRF disabled. Fanout excludes adapter I/O. Not network throughput or connection capacity."
}
write_json(File.join(options[:output], "summary.json"), metadata: metadata, results: summary)
summary.each do |name, value|
puts "%s: %.2f -> %.2f ms (%.2fx)" % [ name, value["before"]["milliseconds"], value["after"]["milliseconds"], value["speedup"] ]
end
+104
View File
@@ -0,0 +1,104 @@
require "cgi"
require "net/http"
class BenchmarkHTTPClient
def initialize(base)
@base = URI(base)
end
def ready?
connection.start { |http| http.get("/up").code == "200" }
rescue IOError, SystemCallError, Timeout::Error, SocketError
false
end
def login(labels)
cookies = {}
connection.start do |http|
response = http.get("/session/new", "Accept-Encoding" => "identity")
raise "sign-in page: HTTP #{response.code}" unless response.code == "200"
merge_cookies(cookies, response)
token = response.body[/<meta name="csrf-token" content="([^"]*)"/, 1]
raise "sign-in page has no CSRF token" unless token
request = Net::HTTP::Post.new("/session")
request["Cookie"] = cookie_header(cookies)
request["Origin"] = @base.to_s
request["Sec-Fetch-Site"] = "same-origin"
request.set_form_data(email_address: labels.fetch("emails.david"), password: labels.fetch("passwords.all"),
authenticity_token: CGI.unescapeHTML(token))
response = http.request(request)
merge_cookies(cookies, response)
raise "login failed: HTTP #{response.code}" unless response.code == "302" && cookies.key?("session_token")
end
cookie_header(cookies)
end
def measure(path, cookie, concurrency:, duration:, contract:)
start = clock
deadline = start + duration
workers = Array.new(concurrency) do
Thread.new do
result = { latencies: [], statuses: Hash.new(0), bytes: 0, errors: 0, invalid_responses: 0 }
while clock < deadline
begin
connection.start do |http|
while clock < deadline
requested = clock
response = http.get(path, "Cookie" => cookie, "Accept-Encoding" => "identity")
result[:latencies] << (clock - requested) * 1000
result[:statuses][response.code] += 1
result[:bytes] += response.body.bytesize
result[:invalid_responses] += 1 unless contract.valid?(response)
end
end
rescue IOError, SystemCallError, Timeout::Error, SocketError, Net::HTTPBadResponse
result[:errors] += 1
sleep 0.01
end
end
result
end
end
samples = workers.map(&:value)
elapsed = clock - start
latencies = samples.flat_map { |sample| sample[:latencies] }.sort
statuses = Hash.new(0)
samples.each { |sample| sample[:statuses].each { |status, count| statuses[status] += count } }
errors = samples.sum { |sample| sample[:errors] }
invalid = samples.sum { |sample| sample[:invalid_responses] }
raise "#{path}: HTTP statuses #{statuses}, #{errors} transport errors, #{invalid} invalid bodies" unless errors.zero? && invalid.zero? && statuses.keys == [ "200" ]
{ path: path, conc: concurrency, gzip: false, secs: elapsed, rps: latencies.size / elapsed,
ok: latencies.size, statuses: statuses, errors: errors, invalid_responses: invalid, validation: "route-contract-v1",
avg_bytes: samples.sum { |sample| sample[:bytes] } / latencies.size,
latency_ms: { p50: percentile(latencies, 0.50), p95: percentile(latencies, 0.95), p99: percentile(latencies, 0.99) } }
end
private
def connection
Net::HTTP.new(@base.host, @base.port, nil).tap do |http|
http.open_timeout = 1
http.read_timeout = 5
http.write_timeout = 5
http.max_retries = 0
end
end
def merge_cookies(cookies, response)
response.get_fields("set-cookie").to_a.each do |header|
name, value = header.split(";", 2).first.split("=", 2)
cookies[name] = value
end
end
def cookie_header(cookies)
cookies.map { |name, value| "#{name}=#{value}" }.join("; ")
end
def percentile(values, fraction)
values[(values.size * fraction).ceil - 1]
end
def clock
Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
end
+92
View File
@@ -0,0 +1,92 @@
# Run through compare_message_hot_paths.rb against isolated production fixtures.
require "json"
require "digest"
require "active_support/testing/time_helpers"
include ActiveSupport::Testing::TimeHelpers
travel_to Time.utc(2026, 10, 4, 12)
# Keep framework/rendering measurements independent of Redis/network variance.
Rails.cache = ActiveSupport::Cache::MemoryStore.new
ActionView::PartialRenderer.collection_cache = Rails.cache
ApplicationController.cache_store = Rails.cache
ApplicationController.descendants.each do |controller|
controller.cache_store = Rails.cache
controller.allow_forgery_protection = false
end
ApplicationController.allow_forgery_protection = false
labels = JSON.parse(File.read(ENV.fetch("BENCH_LABELS")))
client = ActionDispatch::Integration::Session.new(Rails.application)
client.host! "campfire-benchmark.test"
client.post "/session", params: { email_address: labels.fetch("emails.david"), password: labels.fetch("passwords.all") }
raise "login failed" unless client.response.redirect?
paths = {
room: "/rooms/#{labels.fetch('rooms.watercooler')}",
messages: "/rooms/#{labels.fetch('rooms.watercooler')}/messages?before=#{labels.fetch('messages.busy_060')}",
sidebar: Rails.application.routes.url_helpers.user_sidebar_path,
search: "/searches?q=coffee"
}
queries = []
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, event|
queries << event[:sql] unless event[:cached] || event[:name] == "SCHEMA"
end
results = {}
paths.each do |name, path|
%w[cold warm].each do |cache|
2.times { client.get path }
times, counts, allocations, hashes, headers = [], [], [], [], []
Integer(ENV.fetch("BENCH_ITERATIONS", "20")).times do
Rails.cache.clear if cache == "cold"
queries.clear
start_allocations = GC.stat(:total_allocated_objects)
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
client.get path
times << (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
allocations << GC.stat(:total_allocated_objects) - start_allocations
raise "#{name}: HTTP #{client.response.status}" unless client.response.status == 200
counts << queries.length
hashes << Digest::SHA256.hexdigest(client.response.body)
headers << client.response.headers.slice("content-type", "etag", "cache-control", "vary")
end
results["#{name}_#{cache}"] = { milliseconds: times, queries: counts, allocations: allocations,
body_sha256: hashes.uniq, headers: headers.uniq, body_bytes: client.response.body.bytesize }
end
end
ActiveSupport::Notifications.unsubscribe(subscriber)
# Real Action Cable encoding/instrumentation, with adapter I/O removed. Keep fanout
# separate from network delivery capacity, which this probe does not measure.
room = Room.find(labels.fetch("rooms.watercooler"))
class BenchmarkPubsub
attr_reader :messages
def initialize
@messages = []
end
def broadcast(stream, payload)
@messages << [ stream, payload ]
end
end
adapter = BenchmarkPubsub.new
ActionCable.server.instance_variable_set(:@pubsub, adapter)
message = Message.new(room: room)
recipient_ids = (1..1000).to_a
memberships = Object.new
memberships.define_singleton_method(:pluck) { |_| recipient_ids }
room.define_singleton_method(:memberships) { memberships }
fanout_times, fanout_allocations = [], []
20.times do
adapter.messages.clear
allocated = GC.stat(:total_allocated_objects)
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
message.send(:broadcast_unread_room)
fanout_times << (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
fanout_allocations << GC.stat(:total_allocated_objects) - allocated
raise "wrong fanout count" unless adapter.messages.size == recipient_ids.size
raise "wrong payload" unless adapter.messages.all? { |_, body| JSON.parse(body) == { "roomId" => room.id } }
end
results["unread_fanout_1000"] = { milliseconds: fanout_times, allocations: fanout_allocations,
payload_sha256: Digest::SHA256.hexdigest(adapter.messages.first.last) }
puts JSON.pretty_generate({ ruby: RUBY_VERSION, rails: Rails.version, iterations: ENV.fetch("BENCH_ITERATIONS", "20").to_i,
cache: "MemoryStore", results: results })
+43
View File
@@ -0,0 +1,43 @@
require "json"
require "open3"
require "cgi"
# Independent seed SQL determines the required result window before any response is sampled.
class BenchmarkResponseContract
def initialize(kind, database, labels)
@kind = kind
room = Integer(labels.fetch("rooms.watercooler"))
user = labels.fetch("emails.david").gsub("'", "''")
@ids = case kind
when "room"
query(database, "SELECT id FROM messages WHERE room_id=#{room} ORDER BY created_at DESC LIMIT 40").reverse.map { |row| row.fetch("id") }
when "messages"
before = Integer(labels.fetch("messages.busy_060"))
query(database, "SELECT id FROM messages WHERE room_id=#{room} AND created_at<(SELECT created_at FROM messages WHERE id=#{before}) ORDER BY created_at DESC LIMIT 40").reverse.map { |row| row.fetch("id") }
when "search"
query(database, "SELECT m.id FROM message_search_index idx JOIN messages m ON m.id=idx.rowid JOIN memberships mm ON mm.room_id=m.room_id JOIN users u ON u.id=mm.user_id WHERE u.email_address='#{user}' AND idx.body MATCH 'coffee' ORDER BY m.id DESC LIMIT 100").reverse.map { |row| row.fetch("id") }
when "sidebar"
@names = query(database, "SELECT r.name FROM rooms r JOIN memberships mm ON mm.room_id=r.id JOIN users u ON u.id=mm.user_id WHERE u.email_address='#{user}' AND r.type='Rooms::Open' AND mm.involvement<>'invisible'").map { |row| CGI.escapeHTML(row.fetch("name")) }
nil
else
raise "Unknown route contract #{kind}"
end
end
def valid?(response)
body = response.body.to_s
return false unless response.code == "200" && response["content-type"].to_s.split(";").first == "text/html" &&
[ nil, "identity" ].include?(response["content-encoding"]) && !body.empty? && body.dup.force_encoding("UTF-8").valid_encoding?
return false if @kind != "messages" && !(body.lstrip.start_with?("<!DOCTYPE html>") && body.rstrip.end_with?("</html>"))
return false if @ids && body.scan(/data-message-id="(\d+)"/).flatten.map(&:to_i) != @ids
return false if @names && (!body.include?("shared_rooms") || @names.any? { |name| !body.include?(name) })
true
end
private
def query(database, sql)
output, errors, status = Open3.capture3("sqlite3", "-readonly", "-json", database, sql)
raise "Cannot read benchmark seed: #{errors}" unless status.success?
JSON.parse(output)
end
end
+84
View File
@@ -0,0 +1,84 @@
require "fileutils"
require "json"
require "open3"
require "optparse"
module BenchmarkSupport
ROOT = File.expand_path("..", __dir__)
WORK = File.join(ROOT, "tmp/rails-optimization")
def parse_options(description, defaults)
options = { baseline: nil, seed: nil, image: "campfire-reference:app", cpus: "8-11", rounds: 2 }.merge(defaults)
parser = OptionParser.new do |parser|
parser.banner = "Usage: ruby #{$PROGRAM_NAME} --baseline PATH --seed PATH [options]\n#{description}"
options.each do |name, default|
type = case default
when Integer then Integer
when Float then Float
else String
end
parser.on("--#{name.to_s.tr('_', '-')} VALUE", type) { |value| options[name] = value }
end
parser.on("-h", "--help") { puts parser; exit }
end
parser.parse!
raise OptionParser::InvalidArgument, "unexpected arguments: #{ARGV.join(' ')}" unless ARGV.empty?
raise OptionParser::MissingArgument, "--baseline and --seed are required" unless options[:baseline] && options[:seed]
raise OptionParser::InvalidArgument, "--rounds must be even and at least 2" unless options[:rounds] >= 2 && options[:rounds].even?
%i[baseline seed output].each { |name| options[name] = File.expand_path(options.fetch(name)) }
options
rescue OptionParser::ParseError => error
abort "#{error.message}\n#{parser}"
end
def run(*command, input: nil)
output, errors, status = Open3.capture3(*command, stdin_data: input, binmode: true)
raise "#{command.first} failed (#{status.exitstatus}): #{errors}" unless status.success?
output
end
def remove_container(name)
Open3.capture3("docker", "rm", "-f", name)
end
def prepare_storage(seed, destination)
FileUtils.rm_rf(destination)
FileUtils.mkdir_p(destination)
FileUtils.cp_r(File.join(seed, "db"), File.join(destination, "db"))
FileUtils.cp_r(File.join(seed, "storage"), File.join(destination, "files"))
end
def prepare_assets(image)
assets = File.join(WORK, "runtime/assets")
FileUtils.mkdir_p(assets)
if Dir.empty?(assets)
archive = run("docker", "run", "--rm", "--entrypoint", "", image,
"tar", "-C", "/rails/public/assets", "-cf", "-", ".")
run("tar", "-xf", "-", "-C", assets, input: archive)
end
assets
end
def mounts(paths)
paths.flat_map { |host, target| [ "-v", "#{host}:#{target}" ] }
end
def environment(values)
values.flat_map { |name, value| [ "-e", "#{name}=#{value}" ] }
end
def write_json(path, data)
FileUtils.mkdir_p(File.dirname(path))
File.write(path, JSON.pretty_generate(data) + "\n")
end
def median(values)
values = values.sort
middle = values.length / 2
values.length.odd? ? values[middle] : (values[middle - 1] + values[middle]) / 2.0
end
def clock
Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
end
+71
View File
@@ -0,0 +1,71 @@
require "minitest/autorun"
require "fileutils"
require "socket"
require "sqlite3"
require "tmpdir"
require_relative "http_client"
require_relative "response_contract"
class BenchmarkHTTPClientTest < Minitest::Test
def setup
@directory = Dir.mktmpdir("campfire-benchmark-test")
database = File.join(@directory, "seed.sqlite3")
SQLite3::Database.new(database).tap do |db|
db.execute("CREATE TABLE messages (id INTEGER PRIMARY KEY, room_id INTEGER, created_at TEXT)")
db.execute("INSERT INTO messages VALUES (42, 1, '2026-10-01'), (43, 1, '2026-10-02')")
db.close
end
@contract = BenchmarkResponseContract.new("room", database, "rooms.watercooler" => 1, "emails.david" => "david@example.com")
@body = '<!DOCTYPE html><html><article data-message-id="42">coffee</article><article data-message-id="43">meeting</article></html>'
end
def teardown
FileUtils.remove_entry(@directory)
end
def test_seeded_contract_rejects_error_pages_truncation_wrong_windows_and_encoding
assert @contract.valid?(response(@body))
refute @contract.valid?(response("Something went wrong"))
refute @contract.valid?(response(@body.delete_suffix("</html>")))
refute @contract.valid?(response(@body.sub('data-message-id="42"', 'data-message-id="99"')))
refute @contract.valid?(response(@body, "text/plain"))
refute @contract.valid?(response(@body + "\xFF".b))
refute @contract.valid?(response(@body, "text/html", "500"))
end
def test_intermittent_200_error_page_rejects_the_loaded_run
server = TCPServer.new("127.0.0.1", 0)
worker = Thread.new do
loop do
socket = server.accept
count = 0
while socket.gets
while (line = socket.gets) && line != "\r\n"
end
body = count % 8 == 0 ? "Something went wrong" : @body
count += 1
socket.write("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: #{body.bytesize}\r\n\r\n#{body}")
end
socket.close
end
rescue IOError, SystemCallError
nil
end
client = BenchmarkHTTPClient.new("http://127.0.0.1:#{server.addr[1]}")
error = assert_raises(RuntimeError) { client.measure("/rooms/1", "", concurrency: 1, duration: 0.1, contract: @contract) }
assert_match(/HTTP statuses \{"200"\s*=>\s*\d+\}, 0 transport errors, [1-9]\d* invalid bodies/, error.message)
ensure
server&.close
worker&.kill
worker&.join
end
private
def response(body, type = "text/html", status = "200")
Net::HTTPResponse.new("1.1", status, "OK").tap do |result|
result["content-type"] = type
result.instance_variable_set(:@body, body)
result.instance_variable_set(:@read, true)
end
end
end
+1 -1
View File
@@ -80,5 +80,5 @@ Rails.application.configure do
config.action_controller.raise_on_missing_callback_actions = true
# Visit /rails/locks to see the locks
config.middleware.insert_before Rack::Sendfile, ActionDispatch::DebugLocks
config.middleware.insert_before ActionDispatch::Executor, ActionDispatch::DebugLocks
end
+15
View File
@@ -1,5 +1,20 @@
require "rails_ext/time_limited_video_previewer"
ActiveSupport.on_load(:active_storage_blob) do
ActiveStorage::DiskController.after_action only: :show do
response.set_header("Cache-Control", "max-age=3600, public")
end
end
Rails.application.configure do
# Rails' filter takes the second of the frames it selects (the first one, keyframes, scene changes), so a video
# with a single keyframe and no scene change is decoded to its end. Selecting any frame from 5 seconds on stops
# it there.
config.active_storage.video_preview_arguments =
"-vf 'select=eq(n\\,0)+eq(key\\,1)+gt(scene\\,0.015)+gte(t\\,5),loop=loop=-1:size=2,trim=start_frame=1'" \
" -frames:v 1 -f image2"
config.active_storage.previewers = config.active_storage.previewers.map do |previewer|
previewer == ActiveStorage::Previewer::VideoPreviewer ? TimeLimitedVideoPreviewer : previewer
end
end
+19 -23
View File
@@ -1,25 +1,21 @@
# Be sure to restart your server when you modify this file.
# Define an application-wide content security policy.
# See the Securing Rails Applications Guide for more information:
# https://guides.rubyonrails.org/security.html#content-security-policy-header
# Rails.application.configure do
# config.content_security_policy do |policy|
# policy.default_src :self, :https
# policy.font_src :self, :https, :data
# policy.img_src :self, :https, :data
# policy.object_src :none
# policy.script_src :self, :https
# policy.style_src :self, :https
# # Specify URI for violation reports
# # policy.report_uri "/csp-violation-report-endpoint"
# end
# Enforcing baseline policy. Application pages narrow script-src from this
# origin to its asset directory, since uploads are served from this origin
# too, and allow the two inline scripts the importmap helper emits by hash
# (InlineScriptHashes). There is no per-request nonce, so rendered HTML stays
# cacheable. 'self' remains for responses outside ApplicationController, none
# of which render user content.
#
# # Generate session nonces for permitted importmap and inline scripts
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
# config.content_security_policy_nonce_directives = %w(script-src)
# Nothing else is restricted: images, media, fonts, styles (including an
# account's custom styles) and connections behave as before.
#
# # Report violations without enforcing the policy.
# # config.content_security_policy_report_only = true
# end
# DISABLE_CONTENT_SECURITY_POLICY=true is the escape hatch for an install whose
# proxy or customization injects scripts the policy would block.
unless ENV["DISABLE_CONTENT_SECURITY_POLICY"] == "true"
Rails.application.configure do
config.content_security_policy do |policy|
policy.script_src :self
policy.object_src :none
policy.base_uri :self
end
end
end
+8
View File
@@ -6,3 +6,11 @@ if Rails.env.production? && ENV["SKIP_TELEMETRY"].blank?
config.release = ENV["GIT_REVISION"]
end
end
# sentry-rails declares its Action Cable handle_open and handle_close wrappers private,
# but Rails 8.2 calls them from outside the connection, so every /cable connection fails.
# Remove once sentry-rails ships getsentry/sentry-ruby#2972 (issue #2975).
ActiveSupport.on_load(:action_cable_connection) do
require "sentry/rails/action_cable"
Sentry::Rails::ActionCableExtensions::Connection.send(:public, :handle_open, :handle_close)
end
+1 -1
View File
@@ -1,2 +1,2 @@
# Used to match JavaScripts (new Date).getTime() for sorting
Time::DATE_FORMATS[:epoch] = ->(time) { (time.to_f * 1000).to_i }
ActiveSupport::TimeFormats.register(:epoch, ->(time) { (time.to_f * 1000).to_i })
@@ -0,0 +1,34 @@
# Uploaded files are served from this origin. Application pages only allow
# scripts from the asset directory (InlineScriptHashes), so an upload can't
# load as a script whatever type it's served with. This guard is the second
# layer: browsers accept sixteen spellings of a JavaScript type, and Disk URLs
# sign the type they were minted with, so instead of refusing a list of types,
# anything Active Storage serves that isn't plainly media goes out as a
# download, whatever its URL says. A multi-range response keeps its
# multipart/byteranges type and boundary, which no browser runs as a script.
# Only reads are touched: uploading (direct upload JSON, Disk PUT) answers in
# its own types.
#
# A cache that stored an upload under a script type before this shipped can
# still serve it that way until the entry expires (an hour for Disk URLs, and
# never for blob proxy URLs, which Rails caches as immutable). The policy's
# path restriction is what keeps such a copy from running.
class UploadedScriptGuard
SERVABLE = %r{\A(image|video|audio)/|\A(application/pdf|application/octet-stream|text/plain|multipart/byteranges)\z}
def initialize(app)
@app = app
end
def call(env)
status, headers, body = @app.call(env)
if env["REQUEST_METHOD"].in?(%w[ GET HEAD ]) && env["PATH_INFO"].start_with?("#{ActiveStorage.routes_prefix}/") && (type = headers["content-type"])
headers["content-type"] = ActiveStorage.binary_content_type unless type.split(";").first.strip.downcase.match?(SERVABLE)
end
[ status, headers, body ]
end
end
Rails.application.config.middleware.use UploadedScriptGuard
+22 -33
View File
@@ -1,4 +1,5 @@
require "web-push"
require "web_push/connections"
require "web_push/pool"
require "web_push/notification"
@@ -17,43 +18,31 @@ end
module WebPush::PersistentRequest
def perform
if endpoint_ip = @options[:endpoint_ip]
# Pin the connection to the public IP resolved (and guarded) by
# Push::Subscription so delivery can't be rebound to a private address
# between resolution and connect. Bypasses the shared persistent pool,
# which would re-resolve the host itself.
#
# The explicit nil proxy address disables proxy discovery from
# http_proxy/https_proxy. An egress proxy would open the TCP connection
# itself and re-resolve the endpoint host, so http.ipaddr would no longer
# pin the destination and the DNS-rebinding guarantee would be lost. This
# path is already committed to a direct connection (it bypasses the pool);
# push delivery to public vendor endpoints goes direct.
http = Net::HTTP.new(uri.host, uri.port, nil)
http.ipaddr = endpoint_ip
http.use_ssl = true
http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil?
http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil?
http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil?
elsif @options[:connection]
http = @options[:connection]
else
http = Net::HTTP.new(uri.host, uri.port, *proxy_options)
http.use_ssl = true
http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil?
http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil?
http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil?
end
# Pin the connection to the public IP resolved (and guarded) by
# Push::Subscription so delivery can't be rebound to a private address
# between resolution and connect. There is no unpinned path: a delivery
# without a resolved IP is not sent.
endpoint_ip = @options[:endpoint_ip] or raise ArgumentError, "Push deliveries must be pinned to a resolved endpoint IP"
# The explicit nil proxy address disables proxy discovery from
# http_proxy/https_proxy. An egress proxy would open the TCP connection
# itself and re-resolve the endpoint host, so http.ipaddr would no longer
# pin the destination and the DNS-rebinding guarantee would be lost.
# Push delivery to public vendor endpoints goes direct.
http = Net::HTTP.new(uri.host, uri.port, nil)
http.ipaddr = endpoint_ip
http.use_ssl = true
http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil?
http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil?
http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil?
req = Net::HTTP::Post.new(uri.request_uri, headers)
req.body = body
if http.is_a?(Net::HTTP::Persistent)
response = http.request uri, req
else
resp = http.request(req)
verify_response(resp)
end
# WebPush::Connections reuses an open connection only for this same host
# and pinned IP, so the guarantee holds for every request it sends.
resp = @options[:connection] ? @options[:connection].request(http, req) : http.request(req)
verify_response(resp)
resp
end
@@ -0,0 +1,5 @@
class AddRoomIdAndCreatedAtIndexToMessages < ActiveRecord::Migration[8.2]
def change
add_index :messages, %i[ room_id created_at ], if_not_exists: true
end
end
@@ -0,0 +1,5 @@
class AddRoomIdAndUpdatedAtIndexToMessages < ActiveRecord::Migration[8.2]
def change
add_index :messages, %i[ room_id updated_at ], if_not_exists: true
end
end
Generated
+3 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_12_12_154340) do
ActiveRecord::Schema[8.2].define(version: 2026_10_05_022000) do
create_table "accounts", force: :cascade do |t|
t.datetime "created_at", null: false
t.text "custom_styles"
@@ -101,6 +101,8 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_12_154340) do
t.integer "room_id", null: false
t.datetime "updated_at", null: false
t.index ["creator_id"], name: "index_messages_on_creator_id"
t.index ["room_id", "created_at"], name: "index_messages_on_room_id_and_created_at"
t.index ["room_id", "updated_at"], name: "index_messages_on_room_id_and_updated_at"
t.index ["room_id"], name: "index_messages_on_room_id"
end
@@ -0,0 +1,27 @@
# Rails waits for ffmpeg however long it takes, and a video's preview is drawn inside the request that posts it.
# This previewer gives ffmpeg a wall-clock limit, kills it past that, and reports a failed preview, so the message
# is posted without one.
class TimeLimitedVideoPreviewer < ActiveStorage::Previewer::VideoPreviewer
TIME_LIMIT = 10 # seconds
private
def capture(*argv, to:)
to.binmode
open_tempfile do |err|
IO.popen(argv, in: IO::NULL, err: err) do |out|
Timeout.timeout(TIME_LIMIT) { IO.copy_stream(out, to) }
rescue Timeout::Error
Process.kill :KILL, out.pid
raise ActiveStorage::PreviewError, "#{argv.first} took longer than #{TIME_LIMIT} seconds"
end
err.rewind
unless $?.success?
raise ActiveStorage::PreviewError, "#{argv.first} failed (status #{$?.exitstatus}): #{err.read.to_s.chomp}"
end
end
to.rewind
end
end
+120
View File
@@ -0,0 +1,120 @@
# This is in lib so we can use it in a thread pool without the Rails executor
#
# Keeps TLS connections to push services open between deliveries, so a push doesn't pay for a new TCP and TLS
# handshake every time. Each connection is pinned to the address Push::Subscription resolved and guarded for the
# delivery that opened it, and is handed out again only to a delivery whose own resolution, just made, returned that
# same address for the same host. Net::HTTP reconnects to its pinned address, never to a new lookup of the host.
class WebPush::Connections
class ConnectionLost < StandardError; end
class StaleConnection < StandardError; end
# Where a request failed. Before writing it, Net::HTTP checks an idle connection (:checking) and connects again
# if the push service closed it (:connecting); then it writes (:sent). Only a failed check means a dead idle
# connection that a new one can replace without sending the push twice.
module Stages
attr_reader :stage
private
def begin_transport(...)
@stage = :checking
super.tap { @stage = :sent }
end
def connect(...)
@stage = :connecting if @stage == :checking
super
end
end
def initialize(keep_alive_timeout: 30, max_idle: 150)
@keep_alive_timeout = keep_alive_timeout
@max_idle = max_idle
@idle = Hash.new { |idle, address| idle[address] = [] }
@mutex = Mutex.new
@pid = Process.pid
end
def request(http, request)
unless http.ipaddr && !http.proxy? && http.use_ssl? && !http.started?
raise ArgumentError, "Only new, direct TLS connections pinned to an address are pooled"
end
address = [ http.address, http.port, http.ipaddr ]
if idle = checkout(address)
begin
return send_over(idle, request, reused: true).tap { checkin(address, idle) }
rescue StaleConnection
# The push service had closed it: a new connection takes the push
end
end
http.extend Stages
http.keep_alive_timeout = @keep_alive_timeout
http.start
send_over(http, request, reused: false).tap { checkin(address, http) }
end
def shutdown
@mutex.synchronize do
forget_after_fork
@shut_down = true
@idle.each_value { |connections| connections.each { |http, _| close(http) } }
@idle.clear
end
end
private
def send_over(http, request, reused:)
http.request(request)
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError => error
close(http)
# Only a connection that was idle can have been dead already: on a new one the error is the push service's
raise StaleConnection if reused && http.stage == :checking
# The push service may have it: don't send it twice, and don't report a dropped connection as a TLS failure
raise ConnectionLost, "#{error.class}: #{error.message}" if http.stage == :sent
raise
end
def checkout(address)
@mutex.synchronize do
forget_after_fork
close_expired
@idle[address].pop&.first
end
end
def checkin(address, http)
@mutex.synchronize do
close_expired
if @shut_down || @idle.values.sum(&:size) >= @max_idle
close(http)
else
@idle[address].push [ http, now ]
end
end
end
# A forked process must not write on its parent's TLS sessions. They're left to the parent, not closed.
def forget_after_fork
unless @pid == Process.pid
@idle = Hash.new { |idle, address| idle[address] = [] }
@pid = Process.pid
end
end
def close_expired
@idle.each_value do |connections|
connections.reject! { |http, idle_since| (now - idle_since > @keep_alive_timeout).tap { |expired| close(http) if expired } }
end
@idle.delete_if { |_, connections| connections.empty? }
end
def close(http)
http.finish if http.started?
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
end
def now
Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
end
+9 -5
View File
@@ -5,13 +5,17 @@ class WebPush::Pool
def initialize(invalid_subscription_handler:)
@delivery_pool = Concurrent::ThreadPoolExecutor.new(max_threads: 50, max_queue: 10000)
@invalidation_pool = Concurrent::FixedThreadPool.new(1)
@connection = Net::HTTP::Persistent.new(name: "web_push", pool_size: 150)
@connection = WebPush::Connections.new
@invalid_subscription_handler = invalid_subscription_handler
end
def queue(payload, subscriptions)
subscriptions.find_each do |subscription|
deliver_later(payload, subscription)
subscriptions.find_in_batches do |batch|
unread_counts = Membership.unread.where(user_id: batch.map(&:user_id)).group(:user_id).count
batch.each do |subscription|
deliver_later(payload, subscription, badge: unread_counts.fetch(subscription.user_id, 0))
end
end
end
@@ -22,9 +26,9 @@ class WebPush::Pool
end
private
def deliver_later(payload, subscription)
def deliver_later(payload, subscription, badge:)
# Ensure any AR operations happen before we post to the thread pool
notification = subscription.notification(**payload)
notification = subscription.notification(**payload, badge: badge)
subscription_id = subscription.id
delivery_pool.post do
+10
View File
@@ -40,6 +40,16 @@ class PresenceChannelTest < ActionCable::Channel::TestCase
end
end
test "subscribing tells the user's other tabs when they read the room" do
membership = users(:david).memberships.first
freeze_time do
assert_broadcast_on "user_#{users(:david).id}_reads", { room_id: membership.room_id, at: Time.current.to_f } do
subscribe room_id: membership.room_id
end
end
end
test "unsubscribing marks the membership as disconnected" do
membership = users(:david).memberships.first
subscribe room_id: membership.room_id
+11 -5
View File
@@ -8,7 +8,7 @@ class UnreadRoomsChannelTest < ActionCable::Channel::TestCase
assert subscription.confirmed?
assert_has_stream "user_#{users(:jz).id}_unreads"
assert_not_includes subscription.streams, "unread_rooms"
assert_not_includes subscription.stream_names, "unread_rooms"
end
test "an outsider is not told about activity in a room they can't see" do
@@ -16,7 +16,9 @@ class UnreadRoomsChannelTest < ActionCable::Channel::TestCase
assert_not direct.users.include?(users(:jz)), "jz must be an outsider for this test to mean anything"
broadcasts = capture_unread_broadcasts_for(users(:jz)) do
direct.messages.create!(body: "Private", creator: users(:kevin), client_message_id: "outsider").broadcast_create
perform_enqueued_jobs only: Message::BroadcastUnreadRoomJob do
direct.messages.create!(body: "Private", creator: users(:kevin), client_message_id: "outsider").broadcast_create
end
end
assert_empty broadcasts
@@ -24,12 +26,16 @@ class UnreadRoomsChannelTest < ActionCable::Channel::TestCase
test "a member is told about activity in their own room" do
direct = rooms(:bender_and_kevin)
message = nil
broadcasts = capture_unread_broadcasts_for(users(:kevin)) do
direct.messages.create!(body: "Private", creator: users(:bender), client_message_id: "member").broadcast_create
perform_enqueued_jobs only: Message::BroadcastUnreadRoomJob do
message = direct.messages.create!(body: "Private", creator: users(:bender), client_message_id: "member")
message.broadcast_create
end
end
assert_equal [ direct.id ], broadcasts.collect { |broadcast| broadcast["roomId"] }
assert_equal [ { "roomId" => direct.id, "at" => message.created_at.to_f } ], broadcasts
end
private
@@ -37,7 +43,7 @@ class UnreadRoomsChannelTest < ActionCable::Channel::TestCase
stub_connection(current_user: user)
subscribe
stream = subscription.streams.sole
stream = subscription.stream_names.sole
before = ActionCable.server.pubsub.broadcasts(stream).size
yield
@@ -10,6 +10,16 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
assert_response :ok
end
test "edit shows whether room creation is restricted to administrators" do
get edit_account_url
assert_select "label.switch input.switch__input[type=checkbox]:not([checked])"
accounts(:signal).update!(settings: { restrict_room_creation_to_administrators: true })
get edit_account_url
assert_select "label.switch input.switch__input[type=checkbox][checked][data-action='change->form#submit']"
end
test "edit groups administrators separately from members with a divider" do
get edit_account_url
@@ -42,6 +52,19 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
end
end
test "edit lists a page of members and the next page carries on from it" do
User.insert_all 501.times.map { |i| { name: "Member #{i}", email_address: "member#{i}@37signals.com" } }
users(:kevin).banned!
get edit_account_url
first_page = listed_user_ids
get account_users_url(page: 2, format: :turbo_stream)
next_page = listed_user_ids
assert_not_empty next_page
assert_equal User.where(status: [ :active, :banned ]).without_bots.ids.sort, (first_page + next_page).sort
end
test "update" do
assert users(:david).administrator?
@@ -58,4 +81,9 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
put account_url, params: { account: { name: "Different" } }
assert_response :forbidden
end
private
def listed_user_ids
response.body.scan(%r{href="/users/(\d+)"}).flatten.map(&:to_i)
end
end
@@ -6,6 +6,22 @@ class Messages::BoostsControllerTest < ActionDispatch::IntegrationTest
@message = messages(:first)
end
test "index wires the new boost link to the soft keyboard" do
get message_boosts_url(@message)
assert_response :success
assert_select ".message__boost-inline a.boost__action[data-action='soft-keyboard#open']"
end
test "index looks up the boosters of all boosts at once" do
@message.boosts.create! booster: users(:jason), content: "🎉"
queries_with_two_boosts = count_queries { get message_boosts_url(@message) }
boost = @message.boosts.create! booster: users(:kevin), content: "👀"
assert_equal queries_with_two_boosts, count_queries { get message_boosts_url(@message) }
assert_select "#" + dom_id(boost)
end
test "create" do
assert_turbo_stream_broadcasts [ @message.room, :messages ], count: 1 do
assert_difference -> { @message.boosts.count }, 1 do
@@ -23,4 +39,12 @@ class Messages::BoostsControllerTest < ActionDispatch::IntegrationTest
end
end
end
private
def count_queries(&block)
count = 0
counter = ->(*, payload) { count += 1 unless payload[:name] == "SCHEMA" || payload[:cached] }
ActiveSupport::Notifications.subscribed(counter, "sql.active_record", &block)
count
end
end
+104
View File
@@ -0,0 +1,104 @@
require "test_helper"
require "active_record/testing/query_assertions"
class MessagesCachingTest < ActionDispatch::IntegrationTest
include ActiveRecord::Assertions::QueryAssertions
setup do
sign_in :david
end
test "cached pages skip presentation queries and refresh edited messages" do
with_memory_cache do
get room_messages_url(rooms(:watercooler))
assert_response :success
original = response.body
assert_no_queries_match(/action_text_rich_texts|active_storage_attachments|boosts/) do
get room_messages_url(rooms(:watercooler))
end
assert_response :success
assert_equal original, response.body
messages(:fourth).update! body: "Updated cached message"
get room_messages_url(rooms(:watercooler))
assert_response :success
assert_select "#" + dom_id(messages(:fourth)), text: /Updated cached message/
end
end
test "boosts are cached inside their message instead of one fragment each" do
with_memory_cache do
cache_keys = []
subscriber = ActiveSupport::Notifications.subscribe(/\Acache_(read|read_multi|write|write_multi)\.active_support\z/) do |*, payload|
cache_keys.concat(payload[:key].is_a?(Hash) ? payload[:key].keys : Array(payload[:key]))
end
get room_messages_url(rooms(:watercooler))
assert_response :success
assert_select "#" + dom_id(boosts(:fourth_by_bender))
assert_select "#" + dom_id(boosts(:thirteenth))
assert_empty cache_keys.grep(%r{messages/boosts/_boost})
boost = messages(:fourth).boosts.create! booster: users(:jason), content: "🎉"
get room_messages_url(rooms(:watercooler))
refreshed = response.body
Rails.cache.clear
get room_messages_url(rooms(:watercooler))
assert_equal refreshed, response.body
assert_select "##{dom_id(messages(:fourth))} ##{dom_id(boost)}", text: /🎉/
ensure
ActiveSupport::Notifications.unsubscribe(subscriber)
end
end
test "messages render their preloaded boosts in order without querying them again" do
earlier = messages(:fourth).boosts.create! booster: users(:jason), content: "🥇", created_at: 1.day.ago
in_order = [ dom_id(earlier), dom_id(boosts(:fourth_by_bender)) ]
assert_no_queries_match(/ORDER BY "boosts"/) do
get room_messages_url(rooms(:watercooler))
end
assert_response :success
assert_equal in_order, css_select("##{dom_id(messages(:fourth), :boosts)} .boost").map { it["id"] }
get message_boosts_url(messages(:fourth))
assert_equal in_order, css_select("##{dom_id(messages(:fourth), :boosts)} .boost").map { it["id"] }
end
test "a page of messages loads whether their previews were made along with the messages, not one at a time" do
room = rooms(:watercooler)
2.times do |copy|
{ "moon.jpg" => "image/jpeg", "alpha-centuri.mov" => "video/quicktime" }.each do |file, content_type|
room.messages.create_with_attachment! creator: users(:david), client_message_id: "#{copy}-#{file}", attachment: fixture_file_upload(file, content_type)
end
end
assert_no_queries_match(/"active_storage_\w+"\."(id|blob_id|record_id)" = \?/) do
get room_messages_url(room)
end
assert_response :success
assert_select "img[src*='moon.jpg']", count: 2
assert_select "video[poster]", count: 2
end
private
def with_memory_cache
old_cache = Rails.cache
old_collection_cache = ActionView::PartialRenderer.collection_cache
old_controller_cache = MessagesController.cache_store
old_caching = MessagesController.perform_caching
Rails.cache = ActiveSupport::Cache::MemoryStore.new
ActionView::PartialRenderer.collection_cache = Rails.cache
MessagesController.cache_store = Rails.cache
MessagesController.perform_caching = true
yield
ensure
Rails.cache = old_cache
ActionView::PartialRenderer.collection_cache = old_collection_cache
MessagesController.cache_store = old_controller_cache
MessagesController.perform_caching = old_caching
end
end
+84 -2
View File
@@ -57,21 +57,89 @@ class MessagesControllerTest < ActionDispatch::IntegrationTest
end
end
test "message copy links preserve the request origin in broadcasts and regular reads" do
[
[ "once.campfire.test:38683", false, "http://once.campfire.test:38683" ],
[ "once.campfire.test:8443", true, "https://once.campfire.test:8443" ],
[ "once.campfire.test:80", false, "http://once.campfire.test" ],
[ "once.campfire.test:443", true, "https://once.campfire.test" ]
].each_with_index do |(host, secure, origin), index|
host! host
https! secure
post room_messages_path(@room, format: :turbo_stream), params: { message: {
body: "Request origin #{index}", client_message_id: "request-origin-#{index}" } }
assert_response :success
message = Message.last
expected_url = "#{origin}/rooms/#{@room.id}/@#{message.id}"
assert_rendered_turbo_stream_broadcast @room, :messages, action: "append", target: [ @room, :messages ] do
assert_copy_link_button expected_url
end
get room_message_path(@room, message)
assert_response :success
assert_copy_link_button expected_url
end
end
test "creating a message with an image that can't be decoded broadcasts the message to the room" do
webp = Vips::Image.new_from_file(file_fixture("moon.jpg").to_s).webpsave_buffer
broken = Rack::Test::UploadedFile.new(StringIO.new(webp.byteslice(0, webp.bytesize / 2)), "image/webp", original_filename: "broken.webp")
post room_messages_url(@room, format: :turbo_stream), params: { message: { attachment: broken, client_message_id: 999 } }
assert_response :success
assert_rendered_turbo_stream_broadcast @room, :messages, action: "append", target: [ @room, :messages ] do
assert_select ".message__body a[href*='broken.webp']"
end
end
test "creating a message broadcasts unread room to each member" do
memberships(:david_watercooler).present # the poster is in the room
@room.users.each do |member|
assert_broadcasts UnreadRoomsChannel.stream_name_for(member.id), 1 do
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one #{member.id}", client_message_id: member.id } }
perform_enqueued_jobs only: Message::BroadcastUnreadRoomJob do
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one #{member.id}", client_message_id: member.id } }
end
end
end
end
test "creating a message leaves the unread fanout to a job" do
member = @room.users.excluding(users(:david)).first
assert_no_broadcasts UnreadRoomsChannel.stream_name_for(member.id) do
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one", client_message_id: 999 } }
end
assert_enqueued_with job: Message::BroadcastUnreadRoomJob, args: [ Message.last ]
end
test "the unread fanout skips a member who has opened the room since" do
membership = memberships(:jason_watercooler)
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one", client_message_id: 999 } }
assert membership.reload.unread?
membership.present
membership.reload.disconnected
assert_no_broadcasts UnreadRoomsChannel.stream_name_for(membership.user_id) do
perform_enqueued_jobs only: Message::BroadcastUnreadRoomJob
end
end
test "creating a message doesn't broadcast unread room to non-members" do
outsiders = User.where.not(id: @room.users.map(&:id))
assert outsiders.any?, "need someone outside the room for this test to mean anything"
outsiders.each do |outsider|
assert_no_broadcasts UnreadRoomsChannel.stream_name_for(outsider.id) do
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one", client_message_id: 999 } }
perform_enqueued_jobs only: Message::BroadcastUnreadRoomJob do
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one", client_message_id: 999 } }
end
end
end
end
@@ -148,6 +216,20 @@ class MessagesControllerTest < ActionDispatch::IntegrationTest
end
end
test "messages sharing a client_message_id render as distinct elements" do
victim = @messages.last
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "Collision", client_message_id: victim.client_message_id } }
colliding = @room.messages.ordered.last
assert_not_equal victim, colliding
get room_messages_url(@room)
ids = css_select(".message[data-message-id]").map { it["id"] }
assert_equal ids.uniq, ids
ensure_messages_present victim, colliding
end
private
def ensure_messages_present(*messages, count: 1)
messages.each do |message|
@@ -25,8 +25,10 @@ class Rooms::DirectsControllerTest < ActionDispatch::IntegrationTest
sign_in :kevin
assert_difference -> { Room.count }, -1 do
delete rooms_direct_url(rooms(:david_and_kevin))
assert_redirected_to root_url
perform_enqueued_jobs do
delete rooms_direct_url(rooms(:david_and_kevin))
assert_redirected_to root_url
end
end
end
+30 -2
View File
@@ -15,6 +15,21 @@ class RoomsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
test "show renders notification help for each platform" do
{
"Firefox on Android" => "Mozilla/5.0 (Android 14; Mobile; rv:131.0) Gecko/131.0 Firefox/131.0",
"Chrome on Android" => "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Mobile Safari/537.36",
"Firefox on desktop" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0",
"Chrome on desktop" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
"Safari on macOS" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15"
}.each do |platform, user_agent|
get room_url(users(:david).rooms.last), headers: { "User-Agent" => user_agent }
assert_response :success, platform
assert_select ".notifications-help ol", { minimum: 1 }, platform
end
end
test "shows records the last room visited in a cookie" do
get room_url(users(:david).rooms.last)
assert response.cookies[:last_room] = users(:david).rooms.last.id
@@ -68,11 +83,24 @@ class RoomsControllerTest < ActionDispatch::IntegrationTest
test "destroy" do
assert_turbo_stream_broadcasts :rooms, count: 1 do
assert_difference -> { Room.count }, -1 do
delete room_url(rooms(:designers))
perform_enqueued_jobs { delete room_url(rooms(:designers)) }
end
end
end
test "destroy takes the room away from its members at once and leaves its messages to a job" do
room = rooms(:designers)
member_ids = room.memberships.pluck(:user_id)
assert_enqueued_with(job: Room::DestroyJob, args: [ room, member_ids ]) do
assert_no_difference -> { Message.count } do
delete room_url(room)
assert_redirected_to root_url
end
end
assert_empty room.memberships.reload
end
test "destroy only allowed for creators or those who can administer" do
sign_in :jz
@@ -84,7 +112,7 @@ class RoomsControllerTest < ActionDispatch::IntegrationTest
rooms(:designers).update! creator: users(:jz)
assert_difference -> { Room.count }, -1 do
delete room_url(rooms(:designers))
perform_enqueued_jobs { delete room_url(rooms(:designers)) }
end
end
@@ -20,6 +20,24 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest
assert_select ".message", text: /Hello world!/
end
test "operator words are searched for as words" do
rooms(:designers).messages.create! body: "Salt and pepper", client_message_id: "operators", creator: users(:david)
get searches_url, params: { q: "salt AND" }
assert_response :success
assert_select ".message", text: /Salt and pepper/
end
test "searching for an operator word alone doesn't fail" do
%w[ AND OR NOT ].each do |word|
get searches_url, params: { q: word }
assert_response :success
assert_select ".message", count: 0
end
end
test "unreachable messages are not found" do
memberships(:david_designers).destroy!
@@ -5,6 +5,10 @@ class Sessions::TransfersControllerTest < ActionDispatch::IntegrationTest
get session_transfer_url("some-token")
assert_response :success
assert_select "form[data-controller='auto-submit']", count: 1
assert_select "input[name='_method'][value='put']", count: 1
assert_equal response.body.scan(/<form\b/).size, response.body.scan("</form>").size
assert_match(/<form\b[^>]*data-controller="auto-submit"[^>]*>(?:\s*<input\b[^>]*>)*\s*<\/form>/, response.body)
end
test "update establishes a session when the code is valid" do
@@ -15,4 +19,15 @@ class Sessions::TransfersControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to root_url
assert parsed_cookies.signed[:session_token]
end
test "update rejects a link made before the password changed" do
user = users(:david)
transfer_id = user.transfer_id
user.update!(password: "a new secret123456")
put session_transfer_url(transfer_id)
assert_response :bad_request
assert_nil parsed_cookies.signed[:session_token]
end
end
@@ -75,7 +75,94 @@ class UnfurlLinksControllerTest < ActionDispatch::IntegrationTest
assert_equal "Hey!", JSON.parse(response.body)["title"]
end
test "create gives up on a host that accepts the connection and never answers" do
with_local_host do |url, connections|
stub_const(Opengraph::Fetch, :TIMEOUT, 0.2.seconds) do
assert_unfurls_nothing_within(1.second) { post unfurl_link_url, params: { url: url } }
end
assert_equal 1, connections.size, "the GET is not retried"
end
end
test "create gives up on headers that trickle in past the deadline" do
trickle_headers = ->(client) do
client.write "HTTP/1.1 200 OK\r\nX-Padding: "
100.times { client.write "x"; sleep 0.05 }
client.write "\r\nContent-Type: text/html\r\nContent-Length: 2\r\n\r\nok"
end
with_local_host(trickle_headers) do |url|
stub_const(UnfurlLinksController, :DEADLINE, 0.5.seconds) do
assert_unfurls_nothing_within(1.5.seconds) { post unfurl_link_url, params: { url: url } }
end
end
end
test "create gives up on a body that trickles in past the deadline" do
trickle_body = ->(client) do
client.write "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 100\r\n\r\n"
100.times { client.write "x"; sleep 0.05 }
end
with_local_host(trickle_body) do |url|
stub_const(UnfurlLinksController, :DEADLINE, 0.5.seconds) do
assert_unfurls_nothing_within(1.5.seconds) { post unfurl_link_url, params: { url: url } }
end
end
end
test "create gives up on redirects that keep coming past the deadline" do
stub_dns_resolution("1.2.3.4")
WebMock.stub_request(:get, "https://www.example.com/").to_return do
sleep 0.2
{ status: 302, headers: { location: "https://www.example.com/" } }
end
stub_const(UnfurlLinksController, :DEADLINE, 0.5.seconds) do
assert_unfurls_nothing_within(1.second) { post unfurl_link_url, params: { url: "https://www.example.com" } }
end
end
test "create gives up on a host name that takes too long to look up" do
Resolv.stubs(:getaddresses).with { sleep 2 }.returns([ "1.2.3.4" ]) # each lookup takes 2 s
WebMock.stub_request(:get, "https://www.example.com/").to_return(status: 200, body: "<html></html>", headers: { content_type: "text/html" })
stub_const(UnfurlLinksController, :DEADLINE, 0.5.seconds) do
assert_unfurls_nothing_within(1.second) { post unfurl_link_url, params: { url: "https://www.example.com" } }
end
end
private
# A real socket, because WebMock reads the whole response before the code under test sees any of it.
# The guard lets the local address through, as it would a public one.
def with_local_host(respond = ->(client) { })
server = TCPServer.new("127.0.0.1", 0)
connections = Queue.new
Thread.new do
loop do
client = server.accept
connections << client
client.readpartial(1024)
respond.call(client)
end
rescue IOError, SystemCallError
end
RestrictedHTTP::PrivateNetworkGuard.stubs(:resolve).returns("127.0.0.1")
WebMock.disable!
yield "http://www.example.com:#{server.addr[1]}/", connections
ensure
WebMock.enable!
server&.close
end
def assert_unfurls_nothing_within(limit)
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
yield
assert_response :no_content
assert_operator Process.clock_gettime(Process::CLOCK_MONOTONIC) - started, :<, limit
end
def stub_successful_request(url: "https://www.example.com/", title: "Hey!", description: "desc..")
WebMock.stub_request(:get, url).to_return(
status: 200,
@@ -11,6 +11,13 @@ class Users::ProfilesControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
test "show includes install instructions for Edge" do
get user_profile_url, headers: { "User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36 Edge/18.18363" }
assert_response :success
assert_select ".pwa__instructions img[src*='install-edge']"
end
test "update" do
put user_profile_url, params: { user: { name: "John Doe", bio: "Acrobat" } }
@@ -20,9 +27,148 @@ class Users::ProfilesControllerTest < ActionDispatch::IntegrationTest
assert_equal "david@37signals.com", users(:david).email_address
end
test "changing the password signs out the account's other devices but not this browser" do
other_device = open_session
other_device.post session_url, params: { email_address: users(:david).email_address, password: "secret123456" }
other_device.get user_profile_url
other_device.assert_response :success
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_redirected_to user_profile_url
other_device.get user_profile_url
other_device.assert_redirected_to new_session_url
get user_profile_url
assert_response :success
assert_equal 1, users(:david).sessions.count
end
test "changing the password gives this browser a new session, so a copy of its cookie stops working" do
copied_cookie = cookies[:session_token]
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_not_equal copied_cookie, cookies[:session_token]
copy = open_session
copy.cookies[:session_token] = copied_cookie
copy.get user_profile_url
copy.assert_redirected_to new_session_url
end
test "changing the password along with the avatar still signs this browser back in and closes live connections" do
remote_connections = mock
remote_connections.expects(:disconnect).with(reconnect: true)
ActionCable.server.stubs(:remote_connections).returns(mock.tap { |m| m.expects(:where).with(current_user: users(:david)).returns(remote_connections) })
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456", avatar: fixture_file_upload("moon.jpg", "image/jpeg") } }
assert_redirected_to user_profile_url
assert users(:david).reload.avatar.attached?
get user_profile_url
assert_response :success
end
test "changing the password closes the account's live connections" do
remote_connections = mock
remote_connections.expects(:disconnect).with(reconnect: true)
ActionCable.server.stubs(:remote_connections).returns(mock.tap { |m| m.expects(:where).with(current_user: users(:david)).returns(remote_connections) })
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_redirected_to user_profile_url
end
test "changing the password still signs out other sessions when the realtime service is unreachable" do
other_session = users(:david).sessions.first
ActionCable.server.stubs(:remote_connections).raises(RuntimeError.new("cable down"))
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_redirected_to user_profile_url
assert_not Session.exists?(other_session.id)
end
test "updating the profile without a new password leaves other sessions signed in" do
ActionCable.server.expects(:remote_connections).never
assert_no_difference -> { users(:david).sessions.count } do
put user_profile_url, params: { user: { name: "David", password: "" } }
end
assert_redirected_to user_profile_url
end
test "changing the password with the current one" do
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_redirected_to user_profile_url
assert_equal "✓", flash[:notice]
assert users(:david).reload.authenticate("a new secret123456")
end
test "changing the password without the current one changes nothing" do
assert_password_change_refused "Enter your current password to change it.",
user: { name: "Someone else", password: "a new secret123456" }
end
test "changing the password with a wrong current one changes nothing" do
assert_password_change_refused "That isn't your current password.",
user: { name: "Someone else", password: "a new secret123456", password_challenge: "not my password" }
end
test "editing the profile without changing the password doesn't need the current one" do
put user_profile_url, params: { user: { name: "Dave", bio: "Acrobat", password: "" } }
assert_redirected_to user_profile_url
assert_equal "Dave", users(:david).reload.name
assert users(:david).authenticate("secret123456")
end
test "attempts to change the password are rate limited like signing in" do
Users::ProfilesController.cache_store.stubs(:increment).returns(11)
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_redirected_to user_profile_url
assert_equal "Too many attempts. Try again in a few minutes.", flash[:alert]
assert users(:david).reload.authenticate("secret123456")
put user_profile_url, params: { user: { name: "Dave" } }
assert_equal "Dave", users(:david).reload.name
end
test "changing the password removes the other devices' push subscriptions, and this browser can register its own again" do
stub_web_push_dns_resolution
endpoint = push_subscriptions(:david_chrome).attributes.slice("endpoint", "p256dh_key", "auth_key")
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_empty users(:david).push_subscriptions
post user_push_subscriptions_url, params: { push_subscription: endpoint }
assert_response :ok
assert_equal [ endpoint["endpoint"] ], users(:david).push_subscriptions.pluck(:endpoint)
end
test "updates are limited to the current user" do
put user_profile_url(users(:jason)), params: { user: { name: "John Doe" } }
assert_equal "Jason", users(:jason).reload.name
end
private
def assert_password_change_refused(alert, params)
other_device = open_session
other_device.post session_url, params: { email_address: users(:david).email_address, password: "secret123456" }
ActionCable.server.expects(:remote_connections).never
assert_no_changes -> { [ Session.count, Push::Subscription.count ] } do
put user_profile_url, params: params
end
assert_redirected_to user_profile_url
assert_equal alert, flash[:alert]
assert users(:david).reload.authenticate("secret123456")
assert_equal "David", users(:david).name
other_device.get user_profile_url
other_device.assert_response :success
end
end
@@ -74,4 +74,98 @@ class Users::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to user_push_subscriptions_url
end
end
test "a signed-out session can't register a subscription" do
Session.delete_all
assert_no_difference -> { Push::Subscription.count } do
post user_push_subscriptions_url, params: { push_subscription: new_subscription_params }
end
assert_redirected_to new_session_url
end
test "a session signed out while its request is in flight doesn't register a subscription" do
Push::Subscription.any_instance.stubs(:valid?).with { Session.delete_all; true }.returns(true)
assert_no_difference -> { Push::Subscription.count } do
post user_push_subscriptions_url, params: { push_subscription: new_subscription_params }
end
assert_response :unauthorized
end
test "a session signed out while its request is in flight doesn't keep an existing subscription alive" do
subscription = push_subscriptions(:david_chrome)
Push::Subscription.any_instance.stubs(:valid?).with { Session.delete_all; true }.returns(true)
assert_no_changes -> { subscription.reload.updated_at } do
post user_push_subscriptions_url, params: { push_subscription: subscription.attributes.slice("endpoint", "p256dh_key", "auth_key") }
end
assert_response :unauthorized
end
test "pages name their session for the browser to post its subscription again in a new one" do
get user_profile_url
assert_equal user_push_subscriptions_path, css_select("[data-push-subscription-sync-url-value]").first["data-push-subscription-sync-url-value"]
get room_url(rooms(:watercooler))
first_marker = css_select("[data-push-subscription-sync-session-value]").first["data-push-subscription-sync-session-value"]
assert_match(/\A\h{64}\z/, first_marker)
assert_not_includes response.body, users(:david).sessions.order(:id).last.token
sign_in :david
get room_url(rooms(:watercooler))
assert_not_equal first_marker, css_select("[data-push-subscription-sync-session-value]").first["data-push-subscription-sync-session-value"]
end
test "two posts of the same new subscription at once register it once" do
Push::Subscription.any_instance.stubs(:valid?).with do
users(:david).push_subscriptions.build(new_subscription_params).save!(validate: false) unless Push::Subscription.exists?(endpoint: new_subscription_params["endpoint"])
true
end.returns(true)
assert_difference -> { Push::Subscription.where(endpoint: new_subscription_params["endpoint"]).count }, +1 do
post user_push_subscriptions_url, params: { push_subscription: new_subscription_params }
end
assert_response :ok
end
private
def new_subscription_params
{ "endpoint" => "https://fcm.googleapis.com/fcm/send/abc123", "p256dh_key" => "123", "auth_key" => "456" }
end
end
class Users::PushSubscriptionsControllerForeignWriteTest < ActionDispatch::IntegrationTest
self.use_transactional_tests = false
setup do
sign_in :david
stub_web_push_dns_resolution
end
test "a subscription another connection registers while this one validates isn't registered twice" do
params = { "endpoint" => "https://fcm.googleapis.com/fcm/send/foreign", "p256dh_key" => "123", "auth_key" => "456" }
Push::Subscription.any_instance.stubs(:valid?).with { foreign_insert(params); true }.returns(true)
post user_push_subscriptions_url, params: { push_subscription: params }
assert_response :ok
assert_equal 1, Push::Subscription.where(endpoint: params["endpoint"]).count
end
private
def foreign_insert(params)
unless @inserted
SQLite3::Database.new(ActiveRecord::Base.connection_db_config.database) do |database|
database.execute "INSERT INTO push_subscriptions (user_id, endpoint, p256dh_key, auth_key, created_at, updated_at) " \
"VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))", [ users(:david).id, *params.values ]
end
@inserted = true
end
end
end
@@ -27,4 +27,25 @@ class Users::SidebarsControllerTest < ActionDispatch::IntegrationTest
get user_sidebar_url
assert_select ".unread", count: users(:david).memberships.reject { |m| m.room.direct? || !m.unread? }.count
end
test "directs are ordered by room recency, not name" do
older = rooms(:david_and_jason)
newer = rooms(:david_and_kevin)
older.update_column :updated_at, 2.days.ago
newer.update_column :updated_at, 1.minute.ago
get user_sidebar_url
assert_operator @response.body.index(dom_id(newer, :list)), :<, @response.body.index(dom_id(older, :list))
end
test "shared rooms stay ordered by name" do
get user_sidebar_url
shared_ids = users(:david).rooms.without_directs.sort_by { |room| room.name.downcase }.map { |room| dom_id(room, :list) }
positions = shared_ids.map { |id| @response.body.index(id) }
assert positions.all?
assert_equal positions.sort, positions
end
end
+1 -1
View File
@@ -1,4 +1,4 @@
<% password_digest = BCrypt::Password.create("secret123456") %>
<% password_digest = BCrypt::Password.create("secret123456", cost: BCrypt::Engine::MIN_COST) %>
david:
name: David
+1 -1
View File
@@ -172,7 +172,7 @@ class ContentFiltersTest < ActionView::TestCase
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body).to_html
assert_equal body, filtered
assert_dom_equal body, filtered
assert_match %r{<table>.*<th><p>Name</p></th>.*<td><p>Jason</p></td>}m, message_presentation(message)
end
+123
View File
@@ -24,4 +24,127 @@ class MessagesHelperTest < ActionView::TestCase
assert_match /<a href="https:\/\/example\.com"[^>]*>example<\/a>/, presentation
assert_match /<strong>bold<\/strong>/, presentation
end
test "message_presentation links URLs and email addresses in text" do
message = Message.create! room: rooms(:pets), body: "<div>See https://example.com/a?b=1&amp;c=2, or write to me@example.com.</div>", client_message_id: "0015", creator: users(:jason)
links = Nokogiri::HTML5.fragment(view.message_presentation(message)).css("a")
assert_equal [ "https://example.com/a?b=1&c=2", "mailto:me@example.com" ], links.map { it["href"] }
assert_equal [ "_blank", "_blank" ], links.map { it["target"] }
assert_equal [ "https://example.com/a?b=1&c=2", "me@example.com" ], links.map(&:text)
end
test "message_presentation links an email address with an apostrophe whole" do
message = Message.create! room: rooms(:pets), body: "<div>Write to o'brien@example.com</div>", client_message_id: "0015", creator: users(:jason)
links = Nokogiri::HTML5.fragment(view.message_presentation(message)).css("a")
assert_equal [ [ "mailto:o%27brien@example.com", "o'brien@example.com" ] ], links.map { [ it["href"], it.text ] }
end
test "message_presentation leaves URLs and email addresses in attribute values as they are" do
[ "class", "title" ].product([ "https://example.com", "www.example.com", "me@example.com" ]).each do |attribute, linkable|
value = "a> #{linkable} <em>b</em> "
message = Message.create! room: rooms(:pets), body: %(<div><span #{attribute}="#{value}">c</span> https://example.org</div>), client_message_id: "0015", creator: users(:jason)
presentation = Nokogiri::HTML5.fragment(view.message_presentation(message))
assert_equal [ value ], presentation.css("span[#{attribute}]").map { it[attribute] }, "#{attribute} with #{linkable}"
assert_empty presentation.css("em"), "#{attribute} with #{linkable}"
assert_equal [ "https://example.org" ], presentation.css("a").map { it["href"] }, "#{attribute} with #{linkable}"
end
end
test "linking text doesn't eat a code block's leading blank line" do
linked = view.send(:auto_link_text, "<pre>\n\n\nfirst https://example.com</pre>".html_safe)
assert_equal "\nfirst https://example.com", Nokogiri::HTML5.fragment(linked).at_css("pre").text
end
test "message_presentation leaves an existing link's text alone" do
message = Message.create! room: rooms(:pets), body: '<div><a href="https://example.com/">https://example.org/</a></div>', client_message_id: "0015", creator: users(:jason)
links = Nokogiri::HTML5.fragment(view.message_presentation(message)).css("a")
assert_equal [ [ "https://example.com/", "https://example.org/" ] ], links.map { [ it["href"], it.text ] }
end
test "message_presentation shows an image's thumbnail made when it was posted" do
presentation = view.message_presentation(attachment_message("moon.jpg", "image/jpeg", processed: true))
assert_match %r{<img[^>]+src="[^"]*/representations/[^"]*moon\.jpg"}, presentation
end
test "message_presentation links an image whose thumbnail wasn't made, rather than making it on view" do
presentation = view.message_presentation(attachment_message("moon.jpg", "image/jpeg", processed: false))
assert_no_match %r{/representations/}, presentation
assert_match %r{<span>moon\.jpg</span>}, presentation
end
test "message_presentation gives a video the poster made when it was posted" do
presentation = view.message_presentation(attachment_message("alpha-centuri.mov", "video/quicktime", processed: true))
assert_match %r{<video[^>]+poster="[^"]*/representations/[^"]*alpha-centuri}, presentation
end
test "message_presentation shows a video whose poster wasn't made without one, rather than making it on view" do
presentation = view.message_presentation(attachment_message("alpha-centuri.mov", "video/quicktime", processed: false))
assert_match %r{<video[^>]+src="[^"]*alpha-centuri\.mov"}, presentation
assert_no_match %r{poster=|/representations/}, presentation
end
test "message_presentation shows a video whose frame was drawn but whose poster wasn't made without one" do
message = attachment_message("alpha-centuri.mov", "video/quicktime", processed: false)
message.attachment.preview(format: :jpg).processed
assert message.attachment.preview(:poster).processed?
presentation = view.message_presentation(message.reload)
assert_match %r{<video[^>]+src="[^"]*alpha-centuri\.mov"}, presentation
assert_no_match %r{poster=|/representations/}, presentation
end
test "message_presentation shows an image in the rich text as a file, rather than making its preview on view" do
message = Message.with_attachment_details.find(message_with_file_in_rich_text("moon.jpg", "image/jpeg").id)
presentation = nil
assert_no_queries_match(/active_storage_variant_records/) { presentation = view.message_presentation(message) }
assert_no_match %r{/representations/}, presentation
assert_match %r{<span class="attachment__name">moon\.jpg</span>}, presentation
end
test "message_presentation shows an image in the rich text as a file even when its preview was made" do
message = message_with_file_in_rich_text("moon.jpg", "image/jpeg")
message.body.embeds.first.blob.variant(resize_to_limit: [ 1024, 768 ]).processed
presentation = view.message_presentation(message.reload)
assert_no_match %r{/representations/}, presentation
assert_match %r{<span class="attachment__name">moon\.jpg</span>}, presentation
end
test "message_presentation shows a video in the rich text as a file" do
presentation = view.message_presentation(message_with_file_in_rich_text("alpha-centuri.mov", "video/quicktime"))
assert_no_match %r{/representations/}, presentation
assert_match %r{<span class="attachment__name">alpha-centuri\.mov</span>}, presentation
end
private
def attachment_message(file, content_type, processed:)
attributes = { creator: users(:jason), client_message_id: "0015", attachment: fixture_file_upload(file, content_type) }
if processed
rooms(:pets).messages.create_with_attachment!(attributes)
else
rooms(:pets).messages.create!(attributes)
end
end
def message_with_file_in_rich_text(file, content_type)
blob = ActiveStorage::Blob.create_and_upload!(io: file_fixture(file).open, filename: file, content_type: content_type)
body = %(<div>Here: <action-text-attachment sgid="#{blob.attachable_sgid}"></action-text-attachment></div>)
Message.create! room: rooms(:pets), body: body, client_message_id: "0015", creator: users(:jason)
end
end
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more