416 lines
11 KiB
Lua
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
|