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:
2026-05-04 12:18:37 +03:00
commit 0751c4b469
26 changed files with 1608 additions and 0 deletions

4
.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
.env
.git
caddy-data/
caddy-cache/

26
.env.example Normal file
View 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
View File

@@ -0,0 +1,3 @@
.env
caddy-data/
caddy-cache/

30
Caddyfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"

View 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");
});
});

View 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,
);
});
});

View 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
View 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
View 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 });
},
});