mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-09 08:10:08 +09:00
Bound native fragment caching and collapse concurrent page renders
This commit is contained in:
@@ -9,14 +9,28 @@ module CachedResponses
|
||||
end
|
||||
|
||||
def perform_caching
|
||||
super && !@rendering_uncached_response
|
||||
super && @response_cache_version.present? && !ActiveRecord::Base.connection.transaction_open?
|
||||
end
|
||||
|
||||
# Keep the class store (including shared rate limits) and Rails.cache unchanged.
|
||||
def cache_store
|
||||
FragmentCache.store
|
||||
end
|
||||
|
||||
def combined_fragment_cache_key(key)
|
||||
@fragment_cache_namespace ||= [
|
||||
@response_cache_version, request.base_url, request.script_name, request.format.to_s, I18n.locale,
|
||||
Current.user&.id, Current.session&.token,
|
||||
(Digest::SHA256.hexdigest(real_csrf_token) if request.format.html? || request.format.turbo_stream?)
|
||||
].freeze
|
||||
super([ @fragment_cache_namespace, key ])
|
||||
end
|
||||
|
||||
private
|
||||
def capture_response_cache_version
|
||||
if request.get? && ResponseCache.instance.budget.positive?
|
||||
@response_cache_version = ResponseCache.instance.version
|
||||
end
|
||||
# Capture for native HTML/JSON/stream renders too, even with page reuse off.
|
||||
# Detached renderers do not run callbacks and therefore render uncached.
|
||||
@response_cache_version = ResponseCache.instance.version
|
||||
end
|
||||
|
||||
# Register after room authorization, but before presentation queries.
|
||||
@@ -26,27 +40,47 @@ module CachedResponses
|
||||
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)
|
||||
return yield if key.bytesize > ResponseCache::MAX_KEY_BYTES
|
||||
|
||||
entry = ResponseCache.instance.read(key, @response_cache_version)
|
||||
rendered = false
|
||||
unless entry
|
||||
ResponseCache.instance.synchronize_render(key, @response_cache_version) do
|
||||
entry = ResponseCache.instance.read(key, @response_cache_version)
|
||||
if !entry && ResponseCache.instance.version == @response_cache_version
|
||||
yield
|
||||
rendered = true
|
||||
cache_completed_response(key, original_session)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if entry
|
||||
response.headers.merge!(entry[:headers])
|
||||
self.response_body = entry[:body].gsub(entry[:marker], token)
|
||||
elsif !rendered
|
||||
# A queued request retains its pre-auth snapshot. If it has expired,
|
||||
# render outside the stripe instead of blocking the next generation.
|
||||
yield
|
||||
end
|
||||
else
|
||||
render_fresh_response { yield }
|
||||
yield
|
||||
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)
|
||||
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
|
||||
|
||||
def cacheable_read_request?
|
||||
@response_cache_version && request.get? && request.format.html? && Current.session &&
|
||||
ResponseCache.instance.budget.positive? && @response_cache_version && request.get? && request.format.html? && Current.session &&
|
||||
!authenticated_by.bot_key? && flash.empty? &&
|
||||
!request.headers["If-None-Match"] && !request.headers["If-Modified-Since"] &&
|
||||
!Rails.application.config.content_security_policy_nonce_generator &&
|
||||
@@ -64,16 +98,6 @@ module CachedResponses
|
||||
])
|
||||
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?("<meta") ? "content" : "value"
|
||||
|
||||
Reference in New Issue
Block a user