feat: добавить Image Gateway с кешем Souin
- добавлена сборка Caddy с Souin, Otter и NutsDB - добавлена конфигурация dev, prod и test Docker Compose - настроено кеширование через Otter L1 и NutsDB L2 - добавлены e2e-тесты Bun для кеша, restart и purge - добавлена документация по запуску, API кеша и тестам
This commit is contained in:
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
.git
|
||||
caddy-data/
|
||||
caddy-cache/
|
||||
26
.env.example
Normal file
26
.env.example
Normal file
@@ -0,0 +1,26 @@
|
||||
# === imgproxy ===
|
||||
# Подпись URL (опционально, только если есть серверная генерация URL)
|
||||
# Для SPA оставьте пустыми — защита через ALLOWED_SOURCES
|
||||
IMGPROXY_KEY=
|
||||
IMGPROXY_SALT=
|
||||
# Количество воркеров обработки (по числу CPU)
|
||||
IMGPROXY_WORKERS=2
|
||||
# Максимальное разрешение исходника в мегапикселях
|
||||
IMGPROXY_MAX_SRC_RESOLUTION=20
|
||||
# Whitelist доменов-источников (через запятую, пусто = все)
|
||||
# Пример: example.com,cdn.example.com
|
||||
IMGPROXY_ALLOWED_SOURCES=
|
||||
# Таймаут загрузки исходника (секунды)
|
||||
IMGPROXY_DOWNLOAD_TIMEOUT=30
|
||||
|
||||
# === Network (опционально) ===
|
||||
# HTTP-прокси для imgproxy (если требуется в корпоративной сети)
|
||||
# HTTP_PROXY=
|
||||
# HTTPS_PROXY=
|
||||
# NO_PROXY=localhost,127.0.0.1
|
||||
|
||||
# === Caddy ===
|
||||
# Домен для HTTPS (пустое значение = localhost без HTTPS)
|
||||
DOMAIN=
|
||||
# Порт Caddy для локальной разработки
|
||||
CADDY_PORT=8888
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.env
|
||||
caddy-data/
|
||||
caddy-cache/
|
||||
30
Caddyfile
Normal file
30
Caddyfile
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
admin 0.0.0.0:2019
|
||||
order cache before basic_auth
|
||||
cache {
|
||||
api {
|
||||
souin
|
||||
}
|
||||
otter {
|
||||
configuration {
|
||||
size 10000
|
||||
}
|
||||
}
|
||||
nuts {
|
||||
path /cache/nuts
|
||||
}
|
||||
storers otter nuts
|
||||
}
|
||||
}
|
||||
|
||||
{$DOMAIN:localhost}:{$CADDY_PORT:80} {
|
||||
route {
|
||||
cache
|
||||
reverse_proxy {$IMGPROXY_UPSTREAM:imgproxy:8080}
|
||||
}
|
||||
|
||||
log {
|
||||
output stdout
|
||||
format console
|
||||
}
|
||||
}
|
||||
15
Dockerfile.caddy
Normal file
15
Dockerfile.caddy
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM caddy:2-builder AS builder
|
||||
|
||||
RUN xcaddy build \
|
||||
--with github.com/darkweak/souin/plugins/caddy \
|
||||
--with github.com/darkweak/storages/otter/caddy \
|
||||
--with github.com/darkweak/storages/nuts/caddy
|
||||
|
||||
FROM caddy:2
|
||||
|
||||
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
||||
COPY entrypoint.caddy.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
||||
39
README.md
Normal file
39
README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Image Gateway
|
||||
|
||||
Self-hosted прокси-сервер для обработки и кеширования изображений.
|
||||
|
||||
**Caddy** + **imgproxy** + **Souin cache** — три компонента, один Docker Compose.
|
||||
|
||||
## Что делает
|
||||
|
||||
- Принимает URL оригинального изображения
|
||||
- Обрабатывает: resize, crop, конвертация в WebP/AVIF, качество
|
||||
- Кеширует результат — повторные запросы отдаются за ~1ms
|
||||
- Purge кеша через API
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
```
|
||||
|
||||
```bash
|
||||
# Обработка
|
||||
curl -s -o /tmp/test.jpg \
|
||||
"http://localhost:8888/unsafe/resize:fit:800:0:0/q:80/plain/https://picsum.photos/1200/800"
|
||||
|
||||
# Кеш: MISS → HIT
|
||||
curl -s -o /dev/null -D - "http://localhost:8888/unsafe/resize:fit:100:0:0/q:80/plain/https://picsum.photos/200/200" | grep Cache-Status
|
||||
|
||||
# Purge
|
||||
curl -X PURGE http://localhost:2019/souin-api/souin/flush
|
||||
```
|
||||
|
||||
## Документация
|
||||
|
||||
→ [docs/index.md](docs/index.md)
|
||||
|
||||
## Лицензия
|
||||
|
||||
Private
|
||||
49
docker-compose.dev.yml
Normal file
49
docker-compose.dev.yml
Normal file
@@ -0,0 +1,49 @@
|
||||
services:
|
||||
imgproxy:
|
||||
image: darthsim/imgproxy:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
GODEBUG: http2client=0
|
||||
IMGPROXY_WORKERS: ${IMGPROXY_WORKERS:-2}
|
||||
IMGPROXY_MAX_SRC_RESOLUTION: ${IMGPROXY_MAX_SRC_RESOLUTION:-20}
|
||||
IMGPROXY_USE_ETAG: "true"
|
||||
IMGPROXY_ENABLE_VIDEO_THUMBNAILS: "false"
|
||||
IMGPROXY_DOWNLOAD_TIMEOUT: ${IMGPROXY_DOWNLOAD_TIMEOUT:-30}
|
||||
IMGPROXY_ALLOWED_SOURCES: ${IMGPROXY_ALLOWED_SOURCES:-}
|
||||
HTTP_PROXY: ${HTTP_PROXY:-}
|
||||
HTTPS_PROXY: ${HTTPS_PROXY:-}
|
||||
NO_PROXY: ${NO_PROXY:-localhost,127.0.0.1}
|
||||
networks:
|
||||
- gateway
|
||||
|
||||
caddy:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${CADDY_PORT:-8888}:${CADDY_PORT:-8888}"
|
||||
- "2019:2019"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data
|
||||
- caddy-cache:/cache
|
||||
environment:
|
||||
IMGPROXY_UPSTREAM: imgproxy:8080
|
||||
DOMAIN: ""
|
||||
CADDY_PORT: ${CADDY_PORT:-8888}
|
||||
ADMIN_USER: ${ADMIN_USER:-admin}
|
||||
ADMIN_PASS: ${ADMIN_PASS:-}
|
||||
depends_on:
|
||||
imgproxy:
|
||||
condition: service_started
|
||||
networks:
|
||||
- gateway
|
||||
|
||||
networks:
|
||||
gateway:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
caddy-data:
|
||||
caddy-cache:
|
||||
71
docker-compose.test.yml
Normal file
71
docker-compose.test.yml
Normal file
@@ -0,0 +1,71 @@
|
||||
services:
|
||||
fixture:
|
||||
image: oven/bun:1
|
||||
working_dir: /app
|
||||
command: ["bun", "tests/fixture/server.js"]
|
||||
volumes:
|
||||
- .:/app:ro
|
||||
networks:
|
||||
- gateway-test
|
||||
|
||||
imgproxy:
|
||||
image: darthsim/imgproxy:latest
|
||||
environment:
|
||||
GODEBUG: http2client=0
|
||||
IMGPROXY_WORKERS: 2
|
||||
IMGPROXY_MAX_SRC_RESOLUTION: 20
|
||||
IMGPROXY_USE_ETAG: "true"
|
||||
IMGPROXY_ENABLE_VIDEO_THUMBNAILS: "false"
|
||||
IMGPROXY_DOWNLOAD_TIMEOUT: 30
|
||||
IMGPROXY_ALLOWED_SOURCES: ""
|
||||
NO_PROXY: localhost,127.0.0.1,fixture,caddy,imgproxy
|
||||
depends_on:
|
||||
fixture:
|
||||
condition: service_started
|
||||
networks:
|
||||
- gateway-test
|
||||
|
||||
caddy:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.caddy
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data
|
||||
- caddy-cache:/cache
|
||||
environment:
|
||||
IMGPROXY_UPSTREAM: imgproxy:8080
|
||||
DOMAIN: ""
|
||||
CADDY_PORT: 8888
|
||||
ADMIN_USER: admin
|
||||
ADMIN_PASS: ""
|
||||
depends_on:
|
||||
imgproxy:
|
||||
condition: service_started
|
||||
networks:
|
||||
- gateway-test
|
||||
|
||||
tester:
|
||||
image: oven/bun:1
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app:ro
|
||||
environment:
|
||||
GATEWAY_URL: http://caddy:8888
|
||||
ADMIN_URL: http://caddy:2019
|
||||
SOURCE_IMAGE_URL: http://fixture:8080/image.png
|
||||
depends_on:
|
||||
caddy:
|
||||
condition: service_started
|
||||
fixture:
|
||||
condition: service_started
|
||||
networks:
|
||||
- gateway-test
|
||||
|
||||
networks:
|
||||
gateway-test:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
caddy-data:
|
||||
caddy-cache:
|
||||
46
docker-compose.yml
Normal file
46
docker-compose.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
services:
|
||||
imgproxy:
|
||||
image: darthsim/imgproxy:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
IMGPROXY_KEY: ${IMGPROXY_KEY:-}
|
||||
IMGPROXY_SALT: ${IMGPROXY_SALT:-}
|
||||
IMGPROXY_WORKERS: ${IMGPROXY_WORKERS:-2}
|
||||
IMGPROXY_MAX_SRC_RESOLUTION: ${IMGPROXY_MAX_SRC_RESOLUTION:-20}
|
||||
IMGPROXY_USE_ETAG: "true"
|
||||
IMGPROXY_ENABLE_VIDEO_THUMBNAILS: "false"
|
||||
IMGPROXY_DOWNLOAD_TIMEOUT: ${IMGPROXY_DOWNLOAD_TIMEOUT:-30}
|
||||
IMGPROXY_ALLOWED_SOURCES: ${IMGPROXY_ALLOWED_SOURCES:-}
|
||||
networks:
|
||||
- gateway
|
||||
|
||||
caddy:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${CADDY_PORT:-80}:${CADDY_PORT:-80}"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data
|
||||
- caddy-cache:/cache
|
||||
environment:
|
||||
IMGPROXY_UPSTREAM: imgproxy:8080
|
||||
DOMAIN: ${DOMAIN:-}
|
||||
CADDY_PORT: ${CADDY_PORT:-80}
|
||||
ADMIN_USER: ${ADMIN_USER:-admin}
|
||||
ADMIN_PASS: ${ADMIN_PASS:-}
|
||||
depends_on:
|
||||
imgproxy:
|
||||
condition: service_started
|
||||
networks:
|
||||
- gateway
|
||||
|
||||
networks:
|
||||
gateway:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
caddy-data:
|
||||
caddy-cache:
|
||||
14
docs/MAP.md
Normal file
14
docs/MAP.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Документация Image Gateway — Карта
|
||||
|
||||
```
|
||||
docs/
|
||||
├── index.md Входная точка, навигация по документации
|
||||
├── MAP.md Карта документации (этот файл)
|
||||
├── overview.md Обзор проекта, архитектура, компоненты
|
||||
├── usage-guide.md Гайд использования: кейсы, curl-команды
|
||||
├── dev-guide.md Запуск, конфигурация, переменные окружения
|
||||
├── url-reference.md Формат URL, параметры обработки, примеры
|
||||
├── cache-api.md Purge кеша, Souin API
|
||||
├── e2e-tests.md Автоматические Docker Compose e2e-тесты
|
||||
└── testing-checklist.md Чек-лист ручного тестирования
|
||||
```
|
||||
147
docs/cache-api.md
Normal file
147
docs/cache-api.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# API кеша (Souin)
|
||||
|
||||
## Обзор
|
||||
|
||||
Кеширование реализовано через Souin plugin для Caddy. Обработанные изображения кешируются с TTL = 1 год.
|
||||
|
||||
Souin API доступен через **Caddy admin API** (порт 2019). Это отдельный endpoint, не доступный извне через основной порт.
|
||||
|
||||
## Заголовки
|
||||
|
||||
### Cache-Status
|
||||
|
||||
Каждый ответ содержит заголовок `Cache-Status`:
|
||||
|
||||
```
|
||||
Cache-Status: Souin; hit; ttl=31535999; key=GET-...; detail=DEFAULT
|
||||
```
|
||||
|
||||
| Значение | Описание |
|
||||
|---|---|
|
||||
| `hit` | Ответ из кеша |
|
||||
| `fwd=uri-miss; stored` | Первый запрос, результат закеширован |
|
||||
| `fwd=uri-miss; detail=UNCACHEABLE-*` | Запрос не может быть закеширован |
|
||||
|
||||
### Cache-Control
|
||||
|
||||
imgproxy возвращает:
|
||||
|
||||
```
|
||||
Cache-Control: max-age=31536000, public
|
||||
```
|
||||
|
||||
Souin использует этот заголовок для определения TTL.
|
||||
|
||||
## Souin API
|
||||
|
||||
API доступно через Caddy admin API на порту `2019`.
|
||||
|
||||
### Список закешированных ключей
|
||||
|
||||
```bash
|
||||
curl http://localhost:2019/souin-api/souin/
|
||||
```
|
||||
|
||||
Ответ — JSON-массив ключей:
|
||||
|
||||
```json
|
||||
[
|
||||
"GET-http-localhost:8888-/unsafe/resize:fit:800:0:0/q:80/plain/https://example.com/photo.jpg",
|
||||
"GET-http-localhost:8888-/unsafe/resize:fit:200:0:0/q:60/plain/https://example.com/photo.jpg"
|
||||
]
|
||||
```
|
||||
|
||||
### Purge по ключу (regex)
|
||||
|
||||
```bash
|
||||
curl -X PURGE "http://localhost:2019/souin-api/souin/.*example.com/photo.jpg"
|
||||
```
|
||||
|
||||
`regex` — регулярное выражение для поиска ключей кеша. Используйте `$` в конце для точного совпадения.
|
||||
|
||||
Примеры:
|
||||
|
||||
```bash
|
||||
# Purge конкретного размера
|
||||
curl -X PURGE "http://localhost:2019/souin-api/souin/.*resize:fit:800.*photo\.jpg$"
|
||||
|
||||
# Purge всех вариантов одного изображения
|
||||
curl -X PURGE "http://localhost:2019/souin-api/souin/.*example.com/photo.jpg"
|
||||
|
||||
# Purge всех изображений домена
|
||||
curl -X PURGE "http://localhost:2019/souin-api/souin/.*example\.com.*"
|
||||
```
|
||||
|
||||
Ответ: `204 No Content` — успешно.
|
||||
|
||||
### Purge всего кеша
|
||||
|
||||
```bash
|
||||
curl -X PURGE http://localhost:2019/souin-api/souin/flush
|
||||
```
|
||||
|
||||
Ответ: `204 No Content` — успешно.
|
||||
|
||||
## Формат ключа кеша
|
||||
|
||||
Souin формирует ключ кеша из HTTP-метода, схемы, хоста и пути:
|
||||
|
||||
```
|
||||
{METHOD}-{scheme}-{host}:{port}-{path}
|
||||
```
|
||||
|
||||
Пример:
|
||||
|
||||
```
|
||||
GET-http-localhost:8888-/unsafe/resize:fit:800:0:0/q:80/plain/https://example.com/photo.jpg
|
||||
```
|
||||
|
||||
При purge по regex ищите совпадение по частям пути.
|
||||
|
||||
## Примеры использования
|
||||
|
||||
### curl
|
||||
|
||||
```bash
|
||||
# Закешировать изображение
|
||||
curl -s -o /dev/null -w "status: %{http_code}, time: %{time_total}s\n" \
|
||||
"http://localhost:8888/unsafe/resize:fit:800:0:0/q:80/plain/https://example.com/photo.jpg"
|
||||
# → status: 200, time: 0.570s (MISS)
|
||||
|
||||
# Повторный запрос
|
||||
curl -s -o /dev/null -w "status: %{http_code}, time: %{time_total}s\n" \
|
||||
"http://localhost:8888/unsafe/resize:fit:800:0:0/q:80/plain/https://example.com/photo.jpg"
|
||||
# → status: 200, time: 0.001s (HIT)
|
||||
|
||||
# Purge
|
||||
curl -X PURGE "http://localhost:2019/souin-api/souin/.*example.com/photo.jpg"
|
||||
```
|
||||
|
||||
### JavaScript
|
||||
|
||||
```ts
|
||||
async function purgeCache(imageUrl: string) {
|
||||
const response = await fetch(
|
||||
`http://localhost:2019/souin-api/souin/.*${escapeRegex(imageUrl)}`,
|
||||
{ method: 'PURGE' }
|
||||
)
|
||||
return response.ok
|
||||
}
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
```
|
||||
|
||||
## Безопасность
|
||||
|
||||
Caddy admin API по умолчанию доступен только на `localhost:2019`. В production:
|
||||
|
||||
- Порт 2019 не должен быть открыт наружу
|
||||
- Можно настроить `admin off` в Caddyfile и использовать альтернативный доступ
|
||||
- Или ограничить через firewall
|
||||
|
||||
## Ограничения
|
||||
|
||||
- **In-memory storage** — по умолчанию кеш хранится в памяти. При перезапуске Caddy кеш теряется
|
||||
- Для production рекомендуется подключить дисковый storage (Badger, NutsDB) через дополнительный плагин
|
||||
128
docs/dev-guide.md
Normal file
128
docs/dev-guide.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Руководство разработчика
|
||||
|
||||
## Требования
|
||||
|
||||
- Docker
|
||||
- Docker Compose
|
||||
|
||||
## Режимы запуска
|
||||
|
||||
### Development (`docker-compose.dev.yml`)
|
||||
|
||||
Bridge-сеть, проброс портов:
|
||||
- `8888` — Caddy (изображения)
|
||||
- `2019` — Caddy Admin API (Souin purge)
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
docker compose -f docker-compose.dev.yml down
|
||||
docker compose -f docker-compose.dev.yml logs -f caddy
|
||||
docker compose -f docker-compose.dev.yml logs -f imgproxy
|
||||
```
|
||||
|
||||
### Production (`docker-compose.yml`)
|
||||
|
||||
Bridge-сеть, порт `80` (или `443` с HTTPS). Порт 2019 **не пробрасывается** — доступен только внутри Docker сети.
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Все настройки через `.env` файл (см. `.env.example`).
|
||||
|
||||
### imgproxy
|
||||
|
||||
| Переменная | Описание | По умолчанию |
|
||||
|---|---|---|
|
||||
| `IMGPROXY_KEY` | Hex-ключ для подписи URL (пусто = unsigned) | — |
|
||||
| `IMGPROXY_SALT` | Hex-соль для подписи URL | — |
|
||||
| `IMGPROXY_WORKERS` | Количество воркеров обработки | `2` |
|
||||
| `IMGPROXY_MAX_SRC_RESOLUTION` | Макс. разрешение исходника (MP) | `20` |
|
||||
| `IMGPROXY_ALLOWED_SOURCES` | Whitelist доменов (через запятую) | — |
|
||||
| `IMGPROXY_DOWNLOAD_TIMEOUT` | Таймаут загрузки исходника (сек) | `30` |
|
||||
|
||||
### Caddy
|
||||
|
||||
| Переменная | Описание | По умолчанию |
|
||||
|---|---|---|
|
||||
| `CADDY_PORT` | Порт Caddy | `8888` (dev) / `80` (prod) |
|
||||
| `DOMAIN` | Домен для HTTPS (пусто = localhost, prod only) | — |
|
||||
|
||||
### Сеть (опционально)
|
||||
|
||||
| Переменная | Описание |
|
||||
|---|---|
|
||||
| `HTTP_PROXY` | HTTP-прокси для imgproxy |
|
||||
| `HTTPS_PROXY` | HTTPS-прокси для imgproxy |
|
||||
| `NO_PROXY` | Исключения прокси |
|
||||
|
||||
### Генерация ключей подписи
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32 # KEY
|
||||
openssl rand -hex 32 # SALT
|
||||
```
|
||||
|
||||
## HTTPS (production)
|
||||
|
||||
Укажите домен в `.env`:
|
||||
|
||||
```env
|
||||
DOMAIN=images.example.com
|
||||
```
|
||||
|
||||
Caddy автоматически получит Let's Encrypt сертификат.
|
||||
|
||||
## Интеграция с внешними сервисами
|
||||
|
||||
Next.js или другой сервис внутри Docker сети ходит к Caddy Admin API для purge:
|
||||
|
||||
```
|
||||
http://caddy:2019/souin-api/souin/ — GET список ключей
|
||||
http://caddy:2019/souin-api/souin/flush — PURGE полный сброс
|
||||
http://caddy:2019/souin-api/souin/{regex} — PURGE по regex
|
||||
```
|
||||
|
||||
Для добавления Next.js в сеть — подключить контейнер к сети `gateway`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
# ...
|
||||
networks:
|
||||
- gateway
|
||||
|
||||
networks:
|
||||
gateway:
|
||||
external: true
|
||||
name: image-gateway_gateway
|
||||
```
|
||||
|
||||
## Корпоративная сеть
|
||||
|
||||
```env
|
||||
HTTP_PROXY=http://proxy.company.com:8080
|
||||
HTTPS_PROXY=http://proxy.company.com:8080
|
||||
NO_PROXY=localhost,127.0.0.1,.company.com
|
||||
```
|
||||
|
||||
`GODEBUG=http2client=0` уже задан в compose — отключает HTTP/2 в Go, решает проблемы с Cloudflare через прокси.
|
||||
|
||||
## Мониторинг
|
||||
|
||||
### Cache-Status заголовок
|
||||
|
||||
```
|
||||
Cache-Status: Souin; hit; ttl=31535999; detail=DEFAULT # из кеша
|
||||
Cache-Status: Souin; fwd=uri-miss; stored; key=GET-... # закешировано
|
||||
Cache-Status: Souin; fwd=uri-miss; detail=UNCACHEABLE-... # не закешировано
|
||||
```
|
||||
|
||||
### Логи
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml logs -f caddy # Caddy + Souin
|
||||
docker compose -f docker-compose.dev.yml logs -f imgproxy # imgproxy
|
||||
```
|
||||
143
docs/e2e-tests.md
Normal file
143
docs/e2e-tests.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# E2E тесты
|
||||
|
||||
Автоматические e2e-тесты проверяют реальную связку `Caddy + Souin + Otter + NutsDB + imgproxy` через Docker Compose.
|
||||
|
||||
## Запуск Локально
|
||||
|
||||
```bash
|
||||
./scripts/test-e2e.sh
|
||||
```
|
||||
|
||||
На хосте нужны только Docker и Docker Compose. Bun устанавливать не нужно: тесты запускаются внутри контейнера `oven/bun:1`.
|
||||
|
||||
Тестовый стек не публикует порты на хост, поэтому он не конфликтует с dev-стеком на `8888` и `2019`.
|
||||
|
||||
## Что Проверяется
|
||||
|
||||
- Caddy собирается с Souin, Otter и NutsDB storage modules.
|
||||
- Souin не падает обратно на default in-memory storage.
|
||||
- Первый запрос дает `Cache-Status: Souin; fwd=uri-miss; stored`.
|
||||
- Повторный запрос отдается из L1 in-memory кеша: `detail=OTTER`.
|
||||
- После `docker compose restart caddy` кеш сохраняется и читается из L2 disk storage: `detail=NUTS`.
|
||||
- `PURGE /souin-api/souin/flush` возвращает `204`.
|
||||
- После purge новые записи в кеш продолжают работать.
|
||||
- В логах Caddy нет `NUTS-INSERTION-ERROR`.
|
||||
|
||||
## Почему Не Используется Внешний Источник
|
||||
|
||||
Тесты не ходят в `picsum.photos` или другие внешние сервисы. Вместо этого отдельный `fixture`-сервис внутри Docker network отдает статичную PNG-картинку.
|
||||
|
||||
Это делает тесты стабильными:
|
||||
|
||||
- нет зависимости от интернета;
|
||||
- нет случайной смены изображения или `ETag`;
|
||||
- нет внешних rate limits;
|
||||
- одинаковое поведение локально и в CI.
|
||||
|
||||
## Состав Тестового Стека
|
||||
|
||||
Файл: `docker-compose.test.yml`.
|
||||
|
||||
Сервисы:
|
||||
|
||||
| Сервис | Назначение |
|
||||
|---|---|
|
||||
| `fixture` | HTTP-сервис со статичной картинкой `/image.png` |
|
||||
| `imgproxy` | Обрабатывает изображение из `fixture` |
|
||||
| `caddy` | Gateway с Souin cache, Otter L1 и NutsDB L2 |
|
||||
| `tester` | Контейнер `oven/bun:1`, запускает `bun test` |
|
||||
|
||||
Тесты обращаются к сервисам по внутренним Docker DNS именам:
|
||||
|
||||
```text
|
||||
http://caddy:8888
|
||||
http://caddy:2019
|
||||
http://fixture:8080/image.png
|
||||
```
|
||||
|
||||
## Структура Файлов
|
||||
|
||||
```text
|
||||
scripts/test-e2e.sh Entry point для локального запуска и CI
|
||||
docker-compose.test.yml Изолированный Docker Compose стек для тестов
|
||||
tests/fixture/server.js Fixture HTTP server на Bun
|
||||
tests/e2e/helpers.js Общие HTTP helpers и assertions
|
||||
tests/e2e/cache-prime.test.js MISS -> stored, HIT -> OTTER
|
||||
tests/e2e/cache-persistence.test.js restart caddy -> HIT -> NUTS
|
||||
tests/e2e/cache-purge.test.js flush -> новые записи продолжают работать
|
||||
```
|
||||
|
||||
## Как Работает Скрипт
|
||||
|
||||
`scripts/test-e2e.sh` выполняет полный lifecycle:
|
||||
|
||||
```bash
|
||||
docker compose -p <project> -f docker-compose.test.yml down -v --remove-orphans
|
||||
docker compose -p <project> -f docker-compose.test.yml up -d --build caddy imgproxy fixture
|
||||
docker compose -p <project> -f docker-compose.test.yml run --rm tester bun test tests/e2e/cache-prime.test.js
|
||||
docker compose -p <project> -f docker-compose.test.yml restart caddy
|
||||
docker compose -p <project> -f docker-compose.test.yml run --rm tester bun test tests/e2e/cache-persistence.test.js
|
||||
docker compose -p <project> -f docker-compose.test.yml run --rm tester bun test tests/e2e/cache-purge.test.js
|
||||
docker compose -p <project> -f docker-compose.test.yml down -v --remove-orphans
|
||||
```
|
||||
|
||||
При ошибке скрипт печатает логи compose-стека и затем удаляет контейнеры и volumes.
|
||||
|
||||
## Compose Project Name
|
||||
|
||||
По умолчанию используется project name:
|
||||
|
||||
```text
|
||||
image-gateway-test-local
|
||||
```
|
||||
|
||||
В CI скрипт автоматически учитывает `CI_JOB_ID` или `GITHUB_RUN_ID`, если они есть.
|
||||
|
||||
Можно задать имя явно:
|
||||
|
||||
```bash
|
||||
COMPOSE_PROJECT_NAME=image-gateway-test ./scripts/test-e2e.sh
|
||||
```
|
||||
|
||||
Это полезно для параллельных запусков или отладки нескольких test stack одновременно.
|
||||
|
||||
## Подключение В CI
|
||||
|
||||
CI job должен запускать тот же entrypoint:
|
||||
|
||||
```bash
|
||||
./scripts/test-e2e.sh
|
||||
```
|
||||
|
||||
Требования к runner:
|
||||
|
||||
- Docker;
|
||||
- Docker Compose plugin;
|
||||
- доступ к Docker daemon;
|
||||
- возможность собирать Docker images.
|
||||
|
||||
Bun, Node.js и npm на runner не нужны.
|
||||
|
||||
## Отладка
|
||||
|
||||
Если тест упал, сначала смотрите вывод `scripts/test-e2e.sh`: он печатает логи всех сервисов перед cleanup.
|
||||
|
||||
Для ручной отладки можно поднять стек без скрипта:
|
||||
|
||||
```bash
|
||||
docker compose -p image-gateway-debug -f docker-compose.test.yml up -d --build caddy imgproxy fixture
|
||||
docker compose -p image-gateway-debug -f docker-compose.test.yml run --rm tester bun test tests/e2e/cache-prime.test.js
|
||||
docker compose -p image-gateway-debug -f docker-compose.test.yml logs caddy
|
||||
docker compose -p image-gateway-debug -f docker-compose.test.yml down -v --remove-orphans
|
||||
```
|
||||
|
||||
## Когда Расширять Набор
|
||||
|
||||
Текущий набор покрывает критичные регрессии кеша. Следующие полезные сценарии:
|
||||
|
||||
- purge по regex;
|
||||
- разные resize-параметры создают разные cache keys;
|
||||
- разные source URL не конфликтуют;
|
||||
- невалидный upstream не кешируется;
|
||||
- `docker compose down` без `-v` сохраняет volume;
|
||||
- `docker compose down -v` удаляет кеш.
|
||||
25
docs/index.md
Normal file
25
docs/index.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Image Gateway — Документация
|
||||
|
||||
Self-hosted прокси-сервер для обработки и кеширования изображений.
|
||||
|
||||
## Содержание
|
||||
|
||||
| Документ | Описание |
|
||||
|---|---|
|
||||
| [Обзор проекта](overview.md) | Что это, зачем, архитектура, компоненты |
|
||||
| [Гайд использования](usage-guide.md) | Готовые кейсы: обработка, кеш, purge, подпись |
|
||||
| [Руководство разработчика](dev-guide.md) | Запуск, конфигурация, переменные окружения |
|
||||
| [URL-справочник](url-reference.md) | Формат URL, параметры обработки, примеры |
|
||||
| [API кеша](cache-api.md) | Purge кеша, Souin API |
|
||||
| [E2E тесты](e2e-tests.md) | Автоматические Docker Compose тесты кеша |
|
||||
| [Чек-лист тестирования](testing-checklist.md) | Пошаговая ручная проверка всех функций |
|
||||
|
||||
## Быстрая навигация
|
||||
|
||||
- Хочу **запустить локально** → [dev-guide.md](dev-guide.md)
|
||||
- Хочу **запустить и тестировать** → [usage-guide.md](usage-guide.md)
|
||||
- Хочу **узнать формат URL** → [url-reference.md](url-reference.md)
|
||||
- Хочу **сбросить кеш** → [cache-api.md](cache-api.md)
|
||||
- Хочу **запустить автотесты** → [e2e-tests.md](e2e-tests.md)
|
||||
- Хочу **протестировать вручную** → [testing-checklist.md](testing-checklist.md)
|
||||
- Хочу **понять что это за проект** → [overview.md](overview.md)
|
||||
92
docs/overview.md
Normal file
92
docs/overview.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Обзор проекта
|
||||
|
||||
## Что это
|
||||
|
||||
Image Gateway — self-hosted сервер для обработки и кеширования изображений. Принимает URL оригинального изображения, изменяет размер/формат/качество по запросу, кеширует результат и отдаёт быстро.
|
||||
|
||||
## Зачем
|
||||
|
||||
- **Оптимизация изображений** — автоматический resize, crop, конвертация в WebP/AVIF
|
||||
- **Кеширование** — однократная обработка, последующие запросы отдаются из кеша за ~1ms
|
||||
- **Самостоятельный хостинг** — нет зависимости от внешних SaaS-сервисов (Cloudinary, imgix и т.д.)
|
||||
- **Простая интеграция** — URL-based API, совместимое с `next/image`, `<img>` и любым HTTP-клиентом
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
Клиент → Caddy (:8888)
|
||||
├─ HIT: отдаёт из кеша (~1ms)
|
||||
└─ MISS: → imgproxy → скачивает оригинал → обработка → ответ + кеширование
|
||||
```
|
||||
|
||||
### Компоненты
|
||||
|
||||
| Компонент | Роль |
|
||||
|---|---|
|
||||
| **Caddy** | Reverse proxy, кеширование (Souin plugin), HTTPS |
|
||||
| **imgproxy** | Обработка изображений (resize, crop, конвертация форматов, качество) |
|
||||
|
||||
### Маршрутизация
|
||||
|
||||
```
|
||||
Caddy (:8888)
|
||||
├─ /* → imgproxy (через кеш Souin)
|
||||
└─ /souin-api/* → Souin API (управление кешем)
|
||||
```
|
||||
|
||||
## Возможности
|
||||
|
||||
### Обработка изображений (imgproxy)
|
||||
|
||||
- **Resize** — вписать (`fit`), заполнить (`fill`), точный размер (`force`)
|
||||
- **Crop** — обрезка с указанием ширины/высоты
|
||||
- **Конвертация форматов** — JPEG, PNG, WebP, AVIF, GIF
|
||||
- **Качество** — от 1 до 100
|
||||
- **Гравитация** — `ce` (центр), `no` (север), `ea` (восток), и т.д.
|
||||
- **Подпись URL** — HMAC-SHA256 для защиты от несанкционированного использования
|
||||
- **Whitelist доменов** — ограничение источников изображений через `IMGPROXY_ALLOWED_SOURCES`
|
||||
|
||||
### Кеширование (Souin)
|
||||
|
||||
- Кеширует обработанные изображения в памяти
|
||||
- TTL по умолчанию — 1 год (определяется `Cache-Control` от imgproxy)
|
||||
- HTTP-заголовок `Cache-Status` в каждом ответе — `hit` или `miss`
|
||||
- API для программного сброса кеша (`/souin-api/souin/`)
|
||||
|
||||
### Режимы работы
|
||||
|
||||
| Режим | Описание | Защита |
|
||||
|---|---|---|
|
||||
| **Unsigned** | URL с префиксом `/unsafe/` | Whitelist доменов (`IMGPROXY_ALLOWED_SOURCES`) |
|
||||
| **Signed** | URL с HMAC-SHA256 подписью | Подпись + whitelist |
|
||||
|
||||
Unsigned режим рекомендуется для SPA-приложений — ключи подписи не утекают в браузер.
|
||||
|
||||
## Интеграция
|
||||
|
||||
### HTML
|
||||
|
||||
```html
|
||||
<img src="http://localhost:8888/unsafe/resize:fit:800:0:0/q:80/plain/https://example.com/photo.jpg" />
|
||||
```
|
||||
|
||||
### Next.js (loader)
|
||||
|
||||
```ts
|
||||
const imageGatewayLoader = ({ src, width, quality }) => {
|
||||
const q = quality ?? 80
|
||||
return `http://localhost:8888/unsafe/resize:fit:${width}:0:0/q:${q}/plain/${src}`
|
||||
}
|
||||
```
|
||||
|
||||
### Любой HTTP-клиент
|
||||
|
||||
```
|
||||
GET http://localhost:8888/unsafe/resize:fit:800:0:0/q:80/plain/https://example.com/photo.jpg
|
||||
```
|
||||
|
||||
## Что НЕ входит в проект
|
||||
|
||||
- Админ-панель — реализуется как внешний сервис (Next.js, SPA и т.д.)
|
||||
- Хранилище оригиналов — проект только обрабатывает изображения по URL
|
||||
- CDN-слой — проект работает за CDN или напрямую
|
||||
57
docs/testing-checklist.md
Normal file
57
docs/testing-checklist.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Чек-лист тестирования
|
||||
|
||||
Для автоматической проверки критичных сценариев кеша используйте [e2e-tests.md](e2e-tests.md):
|
||||
|
||||
```bash
|
||||
./scripts/test-e2e.sh
|
||||
```
|
||||
|
||||
Этот чек-лист остается ручной проверкой полного поведения gateway.
|
||||
|
||||
## 1. Инфраструктура
|
||||
|
||||
- [x] Запуск: `docker compose -f docker-compose.dev.yml up -d --build`
|
||||
- [x] Оба контейнера работают: `docker ps` показывает caddy + imgproxy
|
||||
- [x] Логи Caddy: `docker compose -f docker-compose.dev.yml logs caddy` — нет ошибок
|
||||
- [x] Логи imgproxy: `docker compose -f docker-compose.dev.yml logs imgproxy` — нет ошибок
|
||||
- [x] Caddy слушает порт 8888: `curl -s -o /dev/null -w "%{http_code}" http://localhost:8888/` → 200
|
||||
|
||||
## 2. Базовые запросы
|
||||
|
||||
- [x] MISS (первый запрос) → 200, `Cache-Status: Souin; fwd=uri-miss; stored`
|
||||
- [x] HIT (повторный запрос) → 200, `Cache-Status: Souin; hit`, время < 5ms
|
||||
- [x] Несуществующий источник → imgproxy возвращает 500
|
||||
|
||||
## 3. Обработка изображений
|
||||
|
||||
- [x] `resize:fit:800:0:0` — изображение вписано в 800px по ширине
|
||||
- [x] `resize:fill:400:300:0` — изображение 400x300, обрезано
|
||||
- [x] `crop:200:200` — обрезка 200x200
|
||||
- [x] `q:100` vs `q:10` — размер файла: 48931 vs 3910 (разница ~12x)
|
||||
- [x] `format:webp` → `Content-Type: image/webp`
|
||||
- [x] Комбинированные: `resize:fit:800:0:0/g:ce/q:75/format:webp`
|
||||
- [x] `plain/{url}` — прямая передача URL
|
||||
- [x] base64url-encoded source — работает
|
||||
|
||||
## 4. Unsigned режим
|
||||
|
||||
- [x] `/unsafe/resize:fit:100:0:0/...` → 200
|
||||
- [x] Без `/unsafe/` → тоже 200 (ключи не заданы, подпись не проверяется)
|
||||
|
||||
> **Примечание:** Когда `IMGPROXY_KEY` и `IMGPROXY_SALT` пустые, imgproxy принимает любые URL (с и без `/unsafe/`). `/unsafe/` обязателен только при включённой подписи.
|
||||
|
||||
## 5. Кеш
|
||||
|
||||
- [x] Разные размеры = разные ключи кеша (не конфликтуют)
|
||||
- [x] `Cache-Status` заголовок в каждом ответе
|
||||
- [x] Ключ кеша содержит полный путь: `GET-http-localhost:8888-/unsafe/...`
|
||||
|
||||
## 6. Souin API (через Caddy admin :2019)
|
||||
|
||||
- [x] `GET /souin-api/souin/` — возвращает JSON-массив ключей
|
||||
- [x] `PURGE /souin-api/souin/flush` — 204, кеш сброшен
|
||||
- [x] `PURGE /souin-api/souin/{regex}` — 204, purge по regex работает
|
||||
|
||||
## 7. Останов
|
||||
|
||||
- [ ] `docker compose -f docker-compose.dev.yml down` — останавливает и удаляет контейнеры
|
||||
159
docs/url-reference.md
Normal file
159
docs/url-reference.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# URL-справочник
|
||||
|
||||
## Формат URL
|
||||
|
||||
### Unsigned (по умолчанию)
|
||||
|
||||
```
|
||||
/unsafe/{processing_options}/plain/{source_url}
|
||||
```
|
||||
|
||||
### Signed
|
||||
|
||||
```
|
||||
/{signature}/{processing_options}/plain/{source_url}
|
||||
```
|
||||
|
||||
### Source URL
|
||||
|
||||
Два варианта кодирования source URL:
|
||||
|
||||
| Вариант | Пример |
|
||||
|---|---|
|
||||
| `plain/{url}` | `plain/https://example.com/photo.jpg` |
|
||||
| `{base64url}` | `aHR0cHM6Ly9leGFtcGxlLmNvbS9waG90by5qcGc` |
|
||||
|
||||
## Processing options
|
||||
|
||||
### Resize
|
||||
|
||||
Вписать в размер (сохраняет пропорции, не увеличивает):
|
||||
|
||||
```
|
||||
/unsafe/resize:fit:{width}:{height}:0/plain/https://example.com/photo.jpg
|
||||
```
|
||||
|
||||
Заполнить размер (сохраняет пропорции, обрезает лишнее):
|
||||
|
||||
```
|
||||
/unsafe/resize:fill:{width}:{height}:0/plain/https://example.com/photo.jpg
|
||||
```
|
||||
|
||||
Точный размер (растягивает/сжимает):
|
||||
|
||||
```
|
||||
/unsafe/resize:force:{width}:{height}:0/plain/https://example.com/photo.jpg
|
||||
```
|
||||
|
||||
> `height:0` = автоопределение высоты по пропорциям.
|
||||
|
||||
### Crop
|
||||
|
||||
```
|
||||
/unsafe/crop:{width}:{height}:0/plain/https://example.com/photo.jpg
|
||||
```
|
||||
|
||||
### Качество
|
||||
|
||||
```
|
||||
/unsafe/q:{1-100}/plain/https://example.com/photo.jpg
|
||||
```
|
||||
|
||||
### Конвертация формата
|
||||
|
||||
Добавить расширение к source URL:
|
||||
|
||||
```
|
||||
/unsafe/q:80/plain/https://example.com/photo.jpg.webp
|
||||
/unsafe/q:80/plain/https://example.com/photo.jpg.avif
|
||||
```
|
||||
|
||||
Поддерживаемые форматы: JPEG, PNG, WebP, AVIF, GIF, ICO, SVG, TIFF, HEIC
|
||||
|
||||
### Гравитация (для crop/fill)
|
||||
|
||||
```
|
||||
/unsafe/resize:fill:400:300:0/g:ce/plain/https://example.com/photo.jpg
|
||||
```
|
||||
|
||||
| Значение | Описание |
|
||||
|---|---|
|
||||
| `ce` | Центр (по умолчанию) |
|
||||
| `no` | Верх |
|
||||
| `so` | Низ |
|
||||
| `ea` | Право |
|
||||
| `we` | Лево |
|
||||
| `noea` | Верх-право |
|
||||
| `nowe` | Верх-лево |
|
||||
| `soea` | Низ-право |
|
||||
| `sowe` | Низ-лево |
|
||||
|
||||
### Автоопределение высоты
|
||||
|
||||
`height = 0` — imgproxy автоматически вычисляет высоту, сохраняя пропорции:
|
||||
|
||||
```
|
||||
/unsafe/resize:fit:800:0:0/q:80/plain/https://example.com/photo.jpg
|
||||
```
|
||||
|
||||
## Примеры
|
||||
|
||||
### Вписать в 800px по ширине, качество 80
|
||||
|
||||
```
|
||||
http://localhost:8888/unsafe/resize:fit:800:0:0/q:80/plain/https://example.com/photo.jpg
|
||||
```
|
||||
|
||||
### Превью 200x200, качество 60
|
||||
|
||||
```
|
||||
http://localhost:8888/unsafe/resize:fill:200:200:0/g:ce/q:60/plain/https://example.com/photo.jpg
|
||||
```
|
||||
|
||||
### Конвертация в WebP
|
||||
|
||||
```
|
||||
http://localhost:8888/unsafe/q:80/plain/https://example.com/photo.jpg.webp
|
||||
```
|
||||
|
||||
### Комбинированная обработка
|
||||
|
||||
```
|
||||
http://localhost:8888/unsafe/resize:fit:1200:0:0/g:ce/q:75/plain/https://example.com/photo.jpg.webp
|
||||
```
|
||||
|
||||
### Использование base64url
|
||||
|
||||
```bash
|
||||
echo -n "https://example.com/photo.jpg" | base64 -w0 | tr '+/' '-_' | tr -d '='
|
||||
# → aHR0cHM6Ly9leGFtcGxlLmNvbS9waG90by5qcGc
|
||||
|
||||
http://localhost:8888/unsafe/resize:fit:800:0:0/q:80/aHR0cHM6Ly9leGFtcGxlLmNvbS9waG90by5qcGc
|
||||
```
|
||||
|
||||
## Подпись URL (HMAC-SHA256)
|
||||
|
||||
Когда заданы `IMGPROXY_KEY` и `IMGPROXY_SALT`, каждый запрос должен быть подписан.
|
||||
|
||||
```ts
|
||||
import crypto from 'crypto'
|
||||
|
||||
function signUrl(key: string, salt: string, path: string): string {
|
||||
const hmac = crypto.createHmac('sha256', Buffer.from(key, 'hex'))
|
||||
hmac.update(Buffer.from(salt, 'hex'))
|
||||
hmac.update(path)
|
||||
const signature = hmac.digest().slice(0, 32).toString('base64url')
|
||||
return `/${signature}${path}`
|
||||
}
|
||||
|
||||
// Использование:
|
||||
const path = '/resize:fit:800:0:0/q:80/plain/https://example.com/photo.jpg'
|
||||
const url = signUrl(IMGPROXY_KEY, IMGPROXY_SALT, path)
|
||||
// → /SIGNED_SIGNATURE/resize:fit:800:0:0/q:80/plain/https://example.com/photo.jpg
|
||||
```
|
||||
|
||||
> **Внимание:** В SPA-приложениях ключи подписи утекают в браузер. Рекомендуется использовать unsigned режим (`/unsafe/`) с `IMGPROXY_ALLOWED_SOURCES` для защиты.
|
||||
|
||||
## Справочник
|
||||
|
||||
Полная документация по параметрам обработки: [imgproxy.net/docs](https://imgproxy.net/docs/)
|
||||
272
docs/usage-guide.md
Normal file
272
docs/usage-guide.md
Normal file
@@ -0,0 +1,272 @@
|
||||
# Гайд использования
|
||||
|
||||
Руководство по кейсам — копируй, вставляй, проверяй. Без обдумывания.
|
||||
|
||||
## 1. Запуск
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
```
|
||||
|
||||
Проверка:
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}" \
|
||||
"http://localhost:8888/unsafe/resize:fit:100:0:0/q:80/plain/https://picsum.photos/200/200"
|
||||
# → 200
|
||||
```
|
||||
|
||||
## 2. Обработка изображений
|
||||
|
||||
### Resize — вписать в ширину 800px
|
||||
|
||||
```bash
|
||||
curl -s -o /tmp/fit800.jpg \
|
||||
"http://localhost:8888/unsafe/resize:fit:800:0:0/q:80/plain/https://picsum.photos/1200/800"
|
||||
# → файл /tmp/fit800.jpg, ширина = 800px
|
||||
```
|
||||
|
||||
### Resize — вписать в квадрат 200x200
|
||||
|
||||
```bash
|
||||
curl -s -o /tmp/fit200.jpg \
|
||||
"http://localhost:8888/unsafe/resize:fit:200:200:0/q:80/plain/https://picsum.photos/1200/800"
|
||||
# → файл 200x?, пропорции сохранены
|
||||
```
|
||||
|
||||
### Resize — заполнить 400x300 (с обрезкой)
|
||||
|
||||
```bash
|
||||
curl -s -o /tmp/fill400.jpg \
|
||||
"http://localhost:8888/unsafe/resize:fill:400:300:0/g:ce/q:80/plain/https://picsum.photos/1200/800"
|
||||
# → файл точно 400x300, лишнее обрезано по центру
|
||||
```
|
||||
|
||||
### Crop — обрезка 200x200
|
||||
|
||||
```bash
|
||||
curl -s -o /tmp/crop200.jpg \
|
||||
"http://localhost:8888/unsafe/crop:200:200/plain/https://picsum.photos/1200/800"
|
||||
# → файл 200x200
|
||||
```
|
||||
|
||||
### WebP — конвертация формата
|
||||
|
||||
```bash
|
||||
curl -s -D - -o /tmp/webp.webp \
|
||||
"http://localhost:8888/unsafe/format:webp/q:80/plain/https://picsum.photos/800/600" \
|
||||
2>&1 | grep Content-Type
|
||||
# → Content-Type: image/webp
|
||||
```
|
||||
|
||||
### AVIF
|
||||
|
||||
```bash
|
||||
curl -s -D - -o /tmp/avif.avif \
|
||||
"http://localhost:8888/unsafe/format:avif/q:80/plain/https://picsum.photos/800/600" \
|
||||
2>&1 | grep Content-Type
|
||||
# → Content-Type: image/avif
|
||||
```
|
||||
|
||||
### Качество — сравнить q:10 vs q:100
|
||||
|
||||
```bash
|
||||
curl -s -o /tmp/q10.jpg \
|
||||
"http://localhost:8888/unsafe/resize:fit:400:0:0/q:10/plain/https://picsum.photos/800/600"
|
||||
curl -s -o /tmp/q100.jpg \
|
||||
"http://localhost:8888/unsafe/resize:fit:400:0:0/q:100/plain/https://picsum.photos/800/600"
|
||||
ls -la /tmp/q10.jpg /tmp/q100.jpg
|
||||
# → q10 ~4KB, q100 ~50KB — разница ~12x
|
||||
```
|
||||
|
||||
### Комбинированные — resize + WebP + качество
|
||||
|
||||
```bash
|
||||
curl -s -o /tmp/combined.webp \
|
||||
"http://localhost:8888/unsafe/resize:fit:800:0:0/g:ce/q:75/format:webp/plain/https://picsum.photos/1200/800"
|
||||
# → 800px по ширине, WebP, качество 75
|
||||
```
|
||||
|
||||
### base64url — закодированный source URL
|
||||
|
||||
```bash
|
||||
B64=$(echo -n "https://picsum.photos/800/600" | base64 -w0 | tr '+/' '-_' | tr -d '=')
|
||||
curl -s -o /tmp/b64.jpg "http://localhost:8888/unsafe/resize:fit:100:0:0/q:80/$B64"
|
||||
# → работает как plain/, но без /plain/ префикса
|
||||
```
|
||||
|
||||
## 3. Кеширование
|
||||
|
||||
### MISS → HIT — базовая проверка
|
||||
|
||||
```bash
|
||||
URL="http://localhost:8888/unsafe/resize:fit:100:0:0/q:80/plain/https://picsum.photos/400/300"
|
||||
|
||||
# Первый запрос — MISS (обработка через imgproxy)
|
||||
curl -s -o /dev/null -D - -w "\ntime: %{time_total}s\n" "$URL" | grep -E "Cache-Status|time"
|
||||
# → Cache-Status: Souin; fwd=uri-miss; stored
|
||||
# → time: 0.5s
|
||||
|
||||
# Второй запрос — HIT (из кеша)
|
||||
curl -s -o /dev/null -D - -w "\ntime: %{time_total}s\n" "$URL" | grep -E "Cache-Status|time"
|
||||
# → Cache-Status: Souin; hit; ttl=31535999; detail=DEFAULT
|
||||
# → time: 0.001s
|
||||
```
|
||||
|
||||
### Что значит Cache-Status
|
||||
|
||||
```
|
||||
Cache-Status: Souin; hit; ... → из кеша
|
||||
Cache-Status: Souin; fwd=uri-miss; stored; key=GET-... → первый запрос, закешировано
|
||||
Cache-Status: Souin; fwd=uri-miss; detail=UNCACHEABLE-... → не закешировано (ошибка upstream)
|
||||
```
|
||||
|
||||
### Разные размеры = разные ключи
|
||||
|
||||
```bash
|
||||
SRC="https://picsum.photos/id/42/800/600"
|
||||
curl -s -o /dev/null "http://localhost:8888/unsafe/resize:fit:200:0:0/q:80/plain/$SRC"
|
||||
curl -s -o /dev/null "http://localhost:8888/unsafe/resize:fit:400:0:0/q:80/plain/$SRC"
|
||||
curl -s "http://localhost:2019/souin-api/souin/"
|
||||
# → 2 ключа: ...resize:fit:200... и ...resize:fit:400...
|
||||
```
|
||||
|
||||
## 4. Purge кеша
|
||||
|
||||
> Все purge-запросы идут через Caddy Admin API на порту **2019**.
|
||||
|
||||
### Purge — сбросить всё
|
||||
|
||||
```bash
|
||||
# Закешировать
|
||||
curl -s -o /dev/null "http://localhost:8888/unsafe/resize:fit:100:0:0/q:80/plain/https://picsum.photos/400/300"
|
||||
|
||||
# Purge
|
||||
curl -s -w "status: %{http_code}\n" -X PURGE http://localhost:2019/souin-api/souin/flush
|
||||
# → status: 204
|
||||
|
||||
# Проверить — снова MISS
|
||||
curl -s -o /dev/null -D - "http://localhost:8888/unsafe/resize:fit:100:0:0/q:80/plain/https://picsum.photos/400/300" \
|
||||
| grep Cache-Status
|
||||
# → Cache-Status: Souin; fwd=uri-miss; stored
|
||||
```
|
||||
|
||||
### Purge — конкретное изображение (все размеры)
|
||||
|
||||
```bash
|
||||
# Закешировать 2 размера
|
||||
SRC="https://picsum.photos/id/77/800/600"
|
||||
curl -s -o /dev/null "http://localhost:8888/unsafe/resize:fit:200:0:0/q:80/plain/$SRC"
|
||||
curl -s -o /dev/null "http://localhost:8888/unsafe/resize:fit:400:0:0/q:80/plain/$SRC"
|
||||
|
||||
# Purge по regex (id/77)
|
||||
curl -s -w "status: %{http_code}\n" -X PURGE "http://localhost:2019/souin-api/souin/.*id/77.*"
|
||||
# → status: 204
|
||||
|
||||
# Оба размера сброшены
|
||||
```
|
||||
|
||||
### Purge — конкретный размер
|
||||
|
||||
```bash
|
||||
curl -s -w "status: %{http_code}\n" -X PURGE \
|
||||
"http://localhost:2019/souin-api/souin/.*resize:fit:200.*id/77.*$"
|
||||
# → status: 204 — только 200px сброшен, 400px остался
|
||||
```
|
||||
|
||||
## 5. Caddy Admin API (Souin)
|
||||
|
||||
Порт **2019**, только localhost.
|
||||
|
||||
### Список закешированных ключей
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:2019/souin-api/souin/
|
||||
# → ["GET-http-localhost:8888-/unsafe/resize:fit:200:0:0/q:80/plain/https://..."]
|
||||
```
|
||||
|
||||
### Purge — все методы
|
||||
|
||||
```bash
|
||||
# Полный сброс
|
||||
curl -X PURGE http://localhost:2019/souin-api/souin/flush
|
||||
# → 204
|
||||
|
||||
# По regex
|
||||
curl -X PURGE "http://localhost:2019/souin-api/souin/.*example.com/photo.jpg"
|
||||
# → 204
|
||||
|
||||
# Точное совпадение (с $ в конце)
|
||||
curl -X PURGE "http://localhost:2019/souin-api/souin/.*resize:fit:800.*photo\.jpg$"
|
||||
# → 204
|
||||
```
|
||||
|
||||
## 6. Signed vs Unsigned
|
||||
|
||||
### Unsigned — по умолчанию
|
||||
|
||||
Ключи не заданы → `/unsafe/` работает:
|
||||
|
||||
```bash
|
||||
# С /unsafe/ → работает
|
||||
curl -s -o /dev/null -w "%{http_code}" \
|
||||
"http://localhost:8888/unsafe/resize:fit:100:0:0/q:80/plain/https://picsum.photos/200/200"
|
||||
# → 200
|
||||
|
||||
# Без /unsafe/ → тоже работает (ключи не заданы, подпись не проверяется)
|
||||
curl -s -o /dev/null -w "%{http_code}" \
|
||||
"http://localhost:8888/resize:fit:100:0:0/q:80/plain/https://picsum.photos/200/200"
|
||||
# → 200
|
||||
```
|
||||
|
||||
### Signed — включить подпись
|
||||
|
||||
1. Сгенерировать ключи:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32 # → KEY
|
||||
openssl rand -hex 32 # → SALT
|
||||
```
|
||||
|
||||
2. Добавить в `.env`:
|
||||
|
||||
```env
|
||||
IMGPROXY_KEY=<сгенерированный_ключ>
|
||||
IMGPROXY_SALT=<сгенерированная_соль>
|
||||
```
|
||||
|
||||
3. Перезапустить:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml restart imgproxy
|
||||
```
|
||||
|
||||
4. Проверить:
|
||||
|
||||
```bash
|
||||
# Без /unsafe/ и без подписи → ошибка
|
||||
curl -s -o /dev/null -w "%{http_code}" \
|
||||
"http://localhost:8888/resize:fit:100:0:0/q:80/plain/https://picsum.photos/200/200"
|
||||
# → 403
|
||||
|
||||
# С /unsafe/ → тоже ошибка (ключи заданы, unsafe запрещён)
|
||||
curl -s -o /dev/null -w "%{http_code}" \
|
||||
"http://localhost:8888/unsafe/resize:fit:100:0:0/q:80/plain/https://picsum.photos/200/200"
|
||||
# → 403
|
||||
```
|
||||
|
||||
5. Подписать URL:
|
||||
|
||||
```bash
|
||||
KEY="<сгенерированный_ключ>"
|
||||
SALT="<сгенерированная_соль>"
|
||||
PATH_URL="/resize:fit:100:0:0/q:80/plain/https://picsum.photos/200/200"
|
||||
|
||||
SIG=$(echo -n "$SALT" | xxd -r -p | openssl dgst -sha256 -hmac "$(echo -n "$KEY" | xxd -r -p)" -binary | base64 | tr '+/' '-_' | tr -d '=' | head -c 32)
|
||||
|
||||
curl -s -o /dev/null -w "%{http_code}" "http://localhost:8888/${SIG}${PATH_URL}"
|
||||
# → 200
|
||||
```
|
||||
|
||||
6. Вернуть unsigned — убрать ключи из `.env` и перезапустить.
|
||||
9
entrypoint.caddy.sh
Normal file
9
entrypoint.caddy.sh
Normal file
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
if [ -n "$ADMIN_PASS" ]; then
|
||||
ADMIN_PASS_HASH=$(caddy hash-password --plaintext "$ADMIN_PASS")
|
||||
export ADMIN_PASS_HASH
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
70
examples/js-wrapper.js
Normal file
70
examples/js-wrapper.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
/**
|
||||
* Утилита для формирования URL imgproxy через Image Gateway.
|
||||
*
|
||||
* Используется для:
|
||||
* - генерации signed URL для imgproxy
|
||||
* - программного построения URL обработки изображений
|
||||
*/
|
||||
|
||||
const GATEWAY_URL = process.env.IMAGE_GATEWAY_URL ?? 'http://localhost:80';
|
||||
const KEY = process.env.IMGPROXY_KEY ?? '';
|
||||
const SALT = process.env.IMGPROXY_SALT ?? '';
|
||||
|
||||
function sign(path) {
|
||||
if (!KEY || !SALT) {
|
||||
return `${GATEWAY_URL}/unsafe${path}`;
|
||||
}
|
||||
|
||||
const key = Buffer.from(KEY, 'hex');
|
||||
const salt = Buffer.from(SALT, 'hex');
|
||||
|
||||
const hmac = crypto.createHmac('sha256', key);
|
||||
hmac.update(salt);
|
||||
hmac.update(path);
|
||||
|
||||
const sig = hmac.digest().slice(0, 32).toString('base64url');
|
||||
return `${GATEWAY_URL}/${sig}${path}`;
|
||||
}
|
||||
|
||||
function encodeSrc(url) {
|
||||
return Buffer.from(url).toString('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Формирует URL resize-операции.
|
||||
*/
|
||||
export function resize(url, { width, height, quality = 80 }) {
|
||||
const h = height ?? 0;
|
||||
const encoded = encodeSrc(url);
|
||||
const path = `/resize:fit:${width}:${h}:0/q:${quality}/${encoded}`;
|
||||
return sign(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Формирует URL crop-операции.
|
||||
*/
|
||||
export function crop(url, { width, height, quality = 80 }) {
|
||||
const encoded = encodeSrc(url);
|
||||
const path = `/crop:${width}:${height}:0/q:${quality}/${encoded}`;
|
||||
return sign(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Формирует URL с конвертацией формата.
|
||||
*/
|
||||
export function convert(url, { format = 'webp', quality = 80 }) {
|
||||
const encoded = encodeSrc(url);
|
||||
const path = `/q:${quality}/${encoded}.${format}`;
|
||||
return sign(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Формирует произвольный URL обработки.
|
||||
*/
|
||||
export function custom(url, processingOptions) {
|
||||
const encoded = encodeSrc(url);
|
||||
const path = `/${processingOptions}/${encoded}`;
|
||||
return sign(path);
|
||||
}
|
||||
46
scripts/test-e2e.sh
Executable file
46
scripts/test-e2e.sh
Executable file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
project_name="${COMPOSE_PROJECT_NAME:-image-gateway-test-${CI_JOB_ID:-${GITHUB_RUN_ID:-local}}}"
|
||||
compose=(docker compose -p "${project_name}" -f docker-compose.test.yml)
|
||||
|
||||
cleanup() {
|
||||
status=$?
|
||||
|
||||
if [ "${status}" -ne 0 ]; then
|
||||
"${compose[@]}" logs --no-color || true
|
||||
fi
|
||||
|
||||
"${compose[@]}" down -v --remove-orphans >/dev/null 2>&1 || true
|
||||
exit "${status}"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
"${compose[@]}" down -v --remove-orphans >/dev/null 2>&1 || true
|
||||
"${compose[@]}" up -d --build caddy imgproxy fixture
|
||||
|
||||
"${compose[@]}" run --rm tester bun test tests/e2e/cache-prime.test.js
|
||||
|
||||
"${compose[@]}" restart caddy
|
||||
"${compose[@]}" run --rm tester bun test tests/e2e/cache-persistence.test.js
|
||||
|
||||
"${compose[@]}" run --rm tester bun test tests/e2e/cache-purge.test.js
|
||||
|
||||
logs=$("${compose[@]}" logs --no-color caddy)
|
||||
|
||||
case "${logs}" in
|
||||
*"default storage that is not optimized"*)
|
||||
printf '%s\n' "Caddy logs contain Souin default storage warning"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "${logs}" in
|
||||
*"NUTS-INSERTION-ERROR"*)
|
||||
printf '%s\n' "Caddy logs contain NUTS-INSERTION-ERROR"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
printf '%s\n' "E2E cache tests passed"
|
||||
12
tests/e2e/cache-persistence.test.js
Normal file
12
tests/e2e/cache-persistence.test.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { expectHit, requestImage, waitForReady } from "./helpers.js";
|
||||
|
||||
describe("cache persistence", () => {
|
||||
test("serves cached response from NutsDB after Caddy restart", async () => {
|
||||
await waitForReady();
|
||||
|
||||
const hit = await requestImage(103);
|
||||
expect(hit.status).toBe(200);
|
||||
expectHit(hit.cacheStatus, "NUTS");
|
||||
});
|
||||
});
|
||||
29
tests/e2e/cache-prime.test.js
Normal file
29
tests/e2e/cache-prime.test.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
expectHit,
|
||||
expectStored,
|
||||
listKeys,
|
||||
purgeAll,
|
||||
requestImage,
|
||||
waitForReady,
|
||||
} from "./helpers.js";
|
||||
|
||||
describe("cache prime", () => {
|
||||
test("stores responses in NutsDB and serves hot hits from Otter", async () => {
|
||||
await waitForReady();
|
||||
await purgeAll();
|
||||
|
||||
const miss = await requestImage(103);
|
||||
expect(miss.status).toBe(200);
|
||||
expectStored(miss.cacheStatus);
|
||||
|
||||
const hit = await requestImage(103);
|
||||
expect(hit.status).toBe(200);
|
||||
expectHit(hit.cacheStatus, "OTTER");
|
||||
|
||||
const keys = await listKeys();
|
||||
expect(keys.some((key) => key.includes("/unsafe/resize:fit:103"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
23
tests/e2e/cache-purge.test.js
Normal file
23
tests/e2e/cache-purge.test.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
expectHit,
|
||||
expectStored,
|
||||
purgeAll,
|
||||
requestImage,
|
||||
waitForReady,
|
||||
} from "./helpers.js";
|
||||
|
||||
describe("cache purge", () => {
|
||||
test("flush clears cache without breaking new writes", async () => {
|
||||
await waitForReady();
|
||||
await purgeAll();
|
||||
|
||||
const miss = await requestImage(103);
|
||||
expect(miss.status).toBe(200);
|
||||
expectStored(miss.cacheStatus);
|
||||
|
||||
const hit = await requestImage(103);
|
||||
expect(hit.status).toBe(200);
|
||||
expectHit(hit.cacheStatus, "OTTER");
|
||||
});
|
||||
});
|
||||
69
tests/e2e/helpers.js
Normal file
69
tests/e2e/helpers.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { expect } from "bun:test";
|
||||
|
||||
export const gatewayUrl = process.env.GATEWAY_URL ?? "http://caddy:8888";
|
||||
export const adminUrl = process.env.ADMIN_URL ?? "http://caddy:2019";
|
||||
export const sourceImageUrl =
|
||||
process.env.SOURCE_IMAGE_URL ?? "http://fixture:8080/image.png";
|
||||
|
||||
export function imageRequestUrl(width = 103) {
|
||||
return `${gatewayUrl}/unsafe/resize:fit:${width}:0:0/q:80/plain/${sourceImageUrl}`;
|
||||
}
|
||||
|
||||
export async function waitForReady() {
|
||||
const deadline = Date.now() + 30_000;
|
||||
let lastError;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(`${adminUrl}/souin-api/souin/`);
|
||||
if (response.ok) {
|
||||
await response.arrayBuffer();
|
||||
return;
|
||||
}
|
||||
lastError = new Error(`admin responded with ${response.status}`);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
throw new Error(`Caddy admin API is not ready: ${lastError}`);
|
||||
}
|
||||
|
||||
export async function purgeAll() {
|
||||
const response = await fetch(`${adminUrl}/souin-api/souin/flush`, {
|
||||
method: "PURGE",
|
||||
});
|
||||
await response.arrayBuffer();
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
}
|
||||
|
||||
export async function listKeys() {
|
||||
const response = await fetch(`${adminUrl}/souin-api/souin/`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function requestImage(width = 103) {
|
||||
const response = await fetch(imageRequestUrl(width));
|
||||
await response.arrayBuffer();
|
||||
|
||||
return {
|
||||
cacheStatus: response.headers.get("cache-status") ?? "",
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
|
||||
export function expectStored(cacheStatus) {
|
||||
expect(cacheStatus).toContain("fwd=uri-miss");
|
||||
expect(cacheStatus).toContain("stored");
|
||||
expect(cacheStatus).not.toContain("NUTS-INSERTION-ERROR");
|
||||
}
|
||||
|
||||
export function expectHit(cacheStatus, storage) {
|
||||
expect(cacheStatus).toContain("hit");
|
||||
expect(cacheStatus).toContain(`detail=${storage}`);
|
||||
}
|
||||
30
tests/fixture/server.js
Normal file
30
tests/fixture/server.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const image = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGklEQVR4nGP8z8Dwn4ECwESJ5lEDRgYAtP4CHcB7d7gAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
|
||||
Bun.serve({
|
||||
port: 8080,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === "/health") {
|
||||
return new Response("ok", {
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === "/image.png") {
|
||||
return new Response(image, {
|
||||
headers: {
|
||||
"cache-control": "public, max-age=31536000",
|
||||
"content-length": String(image.length),
|
||||
"content-type": "image/png",
|
||||
etag: '"fixture-image-v1"',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response("not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user