Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
strategy:
fail-fast: false
matrix:
platform: [ubuntu-20.04]
platform: [ubuntu-24.04]
node: ['20']

runs-on: ${{ matrix.platform }}
Expand Down
1 change: 1 addition & 0 deletions migrations/multitenant/0015-purge-cache-feature.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE tenants ADD COLUMN feature_purge_cache boolean DEFAULT false NOT NULL;
1 change: 1 addition & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@
tracingFeatures?: {
upload: boolean
}
cdnPurgeEndpointURL?: string
cdnPurgeEndpointKey?: string
}

function getOptionalConfigFromEnv(key: string, fallback?: string): string | undefined {
Expand Down Expand Up @@ -323,6 +325,10 @@
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',
Expand Down Expand Up @@ -453,7 +459,7 @@
try {
const parsed = JSON.parse(jwtJWKS)
config.jwtJWKS = parsed
} catch (e: any) {

Check warning on line 462 in src/config.ts

View workflow job for this annotation

GitHub Actions / Test / OS ubuntu-24.04 / Node 20

'e' is defined but never used

Check warning on line 462 in src/config.ts

View workflow job for this annotation

GitHub Actions / Test / OS ubuntu-24.04 / Node 20

Unexpected any. Specify a different type
throw new Error('Unable to parse JWT_JWKS value to JSON')
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/http/plugins/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand All @@ -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 () => {
Expand Down
21 changes: 21 additions & 0 deletions src/http/routes/admin/tenants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ const patchSchema = {
maxResolution: { type: 'number', nullable: true },
},
},
purgeCache: {
type: 'object',
properties: {
enabled: { type: 'boolean' },
},
},
s3Protocol: {
type: 'object',
properties: {
Expand Down Expand Up @@ -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
}
Expand All @@ -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,
Expand All @@ -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,
},
Expand All @@ -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,
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions src/http/routes/cdn/index.ts
Original file line number Diff line number Diff line change
@@ -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)
})
}
63 changes: 63 additions & 0 deletions src/http/routes/cdn/purgeCache.ts
Original file line number Diff line number Diff line change
@@ -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<typeof purgeObjectParamsSchema>
}

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<deleteObjectRequestInterface>(
'/: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'))
}
)
}
1 change: 1 addition & 0 deletions src/http/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
1 change: 1 addition & 0 deletions src/http/routes/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
7 changes: 7 additions & 0 deletions src/internal/database/tenant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ export interface Features {
s3Protocol: {
enabled: boolean
}
purgeCache: {
enabled: boolean
}
}

export enum TenantMigrationStatus {
Expand Down Expand Up @@ -125,6 +128,7 @@ export async function getTenantConfig(tenantId: string): Promise<TenantConfig> {
jwt_secret,
jwks,
service_key,
feature_purge_cache,
feature_image_transformation,
feature_s3_protocol,
image_transformation_max_resolution,
Expand Down Expand Up @@ -159,6 +163,9 @@ export async function getTenantConfig(tenantId: string): Promise<TenantConfig> {
s3Protocol: {
enabled: feature_s3_protocol,
},
purgeCache: {
enabled: feature_purge_cache,
},
},
migrationVersion: migrations_version,
migrationStatus: migrations_status,
Expand Down
60 changes: 60 additions & 0 deletions src/storage/cdn/cdn-cache-manager.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading
Loading