Skip to content

Commit

Permalink
feat: image resizing with imgproxy backend
Browse files Browse the repository at this point in the history
  • Loading branch information
fenos committed Oct 3, 2022
1 parent a14d627 commit a10ec91
Show file tree
Hide file tree
Showing 17 changed files with 2,309 additions and 1,585 deletions.
2 changes: 2 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,5 @@ ENCRYPTION_KEY=encryptionkey
LOGFLARE_ENABLED=false
LOGFLARE_API_KEY=api_key
LOGFLARE_SOURCE_TOKEN=source_token

IMGPROXY_URL=http://localhost:50020
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ jobs:
- name: Tests pass
run: |
mkdir data && chmod -R 777 data && \
npm run test:coverage
env:
ANON_KEY: ${{ secrets.ANON_KEY }}
Expand All @@ -66,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
IMGPROXY_URL: http://127.0.0.1:50020

- name: Upload coverage results to Coveralls
uses: coverallsapp/github-action@master
Expand Down
3,419 changes: 1,845 additions & 1,574 deletions package-lock.json

Large diffs are not rendered by default.

8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@
"node": ">= 14.0.0"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.53.0",
"@aws-sdk/lib-storage": "^3.53.0",
"@aws-sdk/node-http-handler": "^3.53.0",
"@aws-sdk/client-s3": "^3.181.0",
"@aws-sdk/lib-storage": "^3.182.0",
"@aws-sdk/node-http-handler": "^3.178.0",
"@aws-sdk/s3-request-presigner": "^3.182.0",
"@fastify/multipart": "^7.1.0",
"@fastify/swagger": "^7.4.1",
"@supabase/postgrest-js": "^0.36.0",
"axios": "^0.27.2",
"crypto-js": "^4.1.1",
"dotenv": "^16.0.0",
"fastify": "^4.2.1",
Expand Down
2 changes: 2 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import fastifyMultipart from '@fastify/multipart'
import fastifySwagger from '@fastify/swagger'
import bucketRoutes from './routes/bucket/'
import objectRoutes from './routes/object'
import renderRoutes from './routes/render'
import { authSchema } from './schemas/auth'
import { errorSchema } from './schemas/error'
import logTenantId from './plugins/log-tenant-id'
Expand Down Expand Up @@ -57,6 +58,7 @@ const build = (opts: buildOpts = {}): FastifyInstance => {
app.register(logRequest({ excludeUrls: ['/status'] }))
app.register(bucketRoutes, { prefix: 'bucket' })
app.register(objectRoutes, { prefix: 'object' })
app.register(renderRoutes, { prefix: 'render' })

app.get('/status', async (request, response) => response.status(200).send())

Expand Down
4 changes: 4 additions & 0 deletions src/backend/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,8 @@ export class FileBackend implements GenericStorageBackend {
size: data.size,
}
}

async privateAssetUrl(bucket: string, key: string): Promise<string> {
return 'local:///' + path.join(this.filePath, `${bucket}/${key}`)
}
}
3 changes: 3 additions & 0 deletions src/backend/generic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,7 @@ export abstract class GenericStorageBackend {
async headObject(bucket: string, key: string): Promise<ObjectMetadata> {
throw new Error('headObject not implemented')
}
async privateAssetUrl(bucket: string, key: string): Promise<string> {
throw new Error('privateAssetUrl not implemented')
}
}
11 changes: 11 additions & 0 deletions src/backend/s3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { NodeHttpHandler } from '@aws-sdk/node-http-handler'
import { ObjectMetadata, ObjectResponse } from '../types/types'
import { GenericStorageBackend, GetObjectHeaders } from './generic'
import { convertErrorToStorageBackendError } from '../utils/errors'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'

export class S3Backend implements GenericStorageBackend {
client: S3Client
Expand Down Expand Up @@ -143,4 +144,14 @@ export class S3Backend implements GenericStorageBackend {
size: data.ContentLength,
}
}

async privateAssetUrl(bucket: string, key: string): Promise<string> {
const input: GetObjectCommandInput = {
Bucket: bucket,
Key: key,
}

const command = new GetObjectCommand(input)
return getSignedUrl(this.client, command, { expiresIn: 3600 })
}
}
72 changes: 72 additions & 0 deletions src/renderer/imgproxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { GenericStorageBackend } from '../backend/generic'
import axios, { Axios } from 'axios'

interface TransformOptions {
width?: number
height?: number
resize?: 'fill' | 'fit' | 'fill-down' | 'force' | 'auto'
}

interface ImgProxyOptions {
url: string
}

const LIMITS = {
height: {
max: 5000,
min: 20,
},
width: {
max: 5000,
min: 20,
},
}

