From 0751c4b4690e7b0d7ea64e22a1f3caea8c79ca97 Mon Sep 17 00:00:00 2001 From: "S.Gromov" Date: Mon, 4 May 2026 12:18:37 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20Image=20Gateway=20=D1=81=20=D0=BA=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BC=20Souin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - добавлена сборка Caddy с Souin, Otter и NutsDB - добавлена конфигурация dev, prod и test Docker Compose - настроено кеширование через Otter L1 и NutsDB L2 - добавлены e2e-тесты Bun для кеша, restart и purge - добавлена документация по запуску, API кеша и тестам --- .dockerignore | 4 + .env.example | 26 +++ .gitignore | 3 + Caddyfile | 30 +++ Dockerfile.caddy | 15 ++ README.md | 39 ++++ docker-compose.dev.yml | 49 +++++ docker-compose.test.yml | 71 ++++++++ docker-compose.yml | 46 +++++ docs/MAP.md | 14 ++ docs/cache-api.md | 147 +++++++++++++++ docs/dev-guide.md | 128 +++++++++++++ docs/e2e-tests.md | 143 +++++++++++++++ docs/index.md | 25 +++ docs/overview.md | 92 ++++++++++ docs/testing-checklist.md | 57 ++++++ docs/url-reference.md | 159 ++++++++++++++++ docs/usage-guide.md | 272 ++++++++++++++++++++++++++++ entrypoint.caddy.sh | 9 + examples/js-wrapper.js | 70 +++++++ scripts/test-e2e.sh | 46 +++++ tests/e2e/cache-persistence.test.js | 12 ++ tests/e2e/cache-prime.test.js | 29 +++ tests/e2e/cache-purge.test.js | 23 +++ tests/e2e/helpers.js | 69 +++++++ tests/fixture/server.js | 30 +++ 26 files changed, 1608 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Caddyfile create mode 100644 Dockerfile.caddy create mode 100644 README.md create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.test.yml create mode 100644 docker-compose.yml create mode 100644 docs/MAP.md create mode 100644 docs/cache-api.md create mode 100644 docs/dev-guide.md create mode 100644 docs/e2e-tests.md create mode 100644 docs/index.md create mode 100644 docs/overview.md create mode 100644 docs/testing-checklist.md create mode 100644 docs/url-reference.md create mode 100644 docs/usage-guide.md create mode 100644 entrypoint.caddy.sh create mode 100644 examples/js-wrapper.js create mode 100755 scripts/test-e2e.sh create mode 100644 tests/e2e/cache-persistence.test.js create mode 100644 tests/e2e/cache-prime.test.js create mode 100644 tests/e2e/cache-purge.test.js create mode 100644 tests/e2e/helpers.js create mode 100644 tests/fixture/server.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d20dc23 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.env +.git +caddy-data/ +caddy-cache/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7d89125 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d7676cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +caddy-data/ +caddy-cache/ diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..2377852 --- /dev/null +++ b/Caddyfile @@ -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 + } +} diff --git a/Dockerfile.caddy b/Dockerfile.caddy new file mode 100644 index 0000000..139ebca --- /dev/null +++ b/Dockerfile.caddy @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..b07e6b3 --- /dev/null +++ b/README.md @@ -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 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..5e7c710 --- /dev/null +++ b/docker-compose.dev.yml @@ -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: diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..974a73a --- /dev/null +++ b/docker-compose.test.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fc798cf --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/MAP.md b/docs/MAP.md new file mode 100644 index 0000000..d4865b5 --- /dev/null +++ b/docs/MAP.md @@ -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 Чек-лист ручного тестирования +``` diff --git a/docs/cache-api.md b/docs/cache-api.md new file mode 100644 index 0000000..bbd8580 --- /dev/null +++ b/docs/cache-api.md @@ -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) через дополнительный плагин diff --git a/docs/dev-guide.md b/docs/dev-guide.md new file mode 100644 index 0000000..101ac92 --- /dev/null +++ b/docs/dev-guide.md @@ -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 +``` diff --git a/docs/e2e-tests.md b/docs/e2e-tests.md new file mode 100644 index 0000000..adb1693 --- /dev/null +++ b/docs/e2e-tests.md @@ -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 -f docker-compose.test.yml down -v --remove-orphans +docker compose -p -f docker-compose.test.yml up -d --build caddy imgproxy fixture +docker compose -p -f docker-compose.test.yml run --rm tester bun test tests/e2e/cache-prime.test.js +docker compose -p -f docker-compose.test.yml restart caddy +docker compose -p -f docker-compose.test.yml run --rm tester bun test tests/e2e/cache-persistence.test.js +docker compose -p -f docker-compose.test.yml run --rm tester bun test tests/e2e/cache-purge.test.js +docker compose -p -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` удаляет кеш. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..65d119f --- /dev/null +++ b/docs/index.md @@ -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) diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 0000000..614199e --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,92 @@ +# Обзор проекта + +## Что это + +Image Gateway — self-hosted сервер для обработки и кеширования изображений. Принимает URL оригинального изображения, изменяет размер/формат/качество по запросу, кеширует результат и отдаёт быстро. + +## Зачем + +- **Оптимизация изображений** — автоматический resize, crop, конвертация в WebP/AVIF +- **Кеширование** — однократная обработка, последующие запросы отдаются из кеша за ~1ms +- **Самостоятельный хостинг** — нет зависимости от внешних SaaS-сервисов (Cloudinary, imgix и т.д.) +- **Простая интеграция** — URL-based API, совместимое с `next/image`, `` и любым 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 + +``` + +### 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 или напрямую diff --git a/docs/testing-checklist.md b/docs/testing-checklist.md new file mode 100644 index 0000000..00a99e4 --- /dev/null +++ b/docs/testing-checklist.md @@ -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` — останавливает и удаляет контейнеры diff --git a/docs/url-reference.md b/docs/url-reference.md new file mode 100644 index 0000000..1164b87 --- /dev/null +++ b/docs/url-reference.md @@ -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/) diff --git a/docs/usage-guide.md b/docs/usage-guide.md new file mode 100644 index 0000000..0c46d0e --- /dev/null +++ b/docs/usage-guide.md @@ -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` и перезапустить. diff --git a/entrypoint.caddy.sh b/entrypoint.caddy.sh new file mode 100644 index 0000000..3378a65 --- /dev/null +++ b/entrypoint.caddy.sh @@ -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 "$@" diff --git a/examples/js-wrapper.js b/examples/js-wrapper.js new file mode 100644 index 0000000..70350ff --- /dev/null +++ b/examples/js-wrapper.js @@ -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); +} diff --git a/scripts/test-e2e.sh b/scripts/test-e2e.sh new file mode 100755 index 0000000..990a4d3 --- /dev/null +++ b/scripts/test-e2e.sh @@ -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" diff --git a/tests/e2e/cache-persistence.test.js b/tests/e2e/cache-persistence.test.js new file mode 100644 index 0000000..0d23991 --- /dev/null +++ b/tests/e2e/cache-persistence.test.js @@ -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"); + }); +}); diff --git a/tests/e2e/cache-prime.test.js b/tests/e2e/cache-prime.test.js new file mode 100644 index 0000000..a2fc580 --- /dev/null +++ b/tests/e2e/cache-prime.test.js @@ -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, + ); + }); +}); diff --git a/tests/e2e/cache-purge.test.js b/tests/e2e/cache-purge.test.js new file mode 100644 index 0000000..baafb49 --- /dev/null +++ b/tests/e2e/cache-purge.test.js @@ -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"); + }); +}); diff --git a/tests/e2e/helpers.js b/tests/e2e/helpers.js new file mode 100644 index 0000000..bf0f432 --- /dev/null +++ b/tests/e2e/helpers.js @@ -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}`); +} diff --git a/tests/fixture/server.js b/tests/fixture/server.js new file mode 100644 index 0000000..365f045 --- /dev/null +++ b/tests/fixture/server.js @@ -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 }); + }, +});