Skip to content

Commit 954644b

Browse files
authored
feat: purge cache for specific objects (#650)
1 parent f9ea21c commit 954644b

File tree

15 files changed

+310
-1
lines changed

15 files changed

+310
-1
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
strategy:
1414
fail-fast: false
1515
matrix:
16-
platform: [ubuntu-20.04]
16+
platform: [ubuntu-24.04]
1717
node: ['20']
1818

1919
runs-on: ${{ matrix.platform }}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE tenants ADD COLUMN feature_purge_cache boolean DEFAULT false NOT NULL;

src/app.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ const build = (opts: buildOpts = {}): FastifyInstance => {
6060
app.register(routes.object, { prefix: 'object' })
6161
app.register(routes.render, { prefix: 'render/image' })
6262
app.register(routes.s3, { prefix: 's3' })
63+
app.register(routes.cdn, { prefix: 'cdn' })
6364
app.register(routes.healthcheck, { prefix: 'health' })
6465

6566
setErrorHandler(app)

src/config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ type StorageConfigType = {
124124
tracingFeatures?: {
125125
upload: boolean
126126
}
127+
cdnPurgeEndpointURL?: string
128+
cdnPurgeEndpointKey?: string
127129
}
128130

129131
function getOptionalConfigFromEnv(key: string, fallback?: string): string | undefined {
@@ -323,6 +325,10 @@ export function getConfig(options?: { reload?: boolean }): StorageConfigType {
323325
10
324326
),
325327

328+
// CDN
329+
cdnPurgeEndpointURL: getOptionalConfigFromEnv('CDN_PURGE_ENDPOINT_URL'),
330+
cdnPurgeEndpointKey: getOptionalConfigFromEnv('CDN_PURGE_ENDPOINT_KEY'),
331+
326332
// Monitoring
327333
logLevel: getOptionalConfigFromEnv('LOG_LEVEL') || 'info',
328334
logflareEnabled: getOptionalConfigFromEnv('LOGFLARE_ENABLED') === 'true',

src/http/plugins/storage.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import { StorageBackendAdapter, createStorageBackend } from '@storage/backend'
33
import { Storage } from '@storage/storage'
44
import { StorageKnexDB } from '@storage/database'
55
import { getConfig } from '../../config'
6+
import { CdnCacheManager } from '@storage/cdn/cdn-cache-manager'
67

78
declare module 'fastify' {
89
interface FastifyRequest {
910
storage: Storage
1011
backend: StorageBackendAdapter
12+
cdnCache: CdnCacheManager
1113
}
1214
}
1315

@@ -27,6 +29,7 @@ export const storage = fastifyPlugin(
2729
})
2830
request.backend = storageBackend
2931
request.storage = new Storage(storageBackend, database)
32+
request.cdnCache = new CdnCacheManager(request.storage)
3033
})
3134

