Compare commits

..
Author SHA1 Message Date
dependabot[bot] ae19c0f5a4 Bump importmap-rails from 51c1a53 to f255e55
Bumps [importmap-rails](https://github.com/rails/importmap-rails) from `51c1a53` to `f255e55`.
- [Release notes](https://github.com/rails/importmap-rails/releases)
- [Commits](https://github.com/rails/importmap-rails/compare/51c1a531327fc04ed4552bb0fd523eb43561b817...f255e55482d9173d8f64db3bfa4317b64445eeb4)

---
updated-dependencies:
- dependency-name: importmap-rails
  dependency-version: f255e55482d9173d8f64db3bfa4317b64445eeb4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-26 22:27:46 +00:00
Mike Dalessio ca9b963961 dep: update sqlite3 and crass (#418)
* dep: update sqlite3 to 2.10.2

sqlite3 2.9.6 truncated SQL strings and bound values longer than
`INT_MAX` bytes, and could crash or deadlock when a callback raised.

ref: https://github.com/sparklemotion/sqlite3-ruby/releases/tag/v2.10.2
ref: https://github.com/sparklemotion/sqlite3-ruby/security/advisories/GHSA-4fqr-96q8-xrfx

* dep: update crass to 1.0.8

crass 1.0.7 tokenized bad URLs incorrectly, which allowed a CSS
sanitizer bypass.

ref: https://github.com/rgrove/crass/security/advisories/GHSA-pq4g-8rxf-4g29
2026-10-26 18:24:58 -04:00
Jeremy Daer 59d2164b11 Merge pull request #379 from basecamp/check-trusted-ca-certificates
Check the trusted CA certificates at build time
2026-10-24 12:05:52 -07:00
Mike Dalessio eccb2d6c91 Upgrade runtime to Ruby 4.0.9 (#381)
* Upgrade runtime to Ruby 4.0.9

Ruby 4.0.9 fixes the Regexp compilation bug behind GitLab's
CVE-2026-110264. Move `.ruby-version`, `Dockerfile` and
`Dockerfile-export` to it.

ref: https://www.ruby-lang.org/en/news/2026/10/23/ruby-4-0-9-released/

* Bump setup-ruby to v1.334.0 for Ruby 4.0.9

The pinned v1.327.0 and v1.324.0 did not know Ruby 4.0.9, so every CI
job failed at Ruby setup. v1.334.0 is the first release that does.
2026-10-23 18:34:43 -04:00
Jeremy Daer d7589951cf Check the trusted CA certificates at build time
ruby:4.0.7-slim already ships ca-certificates 20250419+deb13u1, which removes
emSign ECC Root CA - G3 and AAA Certificate Services. Fail the build if either
root comes back in the store Ruby's OpenSSL loads.
2026-10-21 14:02:55 -07:00
Sam RubyandClaude Opus 5.5 c8f74a0b0e Compute a page's script sources once per importmap and host
Every page parsed the importmap and joined each of its URLs against the
request's base URL, about a millisecond of CPU a page, though the answer
changes only with the importmap and the host. Keep the last answer, keyed
by both. One entry, so a request with a made-up Host header can't grow it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:06:01 +09:00
Sam RubyandClaude Opus 5.5 8d4a5264cf Raise the TLS alert from a subclass instead of extending one connection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:04:16 +09:00
Jeremy Daer ce7b5d4ea0 Check a cable connection's session while it's open (#376)
* Check a cable connection's session while it's open

Signing out or changing the password ends sessions and then disconnects
their connections. A connection that found its session just before isn't
subscribed to its disconnects yet, so it missed that and stayed open.

Each connection now looks its session up every minute while it's open, and
closes once the session is gone, asking the browser to reconnect so one
holding a current session comes back. That also covers any other way a
connection misses the disconnect, such as a subscription that takes long to
register, and a lookup that fails is simply tried again.

* Check the session on the server's heartbeat rather than a timer of its own

A connection's own timer could outlive it: closing can run before opening finishes, or a channel's teardown can fail before the connection's. The heartbeat only reaches connections that are open, so nothing is left to stop.

* Skip the session check on a rejected connection

A rejected connection stays registered until its socket closes, and has no session to check, so its heartbeat mustn't fail and stop the heartbeat reaching the connections after it.
2026-10-20 20:42:54 -07:00
Jeremy Daer a10211fb8a Merge pull request #375 from basecamp/refuse-session-for-changed-password
Refuse a session for a password that changed after it was checked
2026-10-20 10:17:29 -07:00
Jeremy Daer a476cbfa24 Sign the browser out when its new session is refused after a password change
If the password changes again between this browser's change and its new session, that session is refused, and the browser is sent to sign in instead of an error.
2026-10-20 08:57:40 -07:00
Jeremy Daer c3faccb0f4 Read the password digest past the query cache
A digest cached earlier in the request would let the check pass on a password that has since changed.
2026-10-20 08:47:47 -07:00
Jeremy Daer 19319a1cff Refuse a session for a password that changed after it was checked
A password change ends every session, but a sign-in or sign-in link checked
against the old password just before the change could still save its session
just after, and that session outlived the change.

The session now checks, as it's saved, that the user's password is still the
one the request loaded. The save's transaction holds SQLite's write lock from
the start, so a change commits either before the check, which fails it, or
after the insert, and ends this session with the others. Sessions start
through the user's association so the check sees the user that was
authenticated, and a refused sign-in is answered like a wrong password.
2026-10-20 08:37:07 -07:00
Jeremy Daer ac073010c2 Merge pull request #372 from basecamp/password-check-on-digest-change
Check the current password whenever the saved password changes
2026-10-19 16:04:53 -07:00
Jeremy Daer 4a580302a5 Check the current password whenever the saved password changes
The profile asked for the current password when the request's password
field was filled in. The check now follows the save itself: the model
checks the challenge whenever the password digest is about to change,
and the profile always hands it the challenge. Editing a name, avatar
or email without a new password still needs none, and scripts that
reset a password, which set no challenge, are unaffected.

Only a string counts as a challenge, and the rate limit counts every
non-empty one, so what's limited is exactly what gets checked.
2026-10-19 15:05:19 -07:00
Jeremy Daer af4dd9fd59 Merge pull request #371 from basecamp/sign-out-elsewhere-on-password-change
Sign out the account's other sessions when its password changes
2026-10-19 14:08:53 -07:00
Jeremy Daer aa93269ceb 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.
2026-10-19 13:34:34 -07:00
Jeremy Daer 3786ee5244 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.
2026-10-19 13:15:10 -07:00
Jeremy Daer 1af62a5bff 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.
2026-10-19 12:55:14 -07:00
Jeremy Daer 6cab924097 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.
2026-10-19 12:39:03 -07:00
Jeremy Daer 44a92ff6c6 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.
2026-10-19 12:17:14 -07:00
Jeremy Daer 160ba295b5 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.
2026-10-19 11:36:16 -07:00
Jeremy Daer e158388f3d 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.
2026-10-19 10:57:14 -07:00
Jeremy Daer b01bb7a0a7 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.
2026-10-19 10:45:34 -07:00
Jeremy Daer c6e0105491 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.
2026-10-19 10:33:46 -07:00
Jeremy Daer c78c3fd7ef 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.
2026-10-19 10:28:06 -07:00
Jeremy Daer 11d3fb9ce0 Merge pull request #365 from basecamp/enforce-script-policy
Enforce a script Content-Security-Policy
2026-10-15 19:20:35 -07:00
Jeremy Daer 2ba940c683 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.
2026-10-15 19:13:26 -07:00
Jeremy Daer 0a494a10cb 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.
2026-10-15 19:01:12 -07:00
Jeremy Daer 2be2227f40 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.
2026-10-15 19:01:12 -07:00
Jeremy Daer 2a449401ec 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.
2026-10-15 19:01:12 -07:00
Jeremy Daer 9c37f41ff6 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.
2026-10-15 19:01:12 -07:00
Jeremy Daer d713f2631d 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.
2026-10-15 19:01:12 -07:00
Jeremy Daer 562888251e 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.
2026-10-15 19:01:12 -07:00
Jeremy Daer cad2d7211d 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.
2026-10-15 19:01:12 -07:00
Jeremy Daer 821ff92dda Merge pull request #364 from basecamp/autolink-text-only
Link URLs only in a message's text
2026-10-15 18:59:57 -07:00
Jeremy Daer 9b271d0e19 Hand auto_link text escaped the way the serializer writes it
That's exactly what it was given before, with quotes and apostrophes
left as they are, rather than relying on its own sanitize pass to undo
h's &#39;. Covers an email address with an apostrophe.
2026-10-15 18:42:06 -07:00
Jeremy Daer 4e29c6e32b Keep a code block's leading newline when linking a message's text
A <pre> whose text starts with a newline needs one more written out to
survive being parsed again, as the html5 serializer does with
preserve_newline. Without it, linking text dropped one more of a code
block's leading blank lines than rendering did before.
2026-10-15 18:42:06 -07:00
Jeremy Daer 6052b123d5 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.
2026-10-15 18:42:06 -07:00
Jeremy Daer 7ea05e18f8 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, after the final sanitize, so links only ever land between tags.

Bump the presentation cache versions so nothing rendered the old way is
served again.
2026-10-15 18:42:06 -07:00
Jeremy Daer 1ac6dfde84 Merge pull request #366 from basecamp/bump-brakeman
Update Brakeman to 8.1.0
2026-10-15 18:41:55 -07:00
Jeremy Daer b2140cf2aa Update Brakeman to 8.1.0 2026-10-15 18:33:17 -07:00
Sam RubyandClaude Opus 5.5 edbc779f4d Test the pooled-connection class check on its own, and name the class
The rejection cases built plain Net::HTTP connections that were also
unpinned or proxied, so they failed the guard either way. They're built
as WebPush::Connections::HTTP now, so each fails only for its stated
reason, and a pinned, direct, TLS plain Net::HTTP is refused for being
one. The error says which class is required.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:41:45 +02:00
Sam RubyandClaude Opus 5.5 2daa01e26b Open pooled push connections as a Net::HTTP subclass that includes Stages
WebPush::Connections mixed Stages into each new connection with
`http.extend Stages` just before starting it. The connection is built in
one place, WebPush::PersistentRequest, so it can be built from a class
that already includes Stages: the same method lookup, without giving
every pooled connection its own singleton class. Connections#request
now also refuses a connection that isn't one, so a caller that builds a
plain Net::HTTP gets the ArgumentError rather than a connection whose
failures can't be told apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:41:45 +02:00
Rosa GutierrezandClaude Opus 5.5 2d4b05a74a Clear no_proxy in the link preview proxy test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:45:43 +02:00
Rosa GutierrezandClaude Opus 5.5 a7e9ea53dd 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:45:43 +02:00
GPT on behalf of DHH b52b244f38 Include executable template code in application KLOC 2026-10-09 12:52:12 +02:00
GPT on behalf of DHH 05c5a2c0d7 Count only backend application code in benchmark KLOC 2026-10-08 22:13:01 +02:00
GPT on behalf of DHH 81973c564c Document application KLOC alongside validated benchmarks 2026-10-08 22:07:01 +02:00
GPT on behalf of DHH 5796e5c837 Update verified Rails and Django benchmark figures 2026-10-08 21:08:12 +02:00
GPT on behalf of DHH 19c9e6b6f9 Merge Rails empty-mentions optimization by GPT on behalf of DHH 2026-10-08 21:05:15 +02:00
GPT on behalf of DHH 0aa339d81e Avoid room queries for messages without mentions 2026-10-08 20:46:01 +02:00
GPT on behalf of DHH b042182791 Use final published Rails benchmark results 2026-10-08 15:55:51 +02:00
GPT on behalf of DHH 6944a4a796 Update verified Campfire benchmark comparison 2026-10-08 15:38:48 +02:00
GPT on behalf of DHH 4bcc745f7a Interrupt WAL checkpoint shutdown and retain exclusive lock ownership 2026-10-08 15:28:42 +02:00
GPT on behalf of DHH 2e89e08003 Merge #303: retain Sam Ruby’s previously incorporated Herb changes 2026-10-08 13:27:39 +02:00
GPT on behalf of DHH 7331d3a946 Create messages, search entries and unread state atomically
Apply the Laravel implementation’s MessageWriter architecture to Rails creation: save native Action Text and attachment metadata, insert the search entry and update unread memberships in the message transaction. Keep notification jobs after commit and preserve Room#receive and existing serialized jobs.

Cover native HTML/mentions, attachment-only search, derived-write failures, outer rollback and SQL commit ordering. Updates and destruction retain their current indexing behavior.
2026-10-08 13:22:14 +02:00
GPT on behalf of DHH e9f8b7117e Merge #344: upgrade the production and exporter runtimes to Ruby 4.0.7 2026-10-08 13:15:30 +02:00
GPT on behalf of DHH 59eb690f23 Merge #343: upgrade Puma to 8.0.2 2026-10-08 13:15:30 +02:00
GPT on behalf of DHH 9f1b73a75f Merge #342: update the RuboCop toolchain 2026-10-08 13:15:30 +02:00
GPT on behalf of DHH da44351c5f Merge #341: exercise actual libvips loader blocks 2026-10-08 13:15:30 +02:00
Thomas KlemmandAmp 24042e6b4c Upgrade runtime to Ruby 4.0.7
Amp-Thread-ID: https://ampcode.com/threads/T-01a11acc-6153-7316-a06c-fcf2d713cd64
Co-authored-by: Amp <amp@ampcode.com>
2026-10-08 10:54:35 +00:00
Thomas KlemmandAmp 66883b6fb1 Update RuboCop toolchain
Amp-Thread-ID: https://ampcode.com/threads/T-01a11acc-6153-7316-a06c-fcf2d713cd64
Co-authored-by: Amp <amp@ampcode.com>
2026-10-08 10:54:35 +00:00
Thomas KlemmandAmp bb338a8c74 Test blocked libvips loaders directly
Amp-Thread-ID: https://ampcode.com/threads/T-01a11acc-6153-7316-a06c-fcf2d713cd64
Co-authored-by: Amp <amp@ampcode.com>
2026-10-08 10:54:35 +00:00
GPT on behalf of DHH 9b8a1683a7 Reuse native plain text conversion for attachment-free content 2026-10-08 12:41:14 +02:00
GPT on behalf of DHH a0d72938f3 Keep transaction guards temporary and simplify quick boost forms 2026-10-08 12:40:31 +02:00
GPT on behalf of DHH a9007010c1 Check cache transactions without leasing a database connection 2026-10-08 12:34:18 +02:00
GPT on behalf of DHH bbee420097 Reuse epoch-verified record snapshots and avoid empty push payloads 2026-10-08 12:26:50 +02:00
GPT on behalf of DHH 74381e8b58 Retain content-validated message fragments across unrelated commits 2026-10-08 12:00:45 +02:00
Thomas KlemmandAmp 9ad45892ed Update RuboCop toolchain
Amp-Thread-ID: https://ampcode.com/threads/T-01a11acc-6153-7316-a06c-fcf2d713cd64
Co-authored-by: Amp <amp@ampcode.com>
2026-10-08 09:48:51 +00:00
GPT on behalf of DHH 59e1c6d3d7 Merge #335: reject invalid QR code requests before rendering 2026-10-08 11:43:27 +02:00
GPT on behalf of DHH 2c53c46767 Reuse token-free message controls and render posting deliveries once 2026-10-08 11:43:27 +02:00
Thomas KlemmandAmp 5940ea068f Test blocked libvips loaders directly
Amp-Thread-ID: https://ampcode.com/threads/T-01a11acc-6153-7316-a06c-fcf2d713cd64
Co-authored-by: Amp <amp@ampcode.com>
2026-10-08 09:35:53 +00:00
GPT on behalf of DHH e55b6dc3e5 Merge #339: checkpoint SQLite WAL outside request threads 2026-10-08 11:33:18 +02:00
GPT on behalf of DHH f36b5a2fae Merge #336: avoid rewriting already-unread room memberships 2026-10-08 11:33:18 +02:00
Thomas KlemmandAmp bec1577529 Upgrade Puma to 8.0
Amp-Thread-ID: https://ampcode.com/threads/T-01a11acc-6153-7316-a06c-fcf2d713cd64
Co-authored-by: Amp <amp@ampcode.com>
2026-10-08 09:21:37 +00:00
GPT on behalf of DHH ec31092919 Publish verified Fetch Metadata cache benchmarks 2026-10-08 10:17:30 +02:00
GPT on behalf of DHH 0f5d0b2b6e Use header-only forgery protection and cache complete responses 2026-10-08 09:05:10 +02:00
GPT on behalf of DHH 008ea1ab2a Update verified benchmarks for final upstream Rails revision 2026-10-08 00:09:25 +02:00
GPT on behalf of DHH 8de3e22ab4 Clarify database task repair hook scope 2026-10-07 23:51:12 +02:00
GPT on behalf of DHH f792e16e64 Repair message counters within database task pools 2026-10-07 23:50:37 +02:00
GPT on behalf of DHH fd49099729 Merge SQLite-maintained bot pagination counts from PR #337 2026-10-07 23:28:53 +02:00
GPT on behalf of DHH d9c3207886 Merge upstream banned-user push notification fix 2026-10-07 23:19:33 +02:00
GPT on behalf of DHH cd0414ef8f Publish verified architecture-transfer benchmarks 2026-10-07 23:15:30 +02:00
Cursor AgentandThomas Klemm b4ab2df20f Repair messages_count triggers in one SQLite write transaction
ensure! now rechecks trigger presence under BEGIN IMMEDIATE, backfills
drifted rooms.messages_count, and reinstalls all three triggers before
commit so concurrent writers and concurrent boot repairs cannot observe
a partial install or keep a wrong total. install! uses the same
immediate write lock for drop/recreate. Lifecycle regressions cover
missing/partial trigger drift, concurrent ensure!, and writes racing
repair.

Co-authored-by: Thomas Klemm <github@tklemm.eu>
2026-10-07 20:58:32 +00:00
Cursor AgentandThomas Klemm 32748e7fd6 Keep fixtures :all; isolate trigger lifecycle tests
Drop the explicit fixture list and prepend module. Override load_fixtures
only long enough to ensure! + backfill after alphabetical fixture load.
Move destructive trigger DDL into its own test file so parallel CI workers
do not strip triggers from the counter examples.

Co-authored-by: Thomas Klemm <github@tklemm.eu>
2026-10-07 20:14:13 +00:00
GPT on behalf of DHH 7271eb59f4 Wait for persisted messages before leaving system job helpers 2026-10-07 21:56:09 +02:00
Cursor AgentandThomas Klemm 9fd1563c9b Simplify messages_count trigger install and harden ensure!
Require all three SQLite triggers before treating the counter as installed,
drop the schema.rb / dump-rewrite install paths in favor of rake ensure after
schema load, isolate destructive trigger tests, and cover foreign room moves
plus fixture baseline counts.

Co-authored-by: Thomas Klemm <github@tklemm.eu>
2026-10-07 19:46:57 +00:00
GPT on behalf of DHH 105f4b8fd1 Digest session credentials in instrumented fragment keys 2026-10-07 21:45:31 +02:00
GPT on behalf of DHH a95361dd15 Recheck captured snapshot before native fragment lookup 2026-10-07 21:43:26 +02:00
GPT on behalf of DHH 8d02540e31 Bound native fragment caching and collapse concurrent page renders 2026-10-07 21:38:36 +02:00
Cursor AgentandThomas Klemm 1f5858098b Keep bot page totals on SQLite-maintained rooms.messages_count
X-Total-Count on GET /rooms/:id/:bot_key/messages was COUNT(*) of the
room on every page. Serve it from rooms.messages_count updated by SQLite
triggers so Rails, bulk SQL, and foreign writers stay in step — without
ActiveRecord counter_cache callbacks those paths skip.

Fixes #309.

Co-authored-by: Thomas Klemm <github@tklemm.eu>
2026-10-07 19:36:01 +00:00
Cursor AgentandThomas Klemm 85ad590632 Wait for checkpoint thread exit; avoid lock spin without a DB
stop joins until the contender finishes so before_fork never inherits an
open flock or SQLite connection. Skip acquire when the database file is
missing, and sleep if with_database no-ops after a race.

Co-authored-by: Thomas Klemm <github@tklemm.eu>
2026-10-07 19:23:20 +00:00
Cursor AgentandThomas Klemm 8704baa860 Load WAL checkpointer outside Zeitwerk reload; harden fork stop
Keep the module under lib/rails_ext, use before_worker_boot, shorten the
SQLite busy timeout under stop's join, and PASSIVE-checkpoint on exit for
short-lived writers.

Co-authored-by: Thomas Klemm <github@tklemm.eu>
2026-10-07 19:05:44 +00:00
Cursor AgentandThomas Klemm 61631ea5f9 Stop WAL checkpointer before fork; one thread owns the flock
Unify startup: initializer starts every non-test process, Puma and Resque
stop before fork and start again in the child. Only the contender thread
releases the lock. Drop Puma::CLI / single_puma_process? special cases.

Co-authored-by: Thomas Klemm <github@tklemm.eu>
2026-10-07 19:00:40 +00:00
GPT on behalf of DHH b220486c16 Admit paginated HTML and hydrate CSRF tokens outside presentation keys 2026-10-07 20:58:54 +02:00
Cursor AgentandThomas Klemm 88acc4e026 Checkpoint SQLite WAL off the request thread safely
Disable wal_autocheckpoint and run PASSIVE checkpoints from one elected
writer process. Contenders start in console/rake (initializer), Puma
workers (including WEB_CONCURRENCY=auto), and Resque children, with
failover, capped error backoff, and tests for lock takeover.

Co-authored-by: Thomas Klemm <github@tklemm.eu>
2026-10-07 18:53:19 +00:00
GPT on behalf of DHH ac73267b07 Reuse authorized read pages without stale presentation or CSRF masks
Transfer the C completed-response cache lesson into Rails, keeping authentication, room checks and cookies per request. A persistent read-only SQLite observer detects local and foreign commits and rejects racing admission. Whole-page misses render fresh to avoid stale nested fragments; message ETags reflect token-neutral presentation.
2026-10-07 20:02:20 +02:00
Marcello CostagliolaandClaude Opus 5.5 af4f94c4bd Leave members who are unread already out of a new message's update
Every message rewrote the row of every disconnected member of the room,
including the ones who were unread already and stay unread. Open rooms
take in the whole account, so in steady state that is every member on
every message: 325 WAL pages per post at 10,000 members, against 16 when
only the members who had read the room are written.

Directs keep touching all their members: a direct's sidebar row is
cached by its membership and carries the room's recency, so it has to
be refreshed on every message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dFSDHrgrLoBELwjV3Qunq
2026-10-07 16:41:20 +02:00
Marcello CostagliolaandClaude Opus 5.5 1e61cfc8f3 Reject a QR code request that can't become a QR code
QrCodeController#show is open without signing in and decodes its id as
URL-safe base64. An id that isn't base64 raised ArgumentError, and a
URL longer than a QR code holds raised QRCodeRunTimeError, so both
answered 500 and reached error reporting as server errors. Answer them
with 400 instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwoX7uRy5vFZSNKJhSqMSG
2026-10-06 22:53:47 +02:00
Sam RubyandClaude Opus 5.5 244e77241b 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>
2026-10-04 20:23:44 -04:00
81 changed files with 2683 additions and 138 deletions

No files matched your search

+6 -6
View File
@@ -4,7 +4,7 @@ on:
push:
branches: [ main ]
pull_request:
branches: [ main, "release-*" ]
branches: [ main ]
permissions: {}
@@ -21,7 +21,7 @@ jobs:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
uses: ruby/setup-ruby@26e5b7d197ebf60545ee9667bcbace0be11ccaec # v1.334.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@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
uses: ruby/setup-ruby@26e5b7d197ebf60545ee9667bcbace0be11ccaec # v1.334.0
with:
ruby-version: .ruby-version
bundler-cache: true
@@ -63,7 +63,7 @@ jobs:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
uses: ruby/setup-ruby@26e5b7d197ebf60545ee9667bcbace0be11ccaec # v1.334.0
with:
ruby-version: .ruby-version
bundler-cache: true
@@ -110,7 +110,7 @@ jobs:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
uses: ruby/setup-ruby@26e5b7d197ebf60545ee9667bcbace0be11ccaec # v1.334.0
env:
REDIS_URL: redis://localhost:6379/0
with:
@@ -143,7 +143,7 @@ jobs:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
uses: ruby/setup-ruby@26e5b7d197ebf60545ee9667bcbace0be11ccaec # v1.334.0
env:
REDIS_URL: redis://localhost:6379/0
with:
+1 -1
View File
@@ -1 +1 @@
3.4.10
4.0.9
+4 -5
View File
@@ -1,7 +1,7 @@
# syntax = docker/dockerfile:1
# Make sure it matches the Ruby version in .ruby-version and Gemfile
ARG RUBY_VERSION=3.4.10
ARG RUBY_VERSION=4.0.9
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here
@@ -9,13 +9,12 @@ WORKDIR /rails
# Install base packages
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y ca-certificates curl libsqlite3-0 libvips libjemalloc2 ffmpeg redis && \
apt-get install --no-install-recommends -y curl libsqlite3-0 libvips libjemalloc2 ffmpeg redis && \
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
rm -rf /var/lib/apt/lists /var/cache/apt/archive
# Naming ca-certificates above takes the distribution's current trusted roots rather than the
# ones frozen into the base image. Fail the build if either of these withdrawn roots is still
# where Ruby's OpenSSL looks for trusted certificates.
# Fail the build if either of these withdrawn roots is still where Ruby's OpenSSL looks for
# trusted certificates
RUN ruby -ropenssl -e ' \
file = ENV[OpenSSL::X509::DEFAULT_CERT_FILE_ENV] || OpenSSL::X509::DEFAULT_CERT_FILE; \
dir = ENV[OpenSSL::X509::DEFAULT_CERT_DIR_ENV] || OpenSSL::X509::DEFAULT_CERT_DIR; \
+1 -1
View File
@@ -1,7 +1,7 @@
# syntax = docker/dockerfile:1
# Make sure it matches the Ruby version in .ruby-version and Gemfile
ARG RUBY_VERSION=3.4.10
ARG RUBY_VERSION=4.0.9
FROM ruby:$RUBY_VERSION-slim as base
# Install the tools we need
+1 -1
View File
@@ -12,7 +12,7 @@ gem "sqlite3"
gem "redis", "~> 5.4"
# Deployment
gem "puma", "~> 7.2", ">= 7.2.1"
gem "puma", "~> 8.0"
# Jobs
gem "resque", "~> 2.7.0"
+30 -30
View File
@@ -14,9 +14,9 @@ GIT
GIT
remote: https://github.com/rails/importmap-rails.git
revision: 51c1a531327fc04ed4552bb0fd523eb43561b817
revision: f255e55482d9173d8f64db3bfa4317b64445eeb4
specs:
importmap-rails (2.2.2)
importmap-rails (2.2.3)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
@@ -163,12 +163,12 @@ GEM
xpath (~> 3.2)
chunky_png (1.4.0)
concurrent-ruby (1.3.8)
connection_pool (2.5.5)
connection_pool (3.0.2)
crack (1.0.0)
bigdecimal
rexml
crass (1.0.7)
date (3.5.1)
crass (1.0.8)
date (3.6.0)
debug (1.11.0)
irb (~> 1.10)
reline (>= 0.3.8)
@@ -188,10 +188,10 @@ GEM
globalid (1.4.0)
activesupport (>= 6.1)
hashdiff (1.2.0)
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)
herb (0.11.1-aarch64-linux-gnu)
herb (0.11.1-arm64-darwin)
herb (0.11.1-x86_64-darwin)
herb (0.11.1-x86_64-linux-gnu)
i18n (1.15.2)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
@@ -206,7 +206,7 @@ GEM
jbuilder (2.14.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
json (2.21.2)
json (3.1.0)
jwt (3.2.0)
base64
kredis (1.8.0)
@@ -270,13 +270,13 @@ GEM
useragent (~> 0.16.3)
pp (0.6.4)
prettyprint
prettyprint (0.2.0)
prettyprint (0.2.1)
prism (1.9.0)
psych (5.5.0)
date
stringio
public_suffix (6.0.2)
puma (7.2.1)
puma (8.0.2)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.7)
@@ -287,7 +287,7 @@ GEM
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack-test (2.3.0)
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
@@ -336,29 +336,29 @@ GEM
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.1.0)
rubocop (1.80.2)
json (~> 2.3)
rubocop (1.91.0)
json (>= 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10)
parallel (>= 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.46.0, < 2.0)
rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.46.0)
rubocop-ast (1.50.0)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-performance (1.25.0)
prism (~> 1.7)
rubocop-performance (1.27.0)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-rails (2.33.3)
rubocop (>= 1.89.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.38.0)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop (>= 1.89.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rails-omakase (1.1.0)
rubocop (>= 1.72)
@@ -390,10 +390,10 @@ GEM
rack-protection (= 4.2.1)
rack-session (>= 2.0.0, < 3)
tilt (~> 2.0)
sqlite3 (2.9.6-aarch64-linux-gnu)
sqlite3 (2.9.6-arm64-darwin)
sqlite3 (2.9.6-x86_64-darwin)
sqlite3 (2.9.6-x86_64-linux-gnu)
sqlite3 (2.10.2-aarch64-linux-gnu)
sqlite3 (2.10.2-arm64-darwin)
sqlite3 (2.10.2-x86_64-darwin)
sqlite3 (2.10.2-x86_64-linux-gnu)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
@@ -410,7 +410,7 @@ GEM
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
uri (1.1.1)
uri (1.1.2)
useragent (0.16.11)
web-push (3.1.0)
jwt (~> 3.0)
@@ -452,7 +452,7 @@ DEPENDENCIES
ostruct
platform_agent
propshaft!
puma (~> 7.2, >= 7.2.1)
puma (~> 8.0)
rails!
rails_autolink
redis (~> 5.4)
+18 -5
View File
@@ -49,17 +49,26 @@ 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.
Authenticated room, message, sidebar and search pages use a bounded 64 MiB cache per worker.
Set `CAMPFIRE_RESPONSE_CACHE_MB=0` to disable it. Every request still checks authentication
and room access; commits from any SQLite writer invalidate complete cached HTML and gzip bodies.
Native HTML, JSON and stream fragments have a separate 64 MiB memory limit per worker;
shared rate limits retain their existing store.
## 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 |
| Room page | 4,101 | 1,507 | 3,872 | 42,636 | 5,350 | 53,060 | 106,494 | 137,524 |
| Messages page | 4,115 | 1,596 | 3,995 | 74,362 | 5,712 | 54,800 | 102,697 | 144,642 |
| Sidebar | 4,333 | 1,873 | 4,493 | 94,329 | 5,949 | 59,144 | 120,294 | 152,002 |
| Search | 4,282 | 1,862 | 4,172 | 84,665 | 5,848 | 60,509 | 121,378 | 149,487 |
| Post a message | 330 | 262 | 794 | 2,155 | 1,278 | 9,021 | 8,037 | 7,530 |
| Backend KLOC | 5.5 | 5.3 | 4.1 | 6.8 | 11.6 | 23.2 | 30.7 | 94.8 |
KLOC counts backend code plus executable code in templates; excludes plain HTML, frontend, tests, dependencies and generated files.
[Shared verification](https://github.com/basecamp/once-campfire-verification) · [Detailed results](https://github.com/basecamp/once-campfire-verification/blob/main/docs/performance-review.md).
@@ -74,3 +83,7 @@ Please see our [development guide](docs/development.md) for how to get Campfire
## Security
See [SECURITY.md](SECURITY.md) for how to report a vulnerability and a description of our trust model.
Browser writes use Rails’ `Sec-Fetch-Site` header-only protection and `Origin` check.
HTTPS requires browser metadata; plain HTTP retains the missing-header fallback.
Forms contain no CSRF tokens. Existing cookies, bot APIs and signed uploads remain compatible.
+1 -1
View File
@@ -1,4 +1,4 @@
class ApplicationController < ActionController::Base
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders, InlineScriptHashes
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders, CachedResponses, InlineScriptHashes
include Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
end
+1 -1
View File
@@ -7,7 +7,7 @@ module Authentication
before_action :deny_bots
helper_method :signed_in?
protect_from_forgery with: :exception, unless: -> { authenticated_by.bot_key? }
protect_from_forgery using: :header_only, with: :exception, unless: -> { authenticated_by.bot_key? }
end
class_methods do
@@ -1,7 +1,12 @@
module Authentication::SessionLookup
def find_session_by_cookie
if token = cookies.signed[:session_token]
Session.find_by(token: token)
version = @response_cache_version if @response_cache_version && (request.get? || request.head?)
session, user = RecordCache.fetch([ "session", Digest::SHA256.hexdigest(token.to_s) ], version) do
session = Session.find_by(token: token)
[ session, session&.user ]
end
session&.tap { |record| record.user = user }
end
end
end
@@ -0,0 +1,118 @@
require "zlib"
module CachedResponses
extend ActiveSupport::Concern
CACHE_HEADERS = %w[ content-type content-encoding cache-control etag last-modified vary ].freeze
included do
prepend_before_action :capture_response_cache_version
end
def perform_caching
return false unless super && @response_cache_version.present? && !FragmentCache.transaction_open?
# Check again after authentication, before the first native fragment lookup.
# Later renders retain this immutable namespace and cannot poison a new epoch.
unless defined?(@fragment_cache_snapshot_valid)
@fragment_cache_snapshot_valid = @response_cache_version == ResponseCache.instance.version
end
@fragment_cache_snapshot_valid
end
# Keep the class store (including shared rate limits) and Rails.cache unchanged.
def cache_store
FragmentCache.store
end
def combined_fragment_cache_key(key)
@fragment_cache_context ||= [
request.base_url, request.script_name, request.format.to_s, I18n.locale,
Current.user&.id, (Digest::SHA256.hexdigest(Current.session.token) if Current.session)
].freeze
version = @response_cache_version unless Array(key).flatten.any? { |part| part.is_a?(FragmentCache::ContentKey) }
super([ version, @fragment_cache_context, key ])
end
private
def read_record_cache_version
@response_cache_version if request.get? || request.head?
end
def capture_response_cache_version
# Capture for native HTML/JSON/stream renders too, even with page reuse off.
# Detached renderers do not run callbacks and therefore render uncached.
@response_cache_version = ResponseCache.instance.version
end
# Register after room authorization, but before presentation queries.
def cache_read_response
if cacheable_read_request?
encoding = Rack::Utils.select_best_encoding(%w[ gzip identity ], Rack::Utils.q_values(request.headers["Accept-Encoding"]))
return yield unless encoding
key = response_cache_key(encoding)
return yield if key.bytesize > ResponseCache::MAX_KEY_BYTES
entry = ResponseCache.instance.read(key, @response_cache_version)
rendered = false
unless entry
ResponseCache.instance.synchronize_render(key, @response_cache_version) do
entry = ResponseCache.instance.read(key, @response_cache_version)
if !entry && ResponseCache.instance.version == @response_cache_version
original_session = session.to_hash.deep_dup
yield
rendered = true
entry = cache_completed_response(key, original_session, encoding)
end
end
end
if entry
response.headers.merge!(entry[:headers])
response.headers.delete("Content-Length")
self.response_body = entry[:body]
elsif !rendered
# A queued request retains its pre-auth snapshot. If it has expired,
# render outside the stripe instead of blocking the next generation.
yield
end
else
yield
end
end
def cache_completed_response(key, original_session, encoding)
if response.status == 200 && response.media_type == "text/html" && session.to_hash == original_session && !response.headers["Content-Encoding"]
body = encoding == "gzip" ? Zlib.gzip(response.body) : response.body
unless body.empty? || response.headers["ETag"] || response.headers["Last-Modified"]
# Match Rack::ETag once, rather than hashing the same bytes on each hit.
response.headers["ETag"] = %(W/"#{Digest::SHA256.hexdigest(body).byteslice(0, 32)}")
end
response.headers["Content-Encoding"] = "gzip" if encoding == "gzip"
response.headers["Vary"] = (response.headers["Vary"].to_s.split(/,\s*/) | [ "Accept-Encoding" ]).join(", ")
headers = response.headers.slice(*CACHE_HEADERS).to_h.freeze
entry = { body: body.freeze, headers: headers }.freeze
ResponseCache.instance.write(key, @response_cache_version, entry)
entry
end
end
def cacheable_read_request?
ResponseCache.instance.budget.positive? && @response_cache_version && request.get? && request.format.html? && Current.session &&
!authenticated_by.bot_key? && flash.empty? &&
!request.headers["If-None-Match"] && !request.headers["If-Modified-Since"] &&
!Rails.application.config.content_security_policy_nonce_generator &&
!FragmentCache.transaction_open?
end
def response_cache_key(encoding)
ActiveSupport::JSON.encode([
controller_path, request.fullpath, request.base_url, request.user_agent, encoding,
request.headers["Accept"], request.headers["Turbo-Frame"], I18n.locale,
# Old token-bearing installation cookies remain valid without affecting HTML.
Current.user.id, Current.session.token, session.to_hash.except("_csrf_token"),
cookies.to_h.except("_campfire_session", "session_token")
])
end
end
@@ -14,13 +14,32 @@ module InlineScriptHashes
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.directives["script-src"] = (sources - [ "'self'" ]) | script_sources(importmap)
policy.worker_src "#{request.host_with_port}#{service_worker_path(format: :js)}"
end
end
end
mattr_accessor :last_script_sources
private
# Parsing the importmap and resolving each of its URLs costs about a
# millisecond a page, yet the answer changes only with the importmap and
# the host the page is served from. Keep the last one: a single entry, so
# a request with a made-up Host header can't grow it.
def script_sources(importmap)
key = [ importmap, request.base_url, request.host_with_port, config.relative_url_root ]
cached_key, sources = InlineScriptHashes.last_script_sources
if cached_key == key
sources
else
(script_locations(importmap) | script_hashes(importmap, ENTRY_POINT_SCRIPT)).freeze.tap do |sources|
InlineScriptHashes.last_script_sources = [ key, sources ].freeze
end
end
end
# 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
+5 -2
View File
@@ -7,7 +7,10 @@ module RoomScoped
private
def set_room
@membership = Current.user.memberships.find_by!(room_id: params[:room_id])
@room = @membership.room
@membership, @room = RecordCache.fetch([ "membership", Current.user.id, params[:room_id] ], read_record_cache_version) do
membership = Current.user.memberships.find_by!(room_id: params[:room_id])
[ membership, membership.room ]
end
@membership.room = @room
end
end
@@ -37,7 +37,7 @@ class Messages::ByBotsController < MessagesController
end
def set_pagination_headers
headers["X-Total-Count"] = @room.messages.count.to_s
headers["X-Total-Count"] = @room.messages_count.to_s
if next_page = next_page_params
headers["Link"] = %(<#{room_bot_messages_url(@room, params[:bot_key], **next_page)}>; rel="next")
+11 -2
View File
@@ -4,6 +4,7 @@ class MessagesController < ApplicationController
before_action :set_room, except: :create
before_action :set_message, only: %i[ show edit update destroy ]
before_action :ensure_can_administer, only: %i[ edit update destroy ]
around_action :cache_read_response, only: :index
layout false, only: :index
@@ -11,7 +12,13 @@ class MessagesController < ApplicationController
@messages = find_paged_messages
if @messages.any?
fresh_when @messages
body = render_to_string(:index)
# Creator, body and boost edits can change HTML without touching messages.
fresh_when etag: Digest::SHA256.hexdigest(body), template: false
unless performed?
response.content_type = "text/html"
self.response_body = body
end
else
head :no_content
end
@@ -21,7 +28,9 @@ class MessagesController < ApplicationController
set_room
@message = @room.messages.create_with_attachment!(message_params)
@message.broadcast_create
# Both deliveries contain the same token-free, viewer-independent markup.
@message_html = render_to_string partial: "messages/message", formats: :html, locals: { message: @message }
@message.broadcast_create(html: @message_html)
deliver_webhooks_to_bots
rescue ActiveRecord::RecordNotFound
render action: :room_not_found
+2
View File
@@ -7,5 +7,7 @@ class QrCodeController < ApplicationController
expires_in 1.year, public: true
render plain: qr_code, content_type: "image/svg+xml"
rescue ArgumentError, RQRCodeCore::QRCodeRunTimeError
head :bad_request
end
end
+5 -1
View File
@@ -2,6 +2,7 @@ class RoomsController < ApplicationController
before_action :set_room, only: %i[ show destroy ]
before_action :ensure_can_administer, only: %i[ destroy ]
before_action :remember_last_room_visited, only: :show
around_action :cache_read_response, only: :show
def index
redirect_to room_url(Current.user.rooms.last)
@@ -20,7 +21,10 @@ class RoomsController < ApplicationController
private
def set_room
if room = room_scope.find_by(id: params[:room_id] || params[:id])
room = RecordCache.fetch([ "room", self.class.name, Current.user.id, params[:room_id] || params[:id] ], read_record_cache_version) do
[ room_scope.find_by(id: params[:room_id] || params[:id]) ]
end.first
if room
@room = room
else
redirect_to root_url, alert: "Room not found or inaccessible"
+1
View File
@@ -1,4 +1,5 @@
class SearchesController < ApplicationController
around_action :cache_read_response, only: :index
before_action :set_messages
def index
@@ -1,5 +1,6 @@
class Users::SidebarsController < ApplicationController
DIRECT_PLACEHOLDERS = 20
around_action :cache_read_response, only: :show
def show
visible_memberships = Current.user.memberships.visible
+5
View File
@@ -36,6 +36,11 @@ module ApplicationHelper
end
private
# Header-only forgery protection needs no secret in forms or cached HTML.
def token_tag(*)
""
end
def admin_body_class
"admin" if Current.user&.can_administer?
end
+41 -1
View File
@@ -66,7 +66,7 @@ module MessagesHelper
when "sound"
message_sound_presentation(message)
else
auto_link_text h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body))
text_message_presentation(message.body.body)
end
rescue Exception => e
Sentry.capture_exception(e, extra: { message: message })
@@ -75,7 +75,47 @@ module MessagesHelper
""
end
# These controls contain URLs and static markup, but no viewer or token state.
# Keep them across database commits; attachment controls still render afresh.
def cache_message_actions(message, &block)
return capture(&block) unless controller.perform_caching && !message.attachment?
key = fragment_name_with_digest([
"message-actions-v1", request.base_url, request.script_name, I18n.locale, message.id, message.room_id
], nil)
FragmentCache.store.fetch(key) { capture(&block) }
end
def message_fragment_cache_key(message)
return message unless controller.perform_caching &&
%i[room creator rich_text_body boosts attachment_attachment].all? { |name| message.association(name).loaded? }
room = message.room
body = message.body.body
return message if room.direct? || message.attachment? || !body || body.to_html.include?("<action-text-attachment") ||
message.boosts.any? { |boost| !boost.association(:booster).loaded? }
# Collection caching preloads these rows before deriving keys. Read their
# actual content: foreign SQL writers need not touch Rails timestamps.
dependencies = [ message.attributes, room.attributes.slice("id", "type", "name"), message.creator.attributes,
body.to_html, message.boosts.sort_by(&:created_at).map { |boost| [ boost.attributes, boost.booster.attributes ] } ]
FragmentCache::ContentKey.new(Digest::SHA256.hexdigest(ActiveSupport::JSON.encode(dependencies)))
end
private
def text_message_presentation(body)
render = -> { auto_link_text h(ContentFilters::TextMessagePresentationFilters.apply(body)) }
# Embedded attachments render database-backed metadata and signed URLs.
# Plain text HTML depends only on its content, even after a foreign edit.
html = body.to_html
if controller.perform_caching && !html.include?("<action-text-attachment") && html.bytesize <= ResponseCache::MAX_ENTRY_BYTES
FragmentCache.store.fetch([ "text-presentation-v2", I18n.locale, Digest::SHA256.hexdigest(html) ]) { render.call }
else
render.call
end
end
# 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.
-1
View File
@@ -13,7 +13,6 @@ export default class FileUploader {
const req = new XMLHttpRequest()
req.open("POST", this.url)
req.setRequestHeader("X-CSRF-Token", document.querySelector("meta[name=csrf-token]").content)
req.upload.addEventListener("progress", this.#uploadProgress.bind(this))
const result = new Promise((resolve, reject) => {
+20
View File
@@ -0,0 +1,20 @@
# Native view caches share one byte budget across every database generation.
class FragmentCache
# Only callers that fingerprint every rendered dependency may omit the epoch.
ContentKey = Data.define(:digest) do
def cache_key
digest
end
end
STORE = ActiveSupport::Cache::MemoryStore.new(size: 64.megabytes)
private_constant :STORE
def self.store
STORE
end
def self.transaction_open?
ActiveRecord::Base.connection_pool.with_connection(&:transaction_open?)
end
end
+18 -2
View File
@@ -9,7 +9,9 @@ class Message < ApplicationRecord
has_rich_text :body
before_create -> { self.client_message_id ||= Random.uuid } # Bots don't care
after_create_commit -> { room.receive(self) }
# Run after Action Text and Active Storage autosave, while creation is still atomic.
after_save :record_creation, if: :previously_new_record?
after_create_commit -> { room.push_later(self) }
scope :ordered, -> { order(:created_at) }
scope :with_creator, -> { preload(creator: :avatar_attachment) }
@@ -21,7 +23,15 @@ class Message < ApplicationRecord
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 || ""
content = body.body
text = if content && content.fragment.find_all(ActionText::Attachment.tag_name).empty?
# ActionText::Content#to_plain_text duplicates the fragment to replace
# attachments. Without them, reuse its native Fragment conversion instead.
content.fragment.to_plain_text.dup
else
body.to_plain_text
end
text.presence || attachment&.filename&.to_s || ""
end
def content_type
@@ -37,4 +47,10 @@ class Message < ApplicationRecord
Sound.find_by_name match[:name]
end
end
private
def record_creation
create_in_index
room.unread_memberships(self)
end
end
+2 -2
View File
@@ -1,6 +1,6 @@
module Message::Broadcasts
def broadcast_create
broadcast_append_to room, :messages, target: [ room, :messages ]
def broadcast_create(html: nil)
broadcast_append_to room, :messages, target: [ room, :messages ], **(html ? { html: html } : {})
broadcast_unread_room_later
end
+2 -1
View File
@@ -2,7 +2,8 @@ module Message::Mentionee
extend ActiveSupport::Concern
def mentionees
room.users.where(id: mentioned_users.map(&:id))
ids = mentioned_users.map(&:id)
ids.empty? ? User.none : room.users.where(id: ids)
end
private
-1
View File
@@ -2,7 +2,6 @@ module Message::Searchable
extend ActiveSupport::Concern
included do
after_create_commit :create_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
+24
View File
@@ -0,0 +1,24 @@
# Rebuild request-local models from bounded, immutable database snapshots.
# The observer must still see the captured epoch after lookup and admission.
class RecordCache
def self.fetch(key, version)
return yield unless version && ResponseCache.instance.budget.positive? && !FragmentCache.transaction_open?
key = ActiveSupport::Cache.expand_cache_key([ "record-snapshot-v1", version, key ])
return yield if key.bytesize > ResponseCache::MAX_KEY_BYTES
if snapshot = FragmentCache.store.read(key)
if ResponseCache.instance.version == version
return ActiveSupport::JSON.decode(snapshot).map { |name, attributes| name.constantize.instantiate(attributes) }
end
end
records = yield
if records.all? { |record| record&.persisted? && !record.changed? } && ResponseCache.instance.version == version
# Raw database values retain timestamp precision and native enum binding.
snapshot = ActiveSupport::JSON.encode(records.map { |record| [ record.class.name, record.attributes_before_type_cast ] })
FragmentCache.store.write(key, snapshot) if snapshot.bytesize <= ResponseCache::MAX_ENTRY_BYTES
end
records
end
end
+98
View File
@@ -0,0 +1,98 @@
require "sqlite3"
# A read-only observer sees commits from every writer, including other runtimes.
# PRAGMA data_version can only be compared on the same persistent connection.
class ResponseCache
MAX_ENTRY_BYTES = 1.megabyte
MAX_KEY_BYTES = 2.kilobytes
def self.instance
INSTANCE
end
def initialize
@mutex = Mutex.new
@entries = {}
@bytes = 0
@render_locks = Array.new(16) { Mutex.new }
end
def budget
[ ENV.fetch("CAMPFIRE_RESPONSE_CACHE_MB", "64").to_i, 0 ].max.megabytes
end
def version
@mutex.synchronize { current_version }
rescue SQLite3::Exception
clear
nil
end
def read(key, version)
@mutex.synchronize do
@entries[key]&.first if current_version == version
end
rescue SQLite3::Exception
clear
nil
end
# Collapse cold renders without retaining a mutex for every viewer or URL.
# Rendering must never hold the observer/entry mutex: controllers read it too.
def synchronize_render(key, version, &block)
@render_locks[[ key, version ].hash % @render_locks.length].synchronize(&block)
end
def write(key, version, entry)
size = key.bytesize + entry[:body].bytesize + entry[:headers].sum { |name, value| name.bytesize + value.bytesize } + 256
return if key.bytesize > MAX_KEY_BYTES || size > [ budget, MAX_ENTRY_BYTES ].min
@mutex.synchronize do
return unless current_version == version
return if @entries.key?(key)
while @entries.any? && @bytes + size > budget
_, (_, removed_size) = @entries.shift
@bytes -= removed_size
end
@entries[key] = [ entry, size ]
@bytes += size
end
rescue SQLite3::Exception
clear
end
def clear
@mutex.synchronize do
@entries.clear
@bytes = 0
@observer&.close
@observer = @database = @version = nil
end
end
private
def current_version
database = File.expand_path(ActiveRecord::Base.connection_db_config.database)
if @database != database || !@observer
@entries.clear
@bytes = 0
@observer&.close
@observer = nil
@observer = SQLite3::Database.new(database, readonly: true)
@database = database
@namespace = SecureRandom.hex(16)
@version = nil
end
version = @observer.get_first_value("PRAGMA data_version")
if @version != version
@entries.clear
@bytes = 0
@version = version
end
[ @database, @namespace, @version ]
end
INSTANCE = new
end
+13 -8
View File
@@ -78,6 +78,19 @@ class Room < ApplicationRecord
push_later(message)
end
# Rewriting every member on every message is most of what posting to a large room writes,
# so members who are unread already stay as they are. Directs keep touching them all: a
# direct's sidebar row is cached by membership and shows the room's recency.
def unread_memberships(message)
recipients = memberships.visible.disconnected.where.not(user: message.creator)
recipients = recipients.where(unread_at: nil) unless direct?
recipients.update_all(unread_at: message.created_at, updated_at: Time.current)
end
def push_later(message)
Room::PushMessageJob.perform_later(self, message)
end
def open?
is_a?(Rooms::Open)
end
@@ -103,12 +116,4 @@ class Room < ApplicationRecord
errors.add :type, "can't be changed for a direct room"
end
end
def unread_memberships(message)
memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current)
end
def push_later(message)
Room::PushMessageJob.perform_later(self, message)
end
end
+4 -16
View File
@@ -6,9 +6,9 @@ class Room::MessagePusher
end
def push
build_payload.tap do |payload|
push_to_users_involved_in_everything(payload)
push_to_users_involved_in_mentions(payload)
subscriptions = push_subscriptions_for_users_involved_in_everything.or(push_subscriptions_for_mentionable_users(message.mentionees))
if subscriptions.exists?
Rails.configuration.x.web_push_pool.queue(build_payload, subscriptions)
end
end
@@ -37,14 +37,6 @@ class Room::MessagePusher
}
end
def push_to_users_involved_in_everything(payload)
enqueue_payload_for_delivery payload, push_subscriptions_for_users_involved_in_everything
end
def push_to_users_involved_in_mentions(payload)
enqueue_payload_for_delivery payload, push_subscriptions_for_mentionable_users(message.mentionees)
end
def push_subscriptions_for_users_involved_in_everything
relevant_subscriptions.merge(Membership.involved_in_everything)
end
@@ -59,10 +51,6 @@ class Room::MessagePusher
Push::Subscription
.joins(user: :memberships)
.merge(User.active)
.merge(Membership.visible.disconnected.where(room: room).where.not(user: message.creator))
end
def enqueue_payload_for_delivery(payload, subscriptions)
Rails.configuration.x.web_push_pool.queue(payload, subscriptions)
.merge(Membership.visible.disconnected.where(room: room).where.not(user_id: message.creator_id))
end
end
+99
View File
@@ -0,0 +1,99 @@
# Keeps rooms.messages_count correct for ordinary SQLite writes — Rails, bulk SQL,
# and foreign connections — without ActiveRecord counter_cache callbacks that
# those paths skip (and that would double-count if combined with triggers).
# Foreign REPLACE writes require PRAGMA recursive_triggers=ON so SQLite also
# fires the delete trigger. Migrations rebuilding messages must call ensure!
# before reading counters inside that migration; database tasks repair afterwards.
class Room::MessagesCount
INSERT_TRIGGER = "messages_ai_rooms_messages_count"
DELETE_TRIGGER = "messages_ad_rooms_messages_count"
UPDATE_TRIGGER = "messages_au_rooms_messages_count"
TRIGGERS = [ INSERT_TRIGGER, DELETE_TRIGGER, UPDATE_TRIGGER ].freeze
class << self
def install!(connection = ActiveRecord::Base.connection)
with_immediate_write(connection) do
replace_triggers!(connection)
end
end
def uninstall!(connection = ActiveRecord::Base.connection)
TRIGGERS.each do |name|
connection.execute("DROP TRIGGER IF EXISTS #{name}")
end
end
def backfill!(connection = ActiveRecord::Base.connection)
connection.execute <<~SQL
UPDATE rooms SET messages_count = (
SELECT COUNT(*) FROM messages WHERE messages.room_id = rooms.id
)
SQL
end
# schema.rb does not dump SQLite triggers; reinstall after schema:load.
# Repair rechecks, rebuilds counts, and installs triggers in one write txn
# so concurrent writers and concurrent boot repairs cannot observe a gap.
def ensure!(connection = ActiveRecord::Base.connection)
return unless connection.adapter_name.match?(/sqlite/i)
return unless connection.data_source_exists?(:rooms)
return unless connection.column_exists?(:rooms, :messages_count)
return if triggers_installed?(connection)
with_immediate_write(connection) do
next if triggers_installed?(connection)
uninstall!(connection)
backfill!(connection)
create_triggers!(connection)
end
end
def trigger_installed?(connection, name)
connection.select_value(
"SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = #{connection.quote(name)}"
).present?
end
def triggers_installed?(connection = ActiveRecord::Base.connection)
TRIGGERS.all? { |name| trigger_installed?(connection, name) }
end
private
def with_immediate_write(connection)
if connection.transaction_open?
yield
else
connection.raw_connection.transaction(:immediate) { yield }
end
end
def replace_triggers!(connection)
uninstall!(connection)
create_triggers!(connection)
end
def create_triggers!(connection)
connection.execute <<~SQL
CREATE TRIGGER #{INSERT_TRIGGER} AFTER INSERT ON messages
BEGIN
UPDATE rooms SET messages_count = messages_count + 1 WHERE id = NEW.room_id;
END
SQL
connection.execute <<~SQL
CREATE TRIGGER #{DELETE_TRIGGER} AFTER DELETE ON messages
BEGIN
UPDATE rooms SET messages_count = messages_count - 1 WHERE id = OLD.room_id;
END
SQL
connection.execute <<~SQL
CREATE TRIGGER #{UPDATE_TRIGGER} AFTER UPDATE OF room_id ON messages
WHEN OLD.room_id IS NOT NEW.room_id
BEGIN
UPDATE rooms SET messages_count = messages_count - 1 WHERE id = OLD.room_id;
UPDATE rooms SET messages_count = messages_count + 1 WHERE id = NEW.room_id;
END
SQL
end
end
end
-1
View File
@@ -9,7 +9,6 @@
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)">
<meta name="apple-mobile-web-app-capable" content="yes">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= current_user_meta_tags %>
<%= script_aware_action_cable_meta_tag %>
+9 -5
View File
@@ -1,5 +1,6 @@
<%# Be sure to check/update messages/_template.html.erb when changing this file %>
<%= cache_message_actions(message) do %>
<div class="message__actions" data-controller="soft-keyboard">
<%= tag.details class: "position-relative",
data: { controller: "popup", action: "keydown.esc->popup#close toggle->popup#toggle click@document->popup#closeOnClickOutside", popup_orientation_top_class: "popup-orientation-top" } do %>
@@ -10,14 +11,16 @@
<div class="message__actions-menu border shadow" data-popup-target="menu">
<div class="quick-boosts">
<% boost_path = message_boosts_path(message) %>
<% boost_frame = dom_id(message, :boosting) %>
<% EmojiHelper::REACTIONS.each do |character, title| %>
<%= form_with model: [ message, Boost.new ], data: { turbo_frame: dom_id(message, :boosting), action: "popup#close"} do |form| %>
<form data-turbo-frame="<%= boost_frame %>" data-action="popup#close" action="<%= boost_path %>" accept-charset="UTF-8" method="post">
<%= hidden_field_tag "boost[content]", character %>
<%= form.button type: "submit", title: title, class: "btn message__action-btn", data: { emoji: character } do %>
<button name="button" type="submit" title="<%= title %>" class="btn message__action-btn" data-emoji="<%= character %>">
<figure class="margin-none boost-character"><%= character %></figure>
<span class="for-screen-reader"><%= title %></span>
<% end %>
<% end %>
</button>
</form>
<% end %>
<%= link_to new_message_boost_path(message),
@@ -29,7 +32,7 @@
</div>
<div class="flex flex-wrap border-top margin-block-start-half pad-block-start-half message__actions-grid">
<% if message.content_type.attachment? %>
<% if message.attachment? %>
<%= link_to rails_blob_path(message.attachment, disposition: "attachment", only_path: true), class: "btn message__action-btn center full-width hide-in-ios-pwa", title: "Download", aria: { label: "Download" } do %>
<%= image_tag "download.svg", class: "colorize--black", size: 20, aria: { hidden: "true" } %>
<% end %>
@@ -56,3 +59,4 @@
</div>
<% end %>
</div>
<% end %>
+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-v7" ] do %>
<% cache [ message_fragment_cache_key(message), "presentation-v8" ] do %>
<%= message_tag message do %>
<h2 class="message__day-separator"><%= local_datetime_tag message.created_at, style: :date %></h2>
+1 -1
View File
@@ -1 +1 @@
<%= turbo_stream.append dom_id(@message.room, :messages), @message %>
<%= turbo_stream.append dom_id(@message.room, :messages), @message_html %>
+1 -1
View File
@@ -1 +1 @@
<%= render partial: "messages/message", collection: @messages, cached: true %>
<%= render partial: "messages/message", collection: @messages, cached: ->(message) { message_fragment_cache_key(message) } %>
@@ -1,5 +1,5 @@
<%= turbo_stream.append dom_id(@room, :messages) do %>
<%= render partial: "messages/message", collection: @new_messages, cached: true %>
<%= render partial: "messages/message", collection: @new_messages, cached: ->(message) { message_fragment_cache_key(message) } %>
<% end if @new_messages.any? %>
<% @updated_messages.each do |message| %>
+1 -1
View File
@@ -14,7 +14,7 @@
<%= messages_tag(@room) do %>
<%= render "rooms/show/invitation", room: @room %>
<%= render partial: "messages/message", collection: @messages, cached: true %>
<%= render partial: "messages/message", collection: @messages, cached: ->(message) { message_fragment_cache_key(message) } %>
<% end %>
<%= turbo_stream_from @room, :messages, channel: "RoomMessagesChannel" %>
+1 -1
View File
@@ -54,7 +54,7 @@
</div>
<%= search_results_tag do %>
<%= render partial: "messages/message", collection: @messages, cached: true %>
<%= render partial: "messages/message", collection: @messages, cached: ->(message) { message_fragment_cache_key(message) } %>
<% end %>
</div>
+5
View File
@@ -9,6 +9,11 @@ default: &default
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 10 } %>
timeout: 5000
default_transaction_mode: immediate
# Checkpoint on a background connection instead (SqliteWalCheckpoint).
# Non-test processes start a contender; forking servers stop before fork and
# start again in the child so the flock is never inherited.
pragmas:
wal_autocheckpoint: 0
development:
primary:
@@ -7,6 +7,7 @@
# can read the public login page. Require a valid Campfire session before an
# anonymous caller can allocate a Blob or persist bytes to disk.
Rails.application.config.to_prepare do
ActiveStorage::BaseController.forgery_protection_verification_strategy = :header_only
ActiveStorage::DirectUploadsController.include ActiveStorageAuthentication
ActiveStorage::DirectUploadsController.before_action :require_active_storage_authentication
+24
View File
@@ -0,0 +1,24 @@
require "jbuilder/jbuilder_template"
# Jbuilder 2.14.1 uses Rails.cache directly; use the same bounded store as ERB
# while retaining its native keys and controller instrumentation.
module BoundedJbuilderFragments
private
def _read_fragment_cache(key, options = nil)
@context.controller.instrument_fragment_cache :read_fragment, key do
@context.controller.cache_store.read(key, options)
end
end
def _write_fragment_cache(key, options = nil)
@context.controller.instrument_fragment_cache :write_fragment, key do
yield.tap { |value| @context.controller.cache_store.write(key, value, options) }
end
end
end
JbuilderTemplate.prepend(BoundedJbuilderFragments)
Rails.application.config.to_prepare do
ActionView::PartialRenderer.collection_cache = FragmentCache.store
end
@@ -0,0 +1,5 @@
# schema.rb cannot dump SQLite triggers. Cover existing DBs that lost them.
Rails.application.config.after_initialize do
Room::MessagesCount.ensure!
rescue ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished
end
+1 -1
View File
@@ -1,4 +1,4 @@
Rails.application.config.session_store :cookie_store,
key: "_campfire_session",
# Persist session cookie as permament so re-opened browser windows maintain a CSRF token
# Preserve the existing installation's persistent browser-session cookie.
expire_after: 20.years
@@ -0,0 +1,8 @@
# Loaded from lib/rails_ext (autoload_lib ignore list) so reloads do not orphan
# the contender thread. Non-test processes start here; Puma/Resque stop before
# fork and start again in the child.
require Rails.root.join("lib/rails_ext/sqlite_wal_checkpoint")
Rails.application.config.after_initialize do
SqliteWalCheckpoint.start unless Rails.env.test?
end
+1 -1
View File
@@ -29,7 +29,7 @@ module WebPush::PersistentRequest
# 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 = (@options[:connection] ? WebPush::Connections::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?
+6
View File
@@ -50,6 +50,12 @@ plugin :tmp_restart
# Reset all membership connections
Membership.disconnect_all
# Initializer starts a contender in this process. Stop before fork so workers
# do not inherit the flock; each worker starts its own contender. Single-process
# mode never forks, so the initializer's contender keeps running.
before_fork { SqliteWalCheckpoint.stop }
before_worker_boot { SqliteWalCheckpoint.start }
Signal.trap :SIGPROF do
Thread.list.each do |t|
puts t
@@ -0,0 +1,15 @@
class AddMessagesCountToRooms < ActiveRecord::Migration[8.2]
def up
add_column :rooms, :messages_count, :integer, null: false, default: 0
# Backfill before installing triggers so the COUNT rewrite does not race with
# concurrent inserts, and so we never rely on Rails callbacks for the tally.
Room::MessagesCount.backfill!(connection)
Room::MessagesCount.install!(connection)
end
def down
Room::MessagesCount.uninstall!(connection)
remove_column :rooms, :messages_count
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: 2026_10_05_022000) do
ActiveRecord::Schema[8.2].define(version: 2026_10_07_190000) do
create_table "accounts", force: :cascade do |t|
t.datetime "created_at", null: false
t.text "custom_styles"
@@ -124,6 +124,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_10_05_022000) do
t.string "name"
t.string "type", null: false
t.datetime "updated_at", null: false
t.integer "messages_count", default: 0, null: false
end
create_table "searches", force: :cascade do |t|
@@ -183,3 +184,4 @@ ActiveRecord::Schema[8.2].define(version: 2026_10_05_022000) do
# Note that virtual tables may not work with other database engines. Be careful if changing database.
create_virtual_table "message_search_index", "fts5", ["body", "tokenize=porter"]
end
+187
View File
@@ -0,0 +1,187 @@
# SQLite's default WAL auto-checkpoint (~1,000 pages) runs inside the committing
# writer and fsyncs during the request. database.yml sets wal_autocheckpoint=0;
# this module copies pages off the request thread with PASSIVE checkpoints.
#
# Every non-test process starts a contender. Callers that fork (Puma, Resque pool)
# must stop before fork and start again in the child so the flock is never shared
# across an inherited file descriptor.
module SqliteWalCheckpoint
INTERVAL = 0.25
MAX_BACKOFF = 30.0
STOP_TIMEOUT = 5.0
LOCK_PATH = Rails.root.join("tmp/pids/sqlite_wal_checkpoint.lock")
LIFECYCLE_MUTEX = Mutex.new
private_constant :LIFECYCLE_MUTEX
class StopTimeout < StandardError; end
class << self
attr_writer :lock_path, :database_path_override
def start(interval: INTERVAL, enabled: !Rails.env.test?)
return unless enabled
LIFECYCLE_MUTEX.synchronize do
if @thread&.alive?
raise StopTimeout, "SQLite WAL checkpointer is still stopping" if @wakeup.closed?
return @thread
end
@wakeup = Thread::Queue.new
install_exit_checkpoint
@thread = Thread.new(@wakeup) { |wakeup| run(interval, wakeup) }
@thread.report_on_exception = false
@thread
end
end
# Signal the contender to exit and wait until it has released the flock and
# closed its SQLite connection. before_fork must not return while those are open.
def stop(timeout: STOP_TIMEOUT)
LIFECYCLE_MUTEX.synchronize do
return unless @thread
@wakeup.close
unless @thread.join(timeout)
# Never let a caller fork with live SQLite or flock ownership.
raise StopTimeout, "SQLite WAL checkpointer did not stop within #{timeout}s"
end
@thread = @wakeup = nil
end
end
def checkpoint
with_database { |database| checkpoint_on(database) }
end
# One leadership attempt for tests: acquire the lock, checkpoint once, release.
def tick
return :no_database unless database_path
lock = acquire_lock
return :busy unless lock
begin
result = checkpoint
result.nil? ? :no_database : :checkpointed
ensure
release_lock(lock)
end
end
def lock_path
@lock_path || LOCK_PATH
end
def reset!
stop
@lock_path = nil
@database_path_override = nil
end
private
def run(interval, wakeup)
Thread.current.name = "sqlite-wal-checkpoint"
backoff = interval
until wakeup.closed?
begin
path = database_path
unless path && File.exist?(path)
wait(wakeup, interval)
next
end
lock = acquire_lock
unless lock
wait(wakeup, interval)
next
end
begin
ran = false
with_database do |database|
ran = true
until wakeup.closed?
checkpoint_on(database)
backoff = interval
wait(wakeup, interval)
end
end
ensure
release_lock(lock)
end
# with_database no-ops if the file vanished between the exist? check
# and open; sleep so we do not spin on the lock file.
wait(wakeup, interval) unless ran || wakeup.closed?
rescue => error
Rails.logger.warn "SQLite WAL checkpoint failed: #{error.class}: #{error.message}"
wait(wakeup, backoff)
backoff = [ backoff * 2, MAX_BACKOFF ].min
end
end
end
def wait(wakeup, timeout)
wakeup.pop(timeout: timeout)
end
def checkpoint_on(database)
database.execute("PRAGMA wal_checkpoint(PASSIVE)").first
end
def with_database
path = database_path
return unless path && File.exist?(path)
result = nil
SQLite3::Database.new(path) do |database|
# Bounds busy-handler retries, not PASSIVE checkpoint I/O.
database.busy_handler_timeout = 1_000
result = yield database
end
result
end
def database_path
return @database_path_override if @database_path_override
config = ActiveRecord::Base.connection_db_config
return unless config.adapter.to_s == "sqlite3"
ActiveRecord::ConnectionAdapters::SQLite3Adapter.resolve_path(config.database)
end
def acquire_lock
FileUtils.mkdir_p(File.dirname(lock_path))
file = File.open(lock_path, File::RDWR | File::CREAT, 0644)
if file.flock(File::LOCK_EX | File::LOCK_NB)
file
else
file.close
nil
end
rescue
file&.close
raise
end
def release_lock(file)
return unless file
file.flock(File::LOCK_UN)
ensure
file&.close
end
# Best-effort PASSIVE for short-lived console/rake writers that exit before
# the contender acquires the flock. Does not touch lock ownership.
def install_exit_checkpoint
return if @exit_checkpoint_installed
@exit_checkpoint_installed = true
at_exit { checkpoint rescue nil }
end
end
end
+4
View File
@@ -4,9 +4,13 @@ end
task "resque:pool:setup" do
ActiveRecord::Base.connection.disconnect!
# Initializer may have started a contender in the pool master; drop it before
# workers fork so they do not inherit the flock.
SqliteWalCheckpoint.stop
Resque::Pool.after_prefork do |job|
ActiveRecord::Base.establish_connection
Resque.redis.client.close
SqliteWalCheckpoint.start
end
end
+29
View File
@@ -0,0 +1,29 @@
# Repair the database selected by each schema-load or migration operation.
# db:prepare calls DatabaseTasks directly, bypassing Rake enhancements;
# method hooks also run on every invocation.
module RoomMessagesCountDatabaseTasks
def load_schema(...)
super.tap { repair_room_messages_count }
end
def migrate(...)
super.tap { repair_room_messages_count }
end
private
def repair_room_messages_count
migration_connection_pool.with_connection do |connection|
Room::MessagesCount.ensure!(connection)
end
end
end
ActiveRecord::Tasks::DatabaseTasks.singleton_class.prepend(RoomMessagesCountDatabaseTasks)
namespace :room_messages_count do
task ensure: :environment do
ActiveRecord::Base.connection_pool.with_connection do |connection|
Room::MessagesCount.ensure!(connection)
end
end
end
+8 -3
View File
@@ -26,6 +26,12 @@ class WebPush::Connections
end
end
# The Net::HTTP a pooled delivery is opened with: Stages is part of the class, so a connection carries its stage
# from the start instead of having the module mixed into each one as it's opened.
class HTTP < Net::HTTP
include Stages
end
def initialize(keep_alive_timeout: 30, max_idle: 150)
@keep_alive_timeout = keep_alive_timeout
@max_idle = max_idle
@@ -35,8 +41,8 @@ class WebPush::Connections
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"
unless http.is_a?(HTTP) && http.ipaddr && !http.proxy? && http.use_ssl? && !http.started?
raise ArgumentError, "Only new, direct TLS connections pinned to an address and opened as WebPush::Connections::HTTP are pooled"
end
address = [ http.address, http.port, http.ipaddr ]
@@ -48,7 +54,6 @@ class WebPush::Connections
end
end
http.extend Stages
http.keep_alive_timeout = @keep_alive_timeout
http.start
send_over(http, request, reused: false).tap { checkin(address, http) }
+217
View File
@@ -0,0 +1,217 @@
require "test_helper"
class CachedResponsesTest < ActionDispatch::IntegrationTest
self.use_transactional_tests = false
setup do
host! "once.campfire.test"
sign_in :david
@login_cookie = cookies["_campfire_session"]
@previous_forgery = ActionController::Base.allow_forgery_protection
ActionController::Base.allow_forgery_protection = true
@previous_cache = Rails.cache
@previous_caching = ActionController::Base.perform_caching
Rails.cache = ActiveSupport::Cache::MemoryStore.new
ActionController::Base.perform_caching = true
@room = rooms(:watercooler)
# Establish last-room cookies before checking reuse.
2.times { get room_url(@room) }
ResponseCache.instance.clear
end
teardown do
ResponseCache.instance.clear
ActionController::Base.allow_forgery_protection = @previous_forgery
Rails.cache = @previous_cache
ActionController::Base.perform_caching = @previous_caching
end
test "a reused page carries the same enforcing policy, with no nonce" do
get room_messages_url(@room)
first = response.headers["Content-Security-Policy"]
assert_match %r{script-src [^/ ]+/assets/ 'sha256-}, first
assert_no_match "nonce-", first
MessagesController.any_instance.expects(:find_paged_messages).never
get room_messages_url(@room)
assert_response :success
assert_equal first, response.headers["Content-Security-Policy"]
end
test "all four read actions reuse completed pages" do
urls = [ room_url(@room), room_messages_url(@room), user_sidebar_url(:me), searches_url(q: "hello") ]
urls.each do |url|
get url
assert_response :success
end
ResponseCache.instance.expects(:write).never
urls.each do |url|
get url
assert_response :success
end
end
test "pagination caches the completed HTML rather than silently bypassing admission" do
get room_messages_url(@room)
assert_response :success
assert_equal "text/html", response.media_type
MessagesController.any_instance.expects(:find_paged_messages).never
get room_messages_url(@room)
assert_response :success
end
test "clients without token state reuse complete HTML and post without tokens" do
cookies["_campfire_session"] = @login_cookie
get room_url(@room)
first = response.body
assert_select 'meta[name="csrf-token"]', count: 0
assert_select 'input[name="authenticity_token"]', count: 0
cookies["_campfire_session"] = @login_cookie
ResponseCache.instance.expects(:write).never
get room_url(@room)
assert_response :success
assert_equal first, response.body
post room_messages_url(@room, format: :turbo_stream), params: {
message: { body: "tokenless replay works", client_message_id: "cache-replay" } },
headers: { "Sec-Fetch-Site" => "same-origin", "Origin" => "http://once.campfire.test" }
assert_response :success
end
test "literal token-like text survives complete page reuse" do
literal = "campfire-csrf-literal authenticity_token csrf-token"
@room.messages.create!(creator: users(:david), body: "literal #{literal}")
get room_url(@room)
first = response.body
get room_url(@room)
assert_equal first, response.body
assert_includes response.body, "literal #{literal}"
end
test "gzip bytes are reused and identity negotiation stays separate" do
require "stringio"
get room_url(@room), headers: { "Accept-Encoding" => "gzip" }
assert_equal "gzip", response.headers["Content-Encoding"]
encoded = response.body
decoded = Zlib::GzipReader.new(StringIO.new(encoded)).read
assert_includes decoded, "<html"
assert_includes response.headers["Vary"], "Accept-Encoding"
get room_url(@room)
assert_equal decoded, response.body
ResponseCache.instance.expects(:write).never
get room_url(@room), headers: { "Accept-Encoding" => "gzip" }
assert_equal encoded, response.body
get room_url(@room)
assert_nil response.headers["Content-Encoding"]
assert_equal decoded, response.body
get room_url(@room), headers: { "Accept-Encoding" => "gzip;q=0, identity;q=1" }
assert_nil response.headers["Content-Encoding"]
assert_equal decoded, response.body
get room_url(@room), headers: { "Accept-Encoding" => "*;q=0" }
assert_nil response.headers["Content-Encoding"]
end
test "local and foreign commits invalidate pages and nested fragments" do
message = @room.messages.ordered.last
get room_url(@room)
@room.messages.create!(creator: users(:david), body: "local commit")
get room_url(@room)
assert_includes response.body, "local commit"
foreign_write("UPDATE users SET name = ? WHERE id = ?", "Foreign creator", message.creator_id)
get room_url(@room)
assert_includes response.body, "Foreign creator"
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign message body", message.id)
get room_url(@room)
assert_includes response.body, "foreign message body"
end
test "cached room access and sessions are checked afresh" do
get room_url(@room)
foreign_write("DELETE FROM memberships WHERE room_id = ? AND user_id = ?", @room.id, users(:david).id)
get room_url(@room)
assert_redirected_to root_url
get user_sidebar_url(:me)
foreign_write("DELETE FROM sessions WHERE user_id = ?", users(:david).id)
get user_sidebar_url(:me)
assert_redirected_to new_session_url
end
test "a session signed out by a password change isn't served from a cached snapshot or page" do
other_device = open_session { |session| session.host! "once.campfire.test" }
other_device.post session_url, params: { email_address: users(:david).email_address, password: "secret123456" }
other_device.get user_sidebar_url(:me)
other_device.assert_response :success
Session.expects(:find_by).never
ResponseCache.instance.expects(:write).never
other_device.get user_sidebar_url(:me)
other_device.assert_response :success
Session.unstub(:find_by)
ResponseCache.instance.unstub(:write)
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } },
headers: { "Sec-Fetch-Site" => "same-origin", "Origin" => "http://once.campfire.test" }
assert_redirected_to user_profile_url
other_device.get user_sidebar_url(:me)
other_device.assert_redirected_to new_session_url
get user_sidebar_url(:me)
assert_response :success
end
test "conditional requests keep native validators" do
get room_messages_url(@room)
etag = response.headers["ETag"]
assert etag.present?
get room_messages_url(@room), headers: { "If-None-Match" => etag }
assert_response :not_modified
end
test "foreign presentation changes cannot return a false not-modified response" do
get room_messages_url(@room)
etag = response.headers["ETag"]
message = @room.messages.ordered.last
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "changed presentation", message.id)
get room_messages_url(@room), headers: { "If-None-Match" => etag }
assert_response :success
assert_includes response.body, "changed presentation"
assert_not_equal etag, response.headers["ETag"]
end
test "origins and frame variants have separate entries" do
get room_url(@room)
ResponseCache.instance.expects(:write).twice
get room_url(@room), headers: { "Turbo-Frame" => "different-frame" }
host! "once.campfire.test:8081"
get room_path(@room)
assert_response :success
end
test "cache can be disabled" do
ResponseCache.instance.stubs(:budget).returns(0)
ResponseCache.instance.expects(:read).never
2.times { get room_url(@room) }
assert_response :success
end
test "a commit after authentication cannot admit captured user fields" do
Users::SidebarsController.any_instance.stubs(:set_version_headers).with do
foreign_write("UPDATE users SET name = ? WHERE id = ?", "During authentication", users(:david).id)
true
end
get user_sidebar_url(:me)
Users::SidebarsController.any_instance.unstub(:set_version_headers)
ResponseCache.instance.expects(:write).once
get user_sidebar_url(:me)
assert_includes response.body, "During authentication"
end
private
def foreign_write(sql, *bindings)
SQLite3::Database.new(ActiveRecord::Base.connection_db_config.database) do |database|
database.execute(sql, bindings)
end
# Fixtures share this thread's query cache outside the request executor.
ActiveRecord::Base.clear_query_caches_for_current_thread
end
end
+91
View File
@@ -0,0 +1,91 @@
require "test_helper"
class FetchMetadataTest < ActionDispatch::IntegrationTest
setup do
host! "once.campfire.test"
@previous_exceptions = Rails.application.env_config["action_dispatch.show_exceptions"]
Rails.application.env_config["action_dispatch.show_exceptions"] = :rescuable
@previous_forgery = ActionController::Base.allow_forgery_protection
ActionController::Base.allow_forgery_protection = true
end
teardown do
ActionController::Base.allow_forgery_protection = @previous_forgery
Rails.application.env_config["action_dispatch.show_exceptions"] = @previous_exceptions
end
test "login and authenticated forms have no token fields" do
get new_session_url
assert_response :success
assert_select 'meta[name="csrf-token"]', count: 0
assert_select 'input[name="authenticity_token"]', count: 0
sign_in :david
get room_url(rooms(:watercooler))
assert_response :success
assert_select 'meta[name="csrf-token"]', count: 0
assert_select 'input[name="authenticity_token"]', count: 0
end
test "HTTPS login accepts browser metadata without a token" do
https!
%w[ same-origin same-site ].each do |site|
reset!
host! "once.campfire.test"
https!
post session_url, params: credentials, headers: {
"Sec-Fetch-Site" => site, "Origin" => "https://once.campfire.test" }
assert_response :redirect
assert cookies["session_token"].present?
end
end
test "HTTPS writes reject missing metadata even with a legacy token parameter" do
https!
assert_no_difference "Session.count" do
post session_url, params: credentials.merge(authenticity_token: "old token"), headers: { "Origin" => "https://once.campfire.test" }
end
assert_response :unprocessable_entity
end
test "cross-site none and malformed metadata cannot sign in over HTTP or HTTPS" do
[ false, true ].each do |tls|
https! tls
%w[ cross-site none garbage ].each do |site|
assert_no_difference "Session.count" do
post session_url, params: credentials, headers: { "Sec-Fetch-Site" => site }
end
assert_response :unprocessable_entity
end
end
end
test "provided foreign and null origins fail even with same-site metadata" do
%w[ https://foreign.example null ].each do |origin|
assert_no_difference "Session.count" do
post session_url, params: credentials, headers: { "Sec-Fetch-Site" => "same-site", "Origin" => origin }
end
assert_response :unprocessable_entity
end
end
test "plain HTTP retains the missing-header fallback" do
post session_url, params: credentials, headers: { "Origin" => "http://once.campfire.test" }
assert_response :redirect
assert cookies["session_token"].present?
end
test "cross-site reads remain available and method-overridden writes stay protected" do
get new_session_url, headers: { "Sec-Fetch-Site" => "cross-site" }
assert_response :success
sign_in :david
assert_no_difference "Room.count" do
post room_path(rooms(:watercooler)), params: { _method: "delete" }, headers: { "Sec-Fetch-Site" => "cross-site" }
end
assert_response :unprocessable_entity
end
private
def credentials
{ email_address: users(:david).email_address, password: "secret123456" }
end
end
+170
View File
@@ -0,0 +1,170 @@
require "test_helper"
class FragmentRenderingTest < ActionDispatch::IntegrationTest
self.use_transactional_tests = false
setup do
host! "once.campfire.test"
sign_in :david
@previous_caching = ActionController::Base.perform_caching
@previous_message_caching = MessagesController.perform_caching
@class_store = ApplicationController.cache_store
@global_store = Rails.cache
ActionController::Base.perform_caching = true
MessagesController.perform_caching = true
FragmentCache.store.clear
ResponseCache.instance.clear
@room = rooms(:watercooler)
@message = @room.messages.ordered.last
end
teardown do
ActionController::Base.perform_caching = @previous_caching
MessagesController.perform_caching = @previous_message_caching
FragmentCache.store.clear
ResponseCache.instance.clear
end
test "native collection caching uses the bounded store without replacing shared stores" do
assert_same FragmentCache.store, ApplicationController.new.cache_store
assert_same FragmentCache.store, ActionView::PartialRenderer.collection_cache
assert_same @class_store, ApplicationController.cache_store
assert_same @global_store, Rails.cache
ResponseCache.instance.stubs(:budget).returns(0)
get room_messages_url(@room)
assert_response :success
hits = []
ActiveSupport::Notifications.subscribed(->(event) { hits.concat(event.payload[:hits]) }, "cache_read_multi.active_support") do
get room_messages_url(@room)
end
assert_response :success
assert_not_empty hits
token = users(:david).sessions.order(:id).last.token
assert_not_includes hits.join, token
end
test "disabled page caching retains fresh native fragments after foreign leaf writes" do
ResponseCache.instance.stubs(:budget).returns(0)
get room_messages_url(@room)
assert_response :success
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign disabled body", @message.id)
get room_messages_url(@room)
assert_response :success
assert_includes response.body, "foreign disabled body"
end
test "conditional pagination invalidates warmed native fragments without timestamp changes" do
get room_messages_url(@room), headers: { "If-None-Match" => "unmatched" }
assert_response :success
etag = response.headers["ETag"]
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign conditional body", @message.id)
get room_messages_url(@room), headers: { "If-None-Match" => etag }
assert_response :success
assert_includes response.body, "foreign conditional body"
end
test "message controls survive creator edits while the author is rendered fresh" do
get room_messages_url(@room)
assert_response :success
foreign_write("UPDATE users SET name = ? WHERE id = ?", "Fresh creator with cached controls", @message.creator_id)
ActionView::Base.any_instance.expects(:form_with).never
get room_messages_url(@room)
assert_response :success
assert_includes response.body, "Fresh creator with cached controls"
assert_select "form[action=?]", message_boosts_path(@message)
end
test "a commit in another room retains content-validated message collection hits" do
ResponseCache.instance.stubs(:budget).returns(0)
get room_messages_url(@room)
assert_response :success
foreign_write("UPDATE rooms SET name = ? WHERE id = ?", "Unrelated room rename", rooms(:pets).id)
collections = []
ActiveSupport::Notifications.subscribed(->(event) { collections << event.payload }, "render_collection.action_view") do
get room_messages_url(@room)
end
assert_response :success
messages = collections.find { |payload| payload[:identifier].end_with?("messages/_message.html.erb") }
assert messages
assert_equal @room.messages.count, messages[:cache_hits]
end
test "a foreign commit after capture bypasses old native fragment lookups" do
ResponseCache.instance.stubs(:budget).returns(0)
get room_messages_url(@room)
assert_response :success
MessagesController.any_instance.stubs(:set_version_headers).with do
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign before fragments", @message.id)
true
end
get room_messages_url(@room), headers: { "If-None-Match" => "unmatched" }
assert_response :success
assert_includes response.body, "foreign before fragments"
end
test "refresh streams recheck native cached creator body and boost presentation" do
message = messages(:first)
room = message.room
get room_refresh_url(room, format: :turbo_stream), params: { since: 0 }
assert_response :success
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign refreshed body", message.id)
foreign_write("UPDATE users SET name = ? WHERE id = ?", "Foreign refreshed creator", message.creator_id)
foreign_write("UPDATE boosts SET content = ? WHERE message_id = ?", "Foreign refreshed boost", message.id)
get room_refresh_url(room, format: :turbo_stream), params: { since: 0 }
assert_response :success
assert_includes response.body, "foreign refreshed body"
assert_includes response.body, "Foreign refreshed creator"
assert_includes response.body, "Foreign refreshed boost"
end
test "Jbuilder fragments use the bounded store and do not create HTML token state" do
api = open_session { |session| session.host! "once.campfire.test" }
path = room_bot_messages_path(@room, users(:bender).bot_key)
api.get path
assert_equal 200, api.response.status
assert_not api.cookies["_campfire_session"]
hits = []
ActiveSupport::Notifications.subscribed(->(event) { hits << event.payload[:key] if event.payload[:hit] }, "cache_read.active_support") do
api.get path
end
assert_equal 200, api.response.status
assert_not_empty hits
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign JSON body", @message.id)
foreign_write("UPDATE users SET name = ? WHERE id = ?", "Foreign JSON creator", @message.creator_id)
api.get path
json = JSON.parse(api.response.body).find { |message| message["id"] == @message.id }
assert_includes json.dig("body", "html"), "foreign JSON body"
assert_equal "Foreign JSON creator", json.dig("creator", "name")
api.host! "another.campfire.test:8081"
api.get path
json = JSON.parse(api.response.body).find { |message| message["id"] == @message.id }
assert_includes json["url"], "another.campfire.test:8081"
assert_same @global_store, Rails.cache
end
test "detached broadcasts bypass fragments without a pre-render model snapshot" do
get room_messages_url(@room)
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign broadcast body", @message.id)
FragmentCache.store.expects(:read).never
FragmentCache.store.expects(:write).never
FragmentCache.store.expects(:read_multi).never
FragmentCache.store.expects(:write_multi).never
@message.reload.broadcast_create
assert_rendered_turbo_stream_broadcast @room, :messages, action: "append", target: [ @room, :messages ] do |stream|
assert_includes stream.to_html, "foreign broadcast body"
end
body = ApplicationController.render(partial: "messages/message", locals: { message: @message.reload })
assert_includes body, "foreign broadcast body"
collection = ApplicationController.render(partial: "messages/message", collection: [ @message.reload ], cached: true)
assert_includes collection, "foreign broadcast body"
assert_not ApplicationController.new.perform_caching
end
private
def foreign_write(sql, *bindings)
SQLite3::Database.new(ActiveRecord::Base.connection_db_config.database) do |database|
database.execute(sql, bindings)
end
ActiveRecord::Base.clear_query_caches_for_current_thread
end
end
@@ -31,6 +31,32 @@ class Messages::BoostsControllerTest < ActionDispatch::IntegrationTest
end
end
test "quick boost controls submit their displayed reaction to the message" do
get room_url(@message.room)
forms = css_select("##{dom_id(@message)} .quick-boosts form")
assert_equal EmojiHelper::REACTIONS.size, forms.size
forms.each do |form|
assert_equal "post", form["method"]
assert_equal message_boosts_path(@message), form["action"]
assert_equal dom_id(@message, :boosting), form["data-turbo-frame"]
assert_equal "popup#close", form["data-action"]
input = form.at_css('input[name="boost[content]"]')
button = form.at_css('button[type="submit"]')
assert_equal input["value"], button["data-emoji"]
assert_equal EmojiHelper::REACTIONS.fetch(input["value"]), button["title"]
assert_nil form.at_css('input[name="authenticity_token"]')
end
form = forms.first
reaction = form.at_css('input[name="boost[content]"]')["value"]
assert_difference -> { @message.boosts.count }, 1 do
post form["action"], params: { boost: { content: reaction } }, headers: {
"Sec-Fetch-Site" => "same-origin", "Origin" => "http://www.example.com" }
assert_redirected_to message_boosts_url(@message)
end
assert_equal reaction, @message.boosts.order(:id).last.content
end
test "destroy" do
assert_turbo_stream_broadcasts [ @message.room, :messages ], count: 1 do
assert_difference -> { @message.boosts.count }, -1 do
@@ -1,6 +1,9 @@
require "test_helper"
require "active_record/testing/query_assertions"
class Messages::ByBotsControllerTest < ActionDispatch::IntegrationTest
include ActiveRecord::Assertions::QueryAssertions
setup do
@room = rooms(:watercooler)
end
@@ -93,7 +96,9 @@ class Messages::ByBotsControllerTest < ActionDispatch::IntegrationTest
@room.messages.create!(body: "Filler #{i}", creator: users(:jason), client_message_id: "filler-#{i}")
end
get room_bot_messages_url(@room, users(:bender).bot_key)
assert_no_queries_match(/SELECT COUNT\(\*\) FROM "messages"/i) do
get room_bot_messages_url(@room, users(:bender).bot_key)
end
assert_response :success
json = JSON.parse(response.body)
@@ -119,7 +124,10 @@ class Messages::ByBotsControllerTest < ActionDispatch::IntegrationTest
end
test "index in a room with no messages" do
get room_bot_messages_url(rooms(:bender_and_kevin), users(:bender).bot_key)
room = rooms(:bender_and_kevin)
assert_equal 0, room.messages_count
get room_bot_messages_url(room, users(:bender).bot_key)
assert_response :success
assert_equal [], JSON.parse(response.body)
@@ -49,8 +49,12 @@ class MessagesControllerTest < ActionDispatch::IntegrationTest
end
test "creating a message broadcasts the message to the room" do
ContentFilters::TextMessagePresentationFilters.expects(:apply).once.with { |body| body.to_plain_text == "New one" }.returns(ActionText::Content.new("New one"))
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one", client_message_id: 999 } }
assert_response :success
assert_select "turbo-stream[action=append] .message__body", text: /New one/
assert_rendered_turbo_stream_broadcast @room, :messages, action: "append", target: [ @room, :messages ] do
assert_select ".message__body", text: /New one/
assert_copy_link_button room_at_message_url(@room, Message.last, host: "once.campfire.test")
@@ -12,4 +12,18 @@ class QrCodeControllerTest < ActionDispatch::IntegrationTest
assert_equal 1.year, response.cache_control[:max_age].to_i
assert response.cache_control[:public]
end
test "show rejects an id that isn't base64" do
get qr_code_path("not-base64!")
assert_response :bad_request
end
test "show rejects a URL too long for a QR code" do
id = Base64.urlsafe_encode64("http://example.com/" + "a" * 3000)
get qr_code_path(id)
assert_response :bad_request
end
end
@@ -28,6 +28,23 @@ class Users::SidebarsControllerTest < ActionDispatch::IntegrationTest
assert_select ".unread", count: users(:david).memberships.reject { |m| m.room.direct? || !m.unread? }.count
end
test "a cached direct that is already unread still sorts by its latest message" do
room = rooms(:david_and_jason)
with_memory_cache do
travel_to 1.hour.ago do
room.messages.create! client_message_id: 998, body: "First", creator: users(:jason)
end
get user_sidebar_url
assert memberships(:david_david_and_jason).reload.unread?
room.messages.create! client_message_id: 999, body: "Second", creator: users(:jason)
get user_sidebar_url
assert_select "##{dom_id(room, :list)}[data-sorted-list-number=?]", room.reload.updated_at.to_fs(:epoch)
end
end
test "directs are ordered by room recency, not name" do
older = rooms(:david_and_jason)
newer = rooms(:david_and_kevin)
@@ -48,4 +65,23 @@ class Users::SidebarsControllerTest < ActionDispatch::IntegrationTest
assert positions.all?
assert_equal positions.sort, positions
end
private
def with_memory_cache
old_cache = Rails.cache
old_collection_cache = ActionView::PartialRenderer.collection_cache
old_controller_cache = Users::SidebarsController.cache_store
old_caching = Users::SidebarsController.perform_caching
Rails.cache = ActiveSupport::Cache::MemoryStore.new
ActionView::PartialRenderer.collection_cache = Rails.cache
Users::SidebarsController.cache_store = Rails.cache
Users::SidebarsController.perform_caching = true
yield
ensure
Rails.cache = old_cache
ActionView::PartialRenderer.collection_cache = old_collection_cache
Users::SidebarsController.cache_store = old_controller_cache
Users::SidebarsController.perform_caching = old_caching
end
end
+12
View File
@@ -1,6 +1,18 @@
require "test_helper"
class MessagesHelperTest < ActionView::TestCase
test "plain text presentation is reused by content rather than database epoch" do
view.controller.stubs(:perform_caching).returns(true)
FragmentCache.store.clear
message = Message.create! room: rooms(:pets), body: "A reusable <strong>safe</strong> body", creator: users(:jason)
first = view.message_presentation(message)
assert_includes first, "A reusable <strong>safe</strong> body"
ContentFilters::TextMessagePresentationFilters.expects(:apply).never
assert_equal first, view.message_presentation(message)
ensure
FragmentCache.store.clear
end
test "message_presentation neutralizes unsafe URI schemes in links" do
message = Message.create! room: rooms(:pets), body: '<div><a href="javascript:alert(1)">x</a></div>', client_message_id: "0015", creator: users(:jason)
@@ -103,6 +103,16 @@ class ContentSecurityPolicyTest < ActionDispatch::IntegrationTest
assert_equal "www.example.com:8443/service-worker.js", policy[/worker-src ([^;]+)/, 1]
end
test "each host's pages carry that host's sources, one after another" do
[ "www.example.com", "www.example.com:8443", "www.example.com" ].each do |host|
host! host
get PAGE_PATH
follow_redirect! while response.redirect?
assert_includes response.headers["Content-Security-Policy"][/script-src ([^;]+)/, 1].split, "#{host}/assets/"
end
end
test "uploads can't load as scripts" do
get PAGE_PATH
follow_redirect! while response.redirect?
+313
View File
@@ -0,0 +1,313 @@
require "test_helper"
class SqliteWalCheckpointTest < ActiveSupport::TestCase
setup do
@tmpdir = Dir.mktmpdir("sqlite-wal-checkpoint")
SqliteWalCheckpoint.reset!
SqliteWalCheckpoint.lock_path = File.join(@tmpdir, "checkpoint.lock")
end
teardown do
SqliteWalCheckpoint.reset!
FileUtils.remove_entry(@tmpdir) if @tmpdir && File.directory?(@tmpdir)
end
test "connections disable WAL auto-checkpoint so commits do not fsync on the writer" do
assert_equal 0, ActiveRecord::Base.connection.select_value("PRAGMA wal_autocheckpoint").to_i
end
test "a passive checkpoint against the primary database does not raise" do
assert_nothing_raised { SqliteWalCheckpoint.checkpoint }
end
test "does not start a background thread in test by default" do
assert_nil SqliteWalCheckpoint.start
assert_empty checkpoint_threads
end
test "start with enabled: true runs a named contender thread that stop joins" do
thread = SqliteWalCheckpoint.start(interval: 0.05, enabled: true)
assert thread.alive?
wait_until { thread.name == "sqlite-wal-checkpoint" }
SqliteWalCheckpoint.stop
assert_not thread.alive?
assert_empty checkpoint_threads
end
test "stop before start again mimics a fork-safe worker boot" do
db_path = build_wal_database(rows: 10)
SqliteWalCheckpoint.database_path_override = db_path
first = SqliteWalCheckpoint.start(interval: 0.05, enabled: true)
wait_until { first.name == "sqlite-wal-checkpoint" }
SqliteWalCheckpoint.stop
assert_not first.alive?
second = SqliteWalCheckpoint.start(interval: 0.05, enabled: true)
wait_until { second.name == "sqlite-wal-checkpoint" }
assert second.alive?
SqliteWalCheckpoint.stop
assert_not second.alive?
assert_equal :checkpointed, SqliteWalCheckpoint.tick
end
test "stop interrupts a long checkpoint interval" do
waits = observe_waits
contender = SqliteWalCheckpoint.start(interval: 30, enabled: true)
assert_equal 30, receive(waits)
SqliteWalCheckpoint.stop(timeout: 0.5)
assert_not contender.alive?
assert_empty checkpoint_threads
end
test "stop interrupts checkpoint error backoff" do
SqliteWalCheckpoint.database_path_override = build_wal_database(rows: 10)
SqliteWalCheckpoint.stubs(:checkpoint_on).raises(SQLite3::Exception, "checkpoint failed")
waits = observe_waits
contender = SqliteWalCheckpoint.start(interval: 30, enabled: true)
assert_equal 30, receive(waits)
SqliteWalCheckpoint.stop(timeout: 0.5)
assert_not contender.alive?
assert_not lock_held_by_other_process?(SqliteWalCheckpoint.lock_path)
end
test "timed out stop prevents replacement until the checkpoint releases its resources" do
entered, release = block_checkpoint
contender = SqliteWalCheckpoint.start(enabled: true)
database = receive(entered)
assert lock_held_by_other_process?(SqliteWalCheckpoint.lock_path)
assert_raises(SqliteWalCheckpoint::StopTimeout) { SqliteWalCheckpoint.stop(timeout: 0.01) }
assert contender.alive?
assert lock_held_by_other_process?(SqliteWalCheckpoint.lock_path)
assert_raises(SqliteWalCheckpoint::StopTimeout) { SqliteWalCheckpoint.start(enabled: true) }
assert_equal [ contender ], checkpoint_threads
release << true
SqliteWalCheckpoint.stop
assert_not contender.alive?
assert database.closed?
assert_not lock_held_by_other_process?(SqliteWalCheckpoint.lock_path)
ensure
release&.push(true)
SqliteWalCheckpoint.stop
end
test "start waits for a concurrent stop to release the previous contender" do
entered, release = block_checkpoint
contender = SqliteWalCheckpoint.start(enabled: true)
receive(entered)
stopping = Thread.new { SqliteWalCheckpoint.stop }
wait_until { stopping.status == "sleep" }
starting = Thread.new { SqliteWalCheckpoint.start(enabled: true) }
wait_until { starting.status == "sleep" }
assert contender.alive?
release << true
stopping.value
replacement = starting.value
assert_not contender.alive?
assert replacement.alive?
assert_not_equal contender, replacement
ensure
release&.push(true)
stopping&.join
starting&.join
SqliteWalCheckpoint.stop
end
test "a contender cannot release a concurrent tick's lock" do
entered, release = block_checkpoint
ticking = Thread.new { SqliteWalCheckpoint.tick }
receive(entered)
waits = observe_waits
contender = SqliteWalCheckpoint.start(interval: 30, enabled: true)
assert_equal 30, receive(waits)
SqliteWalCheckpoint.stop(timeout: 0.5)
assert_not contender.alive?
assert lock_held_by_other_process?(SqliteWalCheckpoint.lock_path)
release << true
assert_equal :checkpointed, ticking.value
assert_not lock_held_by_other_process?(SqliteWalCheckpoint.lock_path)
ensure
release&.push(true)
ticking&.join
SqliteWalCheckpoint.stop
end
test "reset does not reinstall the process exit checkpoint" do
SqliteWalCheckpoint.start(enabled: true)
SqliteWalCheckpoint.reset!
SqliteWalCheckpoint.expects(:at_exit).never
SqliteWalCheckpoint.start(enabled: true)
SqliteWalCheckpoint.stop
end
test "tick checkpoints through the elected lock holder" do
db_path = build_wal_database(rows: 50)
SqliteWalCheckpoint.database_path_override = db_path
assert_equal :checkpointed, SqliteWalCheckpoint.tick
end
test "tick reports busy while another process holds the lock and succeeds after it exits" do
db_path = build_wal_database(rows: 20)
SqliteWalCheckpoint.database_path_override = db_path
lock = SqliteWalCheckpoint.lock_path
ready = File.join(@tmpdir, "holder-ready")
holder = spawn_lock_holder(lock, ready_path: ready, hold_for: 0.4)
begin
wait_until { File.exist?(ready) }
assert_equal :busy, SqliteWalCheckpoint.tick
ensure
Process.wait(holder)
end
assert_equal :checkpointed, SqliteWalCheckpoint.tick
end
test "passive checkpoint moves WAL pages after writes with autocheckpoint disabled" do
db_path = File.join(@tmpdir, "writer-#{SecureRandom.hex(4)}.sqlite3")
SQLite3::Database.new(db_path) do |writer|
writer.execute("PRAGMA journal_mode=WAL")
writer.execute("PRAGMA wal_autocheckpoint=0")
writer.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, body TEXT)")
200.times do |i|
writer.execute("INSERT INTO items (body) VALUES (?)", "row-#{i}-#{"x" * 200}")
end
wal_path = "#{db_path}-wal"
assert File.exist?(wal_path), "expected a WAL file after inserts"
assert File.size(wal_path) > 0
SqliteWalCheckpoint.database_path_override = db_path
lock = SqliteWalCheckpoint.send(:acquire_lock)
assert lock
begin
_busy, _log, checkpointed = SqliteWalCheckpoint.checkpoint
assert checkpointed.to_i > 0, "expected PASSIVE checkpoint to copy WAL pages, got #{checkpointed.inspect}"
ensure
SqliteWalCheckpoint.send(:release_lock, lock)
end
end
end
test "a contender thread takes over checkpointing after the lock holder stops" do
db_path = build_wal_database(rows: 30)
SqliteWalCheckpoint.database_path_override = db_path
lock = SqliteWalCheckpoint.lock_path
ready = File.join(@tmpdir, "holder-ready")
holder = spawn_lock_holder(lock, ready_path: ready, hold_for: 0.25)
wait_until { File.exist?(ready) }
contender = SqliteWalCheckpoint.start(interval: 0.05, enabled: true)
begin
assert_equal :busy, SqliteWalCheckpoint.tick
Process.wait(holder)
holder = nil
wait_until(timeout: 2) { lock_held_by_other_process?(lock) }
ensure
Process.wait(holder) if holder
SqliteWalCheckpoint.stop
assert_not contender.alive?
end
end
private
def observe_waits
waits = Thread::Queue.new
SqliteWalCheckpoint.stubs(:wait).with do |wakeup, timeout|
waits << timeout
wakeup.pop(timeout: timeout)
true
end
waits
end
def block_checkpoint
SqliteWalCheckpoint.database_path_override = build_wal_database(rows: 10)
entered = Thread::Queue.new
release = Thread::Queue.new
blocked = false
SqliteWalCheckpoint.stubs(:checkpoint_on).returns([ 0, 0, 0 ]).with do |database|
unless blocked
blocked = true
entered << database
release.pop
end
true
end
[ entered, release ]
end
def receive(queue)
queue.pop(timeout: 2).tap { |value| assert_not_nil value, "expected checkpoint barrier" }
end
def checkpoint_threads
Thread.list.select { |thread| thread.name == "sqlite-wal-checkpoint" }
end
def build_wal_database(rows:)
path = File.join(@tmpdir, "writer-#{SecureRandom.hex(4)}.sqlite3")
SQLite3::Database.new(path) do |database|
database.execute("PRAGMA journal_mode=WAL")
database.execute("PRAGMA wal_autocheckpoint=0")
database.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, body TEXT)")
rows.times do |i|
database.execute("INSERT INTO items (body) VALUES (?)", "row-#{i}-#{"x" * 200}")
end
end
path
end
def spawn_lock_holder(lock, ready_path:, hold_for:)
Process.spawn(
RbConfig.ruby, "-e", <<~RUBY
require "fileutils"
lock = #{lock.inspect}
ready = #{ready_path.inspect}
FileUtils.mkdir_p(File.dirname(lock))
file = File.open(lock, File::RDWR | File::CREAT, 0644)
abort "lock failed" unless file.flock(File::LOCK_EX | File::LOCK_NB)
File.write(ready, "1")
sleep #{hold_for}
RUBY
)
end
def lock_held_by_other_process?(lock)
return false unless File.exist?(lock)
probe = File.open(lock, File::RDWR | File::CREAT, 0644)
if probe.flock(File::LOCK_EX | File::LOCK_NB)
probe.flock(File::LOCK_UN)
probe.close
false
else
probe.close
true
end
end
def wait_until(timeout: 1)
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
until yield
raise "condition not met within #{timeout}s" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
sleep 0.02
end
end
end
+8 -13
View File
@@ -10,11 +10,6 @@ class VipsLoaderPolicyTest < ActiveSupport::TestCase
# ones are represented by their magic bytes.
FTYP_AVIF = "\x00\x00\x00\x1cftypavif\x00\x00\x00\x00avifmif1miaf".b
FTYP_HEIC = "\x00\x00\x00\x1cftypheic\x00\x00\x00\x00heicmif1miaf".b
BMP = "BM" + [ 0, 0, 54 ].pack("V3") + "\x00" * 40
PSD = "8BPS" + [ 1 ].pack("n") + "\x00" * 26
ICO = "\x00\x00\x01\x00\x01\x00" + "\x00" * 16
SVG = %q(<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"/>)
test "loads PNG" do
assert_equal "VipsForeignLoadPngFile", loader_for(encode("png"))
end
@@ -43,20 +38,20 @@ class VipsLoaderPolicyTest < ActiveSupport::TestCase
assert_equal "VipsForeignLoadHeifFile", loader_for(FTYP_HEIC)
end
test "denies BMP through magickload" do
assert_nil loader_for(BMP)
test "blocks BMP through magickload" do
assert_loader_blocked :magickload, ".bmp"
end
test "denies PSD through magickload" do
assert_nil loader_for(PSD)
test "blocks PSD through magickload" do
assert_loader_blocked :magickload, ".psd"
end
test "denies ICO through magickload" do
assert_nil loader_for(ICO)
test "blocks ICO through magickload" do
assert_loader_blocked :magickload, ".ico"
end
test "denies SVG through svgload" do
assert_nil loader_for(SVG)
test "blocks SVG through svgload" do
assert_loader_blocked :svgload, ".svg"
end
test "denies OpenSlide files through openslideload" do
+10 -7
View File
@@ -76,11 +76,13 @@ class WebPush::ConnectionsTest < ActiveSupport::TestCase
end
end
test "a TLS failure on a new connection before the push is written is reported as it is" do
failing_check = Module.new { private def begin_transport(*) = raise(OpenSSL::SSL::SSLError, "alert right after the handshake") }
class FailingCheckHTTP < WebPush::Connections::HTTP
private def begin_transport(*) = raise(OpenSSL::SSL::SSLError, "alert right after the handshake")
end
test "a TLS failure on a new connection before the push is written is reported as it is" do
with_push_service do |server|
assert_raises(OpenSSL::SSL::SSLError) { @connections.request(pinned_connection(server).extend(failing_check), push_request) }
assert_raises(OpenSSL::SSL::SSLError) { @connections.request(pinned_connection(server, http_class: FailingCheckHTTP), push_request) }
assert_empty server.requests
end
end
@@ -97,14 +99,15 @@ class WebPush::ConnectionsTest < ActiveSupport::TestCase
end
end
test "only new, direct TLS connections pinned to an address are pooled" do
test "only new, direct TLS connections pinned to an address and opened as WebPush::Connections::HTTP are pooled" do
with_push_service do |server|
unpinned = Net::HTTP.new(HOST, server.port, nil).tap { it.use_ssl = true }
proxied = Net::HTTP.new(HOST, server.port, "127.0.0.1", 3128).tap { it.ipaddr = IP; it.use_ssl = true }
unpinned = WebPush::Connections::HTTP.new(HOST, server.port, nil).tap { it.use_ssl = true }
proxied = WebPush::Connections::HTTP.new(HOST, server.port, "127.0.0.1", 3128).tap { it.ipaddr = IP; it.use_ssl = true }
plain = pinned_connection(server).tap { it.use_ssl = false }
started = pinned_connection(server).tap(&:start)
untracked = Net::HTTP.new(HOST, server.port, nil).tap { it.ipaddr = IP; it.use_ssl = true }
[ unpinned, proxied, plain, started ].each do |http|
[ unpinned, proxied, plain, started, untracked ].each do |http|
assert_raises(ArgumentError) { @connections.request(http, push_request) }
end
assert_empty server.requests
+132
View File
@@ -0,0 +1,132 @@
require "test_helper"
class Message::CreationTest < ActiveSupport::TestCase
include ActionDispatch::TestProcess
setup do
@room = rooms(:designers)
@creator = users(:david)
end
test "creation indexes persisted native HTML and mentions before notifying" do
html = "<p>Fish &amp; chips 🌍 #{mention_attachment_for(:jason)}</p>"
message = @room.messages.new(creator: @creator, body: html)
original = message.method(:create_in_index)
message.define_singleton_method(:create_in_index) do
raise "rich text not autosaved" unless ActionText::RichText.exists?(record: self)
original.call
end
message.save!
assert_equal message.body.to_plain_text, indexed_body(message)
assert_equal message.reload.body.to_plain_text, indexed_body(message)
assert_includes message.mentionees, users(:jason)
assert_equal [ message ], @room.messages.search("Fish chips")
assert_no_match(/<p>|&amp;/, indexed_body(message))
end
test "an attachment-only message is attached before its filename is indexed" do
message = @room.messages.new(creator: @creator, attachment: fixture_file_upload("moon.jpg", "image/jpeg"))
original = message.method(:create_in_index)
message.define_singleton_method(:create_in_index) do
raise "attachment not autosaved" unless ActiveStorage::Attachment.exists?(record: self, name: "attachment")
original.call
end
message.save!
assert_equal "moon.jpg", indexed_body(message)
assert_equal [ message ], @room.messages.search("moon")
end
test "a failed index insert rolls back the message and its associations without notifications" do
message = @room.messages.new(creator: @creator, body: "<p>Failed &amp; indexed</p>",
attachment: fixture_file_upload("moon.jpg", "image/jpeg"))
message.define_singleton_method(:create_in_index) { raise "index unavailable" }
assert_rolled_back_creation(message) do
assert_raises(RuntimeError) { message.save! }
end
end
test "a failed unread update rolls back the already inserted index and associations" do
message = @room.messages.new(creator: @creator, body: "<p>Unread &amp; rollback</p>",
attachment: fixture_file_upload("moon.jpg", "image/jpeg"))
original = @room.method(:unread_memberships)
@room.define_singleton_method(:unread_memberships) do |created|
original.call(created)
raise "unread unavailable"
end
assert_rolled_back_creation(message) do
assert_raises(RuntimeError) { message.save! }
end
end
test "an outer rollback restores the index unread state and counter and queues no jobs" do
message = @room.messages.new(creator: @creator, body: "<p>Outer &amp; rollback</p>")
assert_rolled_back_creation(message) do
Message.transaction(requires_new: true) do
message.save!
assert_equal "Outer & rollback", indexed_body(message)
raise ActiveRecord::Rollback
end
end
end
test "the existing public receive operation still marks unread and queues a push" do
message = messages(:first)
room = message.room
room.expects(:unread_memberships).with(message)
assert_enqueued_with(job: Room::PushMessageJob, args: [ room, message ]) { room.receive(message) }
end
private
def indexed_body(message)
Message.connection.select_value(Message.sanitize_sql([ "SELECT body FROM message_search_index WHERE rowid = ?", message.id ]))
end
def assert_rolled_back_creation(message)
models = [ Message, ActionText::RichText, ActiveStorage::Attachment, ActiveStorage::Blob ]
counts = models.map(&:count)
memberships = @room.memberships.order(:id).map(&:attributes)
room_state = @room.reload.attributes.slice("messages_count", "updated_at")
indexed = Message.connection.select_value("SELECT COUNT(*) FROM message_search_index")
clear_enqueued_jobs
yield
assert_equal counts, models.map(&:count)
assert_equal indexed, Message.connection.select_value("SELECT COUNT(*) FROM message_search_index")
assert_equal memberships, @room.memberships.order(:id).map(&:attributes)
assert_equal room_state, @room.reload.attributes.slice("messages_count", "updated_at")
assert_enqueued_jobs 0
end
end
class MessageCreationCommitTest < ActiveSupport::TestCase
self.use_transactional_tests = false
test "creation commits its index and unread writes once before enqueuing push" do
room = rooms(:designers)
sql = []
collect = ->(*, payload) { sql << payload[:sql] }
Room::PushMessageJob.expects(:perform_later).with do |queued_room, message|
assert_not Message.connection.transaction_open?
assert_equal room, queued_room
assert_equal "One native commit", Message.connection.select_value("SELECT body FROM message_search_index WHERE rowid = #{message.id}")
true
end
ActiveSupport::Notifications.subscribed(collect, "sql.active_record") do
room.messages.create!(creator: users(:david), body: "<p>One native commit</p>")
end
assert_equal 1, sql.count { |statement| statement.match?(/\ACOMMIT\b/i) }
commit = sql.index { |statement| statement.match?(/\ACOMMIT\b/i) }
assert_operator sql.index { |statement| statement.match?(/insert into message_search_index/i) }, :<, commit
assert_operator sql.index { |statement| statement.match?(/UPDATE "memberships"/i) }, :<, commit
end
end
+38
View File
@@ -0,0 +1,38 @@
require "test_helper"
class Message::MentioneeTest < ActiveSupport::TestCase
test "ordinary messages return an empty user relation without loading their room" do
message = rooms(:pets).messages.create!(creator: users(:jason), body: "<p>Fish &amp; chips 🌍</p>").reload
message.body
assert_not message.association(:room).loaded?
queries = []
subscriber = ->(*, payload) { queries << payload[:sql] unless payload[:name] == "SCHEMA" || payload[:cached] }
ActiveSupport::Notifications.subscribed(subscriber, "sql.active_record") do
mentionees = message.mentionees
assert_equal User, mentionees.klass
assert_empty mentionees.to_a
assert_empty mentionees.ids
assert_empty mentionees.active_bots.excluding(users(:jason)).to_a
end
assert_empty queries
assert_not message.association(:room).loaded?
assert_not message.changed?
end
test "signed mentions follow a moved message and fresh membership changes" do
message = rooms(:designers).messages.create!(creator: users(:david), body: "<p>Hey #{mention_attachment_for(:kevin)}</p>")
assert_equal [ users(:kevin).id ], message.reload.mentionees.ids
Message.where(id: message.id).update_all(room_id: rooms(:pets).id)
message = Message.find(message.id)
assert_empty message.mentionees.ids
membership = Membership.create!(room: rooms(:pets), user: users(:kevin))
assert_equal [ users(:kevin).id ], message.mentionees.ids
membership.delete
assert_empty message.mentionees.ids
assert_equal rooms(:pets).id, message.room_id
assert_not message.changed?
end
end
+22
View File
@@ -17,6 +17,28 @@ class MessageTest < ActiveSupport::TestCase
assert_not Message.new(body: "🔥 💯").plain_text_body.all_emoji?
end
test "plain text keeps Action Text formatting and attachment conversion" do
bodies = [ nil, "", "Hello &amp; goodbye", "<div>First<br>second</div>", "<p>One</p><p>Two</p>",
"<ul><li>First</li><li><strong>Second</strong></li></ul>", "<blockquote>Quoted</blockquote>",
"<pre><code>one\n two</code></pre>", "<table><tr><td>one</td><td>two</td></tr></table>",
"<div><em>Unclosed", "😄🤘", "<div>Hi #{mention_attachment_for(:david)}</div>" ]
bodies.each do |html|
message = Message.new(body: html)
assert_equal message.body.to_plain_text, message.plain_text_body, html.inspect
end
end
test "plain text follows body replacement and does not expose mutable cached text" do
message = Message.new(body: "<p>First</p>")
message.plain_text_body.replace("Caller edit")
assert_equal "First", message.plain_text_body
message.body = "<p>Replacement</p>"
assert_equal "Replacement", message.plain_text_body
message = messages(:first)
message.update!(body: "<p>Reloaded body</p>")
assert_equal message.body.to_plain_text, message.reload.plain_text_body
end
test "mentionees" do
message = Message.new room: rooms(:pets), body: "<div>Hey #{mention_attachment_for(:david)}</div>", creator: users(:jason), client_message_id: "earth"
assert_equal [ users(:david) ], message.mentionees
+96
View File
@@ -0,0 +1,96 @@
require "test_helper"
require "tmpdir"
class RecordCacheTest < ActiveSupport::TestCase
setup do
@directory = Dir.mktmpdir
@database = File.join(@directory, "records.sqlite3")
SQLite3::Database.new(@database) do |database|
database.execute("PRAGMA journal_mode=WAL")
database.execute("CREATE TABLE changes_for_test (value TEXT)")
end
ActiveRecord::Base.stubs(:connection_db_config).returns(Struct.new(:database).new(@database))
ActiveRecord::Base.connection.stubs(:transaction_open?).returns(false)
@cache = ResponseCache.new
@cache.stubs(:budget).returns(4096)
ResponseCache.stubs(:instance).returns(@cache)
FragmentCache.stubs(:store).returns(ActiveSupport::Cache::MemoryStore.new(size: 64.kilobytes))
@record = User.instantiate(users(:david).attributes_before_type_cast.merge(
"created_at" => "2026-10-08 12:34:56.123456", "updated_at" => "2026-10-08 12:34:57.654321",
"status" => 2, "role" => 1))
end
teardown do
@cache.clear
FileUtils.remove_entry(@directory)
end
test "hits reconstruct independent persisted models without losing raw timestamps or enum values" do
version = @cache.version
assert_same @record, RecordCache.fetch("user", version) { [ @record ] }.first
first = RecordCache.fetch("user", version) { flunk "cache hit queried records" }.first
second = RecordCache.fetch("user", version) { flunk "cache hit queried records" }.first
assert_not_same @record, first
assert_not_same first, second
assert first.persisted?
assert_not first.changed?
assert_equal @record.attributes_before_type_cast, first.attributes_before_type_cast
assert_equal 123456, first.created_at.usec
assert_equal 654321, first.updated_at.usec
assert_equal "banned", first.status
assert_equal "administrator", first.role
first.name = "Request-local edit"
assert_equal @record.name, second.name
assert_equal @record.name, RecordCache.fetch("user", version) { flunk "cache hit queried records" }.first.name
end
test "foreign commit makes an old captured epoch query fresh records instead of reusing a snapshot" do
version = @cache.version
RecordCache.fetch("user", version) { [ @record ] }
foreign_commit
fresh = User.instantiate(@record.attributes_before_type_cast.merge("name" => "Changed elsewhere"))
assert_same fresh, RecordCache.fetch("user", version) { [ fresh ] }.first
assert_same fresh, RecordCache.fetch("user", @cache.version) { [ fresh ] }.first
assert_equal fresh.name, RecordCache.fetch("user", @cache.version) { flunk "fresh snapshot missing" }.first.name
end
test "observer is rechecked after a snapshot lookup" do
version = @cache.version
RecordCache.fetch("user", version) { [ @record ] }
store = FragmentCache.store
original_read = store.method(:read)
commit = method(:foreign_commit)
store.define_singleton_method(:read) do |*arguments|
original_read.call(*arguments).tap { |snapshot| commit.call if snapshot }
end
fresh = User.instantiate(@record.attributes_before_type_cast.merge("name" => "Committed during lookup"))
assert_same fresh, RecordCache.fetch("user", version) { [ fresh ] }.first
end
test "a commit while loading records prevents admission under the old epoch" do
version = @cache.version
FragmentCache.store.expects(:write).never
assert_same @record, RecordCache.fetch("user", version) { foreign_commit; [ @record ] }.first
replacement = User.instantiate(@record.attributes_before_type_cast.merge("name" => "Fresh load"))
assert_same replacement, RecordCache.fetch("user", version) { [ replacement ] }.first
end
test "disabled budget missing epoch and open transactions always query and never admit" do
version = @cache.version
RecordCache.fetch("user", version) { [ @record ] }
FragmentCache.store.expects(:write).never
@cache.stubs(:budget).returns(0)
assert_same @record, RecordCache.fetch("user", version) { [ @record ] }.first
@cache.stubs(:budget).returns(4096)
assert_same @record, RecordCache.fetch("user", nil) { [ @record ] }.first
ActiveRecord::Base.connection.stubs(:transaction_open?).returns(true)
assert_same @record, RecordCache.fetch("user", version) { [ @record ] }.first
assert_same @record, RecordCache.fetch("transaction-only", version) { [ @record ] }.first
end
private
def foreign_commit
SQLite3::Database.new(@database) { |database| database.execute("INSERT INTO changes_for_test VALUES ('committed')") }
end
end
+88
View File
@@ -0,0 +1,88 @@
require "test_helper"
require "tmpdir"
class ResponseCacheTest < ActiveSupport::TestCase
setup do
@directory = Dir.mktmpdir
@database = File.join(@directory, "cache.sqlite3")
SQLite3::Database.new(@database) do |database|
database.execute("PRAGMA journal_mode=WAL")
database.execute("CREATE TABLE values_for_test (value TEXT)")
end
ActiveRecord::Base.stubs(:connection_db_config).returns(Struct.new(:database).new(@database))
@cache = ResponseCache.new
@cache.stubs(:budget).returns(1024)
end
teardown do
@cache.clear
FileUtils.remove_entry(@directory)
end
test "foreign commits expire entries and reject old render admission" do
version = @cache.version
@cache.write("page", version, entry("first"))
assert_equal "first", @cache.read("page", version)[:body]
SQLite3::Database.new(@database) { |database| database.execute("INSERT INTO values_for_test VALUES ('committed')") }
assert_nil @cache.read("page", version)
@cache.write("page", version, entry("old in-flight render"))
assert_nil @cache.read("page", @cache.version)
@cache.write("page", @cache.version, entry("fresh"))
assert_equal "fresh", @cache.read("page", @cache.version)[:body]
end
test "byte budget evicts oldest and bypasses oversized entries and keys" do
version = @cache.version
@cache.stubs(:budget).returns(512)
@cache.write("first", version, entry("a" * 100))
@cache.write("second", version, entry("b" * 100))
assert_nil @cache.read("first", version)
assert_equal "b" * 100, @cache.read("second", version)[:body]
@cache.write("oversized", version, entry("c" * 1000))
assert_nil @cache.read("oversized", version)
@cache.write("k" * 2049, version, entry("small"))
assert_nil @cache.read("k" * 2049, version)
end
test "clearing the observer gives later pages a distinct namespace" do
version = @cache.version
@cache.clear
assert_not_equal version, @cache.version
end
test "cold renders coalesce without holding the observer mutex" do
version = @cache.version
entered = Queue.new
release = Queue.new
first = Thread.new do
@cache.synchronize_render("page", version) do
entered << true
release.pop
@cache.write("page", version, entry("completed render"))
end
end
entered.pop
second_started = Queue.new
second = Thread.new do
second_started << true
@cache.synchronize_render("page", version) { @cache.read("page", version) }
end
second_started.pop
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
Thread.pass until second.status == "sleep" || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
assert_equal "sleep", second.status
assert_nil @cache.read("page", version)
assert_equal version, @cache.version
release << true
first.value
assert_equal "completed render", second.value[:body]
ensure
release << true if release
[ first, second ].compact.each { |thread| thread.join(2) }
end
private
def entry(body)
{ body: body, marker: "unexposed-marker", headers: {} }
end
end
@@ -0,0 +1,237 @@
require "test_helper"
require "sqlite3"
# Destructive trigger DDL and foreign connections need their own file so parallel
# workers (and transactional tests in messages_count_test) keep a stable trigger set.
class Room::MessagesCountLifecycleTest < ActiveSupport::TestCase
self.use_transactional_tests = false
setup do
Room::MessagesCount.ensure!
@room = rooms(:designers)
@other_room = rooms(:pets)
Room::MessagesCount.backfill!
@room.reload
@other_room.reload
end
teardown do
Message.where("client_message_id LIKE ?", "count-%").delete_all
Room::MessagesCount.ensure!
Room::MessagesCount.backfill!
end
test "ensure! installs missing triggers and keeps accurate counts" do
before = @room.reload.messages_count
Room::MessagesCount.uninstall!
assert_not Room::MessagesCount.triggers_installed?
Room::MessagesCount.ensure!
assert Room::MessagesCount.triggers_installed?
assert_equal before, @room.reload.messages_count
assert_equal @room.messages.count, @room.messages_count
assert_difference -> { @room.reload.messages_count }, +1 do
@room.messages.create!(creator: users(:jason), body: "After ensure", client_message_id: "count-ensure")
end
end
test "ensure! repairs a partial trigger install and backfills drifted counts" do
Room::MessagesCount.uninstall!
ActiveRecord::Base.connection.execute <<~SQL
CREATE TRIGGER #{Room::MessagesCount::INSERT_TRIGGER} AFTER INSERT ON messages
BEGIN
UPDATE rooms SET messages_count = messages_count + 1 WHERE id = NEW.room_id;
END
SQL
assert Room::MessagesCount.trigger_installed?(ActiveRecord::Base.connection, Room::MessagesCount::INSERT_TRIGGER)
assert_not Room::MessagesCount.trigger_installed?(ActiveRecord::Base.connection, Room::MessagesCount::DELETE_TRIGGER)
# Delete fires with no delete trigger → counter drifts high.
drifted = @room.messages.create!(creator: users(:jason), body: "Drift", client_message_id: "count-partial-drift")
ActiveRecord::Base.connection.execute("DELETE FROM messages WHERE id = #{drifted.id}")
assert_operator @room.reload.messages_count, :>, @room.messages.count
Room::MessagesCount.ensure!
assert Room::MessagesCount.triggers_installed?
assert_equal @room.messages.count, @room.reload.messages_count
end
test "ensure! backfills writes that landed while the insert trigger was missing" do
Room::MessagesCount.uninstall!
before_count = @room.messages.count
before_cached = @room.reload.messages_count
assert_equal before_count, before_cached
path = File.expand_path(ActiveRecord::Base.connection_db_config.database)
now = Time.current.utc.strftime("%Y-%m-%d %H:%M:%S.%6N")
ActiveRecord::Base.connection_pool.release_connection
SQLite3::Database.new(path) do |db|
db.busy_timeout = 5_000
db.execute(
"INSERT INTO messages (room_id, creator_id, client_message_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
[ @room.id, users(:david).id, "count-missing-insert", now, now ]
)
end
assert_equal before_count + 1, @room.messages.count
assert_equal before_cached, @room.reload.messages_count
Room::MessagesCount.ensure!
assert Room::MessagesCount.triggers_installed?
assert_equal @room.messages.count, @room.reload.messages_count
assert_equal before_cached + 1, @room.messages_count
end
test "concurrent ensure! repairs leave triggers installed and counts accurate" do
Room::MessagesCount.uninstall!
path = File.expand_path(ActiveRecord::Base.connection_db_config.database)
now = Time.current.utc.strftime("%Y-%m-%d %H:%M:%S.%6N")
ActiveRecord::Base.connection_pool.release_connection
SQLite3::Database.new(path) do |db|
db.busy_timeout = 5_000
db.execute(
"INSERT INTO messages (room_id, creator_id, client_message_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
[ @room.id, users(:david).id, "count-concurrent-drift", now, now ]
)
end
assert_not_equal @room.messages.count, @room.reload.messages_count
errors = []
errors_mutex = Mutex.new
workers = 2.times.map do
Thread.new do
ActiveRecord::Base.connection_pool.with_connection do |connection|
Room::MessagesCount.ensure!(connection)
end
rescue => error
errors_mutex.synchronize { errors << error }
end
end
workers.each(&:join)
assert_empty errors, -> { errors.map(&:full_message).join("\n") }
assert Room::MessagesCount.triggers_installed?
assert_equal @room.messages.count, @room.reload.messages_count
end
test "writes concurrent with ensure! repair leave accurate counts" do
Room::MessagesCount.uninstall!
path = File.expand_path(ActiveRecord::Base.connection_db_config.database)
now = Time.current.utc.strftime("%Y-%m-%d %H:%M:%S.%6N")
ActiveRecord::Base.connection_pool.release_connection
errors = []
errors_mutex = Mutex.new
ensure_thread = Thread.new do
ActiveRecord::Base.connection_pool.with_connection do |connection|
Room::MessagesCount.ensure!(connection)
end
rescue => error
errors_mutex.synchronize { errors << error }
end
insert_thread = Thread.new do
SQLite3::Database.new(path) do |db|
db.busy_timeout = 10_000
db.execute(
"INSERT INTO messages (room_id, creator_id, client_message_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
[ @room.id, users(:david).id, "count-during-repair", now, now ]
)
end
rescue => error
errors_mutex.synchronize { errors << error }
end
[ ensure_thread, insert_thread ].each(&:join)
assert_empty errors, -> { errors.map(&:full_message).join("\n") }
assert Room::MessagesCount.triggers_installed?
assert_equal @room.messages.count, @room.reload.messages_count
assert Message.exists?(client_message_id: "count-during-repair")
end
test "install! never exposes a partial trigger set to other connections" do
Room::MessagesCount.uninstall!
path = File.expand_path(ActiveRecord::Base.connection_db_config.database)
ActiveRecord::Base.connection_pool.release_connection
stop = false
partial_snapshots = []
snapshots_mutex = Mutex.new
watcher = Thread.new do
SQLite3::Database.new(path) do |db|
db.busy_timeout = 5_000
until stop
names = db.execute(
"SELECT name FROM sqlite_master WHERE type = 'trigger' AND name IN (#{Room::MessagesCount::TRIGGERS.map { "'#{it}'" }.join(", ")})"
).flatten
if names.any? && names.sort != Room::MessagesCount::TRIGGERS.sort
snapshots_mutex.synchronize { partial_snapshots << names.sort }
end
end
end
end
25.times { Room::MessagesCount.install! }
stop = true
watcher.join
assert_empty partial_snapshots
assert Room::MessagesCount.triggers_installed?
end
test "foreign SQLite connections keep the counter in step" do
path = File.expand_path(ActiveRecord::Base.connection_db_config.database)
now = Time.current.utc.strftime("%Y-%m-%d %H:%M:%S.%6N")
before = @room.reload.messages_count
other_before = @other_room.reload.messages_count
ActiveRecord::Base.connection_pool.release_connection
SQLite3::Database.new(path) do |db|
db.busy_timeout = 5_000
db.execute(
"INSERT INTO messages (room_id, creator_id, client_message_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
[ @room.id, users(:david).id, "count-foreign-insert", now, now ]
)
end
assert_equal before + 1, @room.reload.messages_count
assert_equal @room.messages.count, @room.messages_count
foreign_id = Message.find_by!(client_message_id: "count-foreign-insert").id
ActiveRecord::Base.connection_pool.release_connection
SQLite3::Database.new(path) do |db|
db.busy_timeout = 5_000
db.execute("UPDATE messages SET room_id = ? WHERE id = ?", [ @other_room.id, foreign_id ])
end
assert_equal before, @room.reload.messages_count
assert_equal other_before + 1, @other_room.reload.messages_count
ActiveRecord::Base.connection_pool.release_connection
SQLite3::Database.new(path) do |db|
db.busy_timeout = 5_000
db.execute("DELETE FROM messages WHERE id = ?", [ foreign_id ])
end
assert_equal before, @room.reload.messages_count
assert_equal other_before, @other_room.reload.messages_count
end
end
@@ -0,0 +1,152 @@
require "test_helper"
require "rake"
require "tmpdir"
Rails.application.load_tasks unless Rake::Task.task_defined?("room_messages_count:ensure")
class Room::MessagesCountTasksTest < ActiveSupport::TestCase
self.use_transactional_tests = false
setup do
@directory = Dir.mktmpdir("campfire-counter-tasks")
@schema = File.join(@directory, "schema.rb")
@migrations = File.join(@directory, "migrations")
Dir.mkdir(@migrations)
File.write(@schema, <<~RUBY)
ActiveRecord::Schema[8.2].define(version: 0) do
create_table :rooms, force: true do |t|
t.integer :messages_count, default: 0, null: false
end
create_table :messages, force: true do |t|
t.integer :room_id
t.string :client_message_id
end
end
RUBY
@tasks = ActiveRecord::Tasks::DatabaseTasks
@original_database_url = ENV.delete("DATABASE_URL")
@original_environment = @tasks.env
@original_configurations = ActiveRecord::Base.configurations
@original_migrations = @tasks.migrations_paths
@original_dump = ActiveRecord.dump_schema_after_migration
@original_seed_loader = @tasks.seed_loader
@tasks.migrations_paths = [ @migrations ]
ActiveRecord.dump_schema_after_migration = false
end
teardown do
ActiveRecord::Base.configurations = @original_configurations
ENV["DATABASE_URL"] = @original_database_url if @original_database_url
@tasks.env = @original_environment
@tasks.migrations_paths = @original_migrations
@tasks.seed_loader = @original_seed_loader
ActiveRecord.dump_schema_after_migration = @original_dump
FileUtils.remove_entry(@directory)
end
test "development schema task repairs both databases before restoring its original pool" do
development = configuration("development")
test = configuration("test")
ActiveRecord::Base.configurations = { "development" => { "primary" => development.configuration_hash }, "test" => { "primary" => test.configuration_hash } }
@tasks.env = "development"
@tasks.with_temporary_connection(development) do |connection|
@tasks.load_schema(development)
original_path = connection.pool.db_config.database
2.times do
task = Rake::Task["db:schema:load"]
task.all_prerequisite_tasks.each(&:reenable)
task.reenable
task.invoke
assert_equal original_path, ActiveRecord::Base.connection_db_config.database
assert Room::MessagesCount.triggers_installed?(connection)
@tasks.with_temporary_connection(test) do |test_connection|
assert Room::MessagesCount.triggers_installed?(test_connection)
assert_counter_tracks_insert(test_connection)
end
end
end
end
test "test schema task repairs the test database selected by purge" do
config = configuration("test")
ActiveRecord::Base.configurations = { "test" => { "primary" => config.configuration_hash } }
@tasks.with_temporary_connection(config) do |connection|
task = Rake::Task["db:test:load_schema"]
task.all_prerequisite_tasks.each(&:reenable)
task.reenable
task.invoke
assert Room::MessagesCount.triggers_installed?(connection)
assert_counter_tracks_insert(connection)
end
end
test "prepare installs schema triggers before loading seeds" do
config = configuration("test")
ActiveRecord::Base.configurations = { "test" => { "primary" => config.configuration_hash } }
seeded = false
loader = Object.new
loader.define_singleton_method(:load_seed) do
connection = ActiveRecord::Base.connection
raise "seeds ran without counter triggers" unless Room::MessagesCount.triggers_installed?(connection)
connection.execute("INSERT INTO rooms (id) VALUES (1)")
connection.execute("INSERT INTO messages (room_id) VALUES (1), (1)")
raise "seed counter is stale" unless connection.select_value("SELECT messages_count FROM rooms WHERE id = 1") == 2
seeded = true
end
@tasks.seed_loader = loader
@tasks.with_temporary_connection(config) do |connection|
@tasks.prepare_all
assert seeded
assert Room::MessagesCount.triggers_installed?(connection)
assert_equal 2, connection.select_value("SELECT messages_count FROM rooms WHERE id = 1")
end
end
test "migrate repairs rebuilt message tables and repeats repair with no new migration" do
config = configuration("test")
File.write(File.join(@migrations, "20261008000100_change_counter_task_message_column.rb"), <<~RUBY)
class ChangeCounterTaskMessageColumn < ActiveRecord::Migration[8.2]
def change
change_column :messages, :client_message_id, :text
end
end
RUBY
@tasks.with_temporary_connection(config) do |connection|
@tasks.load_schema(config)
Room::MessagesCount.ensure!(connection)
assert_counter_tracks_insert(connection)
@tasks.migrate
assert Room::MessagesCount.triggers_installed?(connection)
assert_counter_tracks_insert(connection)
Room::MessagesCount.uninstall!(connection)
connection.execute("INSERT INTO messages (room_id) VALUES (1)")
assert_not_equal connection.select_value("SELECT COUNT(*) FROM messages WHERE room_id = 1"), connection.select_value("SELECT messages_count FROM rooms WHERE id = 1")
@tasks.migrate
assert Room::MessagesCount.triggers_installed?(connection)
assert_equal connection.select_value("SELECT COUNT(*) FROM messages WHERE room_id = 1"), connection.select_value("SELECT messages_count FROM rooms WHERE id = 1")
end
end
private
def configuration(environment)
ActiveRecord::DatabaseConfigurations::HashConfig.new(environment, "primary", {
adapter: "sqlite3", database: File.join(@directory, "#{environment}.sqlite3"),
default_transaction_mode: :immediate, schema_dump: @schema,
migrations_paths: [ @migrations ]
})
end
def assert_counter_tracks_insert(connection)
connection.execute("INSERT INTO rooms (id) VALUES (1) ON CONFLICT DO NOTHING")
before = connection.select_value("SELECT messages_count FROM rooms WHERE id = 1")
connection.execute("INSERT INTO messages (room_id) VALUES (1)")
assert_equal before + 1, connection.select_value("SELECT messages_count FROM rooms WHERE id = 1")
end
end
+86
View File
@@ -0,0 +1,86 @@
require "test_helper"
class Room::MessagesCountTest < ActiveSupport::TestCase
setup do
@room = rooms(:designers)
@other_room = rooms(:pets)
end
test "fixture rooms start with an accurate messages_count" do
[ rooms(:watercooler), rooms(:designers), rooms(:bender_and_kevin) ].each do |room|
assert_equal room.messages.count, room.messages_count, "#{room.name || room.id} fixture count"
end
end
test "ActiveRecord create and destroy adjust the counter once" do
assert_difference -> { @room.reload.messages_count }, +1 do
assert_difference -> { @room.messages.count }, +1 do
@room.messages.create!(creator: users(:jason), body: "Hello", client_message_id: "count-ar-create")
end
end
assert_equal @room.messages.count, @room.reload.messages_count
assert_difference -> { @room.reload.messages_count }, -1 do
assert_difference -> { @room.messages.count }, -1 do
@room.messages.order(:id).last.destroy
end
end
assert_equal @room.messages.count, @room.reload.messages_count
end
test "bulk insert_all and delete_all keep the counter in step" do
rows = Array.new(3) do |i|
{
room_id: @room.id,
creator_id: users(:david).id,
client_message_id: "count-bulk-#{i}",
created_at: Time.current,
updated_at: Time.current
}
end
assert_difference -> { @room.reload.messages_count }, +3 do
Message.insert_all!(rows)
end
assert_equal @room.messages.count, @room.reload.messages_count
assert_difference -> { @room.reload.messages_count }, -3 do
@room.messages.where(client_message_id: rows.map { it[:client_message_id] }).delete_all
end
assert_equal @room.messages.count, @room.reload.messages_count
end
test "rolled back writes leave the counter unchanged" do
before = @room.reload.messages_count
Message.transaction do
@room.messages.create!(creator: users(:jason), body: "Nope", client_message_id: "count-rollback")
raise ActiveRecord::Rollback
end
assert_equal before, @room.reload.messages_count
assert_nil Message.find_by(client_message_id: "count-rollback")
end
test "moving a message between rooms moves the counter" do
message = @room.messages.create!(creator: users(:jason), body: "Move me", client_message_id: "count-move")
assert_difference -> { @room.reload.messages_count }, -1 do
assert_difference -> { @other_room.reload.messages_count }, +1 do
message.update!(room: @other_room)
end
end
assert_equal @room.messages.count, @room.reload.messages_count
assert_equal @other_room.messages.count, @other_room.reload.messages_count
end
test "Message does not declare an ActiveRecord counter_cache" do
reflection = Message.reflect_on_association(:room)
assert_not reflection.counter_cache_column
end
end
+10
View File
@@ -89,6 +89,16 @@ class Room::PushTest < ActiveSupport::TestCase
assert_includes pushed_users { post_to_designers_mentioning_kevin }, users(:kevin)
end
test "does not build notification payloads without recipients" do
Push::Subscription.delete_all
message = messages(:first)
message.expects(:plain_text_body).never
message.expects(:creator).never
Rails.configuration.x.web_push_pool.expects(:queue).never
Room::MessagePusher.new(room: message.room, message: message).push
end
private
def post_to_designers_mentioning_kevin
rooms(:designers).messages.create! body: "Hey #{mention_attachment_for(:kevin)}", client_message_id: "earth", creator: users(:david)
+14
View File
@@ -25,6 +25,20 @@ class RoomTest < ActiveSupport::TestCase
assert room.users.include?(users(:david))
end
test "a new message marks unread the members who had read the room and leaves the unread ones as they were" do
room = rooms(:watercooler)
reader = memberships(:jason_watercooler)
behind = memberships(:bender_watercooler)
behind.update_columns unread_at: 1.hour.ago, updated_at: 1.hour.ago
behind_before = behind.reload.attributes.slice("unread_at", "updated_at")
message = room.messages.create! creator: users(:david), body: "Hello", client_message_id: "unread-once"
assert_equal message.created_at, reader.reload.unread_at
assert_equal behind_before, behind.reload.attributes.slice("unread_at", "updated_at")
assert_not memberships(:david_watercooler).reload.unread?
end
test "type" do
assert Rooms::Open.new.open?
assert_not Rooms::Open.new.direct?
+3 -5
View File
@@ -87,18 +87,16 @@ export function sockets() {
export function messages() {
const cookie = `session_token=${dummyCookies[0][0]}`;
const response = http.get(`http://${host}${port}/rooms/1`, { headers: { "Cookie": cookie }, responseType: "text" });
const csrfToken = response.body.match(/<meta name="csrf-token" content="([^"]*)"/i)[1];
const postHeaders = {
"Cookie": cookie,
"Sec-Fetch-Site": "same-origin",
"Origin": `http://${host}${port}`,
"Accept": "text/vnd.turbo-stream.html, text/html, application/xhtml+xml"
}
const payload = {
"message[body]": "Hello from k6",
"message[client_message_id]": Math.random().toString(36),
"authenticity_token": csrfToken
"message[client_message_id]": Math.random().toString(36)
};
for (let i = 0; i < 100; i++) {
+9
View File
@@ -33,4 +33,13 @@ class ActiveSupport::TestCase
teardown do
WebMock.reset!
end
# fixtures :all inserts messages before rooms, so INSERT triggers cannot count
# yet. Backfill once after load; ensure! covers schema.rb (triggers not dumped).
def load_fixtures(config)
fixtures = super
Room::MessagesCount.ensure!
Room::MessagesCount.backfill!
fixtures
end
end
@@ -108,8 +108,8 @@ module PushServiceTestHelper
end
# Built as WebPush::PersistentRequest builds it for a delivery.
def pinned_connection(server, ip = IP)
Net::HTTP.new(HOST, server.port, nil).tap do |http|
def pinned_connection(server, ip = IP, http_class: WebPush::Connections::HTTP)
http_class.new(HOST, server.port, nil).tap do |http|
http.ipaddr = ip
http.use_ssl = true
end
+2
View File
@@ -22,6 +22,8 @@ module SystemTestHelper
def send_message(message)
fill_in_rich_text_area "message_body", with: message
click_on "send"
# Keep job helpers active through the actual response, not the optimistic DOM.
assert_selector ".message[data-message-id]:not([data-pending-message]) .message__body", text: message
end
def within_message(message, &block)