From 1a32426201276fef634d1b04f5472acd2fd46183 Mon Sep 17 00:00:00 2001 From: fenos Date: Wed, 2 Apr 2025 12:29:00 +0200 Subject: [PATCH] feat: purge cache for specific objects --- .github/workflows/ci.yml | 2 +- .../multitenant/0015-purge-cache-feature.sql | 1 + src/app.ts | 1 + src/config.ts | 6 + src/http/plugins/storage.ts | 3 + src/http/routes/admin/tenants.ts | 21 +++ src/http/routes/cdn/index.ts | 14 ++ src/http/routes/cdn/purgeCache.ts | 63 +++++++++ src/http/routes/index.ts | 1 + src/http/routes/operations.ts | 1 + src/internal/database/tenant.ts | 7 + src/storage/cdn/cdn-cache-manager.ts | 60 +++++++++ src/test/cdn.test.ts | 122 ++++++++++++++++++ src/test/tenant.test.ts | 6 + src/test/x-forwarded-host.test.ts | 3 + 15 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 migrations/multitenant/0015-purge-cache-feature.sql create mode 100644 src/http/routes/cdn/index.ts create mode 100644 src/http/routes/cdn/purgeCache.ts create mode 100644 src/storage/cdn/cdn-cache-manager.ts create mode 100644 src/test/cdn.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c22cb51a..1814d005 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - platform: [ubuntu-20.04] + platform: [ubuntu-24.04] node: ['20'] runs-on: ${{ matrix.platform }} diff --git a/migrations/multitenant/0015-purge-cache-feature.sql b/migrations/multitenant/0015-purge-cache-feature.sql new file mode 100644 index 00000000..a2ed12fa --- /dev/null +++ b/migrations/multitenant/0015-purge-cache-feature.sql @@ -0,0 +1 @@ +ALTER TABLE tenants ADD COLUMN feature_purge_cache boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/src/app.ts b/src/app.ts index a61bff9b..e6896305 100644 --- a/src/app.ts +++ b/src/app.ts @@ -60,6 +60,7 @@ const build = (opts: buildOpts = {}): FastifyInstance => { app.register(routes.object, { prefix: 'object' }) app.register(routes.render, { prefix: 'render/image' }) app.register(routes.s3, { prefix: 's3' }) + app.register(routes.cdn, { prefix: 'cdn' }) app.register(routes.healthcheck, { prefix: 'health' }) setErrorHandler(app) diff --git a/src/config.ts b/src/config.ts index c477cbab..a1e82d2c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -124,6 +124,8 @@ type StorageConfigType = { tracingFeatures?: { upload: boolean } + cdnPurgeEndpointURL?: string + cdnPurgeEndpointKey?: string } function getOptionalConfigFromEnv(key: string, fallback?: string): string | undefined { @@ -323,6 +325,10 @@ export function getConfig(options?: { reload?: boolean }): StorageConfigType { 10 ), + // CDN + cdnPurgeEndpointURL: getOptionalConfigFromEnv('CDN_PURGE_ENDPOINT_URL'), + cdnPurgeEndpointKey: getOptionalConfigFromEnv('CDN_PURGE_ENDPOINT_KEY'), + // Monitoring logLevel: getOptionalConfigFromEnv('LOG_LEVEL') || 'info', logflareEnabled: getOptionalConfigFromEnv('LOGFLARE_ENABLED') === 'true', diff --git a/src/http/plugins/storage.ts b/src/http/plugins/storage.ts index 54ee9a1b..d25d4d2b 100644 --- a/src/http/plugins/storage.ts +++ b/src/http/plugins/storage.ts @@ -3,11 +3,13 @@ import { StorageBackendAdapter, createStorageBackend } from '@storage/backend' import { Storage } from '@storage/storage' import { StorageKnexDB } from '@storage/database' import { getConfig } from '../../config' +import { CdnCacheManager } from '@storage/cdn/cdn-cache-manager' declare module 'fastify' { interface FastifyRequest { storage: Storage backend: StorageBackendAdapter + cdnCache: CdnCacheManager } } @@ -27,6 +29,7 @@ export const storage = fastifyPlugin( }) request.backend = storageBackend request.storage = new Storage(storageBackend, database) + request.cdnCache = new CdnCacheManager(request.storage) }) fastify.addHook('onClose', async () => { diff --git a/src/http/routes/admin/tenants.ts b/src/http/routes/admin/tenants.ts index 132715b2..ce170696 100644 --- a/src/http/routes/admin/tenants.ts +++ b/src/http/routes/admin/tenants.ts @@ -41,6 +41,12 @@ const patchSchema = { maxResolution: { type: 'number', nullable: true }, }, }, + purgeCache: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + }, + }, s3Protocol: { type: 'object', properties: { @@ -88,6 +94,7 @@ interface tenantDBInterface { service_key: string file_size_limit?: number feature_s3_protocol?: boolean + feature_purge_cache?: boolean feature_image_transformation?: boolean image_transformation_max_resolution?: number } @@ -108,6 +115,7 @@ export default async function routes(fastify: FastifyInstance) { jwt_secret, jwks, service_key, + feature_purge_cache, feature_image_transformation, feature_s3_protocol, image_transformation_max_resolution, @@ -133,6 +141,9 @@ export default async function routes(fastify: FastifyInstance) { enabled: feature_image_transformation, maxResolution: image_transformation_max_resolution, }, + purgeCache: { + enabled: feature_purge_cache, + }, s3Protocol: { enabled: feature_s3_protocol, }, @@ -156,6 +167,7 @@ export default async function routes(fastify: FastifyInstance) { jwt_secret, jwks, service_key, + feature_purge_cache, feature_s3_protocol, feature_image_transformation, image_transformation_max_resolution, @@ -184,6 +196,9 @@ export default async function routes(fastify: FastifyInstance) { enabled: feature_image_transformation, maxResolution: image_transformation_max_resolution, }, + purgeCache: { + enabled: feature_purge_cache, + }, s3Protocol: { enabled: feature_s3_protocol, }, @@ -222,6 +237,7 @@ export default async function routes(fastify: FastifyInstance) { jwks, service_key: encrypt(serviceKey), feature_image_transformation: features?.imageTransformation?.enabled ?? false, + feature_purge_cache: features?.purgeCache?.enabled ?? false, feature_s3_protocol: features?.s3Protocol?.enabled ?? true, migrations_version: null, migrations_status: null, @@ -277,6 +293,7 @@ export default async function routes(fastify: FastifyInstance) { jwks, service_key: serviceKey !== undefined ? encrypt(serviceKey) : undefined, feature_image_transformation: features?.imageTransformation?.enabled, + feature_purge_cache: features?.purgeCache?.enabled, feature_s3_protocol: features?.s3Protocol?.enabled, image_transformation_max_resolution: features?.imageTransformation?.maxResolution === null @@ -342,6 +359,10 @@ export default async function routes(fastify: FastifyInstance) { tenantInfo.feature_image_transformation = features?.imageTransformation?.enabled } + if (typeof features?.purgeCache?.enabled !== 'undefined') { + tenantInfo.feature_purge_cache = features?.purgeCache?.enabled + } + if (typeof features?.imageTransformation?.maxResolution !== 'undefined') { tenantInfo.image_transformation_max_resolution = features?.imageTransformation ?.image_transformation_max_resolution as number | undefined diff --git a/src/http/routes/cdn/index.ts b/src/http/routes/cdn/index.ts new file mode 100644 index 00000000..46c82c71 --- /dev/null +++ b/src/http/routes/cdn/index.ts @@ -0,0 +1,14 @@ +import { FastifyInstance } from 'fastify' +import { db, jwt, requireTenantFeature, storage } from '../../plugins' +import purgeCache from './purgeCache' + +export default async function routes(fastify: FastifyInstance) { + fastify.register(async function authenticated(fastify) { + fastify.register(jwt) + fastify.register(db) + fastify.register(storage) + fastify.register(requireTenantFeature('purgeCache')) + + fastify.register(purgeCache) + }) +} diff --git a/src/http/routes/cdn/purgeCache.ts b/src/http/routes/cdn/purgeCache.ts new file mode 100644 index 00000000..4b21934d --- /dev/null +++ b/src/http/routes/cdn/purgeCache.ts @@ -0,0 +1,63 @@ +import { FastifyInstance } from 'fastify' +import { FromSchema } from 'json-schema-to-ts' +import { createDefaultSchema, createResponse } from '../../routes-helper' +import { AuthenticatedRequest } from '../../types' +import { ROUTE_OPERATIONS } from '../operations' +import { getConfig } from '../../../config' + +const { dbServiceRole } = getConfig() + +const purgeObjectParamsSchema = { + type: 'object', + properties: { + bucketName: { type: 'string', examples: ['avatars'] }, + '*': { type: 'string', examples: ['folder/cat.png'] }, + }, + required: ['bucketName', '*'], +} as const +const successResponseSchema = { + type: 'object', + properties: { + message: { type: 'string', examples: ['success'] }, + }, +} +interface deleteObjectRequestInterface extends AuthenticatedRequest { + Params: FromSchema +} + +export default async function routes(fastify: FastifyInstance) { + const summary = 'Purge cache for an object' + + const schema = createDefaultSchema(successResponseSchema, { + params: purgeObjectParamsSchema, + summary, + tags: ['object'], + }) + + fastify.delete( + '/:bucketName/*', + { + schema, + config: { + operation: { type: ROUTE_OPERATIONS.PURGE_OBJECT_CACHE }, + }, + }, + async (request, response) => { + // Must be service role to invoke this API + if (request.jwtPayload?.role !== dbServiceRole) { + return response.status(403).send(createResponse('Forbidden', '403', 'Forbidden')) + } + + const { bucketName } = request.params + const objectName = request.params['*'] + + await request.cdnCache.purge({ + objectName, + bucket: bucketName, + tenant: request.tenantId, + }) + + return response.status(200).send(createResponse('success', '200')) + } + ) +} diff --git a/src/http/routes/index.ts b/src/http/routes/index.ts index 96d73418..48b62233 100644 --- a/src/http/routes/index.ts +++ b/src/http/routes/index.ts @@ -4,4 +4,5 @@ export { default as render } from './render' export { default as tus } from './tus' export { default as healthcheck } from './health' export { default as s3 } from './s3' +export { default as cdn } from './cdn' export * from './admin' diff --git a/src/http/routes/operations.ts b/src/http/routes/operations.ts index b7445a84..03c270e6 100644 --- a/src/http/routes/operations.ts +++ b/src/http/routes/operations.ts @@ -25,6 +25,7 @@ export const ROUTE_OPERATIONS = { MOVE_OBJECT: 'storage.object.move', UPDATE_OBJECT: 'storage.object.upload_update', UPLOAD_SIGN_OBJECT: 'storage.object.upload_signed', + PURGE_OBJECT_CACHE: 'storage.object.purge_cache', // Image Transformation RENDER_AUTH_IMAGE: 'storage.render.image_authenticated', diff --git a/src/internal/database/tenant.ts b/src/internal/database/tenant.ts index 3797fac6..fe19ac62 100644 --- a/src/internal/database/tenant.ts +++ b/src/internal/database/tenant.ts @@ -44,6 +44,9 @@ export interface Features { s3Protocol: { enabled: boolean } + purgeCache: { + enabled: boolean + } } export enum TenantMigrationStatus { @@ -125,6 +128,7 @@ export async function getTenantConfig(tenantId: string): Promise { jwt_secret, jwks, service_key, + feature_purge_cache, feature_image_transformation, feature_s3_protocol, image_transformation_max_resolution, @@ -159,6 +163,9 @@ export async function getTenantConfig(tenantId: string): Promise { s3Protocol: { enabled: feature_s3_protocol, }, + purgeCache: { + enabled: feature_purge_cache, + }, }, migrationVersion: migrations_version, migrationStatus: migrations_status, diff --git a/src/storage/cdn/cdn-cache-manager.ts b/src/storage/cdn/cdn-cache-manager.ts new file mode 100644 index 00000000..b6f81932 --- /dev/null +++ b/src/storage/cdn/cdn-cache-manager.ts @@ -0,0 +1,60 @@ +import { Storage } from '@storage/storage' +import axios, { AxiosError } from 'axios' +import { HttpsAgent } from 'agentkeepalive' +import { ERRORS } from '@internal/errors' + +import { getConfig } from '../../config' + +const { cdnPurgeEndpointURL, cdnPurgeEndpointKey } = getConfig() + +const httpsAgent = new HttpsAgent({ + keepAlive: true, + maxFreeSockets: 20, + maxSockets: 200, + freeSocketTimeout: 1000 * 2, +}) + +const client = axios.create({ + baseURL: cdnPurgeEndpointURL, + httpsAgent: httpsAgent, + headers: { + Authorization: `Bearer ${cdnPurgeEndpointKey}`, + 'Content-Type': 'application/json', + }, +}) + +export interface PurgeCacheInput { + tenant: string + bucket: string + objectName: string +} + +export class CdnCacheManager { + constructor(protected readonly storage: Storage) {} + + async purge(opts: PurgeCacheInput) { + if (!cdnPurgeEndpointURL) { + throw ERRORS.MissingParameter('CDN_PURGE_ENDPOINT_URL is not set') + } + + // Check if object exists + await this.storage.from(opts.bucket).asSuperUser().findObject(opts.objectName) + + // Purge cache + try { + await client.post('/purge', { + tenant: { + ref: opts.tenant, + }, + bucketId: opts.bucket, + objectName: opts.objectName, + }) + } catch (e) { + if (e instanceof AxiosError) { + throw ERRORS.InternalError(e, 'Error purging cache') + } + + throw e + } + } +} diff --git a/src/test/cdn.test.ts b/src/test/cdn.test.ts new file mode 100644 index 00000000..cd959edf --- /dev/null +++ b/src/test/cdn.test.ts @@ -0,0 +1,122 @@ +import { getConfig, mergeConfig } from '../config' + +getConfig() +mergeConfig({ + cdnPurgeEndpointURL: 'http://localhost/stub/cache', + cdnPurgeEndpointKey: 'test-key', +}) + +import app from '../app' + +jest.mock('axios', () => { + const instance = { + post: jest.fn(), + interceptors: { + request: { + use: jest.fn(), + }, + response: { + use: jest.fn(), + }, + }, + } + + return { + create: jest.fn().mockReturnValue(instance), + ...instance, + } +}) + +import { useStorage } from './utils/storage' +import axios from 'axios' +import jwt from 'jsonwebtoken' +import { Readable } from 'stream' + +const { serviceKey, anonKey, tenantId, jwtSecret } = getConfig() + +describe('CDN Cache Manager', () => { + const storageHook = useStorage() + + const bucketName = 'cdn-cache-manager-test-' + Date.now() + beforeAll(async () => { + await storageHook.storage.createBucket({ + id: bucketName, + name: bucketName, + }) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + afterAll(() => { + getConfig({ reload: true }) + }) + + it('will not allowing calling the purge endpoint without service_key', async () => { + // cannot call with anon key + const responseAnon = await app().inject({ + method: 'DELETE', + url: `/cdn/${bucketName}/test-anon.txt§`, + headers: { + authorization: `Bearer ${anonKey}`, + }, + }) + + expect(responseAnon.statusCode).toBe(403) + + // Cannot call with authenticated token + const authenticatedJwt = jwt.sign({ role: 'authenticated' }, jwtSecret, { + subject: 'user-id', + }) + + const responseAuthenticated = await app().inject({ + method: 'DELETE', + url: `/cdn/${bucketName}/test-anon.txt§`, + headers: { + authorization: `Bearer ${authenticatedJwt}`, + }, + }) + + expect(responseAuthenticated.statusCode).toBe(403) + }) + + it('will allow calling the purge endpoint when using service_key', async () => { + const objectName = `purge-file-${Date.now()}.txt` + await storageHook.storage.from(bucketName).uploadNewObject({ + isUpsert: true, + objectName, + file: { + body: Readable.from(Buffer.from('test')), + cacheControl: 'public, max-age=31536000', + mimeType: 'text/plain', + isTruncated: () => false, + userMetadata: {}, + }, + }) + + const spy = jest + .spyOn(axios, 'post') + .mockReturnValue(Promise.resolve({ data: { message: 'success' } })) + + const response = await app().inject({ + method: 'DELETE', + url: `/cdn/${bucketName}/${objectName}`, + headers: { + authorization: `Bearer ${serviceKey}`, + }, + }) + + expect(response.statusCode).toBe(200) + + const body = await response.json() + expect(body).toEqual({ message: 'success' }) + expect(spy).toBeCalledWith('/purge', { + tenant: { + ref: tenantId, + }, + bucketId: bucketName, + objectName: objectName, + }) + }) +}) diff --git a/src/test/tenant.test.ts b/src/test/tenant.test.ts index f816e611..fa292a34 100644 --- a/src/test/tenant.test.ts +++ b/src/test/tenant.test.ts @@ -26,6 +26,9 @@ const payload = { s3Protocol: { enabled: true, }, + purgeCache: { + enabled: false, + }, }, disableEvents: null, } @@ -50,6 +53,9 @@ const payload2 = { s3Protocol: { enabled: true, }, + purgeCache: { + enabled: true, + }, }, disableEvents: null, } diff --git a/src/test/x-forwarded-host.test.ts b/src/test/x-forwarded-host.test.ts index 284bb48d..7824bdd3 100644 --- a/src/test/x-forwarded-host.test.ts +++ b/src/test/x-forwarded-host.test.ts @@ -29,6 +29,9 @@ beforeAll(async () => { s3Protocol: { enabled: true, }, + purgeCache: { + enabled: true, + }, }, }))