discourse/lib/middleware/anonymous_cache.rb

157 lines
3.8 KiB
Ruby

require_dependency "mobile_detection"
require_dependency "crawler_detection"
require_dependency "guardian"
module Middleware
class AnonymousCache
def self.anon_cache(env, duration)
env["ANON_CACHE_DURATION"] = duration
end
class Helper
USER_AGENT = "HTTP_USER_AGENT".freeze
RACK_SESSION = "rack.session".freeze
ACCEPT_ENCODING = "HTTP_ACCEPT_ENCODING".freeze
def initialize(env)
@env = env
@request = Rack::Request.new(@env)
end
def is_mobile=(val)
@is_mobile = val ? :true : :false
end
def is_mobile?
@is_mobile ||=
begin
session = @env[RACK_SESSION]
# don't initialize params until later otherwise
# you get a broken params on the request
params = {}
user_agent = @env[USER_AGENT]
MobileDetection.resolve_mobile_view!(user_agent, params, session) ? :true : :false
end
@is_mobile == :true
end
def has_brotli?
@has_brotli ||=
begin
@env[ACCEPT_ENCODING].to_s =~ /br/ ? :true : :false
end
@has_brotli == :true
end
def is_crawler?
@is_crawler ||=
begin
user_agent = @env[USER_AGENT]
CrawlerDetection.crawler?(user_agent) ? :true : :false
end
@is_crawler == :true
end
def cache_key
@cache_key ||= "ANON_CACHE_#{@env["HTTP_ACCEPT"]}_#{@env["HTTP_HOST"]}#{@env["REQUEST_URI"]}|m=#{is_mobile?}|c=#{is_crawler?}|b=#{has_brotli?}|t=#{theme_key}"
end
def theme_key
key, _ = @request.cookies['theme_key']&.split(',')
if key && Guardian.new.allow_theme?(key)
key
else
nil
end
end
def cache_key_body
@cache_key_body ||= "#{cache_key}_body"
end
def cache_key_other
@cache_key_other || "#{cache_key}_other"
end
def get?
@env["REQUEST_METHOD"] == "GET"
end
def has_auth_cookie?
CurrentUser.has_auth_cookie?(@env)
end
def no_cache_bypass
request = Rack::Request.new(@env)
request.cookies['_bypass_cache'].nil?
end
def cacheable?
!!(!has_auth_cookie? && get? && no_cache_bypass)
end
def cached
if body = $redis.get(cache_key_body)
if other = $redis.get(cache_key_other)
other = JSON.parse(other)
[other[0], other[1], [body]]
end
end
end
def cache_duration
@env["ANON_CACHE_DURATION"]
end
# NOTE in an ideal world cache still serves out cached content except for one magic worker
# that fills it up, this avoids a herd killing you, we can probably do this using a job or redis tricks
# but coordinating this is tricky
def cache(result)
status, headers, response = result
if status == 200 && cache_duration
headers_stripped = headers.dup.delete_if { |k, _| ["Set-Cookie", "X-MiniProfiler-Ids"].include? k }
headers_stripped["X-Discourse-Cached"] = "true"
parts = []
response.each do |part|
parts << part
end
$redis.setex(cache_key_body, cache_duration, parts.join)
$redis.setex(cache_key_other, cache_duration, [status, headers_stripped].to_json)
else
parts = response
end
[status, headers, parts]
end
def clear_cache
$redis.del(cache_key_body)
$redis.del(cache_key_other)
end
end
def initialize(app, settings = {})
@app = app
end
def call(env)
helper = Helper.new(env)
if helper.cacheable?
helper.cached || helper.cache(@app.call(env))
else
@app.call(env)
end
end
end
end