chore: добавить каркас image-platform

- добавлен базовый pnpm workspace для будущих приложений

- добавлена dev-инфраструктура PostgreSQL и MinIO

- добавлены env-пример и базовые правила репозитория

- зафиксированы архитектура, data model и API-контракт

- описан контракт с внешним imgproxy
This commit is contained in:
2026-05-04 22:53:55 +03:00
commit 37592c8b81
13 changed files with 675 additions and 0 deletions

124
docs/api-contract-draft.md Normal file
View File

@@ -0,0 +1,124 @@
# Черновик API Контракта
Это не реализация API, а фиксация будущего контракта для NestJS backend.
Backend отдаёт JSON, metadata, statuses и URLs. Он не должен проксировать image bytes на каждый обычный запрос.
## Allowed Hosts
```text
GET /allowed-hosts
POST /allowed-hosts
PATCH /allowed-hosts/:id
DELETE /allowed-hosts/:id
```
## Assets
```text
GET /assets
POST /assets
GET /assets/:id
DELETE /assets/:id
```
`POST /assets` request:
```json
{
"sourceUrl": "https://example.com/photo.jpg"
}
```
Responsibilities:
- validate source URL;
- check `allowed_image_hosts`;
- create or reuse `image_assets` row;
- optionally save original to S3 later.
## Variants
```text
GET /assets/:id/variants
POST /assets/:id/variants
POST /variants/:id/regenerate
DELETE /variants/:id
```
`POST /assets/:id/variants` request:
```json
{
"preset": "card",
"format": "webp",
"width": 640
}
```
Response if ready:
```json
{
"id": "variant_123",
"status": "ready",
"url": "http://localhost:8888/images/asset_123/w640_q80_card.webp"
}
```
Response if generation is async:
```json
{
"id": "variant_123",
"status": "pending",
"url": null
}
```
## Image URLs For UI
Для UI нужен endpoint, который возвращает готовый набор URLs для `<picture>`/`srcset`.
```text
GET /assets/:id/picture?preset=card
```
Example response:
```json
{
"assetId": "asset_123",
"preset": "card",
"sources": [
{
"type": "image/avif",
"srcset": "http://localhost:8888/images/asset_123/w320_card.avif 320w, http://localhost:8888/images/asset_123/w640_card.avif 640w"
},
{
"type": "image/webp",
"srcset": "http://localhost:8888/images/asset_123/w320_card.webp 320w, http://localhost:8888/images/asset_123/w640_card.webp 640w"
}
],
"fallback": {
"src": "http://localhost:8888/images/asset_123/w640_card.jpg",
"width": 640,
"height": null
}
}
```
## Worker Lifecycle
Первый MVP может генерировать sync на request. Если генерация тяжёлая, variant создаётся как `pending`, а worker обрабатывает job.
PostgreSQL может выступить первой очередью:
```text
SELECT * FROM image_variants
WHERE status = 'pending'
FOR UPDATE SKIP LOCKED
LIMIT 1
```
Позже можно добавить Redis/Valkey или отдельную queue, если PostgreSQL станет узким местом.

84
docs/architecture.md Normal file
View File

@@ -0,0 +1,84 @@
# Архитектура
## Назначение
`image-platform` - отдельный control plane для своего Cloudinary-like image pipeline.
Проект отвечает за metadata, S3 artifacts, variants, allowlist, presets и генерацию изображений через внешний `imgproxy`.
## Компоненты
| Компонент | Статус | Роль |
|---|---|---|
| PostgreSQL | сейчас | Источник правды для assets, variants, hosts, statuses |
| S3/MinIO | сейчас | Хранилище originals и generated variants |
| API | позже | JSON API, admin operations, validation, orchestration |
| Worker | позже | Генерация variants, upload в S3, update PostgreSQL |
| Admin UI | позже | Управление hosts/assets/variants/presets |
| Gateway | позже | Caddy/Souin hot cache и delivery layer |
| imgproxy | external | CPU-heavy image processing |
## Целевая delivery схема
```text
client
-> CDN optional
-> gateway Caddy/Souin
-> S3 ready variant
-> generator fallback
-> external imgproxy
-> source image
```
## Разделение ответственности
PostgreSQL отвечает на вопросы:
- какие assets зарегистрированы;
- какие variants созданы;
- где variants лежат в S3;
- какие variants `pending`, `processing`, `ready`, `failed`;
- сколько bytes занимает asset/project/user;
- какие source hosts разрешены.
S3 хранит байты:
- original images, если решим сохранять originals;
- generated variants;
- metadata объектов на уровне storage, но не бизнес-логику.
Gateway отдаёт картинки:
- hot cache HIT - сразу из Souin;
- cache MISS - из S3;
- S3 MISS - через generator fallback.
Backend не должен проксировать картинки на каждый обычный запрос. Он отдаёт JSON, статусы и URLs.
## URL модель
Публичные URL должны быть стабильными и не раскрывать source URL:
```text
/images/{assetId}/{variantHash}.{format}
```
Примеры:
```text
/images/asset_123/w640_q80_cfill.avif
/images/asset_123/w640_q80_cfill.webp
/images/asset_123/w640_q80_cfill.jpg
```
Формат лучше делать явным в URL и отдавать через `<picture>`/`srcset`, а не выбирать по `Accept` header. Так CDN/S3/Gateway cache остаётся предсказуемым.
## External imgproxy
`imgproxy` не входит в этот проект и не деплоится вместе с ним. Он подключается через env:
```env
IMGPROXY_UPSTREAM=http://external-imgproxy.internal:8080
```
Это позволяет держать image processing на отдельной мощной машине и не рисковать основным сервером.