3235
fastify.addHook('onClose', async () => {

src/http/routes/admin/tenants.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ const patchSchema = {
4141
maxResolution: { type: 'number', nullable: true },
4242
},
4343
},
44+
purgeCache: {
45+
type: 'object',
46+
properties: {
47+
enabled: { type: 'boolean' },
48+
},
49+
},
4450
s3Protocol: {
4551
type: 'object',
4652
properties: {
@@ -88,6 +94,7 @@ interface tenantDBInterface {
8894
service_key: string
8995
file_size_limit?: number
9096
feature_s3_protocol?: boolean
97+
feature_purge_cache?: boolean
9198
feature_image_transformation?: boolean
9299
image_transformation_max_resolution?: number
93100
}
@@ -108,6 +115,7 @@ export default async function routes(fastify: FastifyInstance) {
108115
jwt_secret,
109116
jwks,
110117
service_key,
118+
feature_purge_cache,
111119
feature_image_transformation,
112120
feature_s3_protocol,
113121
image_transformation_max_resolution,
@@ -133,6 +141,9 @@ export default async function routes(fastify: FastifyInstance) {
133141
enabled: feature_image_transformation,
134142
maxResolution: image_transformation_max_resolution,
135143
},
144+
purgeCache: {
145+
enabled: feature_purge_cache,
146+
},
136147
s3Protocol: {
137148
enabled: feature_s3_protocol,
138149
},
@@ -156,6 +167,7 @@ export default async function routes(fastify: FastifyInstance) {
156167
jwt_secret,
157168
jwks,
158169
service_key,
170+
feature_purge_cache,
159171
feature_s3_protocol,
160172
feature_image_transformation,
161173
image_transformation_max_resolution,
@@ -184,6 +196,9 @@ export default async function routes(fastify: FastifyInstance) {
184196
enabled: feature_image_transformation,
185197
maxResolution: image_transformation_max_resolution,
186198
},
199+
purgeCache: {
200+
enabled: feature_purge_cache,
201+
},
187202
s3Protocol: {
188203
enabled: feature_s3_protocol,
189204
},
@@ -222,6 +237,7 @@ export default async function routes(fastify: FastifyInstance) {
222237
jwks,
223238
service_key: encrypt(serviceKey),
224239
feature_image_transformation: features?.imageTransformation?.enabled ?? false,
240+
feature_purge_cache: features?.purgeCache?.enabled ?? false,
225241
feature_s3_protocol: features?.s3Protocol?.enabled ?? true,
226242
migrations_version: null,
227243
migrations_status: null,
@@ -277,6 +293,7 @@ export default async function routes(fastify: FastifyInstance) {
277293
jwks,
278294
service_key: serviceKey !== undefined ? encrypt(serviceKey) : undefined,
279295
feature_image_transformation: features?.imageTransformation?.enabled,
296+
feature_purge_cache: features?.purgeCache?.enabled,
280297
feature_s3_protocol: features?.s3Protocol?.enabled,
281298
image_transformation_max_resolution:
282299
features?.imageTransformation?.maxResolution === null
@@ -342,6 +359,10 @@ export default async function routes(fastify: FastifyInstance) {
342359
tenantInfo.feature_image_transformation = features?.imageTransformation?.enabled
343360
}
344361

362+
if (typeof features?.purgeCache?.enabled !== 'undefined') {
363+
tenantInfo.feature_purge_cache = features?.purgeCache?.enabled
364+
}
365+
345366
if (typeof features?.imageTransformation?.maxResolution !== 'undefined') {
346367
tenantInfo.image_transformation_max_resolution = features?.imageTransformation
347368
?.image_transformation_max_resolution as number | undefined

src/http/routes/cdn/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { FastifyInstance } from 'fastify'
2+
import { db, jwt, requireTenantFeature, storage } from '../../plugins'
3+
import purgeCache from './purgeCache'
4+
5+
export default async function routes(fastify: FastifyInstance) {
6+
fastify.register(async function authenticated(fastify) {
7+
fastify.register(jwt)
8+
fastify.register(db)
9+
fastify.register(storage)
10+
fastify.register(requireTenantFeature('purgeCache'))
11+
12+
fastify.register(purgeCache)
13+
})
14+
}

src/http/routes/cdn/purgeCache.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { FastifyInstance } from 'fastify'
2+
import { FromSchema } from 'json-schema-to-ts'
3+
import { createDefaultSchema, createResponse } from '../../routes-helper'
4+
import { AuthenticatedRequest } from '../../types'
5+
import { ROUTE_OPERATIONS } from '../operations'
6+
import { getConfig } from '../../../config'
7+
8+
const { dbServiceRole } = getConfig()
9+
10+
const purgeObjectParamsSchema = {
11+
type: 'object',
12+
properties: {
13+
bucketName: { type: 'string', examples: ['avatars'] },
14+
'*': { type: 'string', examples: ['folder/cat.png'] },
15+
},
16+
required: ['bucketName', '*'],
17+
} as const
18+
const successResponseSchema = {
19+
type: 'object',
20+
properties: {
21+
message: { type: 'string', examples: ['success'] },
22+
},
23+
}
24+
interface deleteObjectRequestInterface extends AuthenticatedRequest {
25+
Params: FromSchema<typeof purgeObjectParamsSchema>
26+
}
27+
28+
export default async function routes(fastify: FastifyInstance) {
29+
const summary = 'Purge cache for an object'
30+
31+
const schema = createDefaultSchema(successResponseSchema, {
32+
params: purgeObjectParamsSchema,
33+
summary,
34+
tags: ['object'],
35+
})
36+
37+
fastify.delete<deleteObjectRequestInterface>(
38+
'/:bucketName/*',
39+
{
40+
schema,
41+
config: {
42+
operation: { type: ROUTE_OPERATIONS.PURGE_OBJECT_CACHE },
43+
},
44+
},
45+
async (request, response) => {
46+
// Must be service role to invoke this API
47+
if (request.jwtPayload?.role !== dbServiceRole) {
48+
return response.status(403).send(createResponse('Forbidden', '403', 'Forbidden'))
49+
}
50+
51+
const { bucketName } = request.params
52+
const objectName = request.params['*']
53+
54+
await request.cdnCache.purge({
55+
objectName,
56+
bucket: bucketName,
57+
tenant: request.tenantId,
58+
})
59+
60+
return response.status(200).send(createResponse('success', '200'))
61+
}
62+
)
63+
}

src/http/routes/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ export { default as render } from './render'
44
export { default as tus } from './tus'
55
export { default as healthcheck } from './health'
66
export { default as s3 } from './s3'
7+
export { default as cdn } from './cdn'
78
export * from './admin'

src/http/routes/operations.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export const ROUTE_OPERATIONS = {
2525
MOVE_OBJECT: 'storage.object.move',
2626
UPDATE_OBJECT: 'storage.object.upload_update',
2727
UPLOAD_SIGN_OBJECT: 'storage.object.upload_signed',
28+
PURGE_OBJECT_CACHE: 'storage.object.purge_cache',
2829

2930
// Image Transformation
3031
RENDER_AUTH_IMAGE: 'storage.render.image_authenticated',

src/internal/database/tenant.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ export interface Features {
4444
s3Protocol: {
4545
enabled: boolean
4646
}
47+
purgeCache: {
48+
enabled: boolean
49+
}
4750
}
4851

4952
export enum TenantMigrationStatus {
@@ -125,6 +128,7 @@ export async function getTenantConfig(tenantId: string): Promise<TenantConfig> {
125128
jwt_secret,
126129
jwks,
127130
service_key,
131+
feature_purge_cache,
128132
feature_image_transformation,
129133
feature_s3_protocol,
130134
image_transformation_max_resolution,
@@ -159,6 +163,9 @@ export async function getTenantConfig(tenantId: string): Promise<TenantConfig> {
159163
s3Protocol: {
160164
enabled: feature_s3_protocol,
161165
},
166+
purgeCache: {
167+
enabled: feature_purge_cache,
168+
},
162169
},
163170
migrationVersion: migrations_version,
164171
migrationStatus: migrations_status,

src/storage/cdn/cdn-cache-manager.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { Storage } from '@storage/storage'
2+
import axios, { AxiosError } from 'axios'
3+
import { HttpsAgent } from 'agentkeepalive'
4+
import { ERRORS } from '@internal/errors'
5+
6+
import { getConfig } from '../../config'
7+
8+
const { cdnPurgeEndpointURL, cdnPurgeEndpointKey } = getConfig()
9+
10+
const httpsAgent = new HttpsAgent({
11+
keepAlive: true,
12+
maxFreeSockets: 20,
13+
maxSockets: 200,
14+
freeSocketTimeout: 1000 * 2,
15+
})
16+
17+
const client = axios.create({
18+
baseURL: cdnPurgeEndpointURL,
19+
httpsAgent: httpsAgent,
20+
headers: {
21+
Authorization: `Bearer ${cdnPurgeEndpointKey}`,
22+
'Content-Type': 'application/json',
23+
},
24+
})
25+
26+
export interface PurgeCacheInput {
27+
tenant: string
28+
bucket: string
29+
objectName: string
30+
}
31+
32+
export class CdnCacheManager {
33+
constructor(protected readonly storage: Storage) {}
34+
35+
async purge(opts: PurgeCacheInput) {
36+
if (!cdnPurgeEndpointURL) {
37+
throw ERRORS.MissingParameter('CDN_PURGE_ENDPOINT_URL is not set')
38+
}
39+
40+
// Check if object exists
41+
await this.storage.from(opts.bucket).asSuperUser().findObject(opts.objectName)
42+
43+
// Purge cache
44+
try {
45+
await client.post('/purge', {
46+
tenant: {
47+
ref: opts.tenant,
48+
},
49+
bucketId: opts.bucket,
50+
objectName: opts.objectName,
51+
})
52+
} catch (e) {
53+
if (e instanceof AxiosError) {
54+
throw ERRORS.InternalError(e, 'Error purging cache')
55+
}
56+
57+
throw e
58+
}
59+
}
60+
}

0 commit comments

Comments
 (0)