16 Commits

Author SHA1 Message Date
7642823517 readme 2022-02-05 23:39:48 +01:00
1d55be5596 gitignore 2022-02-05 23:23:34 +01:00
3a80516401 also use accept header as source of auto format 2022-02-05 23:22:23 +01:00
6f3f9dd9d9 fix bug on local 2022-02-05 23:21:59 +01:00
432d9dd140 enable isolated modules 2022-02-05 23:21:49 +01:00
bfbd25614b update deps 2022-02-05 23:21:39 +01:00
70904d4c04 docker docs 2022-02-05 23:21:33 +01:00
3f258fd900 update deps 2022-01-16 19:12:43 +01:00
20ce00cef9 Update README.md 2021-12-08 21:44:11 +01:00
8893355dbd design 2021-12-08 21:41:46 +01:00
86e4dd47d6 Update README.md 2021-12-08 21:08:45 +01:00
c6f35a8ce6 minio docker example 2021-12-08 21:04:17 +01:00
5eaa4bc108 unsupported warning 2021-12-08 21:00:16 +01:00
bcae089ec0 log level readme 2021-12-08 20:55:19 +01:00
ee0ca0e2cb remove todo 2021-12-06 18:00:25 +01:00
b2660a1a16 Update docker-compose.minio.yaml 2021-12-02 18:25:14 +01:00
13 changed files with 643 additions and 274 deletions

4
.gitignore vendored
View File

@@ -3,7 +3,11 @@ dist
assets assets
*.tsbuildinfo *.tsbuildinfo
data data
.vscode
# Configs # Configs
morphus.* morphus.*
keyfile.json keyfile.json
# Keep
!docker/**/morphus.yaml

View File

