diff --git a/README.md b/README.md
index 4eba778..eb049a2 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,10 @@ 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 pages, and CSRF masks stay fresh.
+
## Other implementations
Campfire also has implementations in Django, Laravel, Express, Elixir, Go, Rust and C:
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 5e60f1e..9ad6c7c 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -1,4 +1,4 @@
class ApplicationController < ActionController::Base
- include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders
+ include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders, CachedResponses
include Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
end
diff --git a/app/controllers/concerns/cached_responses.rb b/app/controllers/concerns/cached_responses.rb
new file mode 100644
index 0000000..6a2e612
--- /dev/null
+++ b/app/controllers/concerns/cached_responses.rb
@@ -0,0 +1,82 @@
+module CachedResponses
+ extend ActiveSupport::Concern
+
+ CACHE_HEADERS = %w[ content-type cache-control etag last-modified vary ].freeze
+ CSRF_TAG = /]*\bname="csrf-token"[^>]*>|]*\bname="authenticity_token"[^>]*>/
+
+ included do
+ prepend_before_action :capture_response_cache_version
+ end
+
+ def perform_caching
+ super && !@rendering_uncached_response
+ end
+
+ private
+ def capture_response_cache_version
+ if request.get? && ResponseCache.instance.budget.positive?
+ @response_cache_version = ResponseCache.instance.version
+ end
+ end
+
+ # 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
+
+ if key.bytesize <= ResponseCache::MAX_KEY_BYTES && (entry = ResponseCache.instance.read(key, @response_cache_version))
+ response.headers.merge!(entry[:headers])
+ self.response_body = entry[:body].gsub(entry[:marker], token)
+ else
+ render_fresh_response { yield }
+ 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)
+ 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)
+ end
+ end
+ else
+ render_fresh_response { yield }
+ end
+ end
+
+ def cacheable_read_request?
+ @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 &&
+ !ActiveRecord::Base.connection.transaction_open?
+ end
+
+ def response_cache_key
+ ActiveSupport::JSON.encode([
+ controller_path, request.fullpath, request.base_url, request.user_agent,
+ request.headers["Accept"], request.headers["Turbo-Frame"], I18n.locale,
+ Current.user.id, Current.session.token, session.to_hash,
+ request.env["action_controller.csrf_token"],
+ cookies.to_h.except("_campfire_session", "session_token")
+ ])
+ end
+
+ def render_fresh_response
+ # Foreign SQL can change content without bumping fragment timestamps.
+ # A whole-page miss renders fresh instead of creating unbounded Redis
+ # fragment namespaces for every database commit.
+ @rendering_uncached_response = true
+ yield
+ ensure
+ @rendering_uncached_response = false
+ end
+
+ def csrf_neutral_body(body, marker)
+ body.gsub(CSRF_TAG) do |tag|
+ attribute = tag.start_with?(" 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
+end
diff --git a/test/controllers/cached_responses_test.rb b/test/controllers/cached_responses_test.rb
new file mode 100644
index 0000000..20c1262
--- /dev/null
+++ b/test/controllers/cached_responses_test.rb
@@ -0,0 +1,139 @@
+require "test_helper"
+
+class CachedResponsesTest < ActionDispatch::IntegrationTest
+ self.use_transactional_tests = false
+
+ setup do
+ host! "once.campfire.test"
+ sign_in :david
+ @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 and CSRF 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 "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 "cached tokens stay fresh and literal token-like text survives" do
+ get room_url(@room)
+ literal = css_select('meta[name="csrf-token"]').first["content"]
+ @room.messages.create!(creator: users(:david), body: "literal #{literal}")
+ get room_url(@room)
+ first = css_select('meta[name="csrf-token"]').first["content"]
+ get room_url(@room)
+ second = css_select('meta[name="csrf-token"]').first["content"]
+ assert_not_equal first, second
+ assert_includes response.body, "literal #{literal}"
+ assert_no_match /campfire-csrf-/, response.body
+
+ 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
+ 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 "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
diff --git a/test/models/response_cache_test.rb b/test/models/response_cache_test.rb
new file mode 100644
index 0000000..8dc6e43
--- /dev/null
+++ b/test/models/response_cache_test.rb
@@ -0,0 +1,57 @@
+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 "observer namespaces cannot reuse old shared fragment keys after restart" do
+ version = @cache.version
+ @cache.clear
+ assert_not_equal version, @cache.version
+ end
+
+ private
+ def entry(body)
+ { body: body, marker: "unexposed-marker", headers: {} }
+ end
+end