From 37592c8b8127df43f4dcb020e99809b8bcaa3087 Mon Sep 17 00:00:00 2001 From: "S.Gromov" Date: Mon, 4 May 2026 22:53:55 +0300 Subject: [PATCH] =?UTF-8?q?chore:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BA=D0=B0=D1=80=D0=BA=D0=B0=D1=81=20image-pla?= =?UTF-8?q?tform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - добавлен базовый pnpm workspace для будущих приложений - добавлена dev-инфраструктура PostgreSQL и MinIO - добавлены env-пример и базовые правила репозитория - зафиксированы архитектура, data model и API-контракт - описан контракт с внешним imgproxy --- .editorconfig | 12 ++++ .env.example | 30 +++++++++ .gitignore | 27 ++++++++ README.md | 60 ++++++++++++++++++ docs/api-contract-draft.md | 124 +++++++++++++++++++++++++++++++++++++ docs/architecture.md | 84 +++++++++++++++++++++++++ docs/data-model.md | 102 ++++++++++++++++++++++++++++++ docs/development.md | 84 +++++++++++++++++++++++++ docs/imgproxy-contract.md | 71 +++++++++++++++++++++ infra/compose.dev.yml | 51 +++++++++++++++ package.json | 18 ++++++ pnpm-lock.yaml | 9 +++ pnpm-workspace.yaml | 3 + 13 files changed, 675 insertions(+) create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/api-contract-draft.md create mode 100644 docs/architecture.md create mode 100644 docs/data-model.md create mode 100644 docs/development.md create mode 100644 docs/imgproxy-contract.md create mode 100644 infra/compose.dev.yml create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4039ff1 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..688dd43 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e41cc40 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..59283b0 --- /dev/null +++ b/README.md @@ -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. diff --git a/docs/api-contract-draft.md b/docs/api-contract-draft.md new file mode 100644 index 0000000..edcf4f6 --- /dev/null +++ b/docs/api-contract-draft.md @@ -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 для ``/`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 станет узким местом. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..b31c166 --- /dev/null +++ b/docs/architecture.md @@ -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 и отдавать через ``/`srcset`, а не выбирать по `Accept` header. Так CDN/S3/Gateway cache остаётся предсказуемым. + +## External imgproxy + +`imgproxy` не входит в этот проект и не деплоится вместе с ним. Он подключается через env: + +```env +IMGPROXY_UPSTREAM=http://external-imgproxy.internal:8080 +``` + +Это позволяет держать image processing на отдельной мощной машине и не рисковать основным сервером. diff --git a/docs/data-model.md b/docs/data-model.md new file mode 100644 index 0000000..16ecc6e --- /dev/null +++ b/docs/data-model.md @@ -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 +``` diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..a074c6a --- /dev/null +++ b/docs/development.md @@ -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. Он остаётся внешней зависимостью платформы. diff --git a/docs/imgproxy-contract.md b/docs/imgproxy-contract.md new file mode 100644 index 0000000..fa2e494 --- /dev/null +++ b/docs/imgproxy-contract.md @@ -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`. diff --git a/infra/compose.dev.yml b/infra/compose.dev.yml new file mode 100644 index 0000000..d0495e9 --- /dev/null +++ b/infra/compose.dev.yml @@ -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: diff --git a/package.json b/package.json new file mode 100644 index 0000000..6ca5bfc --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..9b60ae1 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,9 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..3ff5faa --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "apps/*" + - "packages/*"