From d56f02697aff620483010932049304dd9bdd755b Mon Sep 17 00:00:00 2001 From: Rijk van Zanten Date: Tue, 15 Jun 2021 17:11:29 -0400 Subject: [PATCH] Allow overriding the s-maxage cache header (#6294) * Allow overriding the s-maxage cache header * Only load expiry / set headers when cache exists --- api/src/env.ts | 1 + api/src/middleware/cache.ts | 18 ++++------- api/src/middleware/respond.ts | 21 +++++-------- api/src/utils/get-cache-headers.ts | 42 +++++++++++++++++++++++++ docs/reference/environment-variables.md | 15 ++++----- 5 files changed, 64 insertions(+), 33 deletions(-) create mode 100644 api/src/utils/get-cache-headers.ts diff --git a/api/src/env.ts b/api/src/env.ts index 5873a5e1de..96277e67ee 100644 --- a/api/src/env.ts +++ b/api/src/env.ts @@ -50,6 +50,7 @@ const defaults: Record = { CACHE_TTL: '10m', CACHE_NAMESPACE: 'system-cache', CACHE_AUTO_PURGE: false, + CACHE_CONTROL_S_MAXAGE: '0', OAUTH_PROVIDERS: '', diff --git a/api/src/middleware/cache.ts b/api/src/middleware/cache.ts index 483317226e..0f47869b5c 100644 --- a/api/src/middleware/cache.ts +++ b/api/src/middleware/cache.ts @@ -2,6 +2,7 @@ import { RequestHandler } from 'express'; import cache from '../cache'; import env from '../env'; import asyncHandler from '../utils/async-handler'; +import { getCacheControlHeader } from '../utils/get-cache-headers'; import { getCacheKey } from '../utils/get-cache-key'; const checkCacheMiddleware: RequestHandler = asyncHandler(async (req, res, next) => { @@ -17,18 +18,11 @@ const checkCacheMiddleware: RequestHandler = asyncHandler(async (req, res, next) const cachedData = await cache.get(key); if (cachedData) { - // Set cache-control header, but only for the public role - if (env.CACHE_AUTO_PURGE !== true && !!req.accountability?.role === false) { - const expiresAt = await cache.get(`${key}__expires_at`); - const maxAge = `max-age=${expiresAt - Date.now()}`; - res.setHeader('Cache-Control', `public, ${maxAge}`); - } else { - // This indicates that the browser/proxy is allowed to cache, but has to revalidate with - // the server before use. At this point, we don't include Last-Modified, so it'll always - // recreate the local cache. This does NOT mean that cache is disabled all together, as - // Directus is still pulling the value from it's internal cache. - res.setHeader('Cache-Control', 'no-cache'); - } + const cacheExpiryDate = (await cache.get(`${key}__expires_at`)) as number | null; + const cacheTTL = cacheExpiryDate ? cacheExpiryDate - Date.now() : null; + + res.setHeader('Cache-Control', getCacheControlHeader(req, cacheTTL)); + res.setHeader('Vary', 'Origin, Cache-Control'); return res.json(cachedData); } else { diff --git a/api/src/middleware/respond.ts b/api/src/middleware/respond.ts index 11a81eb215..748d5e48ff 100644 --- a/api/src/middleware/respond.ts +++ b/api/src/middleware/respond.ts @@ -7,6 +7,7 @@ import env from '../env'; import asyncHandler from '../utils/async-handler'; import { getCacheKey } from '../utils/get-cache-key'; import { parse as toXML } from 'js2xmlparser'; +import { getCacheControlHeader } from '../utils/get-cache-headers'; export const respond: RequestHandler = asyncHandler(async (req, res) => { if ( @@ -19,20 +20,12 @@ export const respond: RequestHandler = asyncHandler(async (req, res) => { const key = getCacheKey(req); await cache.set(key, res.locals.payload, ms(env.CACHE_TTL as string)); await cache.set(`${key}__expires_at`, Date.now() + ms(env.CACHE_TTL as string)); - - const noCacheRequested = - req.headers['cache-control']?.includes('no-cache') || req.headers['Cache-Control']?.includes('no-cache'); - - // Set cache-control header - if (env.CACHE_AUTO_PURGE !== true && noCacheRequested === false) { - const maxAge = `max-age=${ms(env.CACHE_TTL as string)}`; - const access = !!req.accountability?.role === false ? 'public' : 'private'; - res.setHeader('Cache-Control', `${access}, ${maxAge}`); - } - - if (noCacheRequested) { - res.setHeader('Cache-Control', 'no-cache'); - } + res.setHeader('Cache-Control', getCacheControlHeader(req, ms(env.CACHE_TTL as string))); + res.setHeader('Vary', 'Origin, Cache-Control'); + } else { + // Don't cache anything by default + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Vary', 'Origin, Cache-Control'); } if (req.sanitizedQuery.export) { diff --git a/api/src/utils/get-cache-headers.ts b/api/src/utils/get-cache-headers.ts new file mode 100644 index 0000000000..8dad8ab3db --- /dev/null +++ b/api/src/utils/get-cache-headers.ts @@ -0,0 +1,42 @@ +import env from '../env'; +import { Request } from 'express'; + +/** + * Returns the Cache-Control header for the current request + * + * @param req Express request object + * @param ttl TTL of the cache in ms + */ +export function getCacheControlHeader(req: Request, ttl: number | null): string { + // When the resource / current request isn't cached + if (ttl === null) return 'no-cache'; + + // When the API cache can invalidate at any moment + if (env.CACHE_AUTO_PURGE === true) return 'no-cache'; + + const noCacheRequested = + req.headers['cache-control']?.includes('no-cache') || req.headers['Cache-Control']?.includes('no-cache'); + + // When the user explicitly asked to skip the cache + if (noCacheRequested) return 'no-cache'; + + // Cache control header uses seconds for everything + const ttlSeconds = Math.round(ttl / 1000); + + const access = !!req.accountability?.role === false ? 'public' : 'private'; + + let headerValue = `${access}, max-age=${ttlSeconds}`; + + // When the s-maxage flag should be included + if (env.CACHE_CONTROL_S_MAXAGE !== false) { + // Default to regular max-age flag when true + if (env.CACHE_CONTROL_S_MAXAGE === true) { + headerValue += `, s-maxage=${ttlSeconds}`; + } else { + // Set to custom value + headerValue += `, s-maxage=${env.CACHE_CONTROL_S_MAXAGE}`; + } + } + + return headerValue; +} diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index 2334a1db67..2cfb7001ec 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -125,13 +125,14 @@ needs, you can extend the above environment variables to configure any of ## Cache -| Variable | Description | Default Value | -| ------------------ | ----------------------------------------------------------------------- | ---------------- | -| `CACHE_ENABLED` | Whether or not caching is enabled. | `false` | -| `CACHE_TTL` | How long the cache is persisted. | `30m` | -| `CACHE_AUTO_PURGE` | Automatically purge the cache on `create`/`update`/`delete` actions. | `false` | -| `CACHE_NAMESPACE` | How to scope the cache data. | `directus-cache` | -| `CACHE_STORE` | Where to store the cache data. Either `memory`, `redis`, or `memcache`. | `memory` | +| Variable | Description | Default Value | +| ------------------------ | -------------------------------------------------------------------------------------- | ---------------- | +| `CACHE_ENABLED` | Whether or not caching is enabled. | `false` | +| `CACHE_TTL` | How long the cache is persisted. | `30m` | +| `CACHE_CONTROL_S_MAXAGE` | Whether to not to add the s-maxage expiration flag. Set to a number for a custom value | `0` | +| `CACHE_AUTO_PURGE` | Automatically purge the cache on `create`/`update`/`delete` actions. | `false` | +| `CACHE_NAMESPACE` | How to scope the cache data. | `directus-cache` | +| `CACHE_STORE` | Where to store the cache data. Either `memory`, `redis`, or `memcache`. | `memory` | Based on the `CACHE_STORE` used, you must also provide the following configurations: