mirror of
https://github.com/cupcakearmy/morphus.git
synced 2025-09-06 00:00:40 +00:00
progress
This commit is contained in:
@@ -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
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 {
|
||||
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'
|
||||
|
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 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 {
|
||||
|
@@ -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
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 { 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
|
||||
}
|
||||
|
Reference in New Issue
Block a user