chore: добавить каркас image-platform
- добавлен базовый pnpm workspace для будущих приложений - добавлена dev-инфраструктура PostgreSQL и MinIO - добавлены env-пример и базовые правила репозитория - зафиксированы архитектура, data model и API-контракт - описан контракт с внешним imgproxy
This commit is contained in:
124
docs/api-contract-draft.md
Normal file
124
docs/api-contract-draft.md
Normal 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
84
docs/architecture.md
Normal 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
102
docs/data-model.md
Normal 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
84
docs/development.md
Normal 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
71
docs/imgproxy-contract.md
Normal 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`.
|
||||
Reference in New Issue
Block a user