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:
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
|
||||
@@ -142,6 +142,7 @@ const allowedEnvironmentVars = [
|
||||
// extensions
|
||||
'EXTENSIONS_PATH',
|
||||
'EXTENSIONS_AUTO_RELOAD',
|
||||
'EXTENSIONS_CACHE_TTL',
|
||||
// messenger
|
||||
'MESSENGER_STORE',
|
||||
'MESSENGER_NAMESPACE',
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
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