EXTENSIONS_CACHE_TTL: Add to allow list & decouple from other cache envs (#17464)

* EXTENSIONS_CACHE_TTL: Add to allow list & decouple from other cache envs

* Remove unnecessary import

* Broaden getCacheControlHeader function, use it for assets & extensions

* Add unit tests

* Apply suggestions from code review

Consistent lowercase "cache-control" in test description

---------

Co-authored-by: ian <licitdev@gmail.com>
This commit is contained in:
Pascal Jufer
2023-02-14 16:28:40 +01:00
committed by GitHub
parent 1e824f7f21
commit 45c4bc89f1
7 changed files with 256 additions and 30 deletions

View File

@@ -14,6 +14,7 @@ import useCollection from '../middleware/use-collection';
import { AssetsService, PayloadService } from '../services';
import { TransformationMethods, TransformationParams, TransformationPreset } from '../types/assets';
import asyncHandler from '../utils/async-handler';
import { getCacheControlHeader } from '../utils/get-cache-headers';
import { getConfigFromEnv } from '../utils/get-config-from-env';
const router = Router();
@@ -159,12 +160,10 @@ router.get(
const { stream, file, stat } = await service.getAsset(id, transformation, range);
const access = req.accountability?.role ? 'private' : 'public';
res.attachment(req.params.filename ?? file.filename_download);
res.setHeader('Content-Type', file.type);
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Cache-Control', `${access}, max-age=${ms(env.ASSETS_CACHE_TTL as string) / 1000}`);
res.setHeader('Cache-Control', getCacheControlHeader(req, ms(env.ASSETS_CACHE_TTL as string), false, true));
const unixTime = Date.parse(file.modified_on);
if (!Number.isNaN(unixTime)) {

View File

@@ -45,10 +45,7 @@ router.get(
}
res.setHeader('Content-Type', 'application/javascript; charset=UTF-8');
res.setHeader(
'Cache-Control',
env.EXTENSIONS_CACHE_TTL ? getCacheControlHeader(req, ms(env.EXTENSIONS_CACHE_TTL as string)) : 'no-store'
);
res.setHeader('Cache-Control', getCacheControlHeader(req, ms(env.EXTENSIONS_CACHE_TTL as string), false, false));
res.setHeader('Vary', 'Origin, Cache-Control');
res.end(extensionSource);
})

View File

@@ -142,6 +142,7 @@ const allowedEnvironmentVars = [
// extensions
'EXTENSIONS_PATH',
'EXTENSIONS_AUTO_RELOAD',
'EXTENSIONS_CACHE_TTL',
// messenger
'MESSENGER_STORE',
'MESSENGER_NAMESPACE',

View File

@@ -43,7 +43,7 @@ const checkCacheMiddleware: RequestHandler = asyncHandler(async (req, res, next)
const cacheTTL = cacheExpiryDate ? cacheExpiryDate - Date.now() : null;
res.setHeader('Cache-Control', getCacheControlHeader(req, cacheTTL));
res.setHeader('Cache-Control', getCacheControlHeader(req, cacheTTL, true, true));
res.setHeader('Vary', 'Origin, Cache-Control');
if (env.CACHE_STATUS_HEADER) res.setHeader(`${env.CACHE_STATUS_HEADER}`, 'HIT');

View File

@@ -39,7 +39,7 @@ export const respond: RequestHandler = asyncHandler(async (req, res) => {
logger.warn(err, `[cache] Couldn't set key ${key}. ${err}`);
}
res.setHeader('Cache-Control', getCacheControlHeader(req, ms(env.CACHE_TTL as string)));
res.setHeader('Cache-Control', getCacheControlHeader(req, ms(env.CACHE_TTL as string), true, true));
res.setHeader('Vary', 'Origin, Cache-Control');
} else {
// Don't cache anything by default

View File

@@ -0,0 +1,222 @@
import type { Request } from 'express';
import { describe, expect, vi, test } from 'vitest';
import { getCacheControlHeader } from '../../src/utils/get-cache-headers';
let factoryEnv: { [k: string]: any } = {};
vi.mock('../../src/env', () => ({
default: new Proxy(
{},
{
get(_target, prop) {
return factoryEnv[prop as string];
},
}
),
}));
const scenarios = [
// Test the cache-control header
{
name: 'when cache-control header includes no-store',
input: {
env: {},
headers: { 'cache-control': 'no-store' },
accountability: null,
ttl: 5678910,
globalCacheSettings: false,
personalized: false,
},
output: 'no-store',
},
{
name: 'when cache-Control header includes no-store',
input: {
env: {},
headers: { 'Cache-Control': 'no-store' },
accountability: null,
ttl: 5678910,
globalCacheSettings: false,
personalized: false,
},
output: 'no-store',
},
{
name: 'when cache-Control header does not include no-store',
input: {
env: {},
headers: { other: 'value' },
accountability: null,
ttl: 5678910,
globalCacheSettings: false,
personalized: false,
},
output: 'max-age=5679',
},
// Test the ttl value
{
name: 'when ttl is null',
input: {
env: {},
headers: {},
accountability: null,
ttl: null,
globalCacheSettings: false,
personalized: false,
},
output: 'no-cache',
},
{
name: 'when ttl is < 0',
input: {
env: {},
headers: {},
accountability: null,
ttl: -1,
globalCacheSettings: false,
personalized: false,
},
output: 'no-cache',
},
{
name: 'when ttl is 0',
input: {
env: {},
headers: {},
accountability: null,
ttl: 0,
globalCacheSettings: false,
personalized: false,
},
output: 'max-age=0',
},
// Test CACHE_AUTO_PURGE env for no-cache
{
name: 'when CACHE_AUTO_PURGE is true and globalCacheSettings is true',
input: {
env: {
CACHE_AUTO_PURGE: true,
},
headers: {},
accountability: null,
ttl: 5678910,
globalCacheSettings: true,
personalized: false,
},
output: 'no-cache',
},
{
name: 'when CACHE_AUTO_PURGE is true and globalCacheSettings is false',
input: {
env: {
CACHE_AUTO_PURGE: true,
},
headers: {},
accountability: null,
ttl: 5678910,
globalCacheSettings: false,
personalized: false,
},
output: 'max-age=5679',
},
{
name: 'when CACHE_AUTO_PURGE is true and globalCacheSettings is true',
input: {
env: {
CACHE_AUTO_PURGE: false,
},
headers: {},
accountability: null,
ttl: 5678910,
globalCacheSettings: true,
personalized: false,
},
output: 'max-age=5679',
},
// Test personalized
{
name: 'when personalized is true and accountability is null',
input: {
env: {},
headers: {},
accountability: null,
ttl: 5678910,
globalCacheSettings: false,
personalized: true,
},
output: 'public, max-age=5679',
},
{
name: 'when personalized is true and accountability is provided',
input: {
env: {},
headers: {},
accountability: {
role: '7efc7413-7ffe-4e6f-a0ac-687bbf9f8076',
},
ttl: 5678910,
globalCacheSettings: false,
personalized: true,
},
output: 'private, max-age=5679',
},
{
name: 'when personalized is true and accountability with missing role is provided',
input: {
env: {},
headers: {},
accountability: {},
ttl: 5678910,
globalCacheSettings: false,
personalized: true,
},
output: 'public, max-age=5679',
},
// Test CACHE_CONTROL_S_MAXAGE env for s-maxage flag
{
name: 'when globalCacheSettings is true and CACHE_CONTROL_S_MAXAGE is set',
input: {
env: {
CACHE_CONTROL_S_MAXAGE: 123456,
},
headers: {},
accountability: null,
ttl: 5678910,
globalCacheSettings: true,
personalized: false,
},
output: 'max-age=5679, s-maxage=123456',
},
{
name: 'when globalCacheSettings is true and CACHE_CONTROL_S_MAXAGE is not set',
input: {
env: {},
headers: {},
accountability: null,
ttl: 5678910,
globalCacheSettings: true,
personalized: false,
},
output: 'max-age=5679',
},
];
describe('get cache headers', () => {
for (const scenario of scenarios) {
test(scenario.name, () => {
const mockRequest = {
headers: scenario.input.headers as any,
accountability: scenario.input.accountability,
} as Partial<Request>;
factoryEnv = scenario.input.env;
const { ttl, globalCacheSettings, personalized } = scenario.input;
expect(getCacheControlHeader(mockRequest as Request, ttl, globalCacheSettings, personalized)).toEqual(
scenario.output
);
});
}
});

View File

@@ -6,37 +6,44 @@ import { Request } from 'express';
*
* @param req Express request object
* @param ttl TTL of the cache in ms
* @param globalCacheSettings Whether requests are affected by the global cache settings (i.e. for dynamic API requests)
* @param personalized Whether requests depend on the authentication status of users
*/
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';
export function getCacheControlHeader(
req: Request,
ttl: number | null,
globalCacheSettings: boolean,
personalized: boolean
): string {
const noCacheRequested =
req.headers['cache-control']?.includes('no-store') || req.headers['Cache-Control']?.includes('no-store');
// When the user explicitly asked to skip the cache
if (noCacheRequested) return 'no-store';
// Cache control header uses seconds for everything
const ttlSeconds = Math.round(ttl / 1000);
// When the resource / current request shouldn't be cached
if (ttl === null || ttl < 0) return 'no-cache';
const access = !!req.accountability?.role === false ? 'public' : 'private';
// When the API cache can invalidate at any moment
if (globalCacheSettings && env.CACHE_AUTO_PURGE === true) return 'no-cache';
let headerValue = `${access}, max-age=${ttlSeconds}`;
const headerValues = [];
// 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}`;
}
// When caching depends on the authentication status of the users
if (personalized) {
// Allow response to be stored in shared cache (public) or local cache only (private)
const access = !!req.accountability?.role === false ? 'public' : 'private';
headerValues.push(access);
}
return headerValue;
// Cache control header uses seconds for everything
const ttlSeconds = Math.round(ttl / 1000);
headerValues.push(`max-age=${ttlSeconds}`);
// When the s-maxage flag should be included
if (globalCacheSettings && Number.isInteger(env.CACHE_CONTROL_S_MAXAGE) && env.CACHE_CONTROL_S_MAXAGE >= 0) {
headerValues.push(`s-maxage=${env.CACHE_CONTROL_S_MAXAGE}`);
}
return headerValues.join(', ');
}