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
1 change: 1 addition & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ type StorageConfigType = {
pgQueueConnectionURL?: string
webhookURL?: string
webhookApiKey?: string
disableImageTransformation: boolean
enableImageTransformation: boolean
imgProxyURL?: string
imgProxyRequestTimeout: number
imgLimits: {
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/http/routes/render/index.ts
Original file line number Diff line number Diff line change
@@ -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
}

Expand All @@ -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)
})
}
3 changes: 3 additions & 0 deletions src/http/routes/render/renderAuthenticatedImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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['*']

Expand All @@ -54,6 +56,7 @@ export default async function routes(fastify: FastifyInstance) {
return renderer.setTransformations(request.query).render(request, response, {
bucket: globalS3Bucket,
key: s3Key,
download,
})
}
)
Expand Down
4 changes: 4 additions & 0 deletions src/http/routes/render/renderPublicImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', '*'],
Expand All @@ -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

Expand All @@ -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['*']

Expand All @@ -54,6 +57,7 @@ export default async function routes(fastify: FastifyInstance) {
return renderer.setTransformations(request.query).render(request, response, {
bucket: globalS3Bucket,
key: s3Key,
download,
})
}
)
Expand Down
76 changes: 76 additions & 0 deletions src/http/routes/render/renderSignedImage.ts
Original file line number Diff line number Diff line change
@@ -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<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>(
'/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,
})
}
)
}
74 changes: 43 additions & 31 deletions src/storage/backend/s3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,14 +154,18 @@ export class S3Backend implements StorageBackendAdapter {
source: string,
destination: string
): Promise<Pick<ObjectMetadata, 'httpStatusCode'>> {
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)
}
}

Expand All @@ -171,17 +175,21 @@ export class S3Backend implements StorageBackendAdapter {
* @param prefixes
*/
async deleteObjects(bucket: string, prefixes: string[]): Promise<void> {
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)
}
}

/**
Expand All @@ -190,19 +198,23 @@ export class S3Backend implements StorageBackendAdapter {
* @param key
*/
async headObject(bucket: string, key: string): Promise<ObjectMetadata> {
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)
}
}

Expand Down
2 changes: 0 additions & 2 deletions src/storage/renderer/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions src/test/render-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
},
Expand All @@ -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)
Expand Down