From 3bfe601b82231d417c74a1821b9d921b5c055bd7 Mon Sep 17 00:00:00 2001 From: fenos Date: Thu, 24 Nov 2022 16:02:36 +0000 Subject: [PATCH] fix: renaming render routes + enchantments --- .env.sample | 1 + .github/workflows/ci.yml | 1 + src/app.ts | 2 +- src/config.ts | 4 +- src/http/routes/render/index.ts | 6 +- .../routes/render/renderAuthenticatedImage.ts | 3 + src/http/routes/render/renderPublicImage.ts | 4 + src/http/routes/render/renderSignedImage.ts | 76 +++++++++++++++++++ src/storage/backend/s3.ts | 74 ++++++++++-------- src/storage/renderer/image.ts | 2 - src/test/render-routes.test.ts | 4 +- 11 files changed, 137 insertions(+), 40 deletions(-) create mode 100644 src/http/routes/render/renderSignedImage.ts diff --git a/.env.sample b/.env.sample index 107bc9f5d..32fe36e47 100644 --- a/.env.sample +++ b/.env.sample @@ -23,6 +23,7 @@ LOGFLARE_ENABLED=false LOGFLARE_API_KEY=api_key LOGFLARE_SOURCE_TOKEN=source_token +ENABLE_IMAGE_TRANSFORMATION=false IMGPROXY_URL=http://localhost:50020 # specify the extra headers will be persisted and passed into Postgrest client, for instance, "x-foo,x-bar" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea45e0f2e..2778dd17f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,7 @@ jobs: MULTITENANT_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5433/postgres POSTGREST_URL_SUFFIX: /rest/v1 ADMIN_API_KEYS: apikey + ENABLE_IMAGE_TRANSFORMATION: true IMGPROXY_URL: http://127.0.0.1:50020 - name: Upload coverage results to Coveralls diff --git a/src/app.ts b/src/app.ts index d1f029bbb..b7efdac12 100644 --- a/src/app.ts +++ b/src/app.ts @@ -51,7 +51,7 @@ const build = (opts: buildOpts = {}): FastifyInstance => { app.register(plugins.logRequest({ excludeUrls: ['/status'] })) app.register(routes.bucket, { prefix: 'bucket' }) app.register(routes.object, { prefix: 'object' }) - app.register(routes.render, { prefix: 'render' }) + app.register(routes.render, { prefix: 'render/image' }) setErrorHandler(app) diff --git a/src/config.ts b/src/config.ts index a3609563d..7f954b44a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -32,7 +32,7 @@ type StorageConfigType = { pgQueueConnectionURL?: string webhookURL?: string webhookApiKey?: string - disableImageTransformation: boolean + enableImageTransformation: boolean imgProxyURL?: string imgProxyRequestTimeout: number imgLimits: { @@ -102,7 +102,7 @@ export function getConfig(): StorageConfigType { pgQueueConnectionURL: getOptionalConfigFromEnv('PG_QUEUE_CONNECTION_URL'), webhookURL: getOptionalConfigFromEnv('WEBHOOK_URL'), webhookApiKey: getOptionalConfigFromEnv('WEBHOOK_API_KEY'), - disableImageTransformation: getOptionalConfigFromEnv('DISABLE_IMAGE_TRANSFORMATION') === 'true', + enableImageTransformation: getOptionalConfigFromEnv('ENABLE_IMAGE_TRANSFORMATION') === 'true', imgProxyRequestTimeout: parseInt( getOptionalConfigFromEnv('IMGPROXY_REQUEST_TIMEOUT') || '15', 10 diff --git a/src/http/routes/render/index.ts b/src/http/routes/render/index.ts index 1a6310274..32fc25a5a 100644 --- a/src/http/routes/render/index.ts +++ b/src/http/routes/render/index.ts @@ -1,13 +1,14 @@ import { FastifyInstance } from 'fastify' import renderPublicImage from './renderPublicImage' import renderAuthenticatedImage from './renderAuthenticatedImage' +import renderSignedImage from './renderSignedImage' import { jwt, postgrest, superUserPostgrest, storage } from '../../plugins' import { getConfig } from '../../../config' -const { disableImageTransformation } = getConfig() +const { enableImageTransformation } = getConfig() export default async function routes(fastify: FastifyInstance) { - if (disableImageTransformation) { + if (!enableImageTransformation) { return } @@ -22,6 +23,7 @@ export default async function routes(fastify: FastifyInstance) { fastify.register(async (fastify) => { fastify.register(superUserPostgrest) fastify.register(storage) + fastify.register(renderSignedImage) fastify.register(renderPublicImage) }) } diff --git a/src/http/routes/render/renderAuthenticatedImage.ts b/src/http/routes/render/renderAuthenticatedImage.ts index 13027b0f4..f8fc8fd70 100644 --- a/src/http/routes/render/renderAuthenticatedImage.ts +++ b/src/http/routes/render/renderAuthenticatedImage.ts @@ -20,6 +20,7 @@ const renderImageQuerySchema = { height: { type: 'integer', examples: [100], minimum: 0 }, width: { type: 'integer', examples: [100], minimum: 0 }, resize: { type: 'string', enum: ['fill', 'fit', 'fill-down', 'force', 'auto'] }, + download: { type: 'string' }, }, } as const @@ -42,6 +43,7 @@ export default async function routes(fastify: FastifyInstance) { }, }, async (request, response) => { + const { download } = request.query const { bucketName } = request.params const objectName = request.params['*'] @@ -54,6 +56,7 @@ export default async function routes(fastify: FastifyInstance) { return renderer.setTransformations(request.query).render(request, response, { bucket: globalS3Bucket, key: s3Key, + download, }) } ) diff --git a/src/http/routes/render/renderPublicImage.ts b/src/http/routes/render/renderPublicImage.ts index 3b74c22e1..dc96e3c33 100644 --- a/src/http/routes/render/renderPublicImage.ts +++ b/src/http/routes/render/renderPublicImage.ts @@ -9,6 +9,7 @@ const renderPublicImageParamsSchema = { type: 'object', properties: { bucketName: { type: 'string', examples: ['avatars'] }, + download: { type: 'string' }, '*': { type: 'string', examples: ['folder/cat.png'] }, }, required: ['bucketName', '*'], @@ -20,6 +21,7 @@ const renderImageQuerySchema = { height: { type: 'integer', examples: [100], minimum: 0 }, width: { type: 'integer', examples: [100], minimum: 0 }, resize: { type: 'string', enum: ['fill', 'fit', 'fill-down', 'force', 'auto'] }, + download: { type: 'string' }, }, } as const @@ -42,6 +44,7 @@ export default async function routes(fastify: FastifyInstance) { }, }, async (request, response) => { + const { download } = request.query const { bucketName } = request.params const objectName = request.params['*'] @@ -54,6 +57,7 @@ export default async function routes(fastify: FastifyInstance) { return renderer.setTransformations(request.query).render(request, response, { bucket: globalS3Bucket, key: s3Key, + download, }) } ) diff --git a/src/http/routes/render/renderSignedImage.ts b/src/http/routes/render/renderSignedImage.ts new file mode 100644 index 000000000..129006b0b --- /dev/null +++ b/src/http/routes/render/renderSignedImage.ts @@ -0,0 +1,76 @@ +import { getConfig } from '../../../config' +import { FromSchema } from 'json-schema-to-ts' +import { FastifyInstance } from 'fastify' +import { ImageRenderer } from '../../../storage/renderer' +import { getJwtSecret, SignedToken, verifyJWT } from '../../../auth' +import { StorageBackendError } from '../../../storage' + +const { globalS3Bucket } = getConfig() + +const renderAuthenticatedImageParamsSchema = { + type: 'object', + properties: { + bucketName: { type: 'string', examples: ['avatars'] }, + '*': { type: 'string', examples: ['folder/cat.png'] }, + }, + required: ['bucketName', '*'], +} as const + +const renderImageQuerySchema = { + type: 'object', + properties: { + height: { type: 'integer', examples: [100], minimum: 0 }, + width: { type: 'integer', examples: [100], minimum: 0 }, + resize: { type: 'string', enum: ['fill', 'fit', 'fill-down', 'force', 'auto'] }, + token: { type: 'string' }, + download: { type: 'string' }, + }, + required: ['token'], +} as const + +interface renderImageRequestInterface { + Params: FromSchema + Querystring: FromSchema +} + +export default async function routes(fastify: FastifyInstance) { + const summary = 'Render an authenticated image with the given transformations' + fastify.get( + '/signed/:bucketName/*', + { + schema: { + params: renderAuthenticatedImageParamsSchema, + querystring: renderImageQuerySchema, + summary, + response: { '4xx': { $ref: 'errorSchema#', description: 'Error response' } }, + tags: ['object'], + }, + }, + async (request, response) => { + const { token } = request.query + const { download } = request.query + + let payload: SignedToken + const jwtSecret = await getJwtSecret(request.tenantId) + + try { + payload = (await verifyJWT(token, jwtSecret)) as SignedToken + } catch (e) { + const err = e as Error + throw new StorageBackendError('Invalid JWT', 400, err.message, err) + } + + const { url } = payload + const s3Key = `${request.tenantId}/${url}` + request.log.info(s3Key) + + const renderer = request.storage.renderer('image') as ImageRenderer + + return renderer.setTransformations(request.query).render(request, response, { + bucket: globalS3Bucket, + key: s3Key, + download, + }) + } + ) +} diff --git a/src/storage/backend/s3.ts b/src/storage/backend/s3.ts index 55067c08c..594b25804 100644 --- a/src/storage/backend/s3.ts +++ b/src/storage/backend/s3.ts @@ -154,14 +154,18 @@ export class S3Backend implements StorageBackendAdapter { source: string, destination: string ): Promise> { - const command = new CopyObjectCommand({ - Bucket: bucket, - CopySource: `/${bucket}/${source}`, - Key: destination, - }) - const data = await this.client.send(command) - return { - httpStatusCode: data.$metadata.httpStatusCode || 200, + try { + const command = new CopyObjectCommand({ + Bucket: bucket, + CopySource: `/${bucket}/${source}`, + Key: destination, + }) + const data = await this.client.send(command) + return { + httpStatusCode: data.$metadata.httpStatusCode || 200, + } + } catch (e: any) { + throw StorageBackendError.fromError(e) } } @@ -171,17 +175,21 @@ export class S3Backend implements StorageBackendAdapter { * @param prefixes */ async deleteObjects(bucket: string, prefixes: string[]): Promise { - const s3Prefixes = prefixes.map((ele) => { - return { Key: ele } - }) + try { + const s3Prefixes = prefixes.map((ele) => { + return { Key: ele } + }) - const command = new DeleteObjectsCommand({ - Bucket: bucket, - Delete: { - Objects: s3Prefixes, - }, - }) - await this.client.send(command) + const command = new DeleteObjectsCommand({ + Bucket: bucket, + Delete: { + Objects: s3Prefixes, + }, + }) + await this.client.send(command) + } catch (e) { + throw StorageBackendError.fromError(e) + } } /** @@ -190,19 +198,23 @@ export class S3Backend implements StorageBackendAdapter { * @param key */ async headObject(bucket: string, key: string): Promise { - const command = new HeadObjectCommand({ - Bucket: bucket, - Key: key, - }) - const data = await this.client.send(command) - return { - cacheControl: data.CacheControl || 'no-cache', - mimetype: data.ContentType || 'application/octet-stream', - eTag: data.ETag || '', - lastModified: data.LastModified, - contentLength: data.ContentLength || 0, - httpStatusCode: data.$metadata.httpStatusCode || 200, - size: data.ContentLength || 0, + try { + const command = new HeadObjectCommand({ + Bucket: bucket, + Key: key, + }) + const data = await this.client.send(command) + return { + cacheControl: data.CacheControl || 'no-cache', + mimetype: data.ContentType || 'application/octet-stream', + eTag: data.ETag || '', + lastModified: data.LastModified, + contentLength: data.ContentLength || 0, + httpStatusCode: data.$metadata.httpStatusCode || 200, + size: data.ContentLength || 0, + } + } catch (e: any) { + throw StorageBackendError.fromError(e) } } diff --git a/src/storage/renderer/image.ts b/src/storage/renderer/image.ts index 0814f5a46..09694569a 100644 --- a/src/storage/renderer/image.ts +++ b/src/storage/renderer/image.ts @@ -98,8 +98,6 @@ export class ImageRenderer extends Renderer { privateURL.startsWith('local://') ? privateURL : encodeURIComponent(privateURL), ] - console.log(url.join('/')) - try { const response = await this.getClient().get(url.join('/'), { responseType: 'stream', diff --git a/src/test/render-routes.test.ts b/src/test/render-routes.test.ts index 2c8e6568c..4ea282446 100644 --- a/src/test/render-routes.test.ts +++ b/src/test/render-routes.test.ts @@ -34,7 +34,7 @@ describe('image rendering routes', () => { const response = await app().inject({ method: 'GET', - url: '/render/authenticated/bucket2/authenticated/casestudy.png?width=100&height=100', + url: '/render/image/authenticated/bucket2/authenticated/casestudy.png?width=100&height=100', headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, @@ -55,7 +55,7 @@ describe('image rendering routes', () => { const response = await app().inject({ method: 'GET', - url: '/render/public/public-bucket-2/favicon.ico?width=100&height=100', + url: '/render/image/public/public-bucket-2/favicon.ico?width=100&height=100', }) expect(response.statusCode).toBe(200)