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

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

30
.env.example Normal file
View File

@@ -0,0 +1,30 @@
# Local dev infrastructure
POSTGRES_DB=image_platform
POSTGRES_USER=image
POSTGRES_PASSWORD=image-password
POSTGRES_PORT=5433
DATABASE_URL=postgres://image:image-password@localhost:5433/image_platform
MINIO_ROOT_USER=image
MINIO_ROOT_PASSWORD=image-password
MINIO_API_PORT=9000
MINIO_CONSOLE_PORT=9001
S3_ENDPOINT=http://localhost:9000
S3_INTERNAL_ENDPOINT=http://minio:9000
S3_REGION=us-east-1
S3_BUCKET=image-platform
S3_ACCESS_KEY_ID=image
S3_SECRET_ACCESS_KEY=image-password
S3_FORCE_PATH_STYLE=true
# Future local services
PUBLIC_API_BASE_URL=http://localhost:3001
PUBLIC_IMAGE_BASE_URL=http://localhost:8888
# imgproxy is always external for image-platform.
# Local example: run imgproxy separately on localhost:18080.
IMGPROXY_UPSTREAM=http://localhost:18080
IMGPROXY_SIGNING_ENABLED=false
IMGPROXY_KEY=
IMGPROXY_SALT=

27
.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# Dependencies
node_modules/
.pnpm-store/
# Build output
dist/
build/
.turbo/
.next/
coverage/
# Runtime env
.env
.env.*
!.env.example
# Logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS / editor
.DS_Store
.idea/
.vscode/

60
README.md Normal file
View File

@@ -0,0 +1,60 @@
# Image Platform
Image Platform - отдельная площадка для управления изображениями, variants, PostgreSQL metadata и S3/Object Storage.
## Статус
Сейчас создан только базовый monorepo и dev-инфраструктура. Приложения `api`, `admin` и `gateway` пока намеренно не созданы.
## Целевая схема
```text
client
-> CDN optional
-> gateway Caddy/Souin hot cache
-> S3/Object Storage persistent variants
-> generator/worker
-> external imgproxy
-> source/original image
```
`imgproxy` всегда считается внешним сервисом и подключается через `IMGPROXY_UPSTREAM`.
## Локальная разработка
В Docker поднимается только базовая инфраструктура:
- PostgreSQL
- MinIO
- MinIO bucket init
Позже нодой будут запускаться:
- NestJS API
- worker
- React/Vite admin
Gateway будет добавлен отдельно позже.
```bash
cp .env.example .env
pnpm install
pnpm infra:up
pnpm infra:config
```
Порты по умолчанию:
| Сервис | URL |
|---|---|
| PostgreSQL | `localhost:5433` |
| MinIO API | `http://localhost:9000` |
| MinIO Console | `http://localhost:9001` |
## Документация
- `docs/architecture.md` - целевая архитектура и ответственность компонентов.
- `docs/development.md` - локальный dev flow.
- `docs/data-model.md` - черновик PostgreSQL модели.
- `docs/api-contract-draft.md` - черновик будущего JSON API.
- `docs/imgproxy-contract.md` - контракт с external imgproxy.

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`.

51
infra/compose.dev.yml Normal file
View File

@@ -0,0 +1,51 @@
name: image-platform
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-image_platform}
POSTGRES_USER: ${POSTGRES_USER:-image}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-image-password}
ports:
- "${POSTGRES_PORT:-5433}:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-image}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-image-password}
ports:
- "${MINIO_API_PORT:-9000}:9000"
- "${MINIO_CONSOLE_PORT:-9001}:9001"
volumes:
- minio-data:/data
minio-init:
image: minio/mc:latest
depends_on:
- minio
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-image}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-image-password}
S3_BUCKET: ${S3_BUCKET:-image-platform}
entrypoint: ["/bin/sh", "-c"]
command: >
"until mc alias set local http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}; do sleep 1; done &&
mc mb --ignore-existing local/$${S3_BUCKET} &&
mc anonymous set download local/$${S3_BUCKET}"
restart: "no"
volumes:
postgres-data:
minio-data:

18
package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "image-platform",
"version": "0.1.0",
"private": true,
"description": "Control plane for image assets, variants, S3 storage and imgproxy generation.",
"packageManager": "pnpm@10.28.1",
"engines": {
"node": ">=22.0.0",
"pnpm": ">=10.0.0"
},
"scripts": {
"infra:config": "docker compose -f infra/compose.dev.yml config",
"infra:up": "docker compose -f infra/compose.dev.yml up -d",
"infra:down": "docker compose -f infra/compose.dev.yml down",
"infra:logs": "docker compose -f infra/compose.dev.yml logs -f",
"check": "pnpm infra:config"
}
}

9
pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,9 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.: {}

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
packages:
- "apps/*"
- "packages/*"