export class Imgproxy {
private client: Axios

constructor(
private readonly backend: GenericStorageBackend,
private readonly options: ImgProxyOptions
) {
this.client = axios.create({
baseURL: options.url,
timeout: 8000,
})
}

getClient() {
return this.client
}

async transform(bucket: string, key: string, options: TransformOptions) {
const privateURL = await this.backend.privateAssetUrl(bucket, key)
const urlTransformation = this.applyURLTransformation(options)

const url = ['/public', ...urlTransformation, 'plain', privateURL]

return this.getClient().get(url.join('/'), {
responseType: 'arraybuffer',
})
}

protected applyURLTransformation(options: TransformOptions) {
const segments = []

if (options.height) {
segments.push(`height:${clamp(options.height, LIMITS.height.min, LIMITS.height.max)}`)
}

if (options.width) {
segments.push(`width:${clamp(options.width, LIMITS.width.min, LIMITS.width.max)}`)
}

if (options.width || options.height) {
segments.push(`resizing_type:${options.resize || 'fill'}`)
}

return segments
}
}

const clamp = (num: number, min: number, max: number) => Math.min(Math.max(num, min), max)
20 changes: 20 additions & 0 deletions src/routes/render/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { FastifyInstance } from 'fastify'
import { postgrest, superUserPostgrest } from '../../plugins/postgrest'
import renderPublicImage from './renderPublicImage'
import renderAuthenticatedImage from './renderAuthenticatedImage'
import jwt from '../../plugins/jwt'

export default async function routes(fastify: FastifyInstance) {
fastify.register(async function authorizationContext(fastify) {
fastify.register(jwt)
fastify.register(postgrest)

fastify.register(renderAuthenticatedImage)
})

fastify.register(async (fastify) => {
fastify.register(superUserPostgrest)

fastify.register(renderPublicImage)
})
}
111 changes: 111 additions & 0 deletions src/routes/render/renderAuthenticatedImage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { getConfig } from '../../utils/config'
import { GenericStorageBackend } from '../../backend/generic'
import { FileBackend } from '../../backend/file'
import { S3Backend } from '../../backend/s3'
import { FromSchema } from 'json-schema-to-ts'
import { FastifyInstance } from 'fastify'
import { Obj } from '../../types/types'
import { normalizeContentType, transformPostgrestError } from '../../utils'
import { Imgproxy } from '../../renderer/imgproxy'
import { AxiosError } from 'axios'

const { region, globalS3Bucket, globalS3Endpoint, storageBackendType, imgProxyURL } = getConfig()

let storageBackend: GenericStorageBackend

if (storageBackendType === 'file') {
storageBackend = new FileBackend()
} else {
storageBackend = new S3Backend(region, globalS3Endpoint)
}

const imageRenderer = new Imgproxy(storageBackend, {
url: imgProxyURL || '',
})

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: 'number', examples: [100] },
width: { type: 'number', examples: [100] },
resize: { type: 'string', enum: ['fill', 'fit', 'fill-down', 'force', 'auto'] },
},
} as const

interface renderImageRequestInterface {
Params: FromSchema<typeof renderAuthenticatedImageParamsSchema>
Querystring: FromSchema<typeof renderImageQuerySchema>
}

export default async function routes(fastify: FastifyInstance) {
const summary = 'Render an authenticated image with the given transformations'
fastify.get<renderImageRequestInterface>(
'/authenticated/:bucketName/*',
{
schema: {
params: renderAuthenticatedImageParamsSchema,
summary,
response: { '4xx': { $ref: 'errorSchema#', description: 'Error response' } },
tags: ['object'],
},
},
async (request, response) => {
const { bucketName } = request.params
const objectName = request.params['*']

const objectResponse = await request.postgrest
.from<Obj>('objects')
.select('id')
.match({
name: objectName,
bucket_id: bucketName,
})
.single()

if (objectResponse.error) {
const { status, error } = objectResponse
request.log.error({ error }, 'error object')
return response.status(400).send(transformPostgrestError(error, status))
}

const s3Key = `${request.tenantId}/${bucketName}/${objectName}`

try {
const imageResponse = await imageRenderer.transform(globalS3Bucket, s3Key, request.query)

response
.status(imageResponse.status)
.header('Accept-Ranges', 'bytes')
.header('Content-Type', normalizeContentType(imageResponse.headers['content-type']))
.header('Cache-Control', imageResponse.headers['cache-control'])
.header('Content-Length', imageResponse.headers['content-length'])
.header('ETag', imageResponse.headers['etag'])

return response.send(imageResponse.data)
} catch (err: any) {
if (err instanceof AxiosError) {
return response.status(err.response?.status || 500).send({
message: err.message,
statusCode: err.response?.status || '500',
error: err.message,
})
}

return response.status(500).send({
message: err.message,
statusCode: '500',
error: err.message,
})
}
}
)
}
Loading

0 comments on commit a10ec91

Please sign in to comment.