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
*.tsbuildinfo
data
.vscode
# Configs
morphus.*
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.
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
- Config driven
- Domain protection
- Host verification
- Multiple storage adapters (Local, Minio, S3, GCP)
- Caniuse based automatic formatting
- Auto format based on `Accept` header and `user-agent`.
- ETag caching
## 🏗 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.
```yaml
# morphus.yaml
allowedDomains:
- !regexp ^https?:\/\/images.unsplash.com
```
```yaml
# docker-compose.yaml
version: '3.8'
services:
@@ -37,15 +47,35 @@ 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`
```
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 |
| --------- | ------------------------------------------------------------------ | ---------------------------------------------------------- |
@@ -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.
| 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 |
| `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 |
| `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 |
| `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

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"
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
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",
"dev": "tsnd src"
},
"engines": {
"node": "16",
"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.12",
"@types/ms": "^0.7.31",
"@types/node": "^16.11.7",
"@types/sharp": "^0.29.3",
"@types/node": "^16.11.22",
"@types/sharp": "^0.29.5",
"ts-node-dev": "^1.1.8",
"typescript": "^4.4.4"
"typescript": "^4.5.5"
},
"dependencies": {
"@google-cloud/storage": "^5.16.0",
"caniuse-db": "^1.0.30001280",
"class-validator": "^0.13.1",
"@google-cloud/storage": "^5.18.1",
"caniuse-db": "^1.0.30001307",
"class-validator": "^0.13.2",
"convict": "^6.2.1",
"convict-format-with-validator": "^6.2.0",
"device-detector-js": "^3.0.0",
"device-detector-js": "^3.0.1",
"fast-crc32c": "^2.0.0",
"fastify": "^3.23.1",
"fastify-caching": "^6.1.0",
"fastify-compress": "^3.6.1",
"fastify": "^3.27.1",
"fastify-caching": "^6.2.0",
"fastify-compress": "^3.7.0",
"fastify-cors": "^6.0.2",
"flat": "^5.0.2",
"js-yaml": "^4.1.0",
"minio": "^7.0.19",
"minio": "^7.0.26",
"ms": "^2.1.3",
"pino-pretty": "^7.2.0",
"pino-pretty": "^7.5.1",
"sharp": "^0.29.3",
"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,
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'
@@ -116,7 +117,7 @@ export class TransformQueryBase {
@ValidateNested()
op: ComplexParameter[] = []
constructor(data: any, options: { ua?: string }) {
constructor(data: any, options: { headers: IncomingHttpHeaders }) {
Object.assign(this, data)
if (this.width) this.width = parseInt(this.width as any)
@@ -128,8 +129,9 @@ 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 user agent')
this.autoFormat(options.headers)
}
validateSyncOrFail(this)
@@ -157,10 +159,29 @@ export class TransformQueryBase {
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'
autoFormat(headers: IncomingHttpHeaders) {
const ua = headers['user-agent']
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
}
}
}
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 {
@@ -170,7 +191,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 +204,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)

View File

@@ -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)

View File

@@ -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. */