Files
kong--kong/kong/plugins/proxy-cache/handler.lua
T
2026-07-13 12:32:21 +08:00

416 lines
11 KiB
Lua

local require = require
local cache_key = require "kong.plugins.proxy-cache.cache_key"
local kong_meta = require "kong.meta"
local mime_type = require "kong.tools.mime_type"
local nkeys = require "table.nkeys"
local splitn = require("kong.tools.string").splitn
local ngx = ngx
local kong = kong
local type = type
local pairs = pairs
local floor = math.floor
local lower = string.lower
local time = ngx.time
local resp_get_headers = ngx.resp and ngx.resp.get_headers
local ngx_re_sub = ngx.re.gsub
local ngx_re_match = ngx.re.match
local parse_mime_type = mime_type.parse_mime_type
local parse_directive_header = require("kong.tools.http").parse_directive_header
local calculate_resource_ttl = require("kong.tools.http").calculate_resource_ttl
local STRATEGY_PATH = "kong.plugins.proxy-cache.strategies"
local CACHE_VERSION = 1
local EMPTY = require("kong.tools.table").EMPTY
-- http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.5.1
-- note content-length is not strictly hop-by-hop but we will be
-- adjusting it here anyhow
local hop_by_hop_headers = {
["connection"] = true,
["keep-alive"] = true,
["proxy-authenticate"] = true,
["proxy-authorization"] = true,
["te"] = true,
["trailers"] = true,
["transfer-encoding"] = true,
["upgrade"] = true,
["content-length"] = true,
}
local function overwritable_header(header)
local n_header = lower(header)
return not hop_by_hop_headers[n_header]
and not ngx_re_match(n_header, "ratelimit-remaining", "jo")
end
local function set_header(conf, header, value)
if ngx.var.http_kong_debug or conf.response_headers[header] then
kong.response.set_header(header, value)
end
end
local function reset_res_header(res)
res.headers["Age"] = nil
res.headers["X-Cache-Status"] = nil
res.headers["X-Cache-Key"] = nil
end
local function set_res_header(res, header, value, conf)
if ngx.var.http_kong_debug or conf.response_headers[header] then
res.headers[header] = value
end
end
local function req_cc()
return parse_directive_header(ngx.var.http_cache_control)
end
local function res_cc()
return parse_directive_header(ngx.var.sent_http_cache_control)
end
local function cacheable_request(conf, cc)
-- TODO refactor these searches to O(1)
do
local method = kong.request.get_method()
local method_match = false
for i = 1, #conf.request_method do
if conf.request_method[i] == method then
method_match = true
break
end
end
if not method_match then
return false
end
end
-- check for explicit disallow directives
-- TODO note that no-cache isnt quite accurate here
if conf.cache_control and (cc["no-store"] or cc["no-cache"] or
ngx.var.authorization) then
return false
end
return true
end
local function cacheable_response(conf, cc)
-- TODO refactor these searches to O(1)
do
local status = kong.response.get_status()
local status_match = false
for i = 1, #conf.response_code do
if conf.response_code[i] == status then
status_match = true
break
end
end
if not status_match then
return false
end
end
do
local content_type = ngx.var.sent_http_content_type
-- bail if we cannot examine this content type
if not content_type or type(content_type) == "table" or
content_type == "" then
return false
end
local t, subtype, params = parse_mime_type(content_type)
local content_match = false
for i = 1, #conf.content_type do
local expected_ct = conf.content_type[i]
local exp_type, exp_subtype, exp_params = parse_mime_type(expected_ct)
if exp_type then
if (exp_type == "*" or t == exp_type) and
(exp_subtype == "*" or subtype == exp_subtype) then
local params_match = true
for key, value in pairs(exp_params or EMPTY) do
if value ~= (params or EMPTY)[key] then
params_match = false
break
end
end
if params_match and
(nkeys(params or EMPTY) == nkeys(exp_params or EMPTY)) then
content_match = true
break
end
end
end
end
if not content_match then
return false
end
end
if conf.cache_control and (cc["private"] or cc["no-store"] or cc["no-cache"])
then
return false
end
if conf.cache_control and calculate_resource_ttl(cc) <= 0 then
return false
end
return true
end
-- indicate that we should attempt to cache the response to this request
local function signal_cache_req(ctx, conf, cache_key, cache_status)
ctx.proxy_cache = {
cache_key = cache_key,
}
set_header(conf, "X-Cache-Status", cache_status or "Miss")
end
local ProxyCacheHandler = {
VERSION = kong_meta.version,
PRIORITY = 100,
}
function ProxyCacheHandler:init_worker()
-- catch notifications from other nodes that we purged a cache entry
-- only need one worker to handle purges like this
-- if/when we introduce inline LRU caching this needs to involve
-- worker events as well
kong.cluster_events:subscribe("proxy-cache:purge", function(data)
kong.log.err("handling purge of '", data, "'")
local t = splitn(data, ":", 3)
local plugin_id, cache_key = t[1], t[2]
local plugin, err = kong.db.plugins:select({ id = plugin_id })
if err then
kong.log.err("error in retrieving plugins: ", err)
return
end
local strategy = require(STRATEGY_PATH)({
strategy_name = plugin.config.strategy,
strategy_opts = plugin.config[plugin.config.strategy],
})
if cache_key ~= "nil" then
local ok, err = strategy:purge(cache_key)
if not ok then
kong.log.err("failed to purge cache key '", cache_key, "': ", err)
return
end
else
local ok, err = strategy:flush(true)
if not ok then
kong.log.err("error in flushing cache data: ", err)
end
end
end)
end
function ProxyCacheHandler:access(conf)
local cc = req_cc()
-- if we know this request isnt cacheable, bail out
if not cacheable_request(conf, cc) then
set_header(conf, "X-Cache-Status", "Bypass")
return
end
local consumer = kong.client.get_consumer()
local route = kong.router.get_route()
local uri = ngx_re_sub(ngx.var.request, "\\?.*", "", "oj")
-- if we want the cache-key uri only to be lowercase
if conf.ignore_uri_case then
uri = lower(uri)
end
local cache_key, err = cache_key.build_cache_key(consumer and consumer.id,
route and route.id,
kong.request.get_method(),
uri,
kong.request.get_query(),
kong.request.get_headers(),
conf)
if err then
kong.log.err(err)
return
end
set_header(conf, "X-Cache-Key", cache_key)
-- try to fetch the cached object from the computed cache key
local strategy = require(STRATEGY_PATH)({
strategy_name = conf.strategy,
strategy_opts = conf[conf.strategy],
})
local ctx = kong.ctx.plugin
local res, err = strategy:fetch(cache_key)
if err == "request object not in cache" then -- TODO make this a utils enum err
-- this request wasn't found in the data store, but the client only wanted
-- cache data. see https://tools.ietf.org/html/rfc7234#section-5.2.1.7
if conf.cache_control and cc["only-if-cached"] then
return kong.response.exit(ngx.HTTP_GATEWAY_TIMEOUT)
end
ctx.req_body = kong.request.get_raw_body()
-- this request is cacheable but wasn't found in the data store
-- make a note that we should store it in cache later,
-- and pass the request upstream
return signal_cache_req(ctx, conf, cache_key)
elseif err then
kong.log.err(err)
return
end
if res.version ~= CACHE_VERSION then
kong.log.notice("cache format mismatch, purging ", cache_key)
strategy:purge(cache_key)
return signal_cache_req(ctx, conf, cache_key, "Bypass")
end
-- figure out if the client will accept our cache value
if conf.cache_control then
if cc["max-age"] and time() - res.timestamp > cc["max-age"] then
return signal_cache_req(ctx, conf, cache_key, "Refresh")
end
if cc["max-stale"] and time() - res.timestamp - res.ttl > cc["max-stale"]
then
return signal_cache_req(ctx, conf, cache_key, "Refresh")
end
if cc["min-fresh"] and res.ttl - (time() - res.timestamp) < cc["min-fresh"]
then
return signal_cache_req(ctx, conf, cache_key, "Refresh")
end
else
-- don't serve stale data; res may be stored for up to `conf.storage_ttl` secs
if time() - res.timestamp > conf.cache_ttl then
return signal_cache_req(ctx, conf, cache_key, "Refresh")
end
end
-- we have cache data yo!
-- expose response data for logging plugins
local response_data = {
res = res,
req = {
body = res.req_body,
},
server_addr = ngx.var.server_addr,
}
kong.ctx.shared.proxy_cache_hit = response_data
local nctx = ngx.ctx
nctx.KONG_PROXIED = true
for k in pairs(res.headers) do
if not overwritable_header(k) then
res.headers[k] = nil
end
end
reset_res_header(res)
set_res_header(res, "age", floor(time() - res.timestamp), conf)
set_res_header(res, "X-Cache-Status", "Hit", conf)
set_res_header(res, "X-Cache-Key", cache_key, conf)
return kong.response.exit(res.status, res.body, res.headers)
end
function ProxyCacheHandler:header_filter(conf)
local ctx = kong.ctx.plugin
local proxy_cache = ctx.proxy_cache
-- don't look at our headers if
-- a) the request wasn't cacheable, or
-- b) the request was served from cache
if not proxy_cache then
return
end
local cc = res_cc()
-- if this is a cacheable request, gather the headers and mark it so
if cacheable_response(conf, cc) then
-- TODO: should this use the kong.conf configured limit?
proxy_cache.res_headers = resp_get_headers(0, true)
proxy_cache.res_ttl = conf.cache_control and calculate_resource_ttl(cc) or conf.cache_ttl
else
set_header(conf, "X-Cache-Status", "Bypass")
ctx.proxy_cache = nil
end
-- TODO handle Vary header
end
function ProxyCacheHandler:body_filter(conf)
local ctx = kong.ctx.plugin
local proxy_cache = ctx.proxy_cache
if not proxy_cache then
return
end
local body = kong.response.get_raw_body()
if body then
local strategy = require(STRATEGY_PATH)({
strategy_name = conf.strategy,
strategy_opts = conf[conf.strategy],
})
local res = {
status = kong.response.get_status(),
headers = proxy_cache.res_headers,
body = body,
body_len = #body,
timestamp = time(),
ttl = proxy_cache.res_ttl,
version = CACHE_VERSION,
req_body = ctx.req_body,
}
local ttl = conf.storage_ttl or conf.cache_control and proxy_cache.res_ttl or
conf.cache_ttl
local ok, err = strategy:store(proxy_cache.cache_key, res, ttl)
if not ok then
kong.log(err)
end
end
end
return ProxyCacheHandler