@@ -1,15 +1,23 @@
# morphus 🖼 # morphus
<p align="center">
<br>
<img src="./design/round.png" width=150 />
<br><br>
</p>
A lightweight image resizing and effect proxy that caches image transformations. 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) The heavy lifting is done by [`libvips`](https://github.com/libvips/libvips) and [`sharp`](https://github.com/lovell/sharp)
> **⚠️ Currently under development, see the [milestone](https://github.com/cupcakearmy/morphus/milestone/1) for status.**
## 🌈 Features ## 🌈 Features
- Config driven - Config driven
- Domain protection - Domain protection
- Host verification - Host verification
- Multiple storage adapters (Local, Minio, S3, GCP) - Multiple storage adapters (Local, Minio, S3, GCP)
- Caniuse based automatic formatting - Auto format based on `Accept` header and `user-agent`.
- ETag caching - ETag caching
## 🏗 Installation ## 🏗 Installation
@@ -17,11 +25,13 @@ The heavy lifting is done by [`libvips`](https://github.com/libvips/libvips) and
The easies way to run is using docker. The easies way to run is using docker.
```yaml ```yaml
# morphus.yaml
allowedDomains: allowedDomains:
- !regexp ^https?:\/\/images.unsplash.com - !regexp ^https?:\/\/images.unsplash.com
``` ```
```yaml ```yaml
# docker-compose.yaml
version: '3.8' version: '3.8'
services: services:
@@ -37,15 +47,35 @@ docker-compose up
> For more realistic `docker-compose` files check the `docker` directory. > 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`
```
http://localhost:3000/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`.
```
http://localhost:3000/api/image?width=2000&resize=contain&op=rotate|angle:90&op=sharpen|sigma:1,flat:2&url=https://images.unsplash.com/photo-1636839270984-1f7cbc2b4c4b
```
## 💻 Usage
| Parameter | Syntax | Example | | Parameter | Syntax | Example |
| --------- | ------------------------------------------------------------------ | ---------------------------------------------------------- | | --------- | ------------------------------------------------------------------ | ---------------------------------------------------------- |
@@ -78,15 +108,16 @@ 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. 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 | | Config | Environment | Default | Description |
| ---------------- | ---------------- | --------- | -------------------------------------------------------------------------------------- | | ---------------- | -------------------------------------------------------------------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `port` | `PORT` | 80 | The port to bind | | `port` | `PORT` | 80 | The port to bind |
| `address` | `ADDRESS` | 127.0.0.1 | The address 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 | | `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 |
| `allowedHosts` | `ALLOWED_HOSTS` | null | The hosts that are allowed to access the images | | `allowedDomains` | `ALLOWED_DOMAINS` [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 |
| `cleanUrls` | `CLEAN_URL` | Fragment | Whether source URLs are cleaned | | `allowedHosts` | `ALLOWED_HOSTS` [unsupported for now as ENV](https://github.com/mozilla/node-convict/issues/399) | null | The hosts that are allowed to access the images |
| `maxAge` | `MAX_AGE` | 1d | How long the served images are marked as cached, after that ETag is used to revalidate | | `cleanUrls` | `CLEAN_URL` | Fragment | Whether source URLs are cleaned |
| `storage` | `STORAGE` | `local` | The storage driver to use. Possible values: `local`, `minio`, `s3`, `gcs`. | | `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 ### Storage Drivers

View File

@@ -1,2 +0,0 @@
- storage drivers
- retention

BIN
design/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
design/round.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -8,13 +8,8 @@ services:
- "80:80" - "80:80"
depends_on: depends_on:
- minio - minio
environment: volumes:
ALLOWED_DOMAINS: https://images.unsplash.com https://example.org - ./morphus.yaml:/app/morphus.yaml:ro
STORAGE: minio
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
MINIO_BUCKET: morphus
MINIO_ENDPOINT: http://minio:9000
minio: minio:
image: minio/minio image: minio/minio

11
docker/minio/morphus.yaml Normal file
View File

@@ -0,0 +1,11 @@
logLevel: warn
allowedDomains:
- example.org
storage: minio
minio:
accessKey: minioadmin
secretKey: minioadmin
bucket: morphus
endpoint: http://localhost:9000

View File

@@ -9,34 +9,40 @@
"build": "tsc", "build": "tsc",
"dev": "tsnd src" "dev": "tsnd src"
}, },
"engines": {
"node": "16",
"yarn": "plase-use-pnpm",
"npm": "plase-use-pnpm"
},
"engineStrict": true,
"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/js-yaml": "^4.0.5",
"@types/minio": "^7.0.11", "@types/minio": "^7.0.12",
"@types/ms": "^0.7.31", "@types/ms": "^0.7.31",
"@types/node": "^16.11.7", "@types/node": "^16.11.22",
"@types/sharp": "^0.29.3", "@types/sharp": "^0.29.5",
"ts-node-dev": "^1.1.8", "ts-node-dev": "^1.1.8",
"typescript": "^4.4.4" "typescript": "^4.5.5"
}, },
"dependencies": { "dependencies": {
"@google-cloud/storage": "^5.16.0", "@google-cloud/storage": "^5.18.1",
"caniuse-db": "^1.0.30001280", "caniuse-db": "^1.0.30001307",
"class-validator": "^0.13.1", "class-validator": "^0.13.2",
"convict": "^6.2.1", "convict": "^6.2.1",
"convict-format-with-validator": "^6.2.0", "convict-format-with-validator": "^6.2.0",
"device-detector-js": "^3.0.0", "device-detector-js": "^3.0.1",
"fast-crc32c": "^2.0.0", "fast-crc32c": "^2.0.0",
"fastify": "^3.23.1", "fastify": "^3.27.1",
"fastify-caching": "^6.1.0", "fastify-caching": "^6.2.0",
"fastify-compress": "^3.6.1", "fastify-compress": "^3.7.0",
"fastify-cors": "^6.0.2", "fastify-cors": "^6.0.2",
"flat": "^5.0.2", "flat": "^5.0.2",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"minio": "^7.0.19", "minio": "^7.0.26",
"ms": "^2.1.3", "ms": "^2.1.3",
"pino-pretty": "^7.2.0", "pino-pretty": "^7.5.1",
"sharp": "^0.29.3", "sharp": "^0.29.3",
"under-pressure": "^5.8.0" "under-pressure": "^5.8.0"
} }

