mirror of
https://github.com/cupcakearmy/morphus.git
synced 2025-04-04 00:37:05 +00:00
Compare commits
30 Commits
v1.0.0-rc.
...
main
Author | SHA1 | Date | |
---|---|---|---|
20d2c1b7a0 | |||
008d1c949d | |||
0fc63ed17d | |||
a47d5974e4 | |||
21f570a289 | |||
9500bcf68c | |||
5bfcacab58 | |||
38d35f40aa | |||
08470ba820 | |||
552fb3c572 | |||
7412b94fb5 | |||
f89ed1b976 | |||
ab609fcb33 | |||
32b1d8f38a | |||
7642823517 | |||
1d55be5596 | |||
3a80516401 | |||
6f3f9dd9d9 | |||
432d9dd140 | |||
bfbd25614b | |||
70904d4c04 | |||
3f258fd900 | |||
20ce00cef9 | |||
8893355dbd | |||
86e4dd47d6 | |||
c6f35a8ce6 | |||
5eaa4bc108 | |||
bcae089ec0 | |||
ee0ca0e2cb | |||
b2660a1a16 |
5
.gitignore
vendored
5
.gitignore
vendored
@ -3,7 +3,12 @@ dist
|
||||
assets
|
||||
*.tsbuildinfo
|
||||
data
|
||||
demo
|
||||
.vscode
|
||||
|
||||
# Configs
|
||||
morphus.*
|
||||
keyfile.json
|
||||
|
||||
# Keep
|
||||
!docker/**/morphus.yaml
|
||||
|
21
Dockerfile
21
Dockerfile
@ -1,19 +1,28 @@
|
||||
FROM node:16-alpine as builder
|
||||
# BASE
|
||||
FROM node:16-alpine as base
|
||||
|
||||
WORKDIR /app
|
||||
RUN npm -g i pnpm@7
|
||||
# Needed for node-gyp
|
||||
RUN apk add --no-cache python3
|
||||
|
||||
# BUILDER
|
||||
FROM base as builder
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN npm exec pnpm i --frozen-lockfile
|
||||
RUN pnpm i --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN npm exec pnpm run build
|
||||
RUN pnpm run build
|
||||
RUN ls -hal
|
||||
|
||||
FROM node:16-alpine
|
||||
# RUNNER
|
||||
FROM base
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN npm exec pnpm i --frozen-lockfile --prod
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
|
121
README.md
121
README.md
@ -1,27 +1,45 @@
|
||||
# morphus 🖼
|
||||
# morphus
|
||||
|
||||
<p align="center">
|
||||
<br>
|
||||
<img src="./design/round.png" width=150 />
|
||||
<br><br>
|
||||
</p>
|
||||
|
||||
<a href="https://discord.gg/nuby6RnxZt">
|
||||
<img alt="discord" src="https://img.shields.io/discord/252403122348097536?style=for-the-badge" />
|
||||
<img alt="docker pulls" src="https://img.shields.io/docker/pulls/cupcakearmy/morphus?style=for-the-badge" />
|
||||
<img alt="Docker image size badge" src="https://img.shields.io/docker/image-size/cupcakearmy/morphus?style=for-the-badge" />
|
||||
<img alt="Latest version" src="https://img.shields.io/github/v/release/cupcakearmy/moprhus?style=for-the-badge" />
|
||||
</a>
|
||||
|
||||
A lightweight image resizing and effect proxy that caches image transformations.
|
||||
The heavy lifting is done by [`libvips`](https://github.com/libvips/libvips) and [`sharp`](https://github.com/lovell/sharp)
|
||||
|
||||
> **⚠️ Currently under development**
|
||||
|
||||
## 🌈 Features
|
||||
|
||||
- Config driven
|
||||
- Domain protection
|
||||
- Host verification
|
||||
- Multiple storage adapters (Local, Minio, S3, GCP)
|
||||
- Caniuse based automatic formatting
|
||||
- Auto format based on `Accept` header
|
||||
- ETag caching
|
||||
- Presets and optional forcing of presets
|
||||
|
||||
## 🏗 Installation
|
||||
|
||||
The easies way to run is using docker.
|
||||
|
||||
```yaml
|
||||
# morphus.yaml
|
||||
allowedDomains:
|
||||
- !regexp ^https?:\/\/images.unsplash.com
|
||||
```
|
||||
|
||||
```yaml
|
||||
# docker-compose.yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
@ -37,15 +55,47 @@ docker-compose up
|
||||
|
||||
> For more realistic `docker-compose` files check the `docker` directory.
|
||||
|
||||
## 💻 Usage
|
||||
## 🎪 Examples
|
||||
|
||||
###### Example
|
||||
**Simple resize**: `?width=2000&resize=contain`
|
||||
|
||||
```html
|
||||
<img
|
||||
url="https://my-morphus.org/api/image?url=https://images.unsplash.com/photo-1636839270984-1f7cbc2b4c4b?format=webp&resize=contain&width=800"
|
||||
/>
|
||||
```
|
||||
https://my-morphus.org/api/image?width=2000&resize=contain&url=https://images.unsplash.com/photo-1636839270984-1f7cbc2b4c4b
|
||||
```
|
||||
|
||||
**Chose a format**: `?format=webp`
|
||||
|
||||
```
|
||||
https://my-morphus.org/api/image?format=webp&width=2000&resize=contain&url=https://images.unsplash.com/photo-1636839270984-1f7cbc2b4c4b
|
||||
```
|
||||
|
||||
**Chose a format with a given quality**: `?format=webp|quality:90`
|
||||
|
||||
```
|
||||
https://my-morphus.org/api/image?format=webp|quality:90&width=2000&resize=contain&url=https://images.unsplash.com/photo-1636839270984-1f7cbc2b4c4b
|
||||
```
|
||||
|
||||
**With some transformation operations**: `?op=rotate|angle:90&op=sharpen|sigma:1,flat:2`
|
||||
|
||||
This is transforming the image once by `rotate` with the argument `angle: 90` and `sharpen` with the arguments of `sigma: 1` and `flat: 2`.
|
||||
|
||||
```
|
||||
https://my-morphus.org/api/image?width=2000&resize=contain&op=rotate|angle:90&op=sharpen|sigma:1,flat:2&url=https://images.unsplash.com/photo-1636839270984-1f7cbc2b4c4b
|
||||
```
|
||||
|
||||
**With custom presets**: `?preset=thumbnail`
|
||||
|
||||
```yaml
|
||||
# morphus.yaml
|
||||
presets:
|
||||
thumbnail: ?width=300&height=150&resize=contain
|
||||
```
|
||||
|
||||
```
|
||||
https://my-morphus.org/api/image?preset=thumbnail&url=https://images.unsplash.com/photo-1636839270984-1f7cbc2b4c4b
|
||||
```
|
||||
|
||||
## 💻 Usage
|
||||
|
||||
| Parameter | Syntax | Example |
|
||||
| --------- | ------------------------------------------------------------------ | ---------------------------------------------------------- |
|
||||
@ -78,15 +128,18 @@ Config files are searched in the current working directory under `morphus.yaml`.
|
||||
|
||||
Configuration can be done either thorough config files or env variables. The usage of a config file is recommended. Below is a table of available configuration options, for more details see below.
|
||||
|
||||
| Config | Environment | Default | Description |
|
||||
| ---------------- | ---------------- | --------- | -------------------------------------------------------------------------------------- |
|
||||
| `port` | `PORT` | 80 | The port to bind |
|
||||
| `address` | `ADDRESS` | 127.0.0.1 | The address to bind |
|
||||
| `allowedDomains` | `ALLOWED_DOMAIN` | null | The domains that are allowed to be used as image sources |
|
||||
| `allowedHosts` | `ALLOWED_HOSTS` | null | The hosts that are allowed to access the images |
|
||||
| `cleanUrls` | `CLEAN_URL` | Fragment | Whether source URLs are cleaned |
|
||||
| `maxAge` | `MAX_AGE` | 1d | How long the served images are marked as cached, after that ETag is used to revalidate |
|
||||
| `storage` | `STORAGE` | `local` | The storage driver to use. Possible values: `local`, `minio`, `s3`, `gcs`. |
|
||||
| Config | Environment | Default | Description |
|
||||
| ------------------ | -------------------------------------------------------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `port` | `PORT` | 80 | The port to bind. |
|
||||
| `address` | `ADDRESS` | 127.0.0.1 | The address to bind. |
|
||||
| `logLevel` | `LOG_LEVEL` | info | The [log level](https://getpino.io/#/docs/api?id=loggerlevel-string-gettersetter) to use. Possible values: trace, debug, info, warn, error, fatal, silent. |
|
||||
| `presets` | not available | null | Predefined presets. See below for an example. |
|
||||
| `onlyAllowPresets` | `ONLY_ALLOW_PRESETS` | false | Whether to allow only presets. This can prevent unfair usage. |
|
||||
| `allowedDomains` | [unsupported for now as ENV](https://github.com/mozilla/node-convict/issues/399) | null | The domains that are allowed to be used as image sources. |
|
||||
| `allowedHosts` | [unsupported for now as ENV](https://github.com/mozilla/node-convict/issues/399) | null | The hosts that are allowed to access the images. |
|
||||
| `cleanUrls` | `CLEAN_URL` | Fragment | Whether source URLs are cleaned. |
|
||||
| `maxAge` | `MAX_AGE` | 1d | How long the served images are marked as cached, after that ETag is used to revalidate. |
|
||||
| `storage` | `STORAGE` | `local` | The storage driver to use. Possible values: `local`, `minio`, `s3`, `gcs`. |
|
||||
|
||||
### Storage Drivers
|
||||
|
||||
@ -155,12 +208,42 @@ s3:
|
||||
```yaml
|
||||
# morphus.yaml
|
||||
|
||||
storage: gsc
|
||||
storage: gcs
|
||||
gcs:
|
||||
bucket: morphus
|
||||
keyFilename: keyfile.json
|
||||
```
|
||||
|
||||
### Presets
|
||||
|
||||
With the help of presets you can give predefined sets of operations and transformations.
|
||||
Clients then can use the presets without specifying the exact parameters.
|
||||
|
||||
The syntax is an object that maps a preset name to a value. The value is a valid url query.
|
||||
|
||||
```yaml
|
||||
presets:
|
||||
sm: ?format=webp|quality:90&width=500&resize=contain
|
||||
md: ?format=webp|quality:90&width=1000&resize=contain
|
||||
lg: ?format=webp|quality:90&width=2000&resize=contain
|
||||
```
|
||||
|
||||
A client can the request an image with the following url
|
||||
|
||||
```
|
||||
https://my-morphus.org/api/image?preset=sm&url=https://images.unsplash.com/photo-1636839270984-1f7cbc2b4c4b
|
||||
```
|
||||
|
||||
### Only allow presets
|
||||
|
||||
This feature can help reduce abuse of the server by only allowing.
|
||||
When `onlyAllowPresets` is set no other parameter is allowed besides `url` and `preset`.
|
||||
If possible it's recommended to turn this on.
|
||||
|
||||
```yaml
|
||||
onlyAllowPresets: true
|
||||
```
|
||||
|
||||
### Allowed Domains
|
||||
|
||||
Allowed domains are a way to secure the service by only allowing certain remote domains as possible sources of images.
|
||||
@ -195,7 +278,7 @@ allowedHosts:
|
||||
|
||||
When using the url in an `<img>` tag you need to add the `<img crossorigin="anonymous">` attribute to enable sending the `origin` header to the server. Read more [here](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-crossorigin)
|
||||
|
||||
## Clean URLs
|
||||
### Clean URLs
|
||||
|
||||
This option allows cleaning the source URLs to remove duplicates. allowed options are `off`, `fragment`, `query`.
|
||||
|
||||
|
BIN
design/favicon.png
Normal file
BIN
design/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.7 KiB |
BIN
design/round.png
Normal file
BIN
design/round.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 163 KiB |
@ -4,7 +4,16 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
|
||||
app:
|
||||
image: cupcakearmy/morphus
|
||||
build: .
|
||||
volumes:
|
||||
- ./morphus.yaml:/app/morphus.yaml:ro
|
||||
ports:
|
||||
- 80:80
|
||||
depends_on:
|
||||
- s3
|
||||
|
||||
s3:
|
||||
image: minio/minio
|
||||
ports:
|
||||
|
4
docker/local/morphus.yaml
Normal file
4
docker/local/morphus.yaml
Normal file
@ -0,0 +1,4 @@
|
||||
logLevel: warn
|
||||
|
||||
allowedDomains:
|
||||
- example.org
|
@ -8,13 +8,8 @@ services:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- minio
|
||||
environment:
|
||||
ALLOWED_DOMAINS: https://images.unsplash.com https://example.org
|
||||
STORAGE: minio
|
||||
MINIO_ACCESS_KEY: minioadmin
|
||||
MINIO_SECRET_KEY: minioadmin
|
||||
MINIO_BUCKET: morphus
|
||||
MINIO_ENDPOINT: http://minio:9000
|
||||
volumes:
|
||||
- ./morphus.yaml:/app/morphus.yaml:ro
|
||||
|
||||
minio:
|
||||
image: minio/minio
|
11
docker/minio/morphus.yaml
Normal file
11
docker/minio/morphus.yaml
Normal file
@ -0,0 +1,11 @@
|
||||
logLevel: warn
|
||||
|
||||
allowedDomains:
|
||||
- example.org
|
||||
|
||||
storage: minio
|
||||
minio:
|
||||
accessKey: minioadmin
|
||||
secretKey: minioadmin
|
||||
bucket: morphus
|
||||
endpoint: http://localhost:9000
|
45
package.json
45
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "morphus",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0-rc.3",
|
||||
"description": "",
|
||||
"author": "Niccolo Borgioli",
|
||||
"license": "MIT",
|
||||
@ -9,35 +9,40 @@
|
||||
"build": "tsc",
|
||||
"dev": "tsnd src"
|
||||
},
|
||||
"engines": {
|
||||
"node": "16",
|
||||
"pnpm": ">=6",
|
||||
"yarn": "plase-use-pnpm",
|
||||
"npm": "plase-use-pnpm"
|
||||
},
|
||||
"engineStrict": true,
|
||||
"devDependencies": {
|
||||
"@types/convict": "^6.1.1",
|
||||
"@types/flat": "^5.0.2",
|
||||
"@types/js-yaml": "^4.0.4",
|
||||
"@types/minio": "^7.0.11",
|
||||
"@types/js-yaml": "^4.0.5",
|
||||
"@types/minio": "^7.0.14",
|
||||
"@types/ms": "^0.7.31",
|
||||
"@types/node": "^16.11.7",
|
||||
"@types/sharp": "^0.29.3",
|
||||
"ts-node-dev": "^1.1.8",
|
||||
"typescript": "^4.4.4"
|
||||
"@types/node": "^16.11.66",
|
||||
"@types/sharp": "^0.29.5",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^4.8.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google-cloud/storage": "^5.16.0",
|
||||
"caniuse-db": "^1.0.30001280",
|
||||
"class-validator": "^0.13.1",
|
||||
"convict": "^6.2.1",
|
||||
"@fastify/caching": "^7.0.0",
|
||||
"@fastify/compress": "^5.0.0",
|
||||
"@fastify/cors": "^7.0.0",
|
||||
"@google-cloud/storage": "^5.20.5",
|
||||
"class-validator": "^0.13.2",
|
||||
"convict": "^6.2.3",
|
||||
"convict-format-with-validator": "^6.2.0",
|
||||
"device-detector-js": "^3.0.0",
|
||||
"fast-crc32c": "^2.0.0",
|
||||
"fastify": "^3.23.1",
|
||||
"fastify-caching": "^6.1.0",
|
||||
"fastify-compress": "^3.6.1",
|
||||
"fastify-cors": "^6.0.2",
|
||||
"fastify": "^3.29.3",
|
||||
"flat": "^5.0.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"minio": "^7.0.19",
|
||||
"minio": "^7.0.32",
|
||||
"ms": "^2.1.3",
|
||||
"pino-pretty": "^7.2.0",
|
||||
"sharp": "^0.29.3",
|
||||
"under-pressure": "^5.8.0"
|
||||
"pino-pretty": "^7.6.1",
|
||||
"sharp": "^0.31.1",
|
||||
"under-pressure": "^5.8.1"
|
||||
}
|
||||
}
|
||||
|
1529
pnpm-lock.yaml
generated
1529
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -2,9 +2,10 @@ import { config, StorageType } from '../dist/src/config.js'
|
||||
|
||||
const schema = config._def
|
||||
|
||||
function stringAsMarkdownCode(string) {
|
||||
return '`' + string + '`'
|
||||
}
|
||||
const asInlineCode = (s) => '`' + s + '`'
|
||||
const formatInline = (s, empty = '') => (s === undefined ? empty : asInlineCode(s))
|
||||
const formatEnv = (s) => formatInline(s, 'not supported')
|
||||
const formatDefault = (s) => formatInline(s, '')
|
||||
|
||||
for (const storage of Object.values(StorageType)) {
|
||||
const storageType = schema[storage]
|
||||
@ -13,7 +14,21 @@ for (const storage of Object.values(StorageType)) {
|
||||
| ---------------- | ------------------ | ------- | ------------------------ |
|
||||
`
|
||||
for (const [key, value] of Object.entries(storageType)) {
|
||||
table += `| \`${storage}.${key}\` | \`${value.env}\` | ${value.default} | ${value.doc} |\n`
|
||||
table += `| \`${storage}.${key}\` | ${formatEnv(value.env)} | ${formatDefault(value.default)} | ${value.doc} |\n`
|
||||
}
|
||||
|
||||
console.log(table)
|
||||
}
|
||||
|
||||
{
|
||||
let table = `
|
||||
| Config | Environment | Default | Description |
|
||||
| ------- | ----------- | ------- | ------------ |
|
||||
`
|
||||
|
||||
for (const [key, value] of Object.entries(schema)) {
|
||||
if (Object.values(StorageType).includes(key)) continue
|
||||
table += `| ${asInlineCode(key)} | ${formatEnv(value.env)} | ${formatDefault(value.default)} | ${value.doc} |\n`
|
||||
}
|
||||
|
||||
console.log(table)
|
||||
|
@ -30,6 +30,7 @@ const Schema = yaml.DEFAULT_SCHEMA.extend([RegExpTag])
|
||||
export type NullableStringOrRegexpArray = (string | RegExp)[] | null
|
||||
|
||||
function formatNullableStringOrRegexpArray(values: any) {
|
||||
if (values === null) return
|
||||
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) {
|
||||
@ -39,6 +40,18 @@ function formatNullableStringOrRegexpArray(values: any) {
|
||||
}
|
||||
}
|
||||
|
||||
type PresetsConfig = Record<string, string> | null
|
||||
function formatPresets(values: any) {
|
||||
if (values === null) return
|
||||
if (typeof values === 'object') {
|
||||
for (const key in values) {
|
||||
if (typeof values[key] !== 'string') throw new Error('entries for presets must be strings')
|
||||
}
|
||||
} else {
|
||||
throw new Error('presets must be an object or null')
|
||||
}
|
||||
}
|
||||
|
||||
convict.addParser({ extension: ['yml', 'yaml'], parse: (s) => yaml.load(s, { schema: Schema }) })
|
||||
|
||||
export const config = convict({
|
||||
@ -70,14 +83,14 @@ export const config = convict({
|
||||
format: formatNullableStringOrRegexpArray,
|
||||
default: null as NullableStringOrRegexpArray,
|
||||
nullable: true,
|
||||
env: 'ALLOWED_DOMAINS',
|
||||
// env: 'ALLOWED_DOMAINS', // See: https://github.com/mozilla/node-convict/issues/399
|
||||
},
|
||||
allowedHosts: {
|
||||
doc: 'The hosts that are allowed to access the images',
|
||||
format: formatNullableStringOrRegexpArray,
|
||||
default: null as NullableStringOrRegexpArray,
|
||||
nullable: true,
|
||||
env: 'ALLOWED_HOSTS',
|
||||
// env: 'ALLOWED_HOSTS', // See: https://github.com/mozilla/node-convict/issues/399
|
||||
},
|
||||
cleanUrls: {
|
||||
doc: 'Whether to clean URLs',
|
||||
@ -90,7 +103,7 @@ export const config = convict({
|
||||
maxAge: {
|
||||
doc: 'The maximum age of a cached image',
|
||||
format: String,
|
||||
default: '1d',
|
||||
default: '90d',
|
||||
env: 'MAX_AGE',
|
||||
},
|
||||
|
||||
@ -101,6 +114,20 @@ export const config = convict({
|
||||
env: 'STORAGE',
|
||||
},
|
||||
|
||||
// Presets
|
||||
presets: {
|
||||
doc: 'The presets to use',
|
||||
format: formatPresets,
|
||||
nullable: true,
|
||||
default: null as PresetsConfig,
|
||||
},
|
||||
onlyAllowPresets: {
|
||||
doc: 'Whether to allow only presets',
|
||||
format: Boolean,
|
||||
default: false,
|
||||
env: 'ONLY_ALLOW_PRESETS',
|
||||
},
|
||||
|
||||
// Local storage
|
||||
local: {
|
||||
assets: {
|
||||
|
@ -9,16 +9,15 @@ import {
|
||||
IsUrl,
|
||||
ValidateNested,
|
||||
} from 'class-validator'
|
||||
import { RouteHandlerMethod } from 'fastify'
|
||||
import type { RouteHandlerMethod } from 'fastify'
|
||||
import { flatten, unflatten } from 'flat'
|
||||
import type { IncomingHttpHeaders } from 'http2'
|
||||
import ms from 'ms'
|
||||
import sharp, { FitEnum, FormatEnum } from 'sharp'
|
||||
|
||||
import { App } from '..'
|
||||
import { Config, URLClean } from '../config'
|
||||
import { storage } from '../storage'
|
||||
import { transform } from '../transform'
|
||||
import { supportsAvif, supportsWebP } from '../utils/caniuse'
|
||||
import { ForbiddenError } from '../utils/errors'
|
||||
import { sha3, sortObjectByKeys, testForPrefixOrRegexp, validateSyncOrFail } from '../utils/utils'
|
||||
|
||||
@ -62,6 +61,11 @@ export class ComplexParameter<N = string, T extends object = {}> {
|
||||
@IsObject()
|
||||
options: T
|
||||
|
||||
/**
|
||||
* parses a parameter value from a string
|
||||
*
|
||||
* @param parameter parameter to parse
|
||||
*/
|
||||
constructor(parameter: string) {
|
||||
const [name, optionsRaw] = parameter.split('|')
|
||||
if (!name) throw new Error('Invalid parameter')
|
||||
@ -116,8 +120,33 @@ export class TransformQueryBase {
|
||||
@ValidateNested()
|
||||
op: ComplexParameter[] = []
|
||||
|
||||
constructor(data: any, options: { ua?: string }) {
|
||||
Object.assign(this, data)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
preset?: string
|
||||
|
||||
constructor(data: any, options: { headers: IncomingHttpHeaders }) {
|
||||
if (Config.onlyAllowPresets) {
|
||||
const { url, preset, ...rest } = data
|
||||
if (!preset) {
|
||||
throw new Error('Preset is required')
|
||||
}
|
||||
if (Object.keys(rest).length > 0) {
|
||||
throw new Error('only preset parameter is allowed')
|
||||
}
|
||||
this.url = url
|
||||
this.preset = data.preset
|
||||
} else {
|
||||
Object.assign(this, data)
|
||||
}
|
||||
|
||||
if (this.preset) {
|
||||
const preset = Config.presets[this.preset]
|
||||
if (!preset) {
|
||||
throw new Error('preset not found')
|
||||
}
|
||||
const params = Object.fromEntries(new URLSearchParams(preset).entries())
|
||||
Object.assign(this, params)
|
||||
}
|
||||
|
||||
if (this.width) this.width = parseInt(this.width as any)
|
||||
if (this.height) this.height = parseInt(this.height as any)
|
||||
@ -128,8 +157,8 @@ export class TransformQueryBase {
|
||||
// @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)
|
||||
if (!options.headers) throw new Error('cannot use auto format without headers')
|
||||
this.autoFormat(options.headers)
|
||||
}
|
||||
|
||||
validateSyncOrFail(this)
|
||||
@ -152,17 +181,26 @@ export class TransformQueryBase {
|
||||
}
|
||||
}
|
||||
|
||||
private autoFormat(headers: IncomingHttpHeaders) {
|
||||
const accept = headers['accept'] // Accept: image/avif,image/webp,*/*
|
||||
if (accept) {
|
||||
const acceptTypes = accept.split(',')
|
||||
for (const type of acceptTypes) {
|
||||
if (type.startsWith('image/')) {
|
||||
this.format!.name = type.split('/')[1] as any
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback
|
||||
this.format!.name = 'jpeg'
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
@ -170,7 +208,7 @@ export class TransformQueryBase {
|
||||
|
||||
export const image: RouteHandlerMethod = async (request, reply) => {
|
||||
try {
|
||||
const q = new TransformQueryBase(request.query, { ua: request.headers['user-agent'] })
|
||||
const q = new TransformQueryBase(request.query, { headers: request.headers })
|
||||
|
||||
if (Config.allowedDomains) {
|
||||
if (!testForPrefixOrRegexp(q.url, Config.allowedDomains))
|
||||
@ -183,14 +221,17 @@ export const image: RouteHandlerMethod = async (request, reply) => {
|
||||
return ForbiddenError(reply, 'origin not allowed')
|
||||
}
|
||||
|
||||
const hash = q.hash
|
||||
|
||||
// @ts-ignore
|
||||
reply.etag(q.hash)
|
||||
reply.etag(hash)
|
||||
// @ts-ignore
|
||||
reply.expires(new Date(Date.now() + ms(Config.maxAge)))
|
||||
|
||||
let stream: NodeJS.ReadableStream
|
||||
App.log.debug('Serving image. Hash: ' + hash)
|
||||
try {
|
||||
stream = await storage.readStream(q.hash)
|
||||
stream = await storage.readStream(hash)
|
||||
} catch {
|
||||
App.log.debug(`Transforming`)
|
||||
stream = await transform(q)
|
||||
|
@ -2,7 +2,7 @@ import { FastifyInstance } from 'fastify'
|
||||
|
||||
export function init(App: FastifyInstance) {
|
||||
App.register(require('under-pressure'))
|
||||
App.register(require('fastify-caching'))
|
||||
App.register(require('fastify-compress'), { global: true })
|
||||
App.register(require('fastify-cors'), { origin: '*' })
|
||||
App.register(require('@fastify/caching'))
|
||||
App.register(require('@fastify/compress'), { global: true })
|
||||
App.register(require('@fastify/cors'), { origin: '*' })
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import fastify from 'fastify'
|
||||
|
||||
import { Config, init as initConfig } from './config'
|
||||
import { init as initRoutes } from './controllers'
|
||||
import { init as initHooks } from './fastify/hooks'
|
||||
|
@ -1,6 +1,7 @@
|
||||
import fs from 'fs'
|
||||
import { join, resolve } from 'path'
|
||||
import { promisify } from 'util'
|
||||
import { App } from '..'
|
||||
|
||||
import { Storage } from './'
|
||||
|
||||
@ -37,8 +38,9 @@ export class Local implements Storage {
|
||||
})
|
||||
}
|
||||
|
||||
readStream(path: string): Promise<NodeJS.ReadableStream> {
|
||||
async readStream(path: string): Promise<NodeJS.ReadableStream> {
|
||||
const file = join(this.root, path)
|
||||
if (!(await this.exists(path))) throw new Error(`File not found: ${path}`)
|
||||
return new Promise((resolve, reject) => {
|
||||
const stream = fs.createReadStream(file)
|
||||
stream.on('error', reject)
|
||||
|
@ -1,71 +0,0 @@
|
||||
import Avif from 'caniuse-db/features-json/avif.json'
|
||||
import WebP from 'caniuse-db/features-json/webp.json'
|
||||
import DeviceDetector from 'device-detector-js'
|
||||
|
||||
const detector = new DeviceDetector()
|
||||
|
||||
function findLowestSupportedVersion(stat: Record<string, string>): number | null {
|
||||
const entries = Object.entries(stat).sort((a, b) => parseInt(a[0]) - parseInt(b[0]))
|
||||
for (const [version, support] of entries) {
|
||||
if (support.startsWith('y') || support.startsWith('a')) {
|
||||
return parseInt(version)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const BrowserMappings = {
|
||||
'Internet Explorer': 'ie',
|
||||
'Microsoft Edge': 'edge',
|
||||
Firefox: 'firefox',
|
||||
Chrome: 'chrome',
|
||||
Safari: 'safari',
|
||||
Opera: 'opera',
|
||||
'Mobile Safari': 'ios_saf',
|
||||
'Opera Mini': 'op_mini',
|
||||
'Android Browser': 'android',
|
||||
'Chrome Mobile': 'and_chr',
|
||||
'Firefox Mobile': 'and_ff',
|
||||
'UC Browser': 'and_uc',
|
||||
'Samsung Browser': 'samsung',
|
||||
'QQ Browser': 'and_qq',
|
||||
}
|
||||
|
||||
function matchBrowserToStat(browser: DeviceDetector.DeviceDetectorResult): string {
|
||||
if (browser.os!.name === 'iOS') {
|
||||
return 'ios_saf'
|
||||
}
|
||||
if (browser.client!.name in BrowserMappings) {
|
||||
return BrowserMappings[browser.client!.name as keyof typeof BrowserMappings]
|
||||
}
|
||||
throw new Error('Could not determine mapping for browser')
|
||||
}
|
||||
|
||||
function match(feature: typeof Avif | typeof WebP, ua: string): boolean {
|
||||
const browser = detector.parse(ua)
|
||||
if (!browser.client || !browser.os) {
|
||||
throw new Error('Could not parse browser')
|
||||
}
|
||||
const stats = feature.stats[matchBrowserToStat(browser) as keyof typeof feature.stats]
|
||||
const lowestSupported = findLowestSupportedVersion(stats)
|
||||
if (lowestSupported === null) {
|
||||
return false
|
||||
}
|
||||
return lowestSupported <= parseInt(browser.client.version)
|
||||
}
|
||||
|
||||
export function supportsAvif(ua: string): boolean {
|
||||
try {
|
||||
return match(Avif, ua)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function supportsWebP(ua: string): boolean {
|
||||
try {
|
||||
return match(WebP, ua)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
@ -66,7 +66,7 @@
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
"isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */,
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */,
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
|
Loading…
x
Reference in New Issue
Block a user