feat: добавить генерацию image variants
- добавлен shared config presets, custom transforms и allowlist hosts - реализованы Backend endpoints для assets, presets и variants - добавлена orchestration через PostgreSQL, RabbitMQ, S3 и worker - обновлён Gateway read-through flow с L1 cache и корректным Vary: Accept - добавлена миграция resize_mode для variants lookup - обновлены dev scripts, env template, lockfile и документация
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Черновик Backend Контракта
|
||||
|
||||
Это черновик будущего бизнес-контракта для NestJS backend. Сейчас реализованы system endpoints, Swagger и placeholder для internal image ensure.
|
||||
Это черновик бизнес-контракта для NestJS backend. Сейчас реализованы system endpoints, Swagger, `POST /api/assets` и internal image ensure MVP.
|
||||
|
||||
## System
|
||||
|
||||
@@ -20,7 +20,7 @@ NestJS backend отдаёт JSON, metadata, statuses и URLs. Public image origi
|
||||
POST /api/internal/images/ensure
|
||||
```
|
||||
|
||||
Текущий статус реализации - endpoint зарезервирован и возвращает `501 Not Implemented`. Gateway `/images/*` пока тоже возвращает placeholder `501 Not Implemented`.
|
||||
Текущий статус реализации - MVP реализован: Backend создаёт/переиспользует variant row, публикует RabbitMQ job, ждёт `ready` до timeout и возвращает image bytes из S3.
|
||||
|
||||
Request body:
|
||||
|
||||
@@ -30,7 +30,10 @@ Request body:
|
||||
"version": 4,
|
||||
"preset": "card",
|
||||
"width": 640,
|
||||
"height": 0,
|
||||
"quality": 80,
|
||||
"requestedFormat": "auto",
|
||||
"resize": "fit",
|
||||
"format": "webp"
|
||||
}
|
||||
```
|
||||
@@ -39,13 +42,16 @@ Query params:
|
||||
|
||||
| Param | Описание |
|
||||
|---|---|
|
||||
| `w` | целевая ширина, должна быть разрешена preset или округлена до ближайшей разрешённой |
|
||||
| `q` | качество, должно быть из allowlist качества |
|
||||
| `w` | целевая ширина; обязательна для responsive presets и custom, запрещена/фиксирована для fixed presets |
|
||||
| `h` | целевая высота; используется для custom, для fixed preset берётся из config |
|
||||
| `q` | качество; если не передано, берётся из preset, иначе должно входить в allowlist preset |
|
||||
| `f` | `auto`, `avif`, `webp`, `jpg`; для Next/image по умолчанию `auto` |
|
||||
| `fit` | `fit` или `fill`; используется только для custom transforms |
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- проверить `assetId` и `preset`;
|
||||
- проверить `assetId` и статический preset/custom transform config;
|
||||
- нормализовать width, height, resize, quality и format;
|
||||
- вычислить deterministic `variantHash`;
|
||||
- проверить PostgreSQL и S3;
|
||||
- если variant готов в S3, вернуть bytes или stream metadata для Gateway;
|
||||
@@ -69,9 +75,8 @@ ETag: "..."
|
||||
|---|---|
|
||||
| `400` | некорректные query params |
|
||||
| `404` | asset или preset не найден |
|
||||
| `409` | variant уже генерируется и sync ожидание отключено |
|
||||
| `422` | source image нельзя обработать |
|
||||
| `502` | external imgproxy недоступен |
|
||||
| `502` | external imgproxy недоступен или S3 object отсутствует после `ready` |
|
||||
| `504` | generation не завершилась до `IMAGE_ENSURE_WAIT_MS` |
|
||||
|
||||
Public endpoint реализуется в Fastify Gateway, internal ensure endpoint - в Backend:
|
||||
|
||||
@@ -82,6 +87,14 @@ client -> CDN -> Gateway /images/* -> Backend ensure -> RabbitMQ -> Worker -> im
|
||||
Важно: `/images/*` не должен жить в NestJS `/api`. Gateway остаётся public image origin, Backend остаётся управлением процессов.
|
||||
Public image URL versioned: `/images/{assetId}/v{version}/{preset}?w={width}&q={quality}&f=auto`.
|
||||
|
||||
Примеры:
|
||||
|
||||
```text
|
||||
/images/asset_demo/v1/card?w=640&q=80&f=auto
|
||||
/images/asset_demo/v1/avatar?f=auto
|
||||
/images/asset_demo/v1/custom?w=777&h=333&q=72&fit=fill&f=webp
|
||||
```
|
||||
|
||||
## Allowed Hosts
|
||||
|
||||
```text
|
||||
@@ -94,63 +107,152 @@ DELETE /allowed-hosts/:id
|
||||
## Assets
|
||||
|
||||
```text
|
||||
GET /assets
|
||||
POST /assets
|
||||
GET /assets/:id
|
||||
GET /assets
|
||||
GET /assets/:publicId
|
||||
GET /assets/:publicId/variants
|
||||
POST /assets/:publicId/variants
|
||||
DELETE /assets/:id
|
||||
```
|
||||
|
||||
Сейчас реализованы `POST /assets`, `GET /assets`, `GET /assets/:publicId`, `GET /assets/:publicId/variants`, `POST /assets/:publicId/variants`.
|
||||
|
||||
`POST /assets` request:
|
||||
|
||||
```json
|
||||
{
|
||||
"sourceUrl": "https://example.com/photo.jpg"
|
||||
"sourceUrl": "https://storage.yandexcloud.net/shared1318/img/1.jpg",
|
||||
"publicId": "asset_demo"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "59fcf4f6-9891-4df4-8bb7-a4dbe570bb66",
|
||||
"publicId": "asset_demo",
|
||||
"version": 1,
|
||||
"sourceHost": "storage.yandexcloud.net",
|
||||
"imageBasePath": "/images/asset_demo/v1/card"
|
||||
}
|
||||
```
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- validate source URL;
|
||||
- check `allowed_image_hosts`;
|
||||
- create or reuse `image_assets` row;
|
||||
- check mock allowlist `SOURCE_ALLOWED_HOSTS`, если `SOURCE_HOST_ALLOW_ALL=false`;
|
||||
- создать `image_assets` row;
|
||||
- создать `image_asset_versions` row версии `1`;
|
||||
- optionally save original to S3 later.
|
||||
|
||||
`GET /assets` response:
|
||||
|
||||
```json
|
||||
{
|
||||
"assets": [
|
||||
{
|
||||
"id": "59fcf4f6-9891-4df4-8bb7-a4dbe570bb66",
|
||||
"publicId": "asset_demo",
|
||||
"currentVersion": 1,
|
||||
"status": "active",
|
||||
"sourceUrl": "https://storage.yandexcloud.net/shared1318/img/1.jpg",
|
||||
"sourceHost": "storage.yandexcloud.net",
|
||||
"createdAt": "2026-05-05T12:00:00.000Z",
|
||||
"updatedAt": "2026-05-05T12:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`GET /assets/:publicId/variants` возвращает rows из `image_variants` с public Gateway URL, S3 key и status.
|
||||
|
||||
## Variants
|
||||
|
||||
```text
|
||||
GET /assets/:id/variants
|
||||
POST /assets/:id/variants
|
||||
GET /assets/:publicId/variants
|
||||
POST /assets/:publicId/variants
|
||||
POST /variants/:id/regenerate
|
||||
DELETE /variants/:id
|
||||
```
|
||||
|
||||
`POST /assets/:id/variants` request:
|
||||
`POST /assets/:publicId/variants` request:
|
||||
|
||||
```json
|
||||
{
|
||||
"preset": "card",
|
||||
"mode": "single",
|
||||
"format": "webp",
|
||||
"width": 640
|
||||
"width": 640,
|
||||
"quality": 80
|
||||
}
|
||||
```
|
||||
|
||||
Response if ready:
|
||||
Family generation:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "variant_123",
|
||||
"status": "ready",
|
||||
"url": "http://localhost:8888/images/asset_123/v4/card?w=640&q=80&f=webp"
|
||||
"preset": "card",
|
||||
"mode": "family"
|
||||
}
|
||||
```
|
||||
|
||||
Response if generation is async:
|
||||
Для `family` Backend создаёт набор variants по разрешённым widths/formats static preset и ставит RabbitMQ jobs. Endpoint не ждёт image bytes, в отличие от internal ensure.
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "variant_123",
|
||||
"status": "pending",
|
||||
"url": null
|
||||
"publicId": "asset_demo",
|
||||
"version": 1,
|
||||
"variants": [
|
||||
{
|
||||
"id": "variant_123",
|
||||
"preset": "card",
|
||||
"version": 1,
|
||||
"width": 640,
|
||||
"height": 0,
|
||||
"resize": "fit",
|
||||
"quality": 80,
|
||||
"requestedFormat": "webp",
|
||||
"format": "webp",
|
||||
"status": "pending",
|
||||
"url": "http://localhost:8888/images/asset_demo/v1/card?w=640&q=80&f=webp",
|
||||
"s3Key": "variants/asset_demo/v1/abc.webp"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Presets
|
||||
|
||||
```text
|
||||
GET /presets
|
||||
```
|
||||
|
||||
Возвращает static presets, custom transform limits и mock allowlist source hosts.
|
||||
|
||||
```json
|
||||
{
|
||||
"presets": [
|
||||
{
|
||||
"name": "card",
|
||||
"mode": "responsive",
|
||||
"formats": ["avif", "webp", "jpg"],
|
||||
"qualities": [75, 80],
|
||||
"quality": 80,
|
||||
"resize": "fit",
|
||||
"widths": [320, 640, 960]
|
||||
}
|
||||
],
|
||||
"custom": {
|
||||
"enabled": true,
|
||||
"formats": ["avif", "webp", "jpg", "png"],
|
||||
"maxWidth": 4096,
|
||||
"maxHeight": 4096,
|
||||
"quality": 80
|
||||
},
|
||||
"allowedSourceHosts": ["storage.yandexcloud.net"]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ requested_format requested_format not null default auto
|
||||
format variant_format not null
|
||||
width integer not null
|
||||
height integer nullable
|
||||
resize_mode resize_mode not null default fit
|
||||
quality integer not null
|
||||
s3_key text not null unique
|
||||
content_type text nullable
|
||||
@@ -89,6 +90,7 @@ updated_at timestamptz not null default now()
|
||||
asset_status: active | disabled | deleted
|
||||
requested_format: auto | avif | webp | jpg | png
|
||||
variant_format: avif | webp | jpg | png
|
||||
resize_mode: fit | fill
|
||||
variant_status: pending | processing | ready | failed
|
||||
```
|
||||
|
||||
@@ -98,7 +100,7 @@ variant_status: pending | processing | ready | failed
|
||||
allowed_image_hosts(hostname)
|
||||
image_assets(public_id)
|
||||
image_asset_versions(asset_id, version)
|
||||
image_variants(asset_id, asset_version, preset, width, quality, format)
|
||||
image_variants(asset_id, asset_version, preset, width, height, resize_mode, quality, format)
|
||||
image_variants(s3_key)
|
||||
image_variants(variant_hash)
|
||||
```
|
||||
@@ -123,6 +125,8 @@ variants/{assetId}/v{version}/{variantHash}.{format}
|
||||
- `assetVersion`;
|
||||
- `preset`;
|
||||
- normalized width;
|
||||
- normalized height, где `0` означает auto height;
|
||||
- normalized resize mode;
|
||||
- normalized quality;
|
||||
- фактический output format;
|
||||
- параметры transform, влияющие на bytes.
|
||||
@@ -135,34 +139,45 @@ variants/asset_123/v4/card_w640_q80_webp.webp
|
||||
variants/asset_123/v4/card_w640_q80_jpg.jpg
|
||||
```
|
||||
|
||||
Public URL также versioned:
|
||||
Public URL также versioned. Для fixed preset `w` и `q` можно не передавать, для responsive preset `w` обязателен:
|
||||
|
||||
```text
|
||||
/images/{assetId}/v{version}/{preset}?w={width}&q={quality}&f=auto
|
||||
/images/{assetId}/v{version}/avatar?f=auto
|
||||
```
|
||||
|
||||
## Presets
|
||||
|
||||
Клиент не должен передавать произвольные трансформации. Сначала нужны ограниченные presets.
|
||||
Клиент не должен бесконтрольно создавать произвольные трансформации. Сейчас есть статический config в `packages/image-config`.
|
||||
|
||||
Режимы:
|
||||
|
||||
- `fixed` - preset задаёт один размер, например `avatar`.
|
||||
- `responsive` - preset задаёт allowlist ширин, например `card` и `hero`.
|
||||
- `custom` - произвольный single image, только если включён `IMAGE_ALLOW_CUSTOM_TRANSFORMS=true`.
|
||||
|
||||
Пример:
|
||||
|
||||
```text
|
||||
avatar:
|
||||
widths: 128, 256, 512
|
||||
mode: fixed
|
||||
width: 256
|
||||
height: 256
|
||||
formats: avif, webp, jpg
|
||||
quality: 80
|
||||
resize: fill
|
||||
|
||||
card:
|
||||
mode: responsive
|
||||
widths: 320, 640, 960
|
||||
formats: avif, webp, jpg
|
||||
quality: 80
|
||||
qualities: 75, 80
|
||||
resize: fit
|
||||
|
||||
hero:
|
||||
mode: responsive
|
||||
widths: 1280, 1920
|
||||
formats: avif, webp, jpg
|
||||
quality: 80
|
||||
qualities: 75, 80
|
||||
resize: fit
|
||||
```
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
- imgproxy dev instance;
|
||||
- RabbitMQ.
|
||||
|
||||
`backend` уже создан как NestJS app. `admin` уже создан как чистый Vite React TS app. `gateway` уже создан как Fastify app. `worker` уже создан как RabbitMQ skeleton.
|
||||
`backend` уже умеет регистрировать assets и выполнять internal ensure. `gateway` уже ходит в Backend и держит L1 memory cache. `worker` уже читает RabbitMQ jobs, вызывает imgproxy и пишет variants в S3.
|
||||
Gateway обязателен для Cloudinary-like поведения и интеграции с `next/image`.
|
||||
|
||||
## Запуск инфраструктуры
|
||||
@@ -21,8 +21,11 @@ Gateway обязателен для Cloudinary-like поведения и инт
|
||||
cp .env.example .env
|
||||
pnpm install
|
||||
pnpm infra:up
|
||||
pnpm db:migrate
|
||||
```
|
||||
|
||||
`.env` используется только локально и игнорируется git. Backend, Gateway, Worker и Drizzle scripts автоматически подхватывают его через Node `--env-file-if-exists`; в production эти переменные должны приходить из окружения процесса.
|
||||
|
||||
Проверить compose config:
|
||||
|
||||
```bash
|
||||
@@ -73,6 +76,31 @@ curl http://localhost:3001/api/health
|
||||
open http://localhost:3001/docs
|
||||
```
|
||||
|
||||
Зарегистрировать source image в dev mode:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST http://localhost:3001/api/assets \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"sourceUrl":"https://storage.yandexcloud.net/shared1318/img/1.jpg","publicId":"asset_demo"}'
|
||||
```
|
||||
|
||||
Посмотреть business API:
|
||||
|
||||
```bash
|
||||
curl -sS http://localhost:3001/api/presets
|
||||
curl -sS http://localhost:3001/api/assets
|
||||
curl -sS http://localhost:3001/api/assets/asset_demo
|
||||
curl -sS http://localhost:3001/api/assets/asset_demo/variants
|
||||
```
|
||||
|
||||
Явно поставить jobs на генерацию family variants без Gateway lazy request:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST http://localhost:3001/api/assets/asset_demo/variants \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"preset":"card","mode":"family"}'
|
||||
```
|
||||
|
||||
## Database
|
||||
|
||||
Drizzle schema живёт в `packages/database/src/schema.ts`, миграции - в `packages/database/drizzle`.
|
||||
@@ -130,17 +158,27 @@ pnpm gateway:dev
|
||||
curl http://localhost:8888/health
|
||||
```
|
||||
|
||||
Проверить placeholder image origin route:
|
||||
Проверить image origin route после запуска Backend и Worker:
|
||||
|
||||
```bash
|
||||
curl -i "http://localhost:8888/images/asset_123/v4/card?w=640&q=80&f=auto"
|
||||
curl -i "http://localhost:8888/images/asset_demo/v1/card?w=640&q=80&f=auto"
|
||||
curl -i "http://localhost:8888/images/asset_demo/v1/avatar?f=auto"
|
||||
curl -i "http://localhost:8888/images/asset_demo/v1/custom?w=777&h=333&q=72&fit=fill&f=webp"
|
||||
```
|
||||
|
||||
Ожидаемый текущий статус - `501`, потому что S3/imgproxy генерация ещё не реализована.
|
||||
Первый запрос должен пройти через Backend/RabbitMQ/Worker/imgproxy/S3 и вернуться с `x-image-platform-l1: MISS`. Повторный запрос должен вернуться из gateway L1 с `x-image-platform-l1: HIT`.
|
||||
|
||||
Статические presets сейчас лежат в `packages/image-config`:
|
||||
|
||||
- `card` - responsive, widths `320`, `640`, `960`.
|
||||
- `hero` - responsive, widths `1280`, `1920`.
|
||||
- `avatar` - fixed `256x256`.
|
||||
|
||||
Mock allowlist source hosts задаётся через `SOURCE_ALLOWED_HOSTS`. В dev по умолчанию разрешён `storage.yandexcloud.net`.
|
||||
|
||||
## Worker
|
||||
|
||||
Worker запускается нодой и сейчас только объявляет RabbitMQ topology, слушает `image.generate-variant` и отправляет полученные jobs в DLQ, потому что генерация ещё не реализована.
|
||||
Worker запускается нодой, объявляет RabbitMQ topology, слушает `image.generate-variant`, вызывает `imgproxy` и пишет готовый variant в S3.
|
||||
|
||||
```bash
|
||||
pnpm worker:dev
|
||||
|
||||
@@ -18,7 +18,7 @@ IMGPROXY_SALT=
|
||||
Пример path:
|
||||
|
||||
```text
|
||||
/unsafe/resize:fit:800:0:0/q:80/plain/https://example.com/photo.jpg
|
||||
/unsafe/resize:fit:800:0:0/q:80/plain/https://storage.yandexcloud.net/shared1318/img/1.jpg
|
||||
```
|
||||
|
||||
## Prod режим
|
||||
@@ -28,7 +28,7 @@ IMGPROXY_SALT=
|
||||
Path для подписи строится без `/unsafe`:
|
||||
|
||||
```text
|
||||
/resize:fit:800:0:0/q:80/plain/https://example.com/photo.jpg
|
||||
/resize:fit:800:0:0/q:80/plain/https://storage.yandexcloud.net/shared1318/img/1.jpg
|
||||
```
|
||||
|
||||
Signature:
|
||||
@@ -66,6 +66,6 @@ Final signed URL:
|
||||
- Source URL валидировать в Backend/worker.
|
||||
- Разрешать только `http` и `https`.
|
||||
- Запрещать localhost, private IP, loopback, link-local.
|
||||
- Source host должен быть enabled в `allowed_image_hosts`.
|
||||
- Не давать клиенту произвольные imgproxy options.
|
||||
- Использовать presets и deterministic `variantHash`.
|
||||
- Source host должен быть разрешён mock allowlist `SOURCE_ALLOWED_HOSTS`; таблица `allowed_image_hosts` остаётся для будущего CRUD.
|
||||
- Не давать клиенту произвольные imgproxy options без `IMAGE_ALLOW_CUSTOM_TRANSFORMS=true`.
|
||||
- Использовать static presets/custom normalization и deterministic `variantHash`.
|
||||
|
||||
@@ -22,7 +22,7 @@ module.exports = {
|
||||
images: {
|
||||
loader: "custom",
|
||||
loaderFile: "./src/image-platform-loader.js",
|
||||
qualities: [60, 75, 80, 90],
|
||||
qualities: [75, 80],
|
||||
},
|
||||
}
|
||||
```
|
||||
@@ -58,7 +58,7 @@ export function ProductCard() {
|
||||
GET /images/{assetId}/v{version}/{preset}?w={width}&q={quality}&f=auto
|
||||
```
|
||||
|
||||
Сейчас route уже зарезервирован в Fastify Gateway, но возвращает placeholder `501`, пока не реализованы PostgreSQL/S3/imgproxy read-through операции.
|
||||
Route реализован в Fastify Gateway. Для `card` ширина должна входить в static preset allowlist: `320`, `640`, `960`.
|
||||
|
||||
Пример:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user