740
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,8 +9,9 @@ import {
IsUrl, IsUrl,
ValidateNested, ValidateNested,
} from 'class-validator' } from 'class-validator'
import { RouteHandlerMethod } from 'fastify' import type { RouteHandlerMethod } from 'fastify'
import { flatten, unflatten } from 'flat' import { flatten, unflatten } from 'flat'
import type { IncomingHttpHeaders } from 'http2'
import ms from 'ms' import ms from 'ms'
import sharp, { FitEnum, FormatEnum } from 'sharp' import sharp, { FitEnum, FormatEnum } from 'sharp'
@@ -116,7 +117,7 @@ export class TransformQueryBase {
@ValidateNested() @ValidateNested()
op: ComplexParameter[] = [] op: ComplexParameter[] = []
constructor(data: any, options: { ua?: string }) { constructor(data: any, options: { headers: IncomingHttpHeaders }) {
Object.assign(this, data) Object.assign(this, data)
if (this.width) this.width = parseInt(this.width as any) if (this.width) this.width = parseInt(this.width as any)
@@ -128,8 +129,9 @@ export class TransformQueryBase {
// @ts-ignore // @ts-ignore
this.format = new ComplexParameter((this.format as any) || 'auto') this.format = new ComplexParameter((this.format as any) || 'auto')
if ((this.format.name as string) === 'auto') { if ((this.format.name as string) === 'auto') {
if (!options.ua) throw new Error('cannot use auto format without user agent') if (!options.headers) throw new Error('cannot use auto format without user agent')
this.autoFormat(options.ua)
this.autoFormat(options.headers)
} }
validateSyncOrFail(this) validateSyncOrFail(this)
@@ -157,10 +159,29 @@ export class TransformQueryBase {
return new URLSearchParams(sortObjectByKeys(data)).toString() return new URLSearchParams(sortObjectByKeys(data)).toString()
} }
autoFormat(ua: string) { autoFormat(headers: IncomingHttpHeaders) {
if (supportsAvif(ua)) this.format!.name = 'avif' const ua = headers['user-agent']
else if (supportsWebP(ua)) this.format!.name = 'webp' const accept = headers['accept'] // Accept: image/avif,image/webp,*/*
else this.format!.name = 'jpeg' if (accept) {
const acceptTypes = accept.split(',')
for (const type of acceptTypes) {
if (type.startsWith('image/')) {
this.format!.name = type.split('/')[1] as any
return
}
}
}
if (ua) {
if (supportsAvif(ua)) {
this.format!.name = 'avif'
return
}
if (supportsWebP(ua)) {
this.format!.name = 'webp'
return
}
}
this.format!.name = 'jpeg'
} }
get hash(): string { get hash(): string {
@@ -170,7 +191,7 @@ export class TransformQueryBase {
export const image: RouteHandlerMethod = async (request, reply) => { export const image: RouteHandlerMethod = async (request, reply) => {
try { 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 (Config.allowedDomains) {
if (!testForPrefixOrRegexp(q.url, Config.allowedDomains)) if (!testForPrefixOrRegexp(q.url, Config.allowedDomains))
@@ -183,14 +204,17 @@ export const image: RouteHandlerMethod = async (request, reply) => {
return ForbiddenError(reply, 'origin not allowed') return ForbiddenError(reply, 'origin not allowed')
} }
const hash = q.hash
// @ts-ignore // @ts-ignore
reply.etag(q.hash) reply.etag(hash)
// @ts-ignore // @ts-ignore
reply.expires(new Date(Date.now() + ms(Config.maxAge))) reply.expires(new Date(Date.now() + ms(Config.maxAge)))
let stream: NodeJS.ReadableStream let stream: NodeJS.ReadableStream
App.log.debug('Serving image. Hash: ' + hash)
try { try {
stream = await storage.readStream(q.hash) stream = await storage.readStream(hash)
} catch { } catch {
App.log.debug(`Transforming`) App.log.debug(`Transforming`)
stream = await transform(q) stream = await transform(q)

View File

@@ -1,6 +1,7 @@
import fs from 'fs' import fs from 'fs'
import { join, resolve } from 'path' import { join, resolve } from 'path'
import { promisify } from 'util' import { promisify } from 'util'
import { App } from '..'
import { Storage } 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) const file = join(this.root, path)
if (!(await this.exists(path))) throw new Error(`File not found: ${path}`)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const stream = fs.createReadStream(file) const stream = fs.createReadStream(file)
stream.on('error', reject) stream.on('error', reject)

View File

@@ -66,7 +66,7 @@
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */ // "declarationDir": "./", /* Specify the output directory for generated declaration files. */
/* Interop Constraints */ /* 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. */ // "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. */, "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. */ // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */