Compare commits

..
Author SHA1 Message Date
dependabot[bot] 1ddda9678f Bump sentry-ruby from 5.26.0 to 7.1.0
Bumps [sentry-ruby](https://github.com/getsentry/sentry-ruby) from 5.26.0 to 7.1.0.
- [Release notes](https://github.com/getsentry/sentry-ruby/releases)
- [Changelog](https://github.com/getsentry/sentry-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-ruby/compare/5.26.0...7.1.0)

---
updated-dependencies:
- dependency-name: sentry-ruby
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-09 19:55:15 +00: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
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
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
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
60 changed files with 1358 additions and 155 deletions

No files matched your search

+13 -5
View File
@@ -41,8 +41,7 @@ jobs:
arch: arm64
env:
REGISTRY: ghcr.io
# Everything goes to <repo>-edge, with this repository's own token.
IMAGE_NAME: ${{ github.repository }}-edge
IMAGE_NAME: ${{ github.repository }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -88,6 +87,9 @@ jobs:
type=ref,event=branch
type=ref,event=tag
type=sha,format=short,prefix=sha-
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
flavor: |
latest=false
suffix=-${{ matrix.arch }}
@@ -126,8 +128,6 @@ jobs:
name: Create multi-arch manifest and sign
needs: build
if: github.event_name != 'pull_request'
# Multi-platform tags, signed, in <repo>-edge only. basecamp/release-images
# promotes v* tags from there into the release package after a reviewer approves.
runs-on: ubuntu-latest
permissions:
contents: read
@@ -136,7 +136,7 @@ jobs:
timeout-minutes: 20
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}-edge
IMAGE_NAME: ${{ github.repository }}
steps:
- name: Set up Docker Buildx (for imagetools)
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
@@ -166,6 +166,10 @@ jobs:
type=ref,event=branch
type=ref,event=tag
type=sha,format=short,prefix=sha-
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
flavor: |
latest=false
labels: |
@@ -184,6 +188,10 @@ jobs:
[ -z "$tag" ] && continue
echo "Creating manifest for $tag"
src_tag="$tag"
if [[ "$tag" == *:latest && "${GITHUB_REF}" == refs/tags/* ]]; then
ref="${GITHUB_REF#refs/tags/}"
src_tag="${tag%:latest}:$ref"
fi
if [ -n "${IMAGE_DESCRIPTION:-}" ]; then
docker buildx imagetools create \
--tag "$tag" \
+1 -1
View File
@@ -1 +1 @@
3.4.10
4.0.7
+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.7
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here
+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.7
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"
+18 -17
View File
@@ -276,7 +276,7 @@ GEM
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)
@@ -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)
@@ -377,12 +377,13 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
sentry-rails (5.26.0)
railties (>= 5.0)
sentry-ruby (~> 5.26.0)
sentry-ruby (5.26.0)
sentry-rails (7.1.0)
railties (>= 5.2.0)
sentry-ruby (~> 7.1.0)
sentry-ruby (7.1.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
logger
sinatra (4.2.1)
logger (>= 1.6.0)
mustermann (~> 3.0)
@@ -452,7 +453,7 @@ DEPENDENCIES
ostruct
platform_agent
propshaft!
puma (~> 7.2, >= 7.2.1)
puma (~> 8.0)
rails!
rails_autolink
redis (~> 5.4)
+13 -6
View File
@@ -51,7 +51,7 @@ If you'd rather run the Docker image yourself, you can read more about that in t
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 pages, and CSRF masks stay fresh.
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.
@@ -61,11 +61,14 @@ Campfire also has implementations in Django, Laravel, Express, Elixir, Go, Rust
| 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 | 710 | 414 | 1,696 | 42,481 | 1,126 | 52,512 | 105,909 | 137,505 |
| Messages page | 1,113 | 454 | 1,890 | 74,779 | 1,407 | 54,100 | 103,301 | 142,669 |
| Sidebar | 1,901 | 576 | 3,364 | 94,460 | 3,621 | 58,714 | 120,930 | 151,001 |
| Search | 1,332 | 549 | 2,615 | 83,493 | 2,127 | 60,444 | 121,502 | 148,766 |
| Post a message | 226 | 113 | 567 | 2,121 | 1,392 | 9,000 | 8,004 | 7,486 |
| 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).
@@ -80,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
@@ -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
+36 -32
View File
@@ -1,15 +1,16 @@
require "zlib"
module CachedResponses
extend ActiveSupport::Concern
CACHE_HEADERS = %w[ content-type cache-control etag last-modified vary ].freeze
CSRF_TAG = /<meta\b[^>]*\bname="csrf-token"[^>]*>|<input\b[^>]*\bname="authenticity_token"[^>]*>/
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? && !ActiveRecord::Base.connection.transaction_open?
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.
@@ -25,15 +26,19 @@ module CachedResponses
end
def combined_fragment_cache_key(key)
@fragment_cache_namespace ||= [
@response_cache_version, request.base_url, request.script_name, request.format.to_s, I18n.locale,
Current.user&.id, (Digest::SHA256.hexdigest(Current.session.token) if Current.session),
(Digest::SHA256.hexdigest(real_csrf_token) if request.format.html? || request.format.turbo_stream?)
@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
super([ @fragment_cache_namespace, key ])
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.
@@ -43,10 +48,10 @@ module CachedResponses
# Register after room authorization, but before presentation queries.
def cache_read_response
if cacheable_read_request?
token = form_authenticity_token
key = response_cache_key
original_session = session.to_hash.deep_dup
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)
@@ -55,16 +60,18 @@ module CachedResponses
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
cache_completed_response(key, original_session)
entry = cache_completed_response(key, original_session, encoding)
end
end
end
if entry
response.headers.merge!(entry[:headers])
self.response_body = entry[:body].gsub(entry[:marker], token)
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.
@@ -75,14 +82,19 @@ module CachedResponses
end
end
def cache_completed_response(key, original_session)
if response.status == 200 && response.media_type == "text/html" && session.to_hash == original_session
marker = "campfire-csrf-#{SecureRandom.hex(32)}"
# Replace only framework token attributes, never a matching token in
# message text. Postprocessing also leaves fragment caches untouched.
body = csrf_neutral_body(response.body, marker)
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
ResponseCache.instance.write(key, @response_cache_version, { body: body.freeze, marker: marker.freeze, headers: headers }.freeze)
entry = { body: body.freeze, headers: headers }.freeze
ResponseCache.instance.write(key, @response_cache_version, entry)
entry
end
end
@@ -91,24 +103,16 @@ module CachedResponses
!authenticated_by.bot_key? && flash.empty? &&
!request.headers["If-None-Match"] && !request.headers["If-Modified-Since"] &&
!Rails.application.config.content_security_policy_nonce_generator &&
!ActiveRecord::Base.connection.transaction_open?
!FragmentCache.transaction_open?
end
def response_cache_key
def response_cache_key(encoding)
ActiveSupport::JSON.encode([
controller_path, request.fullpath, request.base_url, request.user_agent,
controller_path, request.fullpath, request.base_url, request.user_agent, encoding,
request.headers["Accept"], request.headers["Turbo-Frame"], I18n.locale,
# Tokens are hydrated per request, including clients that replay an old
# cookie. Their raw CSRF secret does not select a presentation variant.
# 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
def csrf_neutral_body(body, marker)
body.gsub(CSRF_TAG) do |tag|
attribute = tag.start_with?("<meta") ? "content" : "value"
tag.sub(/\b#{attribute}="[^"]*"/, %(#{attribute}="#{marker}"))
end
end
end
+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
+4 -3
View File
@@ -14,8 +14,7 @@ class MessagesController < ApplicationController
if @messages.any?
body = render_to_string(:index)
# Creator, body and boost edits can change HTML without touching messages.
# Masked CSRF tokens remain fresh while the presentation validator stays stable.
fresh_when etag: Digest::SHA256.hexdigest(csrf_neutral_body(body, "")), template: false
fresh_when etag: Digest::SHA256.hexdigest(body), template: false
unless performed?
response.content_type = "text/html"
self.response_body = body
@@ -29,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
+4 -1
View File
@@ -21,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"
+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
+44 -2
View File
@@ -63,8 +63,7 @@ module MessagesHelper
when "sound"
message_sound_presentation(message)
else
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)),
html: { target: "_blank" }, sanitize_options: { tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES }
text_message_presentation(message.body.body)
end
rescue Exception => e
Sentry.capture_exception(e, extra: { message: message })
@@ -73,7 +72,50 @@ 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 = -> do
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(body)),
html: { target: "_blank" }, sanitize_options: { tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES }
end
# 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-v1", I18n.locale, Digest::SHA256.hexdigest(html) ]) { render.call }
else
render.call
end
end
def messages_actions
"turbo:before-stream-render@document->messages#beforeStreamRender keydown.up@document->messages#editMyLastMessage"
end
-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) => {
+11
View File
@@ -1,9 +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
+4 -1
View File
@@ -23,11 +23,14 @@ class Opengraph::Fetch
end
private
# The nil proxy address ignores http_proxy: a proxy would look the host up again, and the connection
# would no longer go to the checked IP.
#
# The timeouts bound each operation. A host can still send its headers or body a byte at a time,
# in as many reads as it likes: UnfurlLinksController puts the whole unfurl under one deadline.
def request(url, request_class, ip:)
MAX_REDIRECTS.times do
Net::HTTP.start(url.host, url.port, ipaddr: ip, use_ssl: url.scheme == "https", **timeouts) do |http|
Net::HTTP.start(url.host, url.port, nil, ipaddr: ip, use_ssl: url.scheme == "https", **timeouts) do |http|
http.request request_class.new(url) do |response|
if response.is_a?(Net::HTTPRedirection)
url, ip = resolve_redirect(response["location"])
+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
+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
-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-v6" ] do %>
<% cache [ message_fragment_cache_key(message), "presentation-v7" ] 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
+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
+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
+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
+34 -17
View File
@@ -14,7 +14,7 @@ class CachedResponsesTest < ActionDispatch::IntegrationTest
Rails.cache = ActiveSupport::Cache::MemoryStore.new
ActionController::Base.perform_caching = true
@room = rooms(:watercooler)
# Establish last-room and CSRF cookies before checking reuse.
# Establish last-room cookies before checking reuse.
2.times { get room_url(@room) }
ResponseCache.instance.clear
end
@@ -48,37 +48,54 @@ class CachedResponsesTest < ActionDispatch::IntegrationTest
assert_response :success
end
test "clients without a persisted CSRF session still reuse token-neutral HTML" do
test "clients without token state reuse complete HTML and post without tokens" do
cookies["_campfire_session"] = @login_cookie
get room_url(@room)
first = css_select('meta[name="csrf-token"]').first["content"]
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)
second = css_select('meta[name="csrf-token"]').first["content"]
assert_response :success
assert_not_equal first, second
assert_no_match /campfire-csrf-/, response.body
assert_equal first, response.body
post room_messages_url(@room, format: :turbo_stream), params: {
authenticity_token: second, message: { body: "fresh replay token works", client_message_id: "cache-replay-token" } }
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 "cached tokens stay fresh and literal token-like text survives" do
get room_url(@room)
literal = css_select('meta[name="csrf-token"]').first["content"]
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 = css_select('meta[name="csrf-token"]').first["content"]
first = response.body
get room_url(@room)
second = css_select('meta[name="csrf-token"]').first["content"]
assert_not_equal first, second
assert_equal first, response.body
assert_includes response.body, "literal #{literal}"
assert_no_match /campfire-csrf-/, response.body
end
post room_messages_url(@room, format: :turbo_stream), params: {
authenticity_token: second, message: { body: "cached token works", client_message_id: "cache-token" } }
assert_response :success
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
+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
+26
View File
@@ -63,6 +63,32 @@ class FragmentRenderingTest < ActionDispatch::IntegrationTest
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)
@@ -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
@@ -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
+1 -1
View File
@@ -172,7 +172,7 @@ class ContentFiltersTest < ActionView::TestCase
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body).to_html
assert_equal body, filtered
assert_dom_equal body, filtered
assert_match %r{<table>.*<th><p>Name</p></th>.*<td><p>Jason</p></td>}m, message_presentation(message)
end
+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)
+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
+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
+18
View File
@@ -74,6 +74,24 @@ class Opengraph::FetchTest < ActiveSupport::TestCase
end
end
test "#fetch_document connects to the resolved IP even when a proxy is configured" do
url = URI.parse("http://www.example.com/")
saved = ENV.slice("http_proxy", "HTTP_PROXY", "no_proxy", "NO_PROXY")
%w[ no_proxy NO_PROXY ].each { |k| ENV.delete(k) }
%w[ http_proxy HTTP_PROXY ].each { |k| ENV[k] = "http://proxy.internal:3128" }
WebMock.disable_net_connect! allow: [ url.host ]
TCPSocket.expects(:open).with { |*args, **| args.first == "proxy.internal" }.never
TCPSocket.expects(:open).with { |*args, **| args.first == "1.2.3.4" && args[1] == 80 }.throws(:not_proxied)
assert_throws :not_proxied do
@fetch.fetch_document(url, ip: "1.2.3.4")
end
ensure
%w[ http_proxy HTTP_PROXY no_proxy NO_PROXY ].each { |k| ENV.delete(k) }
saved.each { |k, v| ENV[k] = v }
end
test "#fetch_document is empty following redirects that never finish" do
WebMock.stub_request(:get, "https://www.example.com/")
.to_return(status: 302, headers: { location: "https://www.example.com/" })
+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
+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++) {