mirror of
https://github.com/cupcakearmy/morphus.git
synced 2025-12-15 02:44:58 +00:00
Compare commits
16 Commits
v1.0.0-rc.
...
v1.0.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| 7642823517 | |||
| 1d55be5596 | |||
| 3a80516401 | |||
| 6f3f9dd9d9 | |||
| 432d9dd140 | |||
| bfbd25614b | |||
| 70904d4c04 | |||
| 3f258fd900 | |||
| 20ce00cef9 | |||
| 8893355dbd | |||
| 86e4dd47d6 | |||
| c6f35a8ce6 | |||
| 5eaa4bc108 | |||
| bcae089ec0 | |||
| ee0ca0e2cb | |||
| b2660a1a16 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -3,7 +3,11 @@ dist
|
||||
assets
|
||||
*.tsbuildinfo
|
||||
data
|
||||
.vscode
|
||||
|
||||
# Configs
|
||||
morphus.*
|
||||
keyfile.json
|
||||
|
||||
# Keep
|
||||
!docker/**/morphus.yaml
|
||||
|
||||
65
README.md
65
README.md
@@ -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
|
||||
|
||||
|
||||
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: 12 KiB |
@@ -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
|
||||
34
package.json
34
package.json
@@ -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
740
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user