mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
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:
222
api/src/utils/get-cache-headers.test.ts
Normal file
222
api/src/utils/get-cache-headers.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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(', ');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user