This commit is contained in:
2021-11-16 16:16:14 +01:00
parent 65919ef75d
commit bc71889ae2
14 changed files with 321 additions and 228 deletions

View File

@@ -1,5 +1,5 @@
import convict from 'convict'
import yaml from 'yaml'
import yaml from 'js-yaml'
export enum StorageType {
Local = 'local',
@@ -14,15 +14,43 @@ export enum URLClean {
Query = 'query',
}
convict.addParser({ extension: ['yml', 'yaml'], parse: yaml.parse })
const RegExpTag = new yaml.Type('!regexp', {
kind: 'scalar',
construct: (value: string) => new RegExp(value),
instanceOf: RegExp,
})
const Schema = yaml.DEFAULT_SCHEMA.extend([RegExpTag])
export type NullableStringOrRegexpArray = (string | RegExp)[] | null
function formatNullableStringOrRegexpArray(values: any) {
if (!Array.isArray(values)) throw new Error('must be an array')
if (values.length === 0) throw new Error('must be an array with at least one element')
for (const value of values) {
if (typeof value === 'string') continue
if (value instanceof RegExp) continue
throw new Error('must be an array of strings or regexps')
}
}
convict.addParser({ extension: ['yml', 'yaml'], parse: (s) => yaml.load(s, { schema: Schema }) })
const config = convict({
// Security
allowedDomains: {
doc: 'The domains that are allowed to be used as image sources',
format: Array,
default: [] as string[],
format: formatNullableStringOrRegexpArray,
default: null as NullableStringOrRegexpArray,
nullable: true,
env: 'ALLOWED_DOMAINS',
},
allowedHosts: {
doc: 'The hosts that are allowed to access the images',
format: formatNullableStringOrRegexpArray,
default: null as NullableStringOrRegexpArray,
nullable: true,
env: 'ALLOWED_HOSTS',
},
cleanUrls: {
doc: 'Whether to clean URLs',
format: Object.values(URLClean),
@@ -61,6 +89,16 @@ for (const file of ['morphus.yaml', 'morphus.yaml', 'morphus.json']) {
} catch {}
}
try {
config.validate({ allowed: 'strict' })
} catch (e) {
if (e instanceof Error) {
console.error(e.message)
} else {
console.error(e)
}
process.exit(1)
}
export const Config = config.get()
console.debug(Config)

204
src/controllers/image.ts Normal file
View File

@@ -0,0 +1,204 @@
import {
IsDefined,
IsInt,
IsOptional,
IsPositive,
IsString,
IsUrl,
IsIn,
IsObject,
ValidateNested,
} from 'class-validator'
import { RouteHandlerMethod } from 'fastify'
import sharp, { FitEnum, FormatEnum } from 'sharp'
import { flatten, unflatten } from 'flat'
import ms from 'ms'
import { storage } from '../storage'
import { transform } from '../transform'
import { sha3, sortObjectByKeys, testForPrefixOrRegexp, validateSyncOrFail } from '../utils/utils'
import { Config, NullableStringOrRegexpArray, URLClean } from '../config'
import { supportsAvif, supportsWebP } from '../utils/caniuse'
import { ForbiddenError } from '../utils/errors'
export enum ImageOperations {
resize,
flip,
flop,
affine,
sharpen,
median,
blur,
flatten,
gamma,
negate,
normalise,
normalize,
clahe,
convolve,
threshold,
boolean,
linear,
recomb,
modulate,
}
export enum ImageFormat {
jpeg,
png,
webp,
gif,
jp2,
tiff,
avif,
heif,
raw,
}
export class ComplexParameter<N = string, T extends object = {}> {
@IsString()
name: N
@IsObject()
options: T
constructor(parameter: string) {
const [name, optionsRaw] = parameter.split('|')
if (!name) throw new Error('Invalid parameter')
this.name = name as any
this.options = {} as any
if (optionsRaw) {
for (const option of optionsRaw.split(',')) {
const [key, value] = option.split(':')
if (!key || !value) continue
// @ts-ignore
this.options[key] = ComplexParameter.ParseValue(value)
}
}
this.options = unflatten(this.options)
}
static ParseValue(value: string) {
if (value === 'true') return true
if (value === 'false') return false
const asNumber = Number(value)
if (!isNaN(asNumber)) return asNumber
return value
}
}
export class TransformQueryBase {
@IsString()
@IsUrl()
@IsDefined()
url!: string
@IsOptional()
@ValidateNested()
format: ComplexParameter<keyof FormatEnum>
@IsOptional()
@IsIn(Object.values(sharp.fit))
resize?: keyof FitEnum
@IsOptional()
@IsInt()
@IsPositive()
width?: number
@IsOptional()
@IsInt()
@IsPositive()
height?: number
@ValidateNested()
op: ComplexParameter[] = []
constructor(data: any, options: { ua?: string }) {
Object.assign(this, data)
if (this.width) this.width = parseInt(this.width as any)
if (this.height) this.height = parseInt(this.height as any)
this.op = Array.isArray(this.op) ? this.op : [this.op]
this.op = this.op.map((op) => new ComplexParameter(op as any))
// @ts-ignore
this.format = new ComplexParameter((this.format as any) || 'auto')
if ((this.format.name as string) === 'auto') {
if (!options.ua) throw new Error('cannot use auto format without user agent')
this.autoFormat(options.ua)
}
validateSyncOrFail(this)
if (this.resize) {
if (!this.width && !this.height) {
throw new Error('width or height is required when resizing')
}
}
switch (Config.cleanUrls) {
case URLClean.Query: {
this.url = this.url.split('#')[0]!
this.url = this.url.split('?')[0]!
break
}
case URLClean.Fragment: {
this.url = this.url.split('#')[0]!
break
}
}
}
toString(): string {
const data = flatten(this) as Record<string, any>
return new URLSearchParams(sortObjectByKeys(data)).toString()
}
autoFormat(ua: string) {
if (supportsAvif(ua)) this.format!.name = 'avif'
else if (supportsWebP(ua)) this.format!.name = 'webp'
else this.format!.name = 'jpeg'
}
get hash(): string {
return sha3(this.toString())
}
}
export const image: RouteHandlerMethod = async (request, reply) => {
try {
const q = new TransformQueryBase(request.query, { ua: request.headers['user-agent'] })
if (Config.allowedDomains) {
if (!testForPrefixOrRegexp(q.url, Config.allowedDomains))
return ForbiddenError(reply, 'source domain not allowed')
}
if (Config.allowedHosts) {
const host = request.headers.host
console.debug('Testing host', host, Config.allowedHosts)
if (!host || !testForPrefixOrRegexp(host, Config.allowedHosts)) return ForbiddenError(reply, 'host not allowed')
}
// @ts-ignore
reply.etag(q.hash)
// @ts-ignore
reply.expires(new Date(Date.now() + ms(Config.maxAge)))
let stream: NodeJS.ReadableStream = (await storage.exists(q.hash))
? await storage.readStream(q.hash)
: await transform(q)
reply.code(200).headers({
'Content-Type': `image/${q.format?.name}`,
})
return stream
// .send(stream)
} catch (err) {
reply.code(400).send(err)
return
}
}

View File

@@ -1,203 +1,2 @@
import {
IsDefined,
IsInt,
IsOptional,
IsPositive,
IsString,
IsUrl,
IsIn,
IsObject,
ValidateNested,
} from 'class-validator'
import { RouteHandlerMethod } from 'fastify'
import sharp, { FitEnum, FormatEnum } from 'sharp'
import { flatten, unflatten } from 'flat'
import ms from 'ms'
import { storage } from '../storage'
import { transform } from '../transform'
import { sha3, sortObjectByKeys, validateSyncOrFail } from '../utils/utils'
import { Config, URLClean } from '../config'
import { supportsAvif, supportsWebP } from '../utils/caniuse'
export class ComplexParameter<N = string, T extends object = {}> {
@IsString()
name: N
@IsObject()
options: T
constructor(parameter: string) {
const [name, optionsRaw] = parameter.split('|')
if (!name) throw new Error('Invalid parameter')
this.name = name as any
this.options = {} as any
if (optionsRaw) {
for (const option of optionsRaw.split(',')) {
const [key, value] = option.split(':')
if (!key || !value) continue
// @ts-ignore
this.options[key] = ComplexParameter.ParseValue(value)
}
}
this.options = unflatten(this.options)
}
static ParseValue(value: string) {
if (value === 'true') return true
if (value === 'false') return false
const asNumber = Number(value)
if (!isNaN(asNumber)) return asNumber
return value
}
}
export enum ImageOperations {
resize,
flip,
flop,
affine,
sharpen,
median,
blur,
flatten,
gamma,
negate,
normalise,
normalize,
clahe,
convolve,
threshold,
boolean,
linear,
recomb,
modulate,
}
export enum ImageFormat {
jpeg,
png,
webp,
gif,
jp2,
tiff,
avif,
heif,
raw,
}
export class TransformQueryBase {
@IsString()
@IsUrl()
@IsDefined()
url!: string
@IsOptional()
@ValidateNested()
format?: ComplexParameter<keyof FormatEnum>
@IsOptional()
@IsIn(Object.values(sharp.fit))
resize?: keyof FitEnum
@IsOptional()
@IsInt()
@IsPositive()
width?: number
@IsOptional()
@IsInt()
@IsPositive()
height?: number
@ValidateNested()
op: ComplexParameter[] = []
hash: string
constructor(data: any, options: { ua?: string }) {
Object.assign(this, data)
if (this.width) this.width = parseInt(this.width as any)
if (this.height) this.height = parseInt(this.height as any)
this.op = Array.isArray(this.op) ? this.op : [this.op]
this.op = this.op.map((op) => new ComplexParameter(op as any))
if (this.format) this.format = new ComplexParameter(this.format as any)
if ((this.format?.name as string) === 'auto') {
if (!options.ua) throw new Error('cannot use auto format without user agent')
this.autoFormat(options.ua)
}
switch (Config.cleanUrls) {
case URLClean.Query: {
this.url = this.url.split('#')[0]!
this.url = this.url.split('?')[0]!
break
}
case URLClean.Fragment: {
this.url = this.url.split('#')[0]!
break
}
}
validateSyncOrFail(this)
if (this.resize) {
if (!this.width && !this.height) {
throw new Error('width or height is required when resizing')
}
}
this.hash = sha3(this.toString())
}
toString(): string {
const data = flatten(this) as Record<string, any>
return new URLSearchParams(sortObjectByKeys(data)).toString()
}
isAllowed(prefixes: string[]): boolean {
for (const prefix of prefixes) {
if (this.url.startsWith(prefix)) return true
}
return false
}
autoFormat(ua: string) {
if (supportsAvif(ua)) this.format!.name = 'avif'
else if (supportsWebP(ua)) this.format!.name = 'webp'
else this.format!.name = 'jpeg'
}
}
export const handler: RouteHandlerMethod = async (request, reply) => {
try {
const q = new TransformQueryBase(request.query, { ua: request.headers['user-agent'] })
if (!q.isAllowed(Config.allowedDomains)) {
reply.code(403).send({ error: 'Forbidden' })
return
}
// @ts-ignore
reply.etag(q.hash)
// @ts-ignore
reply.expires(new Date(Date.now() + ms(Config.maxAge)))
let stream: NodeJS.ReadableStream = (await storage.exists(q.hash))
? await storage.readStream(q.hash)
: await transform(q)
reply.code(200).headers({
'Content-Type': `image/${q.format?.name}`,
})
return stream
// .send(stream)
} catch (err) {
reply.code(400).send(err)
return
}
}
export * from './image'
export * from './version'

View File

@@ -0,0 +1,7 @@
import { RouteHandlerMethod } from 'fastify'
import { version as v } from '../../package.json'
export const version: RouteHandlerMethod = async (request, reply) => {
return { version: v }
}

View File

@@ -2,24 +2,28 @@
import fastify from 'fastify'
import compress from 'fastify-compress'
import cors from 'fastify-cors'
// @ts-ignore
import cache from 'fastify-caching'
import ms from 'ms'
import underPressure from 'under-pressure'
import { Config } from './config'
import { handler } from './controllers'
import './config'
import { version } from './controllers'
import { image } from './controllers/image'
import { init } from './storage'
init()
const app = fastify({ logger: true })
app.register(underPressure)
app.register(cache, { expiresIn: ms(Config.maxAge) / 1000 })
app.register(require('fastify-caching'))
app.register(compress, { global: true })
app.register(cors, { origin: true })
app.get('/api/image', handler)
app.addHook('preHandler', (request, reply, done) => {
reply.header('Server', 'morphus')
done()
})
app.get('/api/image', image)
app.get('/version', version)
async function start() {
try {

View File

@@ -1,7 +1,7 @@
import { get } from 'https'
import sharp from 'sharp'
import { PassThrough } from 'stream'
import { ComplexParameter, TransformQueryBase } from '../controllers'
import { ComplexParameter, TransformQueryBase } from '../controllers/image'
import { storage } from '../storage'
import { sha3, splitter } from '../utils/utils'

5
src/utils/errors.ts Normal file
View File

@@ -0,0 +1,5 @@
import { FastifyReply } from 'fastify'
export function ForbiddenError(reply: FastifyReply, message?: string) {
reply.code(403).send({ error: 'Forbidden', message })
}

View File

@@ -1,6 +1,7 @@
import { createHash } from 'crypto'
import { validateSync, ValidatorOptions, ValidationError as VE } from 'class-validator'
import { PassThrough, Readable } from 'stream'
import { NullableStringOrRegexpArray } from '../config'
export class ValidationError extends Error {
override message: string
@@ -37,3 +38,12 @@ export function splitter(from: NodeJS.ReadableStream, ...streams: NodeJS.Writabl
}
from.pipe(splitter)
}
export function testForPrefixOrRegexp(str: string, values: (string | RegExp)[]): boolean {
for (const value of values) {
if (typeof value === 'string') {
if (str.startsWith(value)) return true
} else if (value.test(str)) return true
}
return false
}