102
docs/data-model.md Normal file
View File

@@ -0,0 +1,102 @@
# Черновик Data Model
Это черновик для будущих миграций PostgreSQL. Реальные таблицы добавим вместе с API.
## allowed_image_hosts
```text
id
hostname
enabled
description nullable
created_at
updated_at
```
Правила normalization:
- lowercase;
- без protocol;
- без path;
- без trailing slash;
- без wildcard на первом этапе;
- source URL должен быть `http` или `https`;
- запрещены localhost, private IP, loopback, link-local.
## image_assets
```text
id
source_url
source_host
source_hash
original_s3_key nullable
status
width nullable
height nullable
content_type nullable
size_bytes nullable
created_at
updated_at
```
## image_variants
```text
id
asset_id
preset
variant_hash
format
width
height nullable
quality
s3_key
status: pending | processing | ready | failed
size_bytes nullable
error nullable
created_at
updated_at
last_accessed_at nullable
```
## Unique constraints
```text
allowed_image_hosts(hostname)
image_assets(source_hash)
image_variants(asset_id, variant_hash, format)
```
## S3 layout
```text
originals/{assetId}/source
variants/{assetId}/{variantHash}.{format}
```
## Presets
Клиент не должен передавать произвольные трансформации. Сначала нужны ограниченные presets.
Пример:
```text
avatar:
widths: 128, 256, 512
formats: avif, webp, jpg
quality: 80
resize: fill
card:
widths: 320, 640, 960
formats: avif, webp, jpg
quality: 80
resize: fit
hero:
widths: 1280, 1920
formats: avif, webp, jpg
quality: 80
resize: fit
```

84
docs/development.md Normal file
View File

@@ -0,0 +1,84 @@
# Локальная разработка
## Принцип
В Docker запускаем стабильную инфраструктуру. Кодовые сервисы позже запускаем нодой с hot reload.
Сейчас в Docker есть только:
- PostgreSQL;
- MinIO;
- MinIO bucket init.
`api`, `worker`, `admin` и `gateway` пока не созданы.
## Запуск инфраструктуры
```bash
cp .env.example .env
pnpm install
pnpm infra:up
```
Проверить compose config:
```bash
pnpm infra:config
```
Остановить:
```bash
pnpm infra:down
```
Логи:
```bash
pnpm infra:logs
```
## Порты
| Сервис | URL |
|---|---|
| PostgreSQL | `localhost:5433` |
| MinIO API | `http://localhost:9000` |
| MinIO Console | `http://localhost:9001` |
## Будущий dev flow
Когда появятся приложения:
```text
React/Vite admin localhost:5173
-> NestJS API localhost:3001
-> PostgreSQL localhost:5433
-> MinIO localhost:9000
worker node process
-> PostgreSQL
-> MinIO
-> external imgproxy
gateway Caddy/Souin localhost:8888
-> S3/MinIO ready variant
-> API/generator fallback on host.docker.internal:3001
```
Для Linux gateway container должен видеть host services через:
```yaml
extra_hosts:
- "host.docker.internal:host-gateway"
```
## External imgproxy для разработки
`imgproxy` не входит в `image-platform` stack. Для локальной разработки можно использовать любой внешний endpoint и прописать его в `.env`:
```env
IMGPROXY_UPSTREAM=http://localhost:18080
```
Если нужен локальный standalone imgproxy, его можно запустить отдельно вне этого compose stack. Он остаётся внешней зависимостью платформы.

71
docs/imgproxy-contract.md Normal file
View File

@@ -0,0 +1,71 @@
# Контракт с imgproxy
`imgproxy` всегда внешний сервис для `image-platform`.
## Env
```env
IMGPROXY_UPSTREAM=http://external-imgproxy.internal:8080
IMGPROXY_SIGNING_ENABLED=false
IMGPROXY_KEY=
IMGPROXY_SALT=
```
## Dev режим
В dev можно использовать unsigned `/unsafe` URL, если внешний `imgproxy` запущен без key/salt.
Пример path:
```text
/unsafe/resize:fit:800:0:0/q:80/plain/https://example.com/photo.jpg
```
## Prod режим
В prod нужно перейти на signed URLs и закрыть `/unsafe`.
Path для подписи строится без `/unsafe`:
```text
/resize:fit:800:0:0/q:80/plain/https://example.com/photo.jpg
```
Signature:
```text
HMAC-SHA256(binary_key, binary_salt + path_bytes)
base64url without padding
```
Node implementation reference:
```ts
import crypto from "node:crypto"
export function signImgproxyPath(keyHex: string, saltHex: string, path: string) {
const key = Buffer.from(keyHex, "hex")
const salt = Buffer.from(saltHex, "hex")
const hmac = crypto.createHmac("sha256", key)
hmac.update(Buffer.concat([salt, Buffer.from(path)]))
return hmac.digest("base64url")
}
```
Final signed URL:
```text
{IMGPROXY_UPSTREAM}/{signature}{path}
```
## Security rules
- Не отдавать `IMGPROXY_KEY` и `IMGPROXY_SALT` в браузер.
- Source URL валидировать в API/worker.
- Разрешать только `http` и `https`.
- Запрещать localhost, private IP, loopback, link-local.
- Source host должен быть enabled в `allowed_image_hosts`.
- Не давать клиенту произвольные imgproxy options.
- Использовать presets и deterministic `variantHash`.