mirror of
https://github.com/cupcakearmy/morphus.git
synced 2025-01-09 16:56:31 +00:00
progress
This commit is contained in:
parent
65919ef75d
commit
bc71889ae2
10
README.md
Normal file
10
README.md
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# morphus 🖼
|
||||||
|
|
||||||
|
## 🌈 Features
|
||||||
|
|
||||||
|
- Config driven
|
||||||
|
- Domain protection
|
||||||
|
- Host verification
|
||||||
|
- Multiple storage adapters (Local, S3, GCS)
|
||||||
|
- Caniuse based automatic formatting
|
||||||
|
- Highly performant
|
1
TODO.md
1
TODO.md
@ -1,3 +1,2 @@
|
|||||||
- storage drivers
|
- storage drivers
|
||||||
- auto-format
|
|
||||||
- retention
|
- retention
|
||||||
|
@ -1,2 +1,5 @@
|
|||||||
allowedDomains:
|
allowedDomains:
|
||||||
- https://images.unsplash.com
|
- !regexp ^https?:\/\/images.unsplash.com
|
||||||
|
allowedHosts:
|
||||||
|
- foo.local:5000
|
||||||
|
cleanUrls: query
|
||||||
|
@ -11,6 +11,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/convict": "^6.1.1",
|
"@types/convict": "^6.1.1",
|
||||||
"@types/flat": "^5.0.2",
|
"@types/flat": "^5.0.2",
|
||||||
|
"@types/js-yaml": "^4.0.4",
|
||||||
"@types/ms": "^0.7.31",
|
"@types/ms": "^0.7.31",
|
||||||
"@types/node": "^16.11.7",
|
"@types/node": "^16.11.7",
|
||||||
"@types/sharp": "^0.29.3",
|
"@types/sharp": "^0.29.3",
|
||||||
@ -27,9 +28,9 @@
|
|||||||
"fastify-compress": "^3.6.1",
|
"fastify-compress": "^3.6.1",
|
||||||
"fastify-cors": "^6.0.2",
|
"fastify-cors": "^6.0.2",
|
||||||
"flat": "^5.0.2",
|
"flat": "^5.0.2",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
"ms": "^2.1.3",
|
"ms": "^2.1.3",
|
||||||
"sharp": "^0.29.3",
|
"sharp": "^0.29.3",
|
||||||
"under-pressure": "^5.8.0",
|
"under-pressure": "^5.8.0"
|
||||||
"yaml": "^1.10.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
26
pnpm-lock.yaml
generated
26
pnpm-lock.yaml
generated
@ -3,6 +3,7 @@ lockfileVersion: 5.3
|
|||||||
specifiers:
|
specifiers:
|
||||||
'@types/convict': ^6.1.1
|
'@types/convict': ^6.1.1
|
||||||
'@types/flat': ^5.0.2
|
'@types/flat': ^5.0.2
|
||||||
|
'@types/js-yaml': ^4.0.4
|
||||||
'@types/ms': ^0.7.31
|
'@types/ms': ^0.7.31
|
||||||
'@types/node': ^16.11.7
|
'@types/node': ^16.11.7
|
||||||
'@types/sharp': ^0.29.3
|
'@types/sharp': ^0.29.3
|
||||||
@ -15,12 +16,12 @@ specifiers:
|
|||||||
fastify-compress: ^3.6.1
|
fastify-compress: ^3.6.1
|
||||||
fastify-cors: ^6.0.2
|
fastify-cors: ^6.0.2
|
||||||
flat: ^5.0.2
|
flat: ^5.0.2
|
||||||
|
js-yaml: ^4.1.0
|
||||||
ms: ^2.1.3
|
ms: ^2.1.3
|
||||||
sharp: ^0.29.3
|
sharp: ^0.29.3
|
||||||
ts-node-dev: ^1.1.8
|
ts-node-dev: ^1.1.8
|
||||||
typescript: ^4.4.4
|
typescript: ^4.4.4
|
||||||
under-pressure: ^5.8.0
|
under-pressure: ^5.8.0
|
||||||
yaml: ^1.10.2
|
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
caniuse-db: 1.0.30001280
|
caniuse-db: 1.0.30001280
|
||||||
@ -32,14 +33,15 @@ dependencies:
|
|||||||
fastify-compress: 3.6.1
|
fastify-compress: 3.6.1
|
||||||
fastify-cors: 6.0.2
|
fastify-cors: 6.0.2
|
||||||
flat: 5.0.2
|
flat: 5.0.2
|
||||||
|
js-yaml: 4.1.0
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
sharp: 0.29.3
|
sharp: 0.29.3
|
||||||
under-pressure: 5.8.0
|
under-pressure: 5.8.0
|
||||||
yaml: 1.10.2
|
|
||||||
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/convict': 6.1.1
|
'@types/convict': 6.1.1
|
||||||
'@types/flat': 5.0.2
|
'@types/flat': 5.0.2
|
||||||
|
'@types/js-yaml': 4.0.4
|
||||||
'@types/ms': 0.7.31
|
'@types/ms': 0.7.31
|
||||||
'@types/node': 16.11.7
|
'@types/node': 16.11.7
|
||||||
'@types/sharp': 0.29.3
|
'@types/sharp': 0.29.3
|
||||||
@ -64,6 +66,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-3zsplnP2djeps5P9OyarTxwRpMLoe5Ash8aL9iprw0JxB+FAHjY+ifn4yZUuW4/9hqtnmor6uvjSRzJhiVbrEQ==}
|
resolution: {integrity: sha512-3zsplnP2djeps5P9OyarTxwRpMLoe5Ash8aL9iprw0JxB+FAHjY+ifn4yZUuW4/9hqtnmor6uvjSRzJhiVbrEQ==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@types/js-yaml/4.0.4:
|
||||||
|
resolution: {integrity: sha512-AuHubXUmg0AzkXH0Mx6sIxeY/1C110mm/EkE/gB1sTRz3h2dao2W/63q42SlVST+lICxz5Oki2hzYA6+KnnieQ==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@types/ms/0.7.31:
|
/@types/ms/0.7.31:
|
||||||
resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==}
|
resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==}
|
||||||
dev: true
|
dev: true
|
||||||
@ -152,6 +158,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
|
resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/argparse/2.0.1:
|
||||||
|
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/atomic-sleep/1.0.0:
|
/atomic-sleep/1.0.0:
|
||||||
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||||
engines: {node: '>=8.0.0'}
|
engines: {node: '>=8.0.0'}
|
||||||
@ -753,6 +763,13 @@ packages:
|
|||||||
resolution: {integrity: sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=}
|
resolution: {integrity: sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/js-yaml/4.1.0:
|
||||||
|
resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
|
||||||
|
hasBin: true
|
||||||
|
dependencies:
|
||||||
|
argparse: 2.0.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
/json-schema-traverse/0.4.1:
|
/json-schema-traverse/0.4.1:
|
||||||
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
|
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
|
||||||
dev: false
|
dev: false
|
||||||
@ -1401,11 +1418,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
|
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/yaml/1.10.2:
|
|
||||||
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
|
|
||||||
engines: {node: '>= 6'}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/yargs-parser/20.2.9:
|
/yargs-parser/20.2.9:
|
||||||
resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
|
resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import convict from 'convict'
|
import convict from 'convict'
|
||||||
import yaml from 'yaml'
|
import yaml from 'js-yaml'
|
||||||
|
|
||||||
export enum StorageType {
|
export enum StorageType {
|
||||||
Local = 'local',
|
Local = 'local',
|
||||||
@ -14,15 +14,43 @@ export enum URLClean {
|
|||||||
Query = 'query',
|
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({
|
const config = convict({
|
||||||
// Security
|
// Security
|
||||||
allowedDomains: {
|
allowedDomains: {
|
||||||
doc: 'The domains that are allowed to be used as image sources',
|
doc: 'The domains that are allowed to be used as image sources',
|
||||||
format: Array,
|
format: formatNullableStringOrRegexpArray,
|
||||||
default: [] as string[],
|
default: null as NullableStringOrRegexpArray,
|
||||||
|
nullable: true,
|
||||||
env: 'ALLOWED_DOMAINS',
|
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: {
|
cleanUrls: {
|
||||||
doc: 'Whether to clean URLs',
|
doc: 'Whether to clean URLs',
|
||||||
format: Object.values(URLClean),
|
format: Object.values(URLClean),
|
||||||
@ -61,6 +89,16 @@ for (const file of ['morphus.yaml', 'morphus.yaml', 'morphus.json']) {
|
|||||||
} catch {}
|
} 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()
|
export const Config = config.get()
|
||||||
|
|
||||||
console.debug(Config)
|
console.debug(Config)
|
||||||
|
204
src/controllers/image.ts
Normal file
204
src/controllers/image.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
@ -1,203 +1,2 @@
|
|||||||
import {
|
export * from './image'
|
||||||
IsDefined,
|
export * from './version'
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
7
src/controllers/version.ts
Normal file
7
src/controllers/version.ts
Normal 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 }
|
||||||
|
}
|
18
src/index.ts
18
src/index.ts
@ -2,24 +2,28 @@
|
|||||||
import fastify from 'fastify'
|
import fastify from 'fastify'
|
||||||
import compress from 'fastify-compress'
|
import compress from 'fastify-compress'
|
||||||
import cors from 'fastify-cors'
|
import cors from 'fastify-cors'
|
||||||
// @ts-ignore
|
|
||||||
import cache from 'fastify-caching'
|
|
||||||
import ms from 'ms'
|
|
||||||
import underPressure from 'under-pressure'
|
import underPressure from 'under-pressure'
|
||||||
|
|
||||||
import { Config } from './config'
|
import './config'
|
||||||
import { handler } from './controllers'
|
import { version } from './controllers'
|
||||||
|
import { image } from './controllers/image'
|
||||||
import { init } from './storage'
|
import { init } from './storage'
|
||||||
|
|
||||||
init()
|
init()
|
||||||
|
|
||||||
const app = fastify({ logger: true })
|
const app = fastify({ logger: true })
|
||||||
app.register(underPressure)
|
app.register(underPressure)
|
||||||
app.register(cache, { expiresIn: ms(Config.maxAge) / 1000 })
|
app.register(require('fastify-caching'))
|
||||||
app.register(compress, { global: true })
|
app.register(compress, { global: true })
|
||||||
app.register(cors, { origin: 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() {
|
async function start() {
|
||||||
try {
|
try {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { get } from 'https'
|
import { get } from 'https'
|
||||||
import sharp from 'sharp'
|
import sharp from 'sharp'
|
||||||
import { PassThrough } from 'stream'
|
import { PassThrough } from 'stream'
|
||||||
import { ComplexParameter, TransformQueryBase } from '../controllers'
|
import { ComplexParameter, TransformQueryBase } from '../controllers/image'
|
||||||
|
|
||||||
import { storage } from '../storage'
|
import { storage } from '../storage'
|
||||||
import { sha3, splitter } from '../utils/utils'
|
import { sha3, splitter } from '../utils/utils'
|
||||||
|
5
src/utils/errors.ts
Normal file
5
src/utils/errors.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { FastifyReply } from 'fastify'
|
||||||
|
|
||||||
|
export function ForbiddenError(reply: FastifyReply, message?: string) {
|
||||||
|
reply.code(403).send({ error: 'Forbidden', message })
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
import { createHash } from 'crypto'
|
import { createHash } from 'crypto'
|
||||||
import { validateSync, ValidatorOptions, ValidationError as VE } from 'class-validator'
|
import { validateSync, ValidatorOptions, ValidationError as VE } from 'class-validator'
|
||||||
import { PassThrough, Readable } from 'stream'
|
import { PassThrough, Readable } from 'stream'
|
||||||
|
import { NullableStringOrRegexpArray } from '../config'
|
||||||
|
|
||||||
export class ValidationError extends Error {
|
export class ValidationError extends Error {
|
||||||
override message: string
|
override message: string
|
||||||
@ -37,3 +38,12 @@ export function splitter(from: NodeJS.ReadableStream, ...streams: NodeJS.Writabl
|
|||||||
}
|
}
|
||||||
from.pipe(splitter)
|
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
|
||||||
|
}
|
||||||
|
@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
/* Modules */
|
/* Modules */
|
||||||
"module": "commonjs" /* Specify what module code is generated. */,
|
"module": "commonjs" /* Specify what module code is generated. */,
|
||||||
"rootDir": "./src" /* Specify the root folder within your source files. */,
|
// "rootDir": "./src" /* Specify the root folder within your source files. */,
|
||||||
// "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
// "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||||
@ -96,5 +96,6 @@
|
|||||||
/* Completeness */
|
/* Completeness */
|
||||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||||
}
|
},
|
||||||
|
"include": ["./src", "./package.json"]
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user