290 lines
7.3 KiB
TypeScript
290 lines
7.3 KiB
TypeScript
import { ObjectMetadata, StorageBackendAdapter } from '../backend'
|
|
import axios, { Axios, AxiosError } from 'axios'
|
|
import { getConfig } from '../../config'
|
|
import { FastifyRequest } from 'fastify'
|
|
import { Renderer, RenderOptions } from './renderer'
|
|
import axiosRetry from 'axios-retry'
|
|
import { ERRORS } from '@internal/errors'
|
|
import { Stream } from 'stream'
|
|
import Agent from 'agentkeepalive'
|
|
|
|
/**
|
|
* All the transformations options available
|
|
*/
|
|
export interface TransformOptions {
|
|
width?: number
|
|
height?: number
|
|
resize?: 'cover' | 'contain' | 'fill'
|
|
format?: 'origin' | 'avif'
|
|
quality?: number
|
|
}
|
|
|
|
const {
|
|
imgLimits,
|
|
imgProxyHttpMaxSockets,
|
|
imgProxyHttpKeepAlive,
|
|
imgProxyURL,
|
|
imgProxyRequestTimeout,
|
|
} = getConfig()
|
|
|
|
const LIMITS = {
|
|
height: {
|
|
min: imgLimits.size.min,
|
|
max: imgLimits.size.max,
|
|
},
|
|
width: {
|
|
min: imgLimits.size.min,
|
|
max: imgLimits.size.max,
|
|
},
|
|
}
|
|
|
|
const client = axios.create({
|
|
baseURL: imgProxyURL,
|
|
timeout: imgProxyRequestTimeout * 1000,
|
|
httpAgent:
|
|
imgProxyHttpMaxSockets > 0
|
|
? new Agent({
|
|
maxSockets: imgProxyHttpMaxSockets,
|
|
freeSocketTimeout: 2 * 1000,
|
|
keepAlive: true,
|
|
timeout: imgProxyHttpKeepAlive * 1000,
|
|
})
|
|
: undefined,
|
|
})
|
|
|
|
axiosRetry(client, {
|
|
retries: 5,
|
|
shouldResetTimeout: true,
|
|
retryDelay: (retryCount, error) => {
|
|
let exponentialTime = 50
|
|
|
|
if (error.response?.status === 500) {
|
|
exponentialTime = 150
|
|
}
|
|
return retryCount * exponentialTime
|
|
},
|
|
retryCondition: async (err) => {
|
|
return [429, 500].includes(err.response?.status || 0)
|
|
},
|
|
})
|
|
|
|
interface TransformLimits {
|
|
maxResolution?: number
|
|
}
|
|
|
|
/**
|
|
* ImageRenderer
|
|
* renders an image by applying transformations
|
|
*
|
|
* Interacts with an imgproxy backend for the actual transformation
|
|
*/
|
|
export class ImageRenderer extends Renderer {
|
|
private readonly client: Axios
|
|
private transformOptions?: TransformOptions
|
|
private limits?: TransformLimits
|
|
|
|
constructor(private readonly backend: StorageBackendAdapter) {
|
|
super()
|
|
this.client = client
|
|
}
|
|
|
|
/**
|
|
* Applies whitelisted transformations with specific limits applied
|
|
* @param options
|
|
* @param keepOriginal
|
|
*/
|
|
static applyTransformation(options: TransformOptions, keepOriginal?: boolean): string[] {
|
|
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) {
|
|
if (keepOriginal) {
|
|
segments.push(`resize:${options.resize}`)
|
|
} else {
|
|
segments.push(`resizing_type:${this.formatResizeType(options.resize)}`)
|
|
}
|
|
}
|
|
|
|
if (options.quality) {
|
|
segments.push(`quality:${options.quality}`)
|
|
}
|
|
|
|
if (options.format && options.format !== 'origin') {
|
|
segments.push(`format:${options.format}`)
|
|
}
|
|
|
|
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'
|
|
|
|
switch (resize) {
|
|
case 'cover':
|
|
return defaultResize
|
|
case 'contain':
|
|
return 'fit'
|
|
case 'fill':
|
|
return 'force'
|
|
default:
|
|
return defaultResize
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the base http client
|
|
*/
|
|
getClient() {
|
|
return this.client
|
|
}
|
|
|
|
/**
|
|
* Set transformations parameters before calling the render method
|
|
* @param transformations
|
|
*/
|
|
setTransformations(transformations: TransformOptions) {
|
|
this.transformOptions = transformations
|
|
return this
|
|
}
|
|
|
|
setLimits(limits: TransformLimits) {
|
|
this.limits = limits
|
|
return this
|
|
}
|
|
|
|
setTransformationsFromString(transformations: string) {
|
|
const params = transformations.split(',')
|
|
|
|
this.transformOptions = params.reduce((all, param) => {
|
|
const [name, value] = param.split(':') as [keyof TransformOptions, any]
|
|
switch (name) {
|
|
case 'height':
|
|
all.height = parseInt(value, 10)
|
|
break
|
|
case 'width':
|
|
all.width = parseInt(value, 10)
|
|
break
|
|
case 'resize':
|
|
all.resize = value
|
|
break
|
|
case 'format':
|
|
all.format = value
|
|
break
|
|
case 'quality':
|
|
all.quality = parseInt(value, 10)
|
|
break
|
|
}
|
|
return all
|
|
}, {} as TransformOptions)
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Fetch the transformed asset from imgproxy.
|
|
* We use a secure signed url in order for imgproxy to download and
|
|
* transform the image
|
|
* @param request
|
|
* @param options
|
|
*/
|
|
async getAsset(request: FastifyRequest, options: RenderOptions) {
|
|
const [privateURL, headObj] = await Promise.all([
|
|
this.backend.privateAssetUrl(options.bucket, options.key, options.version),
|
|
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),
|
|
]
|
|
|
|
try {
|
|
const acceptHeader =
|
|
this.transformOptions?.format !== 'origin' ? request.headers['accept'] : undefined
|
|
|
|
const response = await this.getClient().get(url.join('/'), {
|
|
responseType: 'stream',
|
|
signal: options.signal,
|
|
headers: acceptHeader
|
|
? {
|
|
accept: acceptHeader,
|
|
}
|
|
: undefined,
|
|
})
|
|
|
|
const contentLength = parseInt(response.headers['content-length'], 10)
|
|
const lastModified = response.headers['last-modified']
|
|
? new Date(response.headers['last-modified'])
|
|
: undefined
|
|
|
|
return {
|
|
body: response.data,
|
|
transformations,
|
|
metadata: {
|
|
httpStatusCode: response.status,
|
|
size: contentLength,
|
|
contentLength: contentLength,
|
|
lastModified: lastModified,
|
|
eTag: headObj.eTag,
|
|
cacheControl: headObj.cacheControl,
|
|
mimetype: response.headers['content-type'],
|
|
} as ObjectMetadata,
|
|
}
|
|
} catch (e) {
|
|
if (e instanceof AxiosError) {
|
|
const error = await this.handleRequestError(e)
|
|
throw error.withMetadata({
|
|
transformations,
|
|
})
|
|
}
|
|
|
|
throw e
|
|
}
|
|
}
|
|
|
|
protected async handleRequestError(error: AxiosError) {
|
|
const stream = error.response?.data as Stream
|
|
if (!stream) {
|
|
throw ERRORS.InternalError(undefined, error.message)
|
|
}
|
|
|
|
const errorResponse = await new Promise<string>((resolve) => {
|
|
let errorBuffer = ''
|
|
|
|
stream.on('data', (data) => {
|
|
errorBuffer += data
|
|
})
|
|
|
|
stream.on('end', () => {
|
|
resolve(errorBuffer)
|
|
})
|
|
})
|
|
|
|
const statusCode = error.response?.status || 500
|
|
return ERRORS.ImageProcessingError(statusCode, errorResponse)
|
|
}
|
|
}
|
|
|
|
const clamp = (num: number, min: number, max: number) => Math.min(Math.max(num, min), max)
|