From f0c69538d97ddb753ba8347937b8a981e0437e48 Mon Sep 17 00:00:00 2001 From: Fabrizio Date: Mon, 21 Oct 2024 17:10:35 +0200 Subject: [PATCH] feat: allow increasing resolution on a per tenant basis (#574) --- .../0012-image-transformation-limits.sql | 2 ++ src/http/routes/admin/tenants.ts | 17 ++++++++++++++- .../routes/render/renderAuthenticatedImage.ts | 10 ++++++++- src/http/routes/render/renderPublicImage.ts | 10 ++++++++- src/http/routes/render/renderSignedImage.ts | 12 +++++++++-- src/internal/database/tenant.ts | 3 +++ src/storage/renderer/image.ts | 21 +++++++++++++++++++ src/test/tenant.test.ts | 2 ++ 8 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 migrations/multitenant/0012-image-transformation-limits.sql diff --git a/migrations/multitenant/0012-image-transformation-limits.sql b/migrations/multitenant/0012-image-transformation-limits.sql new file mode 100644 index 00000000..a166c6df --- /dev/null +++ b/migrations/multitenant/0012-image-transformation-limits.sql @@ -0,0 +1,2 @@ + +ALTER TABLE tenants ADD COLUMN image_transformation_max_resolution int NULL; diff --git a/src/http/routes/admin/tenants.ts b/src/http/routes/admin/tenants.ts index 1d0d3d76..05e68565 100644 --- a/src/http/routes/admin/tenants.ts +++ b/src/http/routes/admin/tenants.ts @@ -32,13 +32,14 @@ const patchSchema = { type: 'object', properties: { enabled: { type: 'boolean' }, + maxResolution: { type: 'number', nullable: true }, }, }, }, }, }, }, - optional: ['tracingMode'], + optional: ['tracingMode', 'maxResolution'], } as const const schema = { @@ -75,6 +76,7 @@ interface tenantDBInterface { service_key: string file_size_limit?: number feature_image_transformation?: boolean + image_transformation_max_resolution?: number } export default async function routes(fastify: FastifyInstance) { @@ -94,6 +96,7 @@ export default async function routes(fastify: FastifyInstance) { jwks, service_key, feature_image_transformation, + image_transformation_max_resolution, migrations_version, migrations_status, tracing_mode, @@ -113,6 +116,7 @@ export default async function routes(fastify: FastifyInstance) { features: { imageTransformation: { enabled: feature_image_transformation, + maxResolution: image_transformation_max_resolution, }, }, }) @@ -134,6 +138,7 @@ export default async function routes(fastify: FastifyInstance) { jwks, service_key, feature_image_transformation, + image_transformation_max_resolution, migrations_version, migrations_status, tracing_mode, @@ -156,6 +161,7 @@ export default async function routes(fastify: FastifyInstance) { features: { imageTransformation: { enabled: feature_image_transformation, + maxResolution: image_transformation_max_resolution, }, }, migrationVersion: migrations_version, @@ -244,6 +250,10 @@ export default async function routes(fastify: FastifyInstance) { jwks, service_key: serviceKey !== undefined ? encrypt(serviceKey) : undefined, feature_image_transformation: features?.imageTransformation?.enabled, + image_transformation_max_resolution: + features?.imageTransformation?.maxResolution === null + ? null + : features?.imageTransformation?.maxResolution, tracing_mode: tracingMode, }) .where('id', tenantId) @@ -300,6 +310,11 @@ export default async function routes(fastify: FastifyInstance) { tenantInfo.feature_image_transformation = features?.imageTransformation?.enabled } + if (typeof features?.imageTransformation?.maxResolution !== 'undefined') { + tenantInfo.image_transformation_max_resolution = features?.imageTransformation + ?.image_transformation_max_resolution as number | undefined + } + if (databasePoolUrl) { tenantInfo.database_pool_url = encrypt(databasePoolUrl) } diff --git a/src/http/routes/render/renderAuthenticatedImage.ts b/src/http/routes/render/renderAuthenticatedImage.ts index 51d0ae3a..657cfbc5 100644 --- a/src/http/routes/render/renderAuthenticatedImage.ts +++ b/src/http/routes/render/renderAuthenticatedImage.ts @@ -4,8 +4,9 @@ import { FastifyInstance } from 'fastify' import { ImageRenderer } from '@storage/renderer' import { transformationOptionsSchema } from '../../schemas/transformations' import { ROUTE_OPERATIONS } from '../operations' +import { getTenantConfig } from '@internal/database' -const { storageS3Bucket } = getConfig() +const { storageS3Bucket, isMultitenant } = getConfig() const renderAuthenticatedImageParamsSchema = { type: 'object', @@ -56,6 +57,13 @@ export default async function routes(fastify: FastifyInstance) { const renderer = request.storage.renderer('image') as ImageRenderer + if (isMultitenant) { + const tenantConfig = await getTenantConfig(request.tenantId) + renderer.setLimits({ + maxResolution: tenantConfig.features.imageTransformation.maxResolution, + }) + } + return renderer.setTransformations(request.query).render(request, response, { bucket: storageS3Bucket, key: s3Key, diff --git a/src/http/routes/render/renderPublicImage.ts b/src/http/routes/render/renderPublicImage.ts index 09ae3ed8..64f175e1 100644 --- a/src/http/routes/render/renderPublicImage.ts +++ b/src/http/routes/render/renderPublicImage.ts @@ -4,8 +4,9 @@ import { FastifyInstance } from 'fastify' import { ImageRenderer } from '@storage/renderer' import { transformationOptionsSchema } from '../../schemas/transformations' import { ROUTE_OPERATIONS } from '../operations' +import { getTenantConfig } from '@internal/database' -const { storageS3Bucket } = getConfig() +const { storageS3Bucket, isMultitenant } = getConfig() const renderPublicImageParamsSchema = { type: 'object', @@ -61,6 +62,13 @@ export default async function routes(fastify: FastifyInstance) { const renderer = request.storage.renderer('image') as ImageRenderer + if (isMultitenant) { + const tenantConfig = await getTenantConfig(request.tenantId) + renderer.setLimits({ + maxResolution: tenantConfig.features.imageTransformation.maxResolution, + }) + } + return renderer.setTransformations(request.query).render(request, response, { bucket: storageS3Bucket, key: s3Key, diff --git a/src/http/routes/render/renderSignedImage.ts b/src/http/routes/render/renderSignedImage.ts index 79e92441..77692ce7 100644 --- a/src/http/routes/render/renderSignedImage.ts +++ b/src/http/routes/render/renderSignedImage.ts @@ -2,14 +2,14 @@ import { FromSchema } from 'json-schema-to-ts' import { FastifyInstance } from 'fastify' import { SignedToken, verifyJWT } from '@internal/auth' -import { getJwtSecret } from '@internal/database' +import { getJwtSecret, getTenantConfig } from '@internal/database' import { ERRORS } from '@internal/errors' import { ImageRenderer } from '@storage/renderer' import { getConfig } from '../../../config' import { ROUTE_OPERATIONS } from '../operations' -const { storageS3Bucket } = getConfig() +const { storageS3Bucket, isMultitenant } = getConfig() const renderAuthenticatedImageParamsSchema = { type: 'object', @@ -86,6 +86,14 @@ export default async function routes(fastify: FastifyInstance) { .findObject(objParts.join('/'), 'id,version') const renderer = request.storage.renderer('image') as ImageRenderer + + if (isMultitenant) { + const tenantConfig = await getTenantConfig(request.tenantId) + renderer.setLimits({ + maxResolution: tenantConfig.features.imageTransformation.maxResolution, + }) + } + return renderer .setTransformationsFromString(transformations || '') .render(request, response, { diff --git a/src/internal/database/tenant.ts b/src/internal/database/tenant.ts index 978fee5d..5d2896b0 100644 --- a/src/internal/database/tenant.ts +++ b/src/internal/database/tenant.ts @@ -39,6 +39,7 @@ interface TenantConfig { export interface Features { imageTransformation: { enabled: boolean + maxResolution?: number } } @@ -202,6 +203,7 @@ export async function getTenantConfig(tenantId: string): Promise { jwks, service_key, feature_image_transformation, + image_transformation_max_resolution, database_pool_url, max_connections, migrations_version, @@ -227,6 +229,7 @@ export async function getTenantConfig(tenantId: string): Promise { features: { imageTransformation: { enabled: feature_image_transformation, + maxResolution: image_transformation_max_resolution, }, }, migrationVersion: migrations_version, diff --git a/src/storage/renderer/image.ts b/src/storage/renderer/image.ts index 40de8f28..1e5c924a 100644 --- a/src/storage/renderer/image.ts +++ b/src/storage/renderer/image.ts @@ -68,6 +68,10 @@ axiosRetry(client, { }, }) +interface TransformLimits { + maxResolution?: number +} + /** * ImageRenderer * renders an image by applying transformations @@ -77,6 +81,7 @@ axiosRetry(client, { export class ImageRenderer extends Renderer { private readonly client: Axios private transformOptions?: TransformOptions + private limits?: TransformLimits constructor(private readonly backend: StorageBackendAdapter) { super() @@ -118,6 +123,15 @@ export class ImageRenderer extends Renderer { return segments } + static applyTransformationLimits(limits: TransformLimits) { + const transforms: string[] = [] + if (typeof limits?.maxResolution === 'number') { + transforms.push(`max_src_resolution:${limits.maxResolution}`) + } + + return transforms + } + protected static formatResizeType(resize: TransformOptions['resize']) { const defaultResize = 'fill' @@ -149,6 +163,11 @@ export class ImageRenderer extends Renderer { return this } + setLimits(limits: TransformLimits) { + this.limits = limits + return this + } + setTransformationsFromString(transformations: string) { const params = transformations.split(',') @@ -190,10 +209,12 @@ export class ImageRenderer extends Renderer { this.backend.headObject(options.bucket, options.key, options.version), ]) const transformations = ImageRenderer.applyTransformation(this.transformOptions || {}) + const transformLimits = ImageRenderer.applyTransformationLimits(this.limits || {}) const url = [ '/public', ...transformations, + ...transformLimits, 'plain', privateURL.startsWith('local://') ? privateURL : encodeURIComponent(privateURL), ] diff --git a/src/test/tenant.test.ts b/src/test/tenant.test.ts index 8508fb57..d25c0f0f 100644 --- a/src/test/tenant.test.ts +++ b/src/test/tenant.test.ts @@ -21,6 +21,7 @@ const payload = { features: { imageTransformation: { enabled: true, + maxResolution: null, }, }, } @@ -40,6 +41,7 @@ const payload2 = { features: { imageTransformation: { enabled: false, + maxResolution: null, }, }, }