Backend (Go/Gin/pgx): env config, pgx pool with retries, Garage S3 client (path-style, region garage), embedded golang-migrate migrations, JWT auth with refresh rotation, albums CRUD, members/roles/invites with no-escalation matrix, photo upload + async imaging/goexif WebP variants, cursor pagination, presigned URLs, likes/comments, SSE hub (album + personal), notifications, share links. Frontend (SvelteKit 2 + Svelte 5 + Tailwind v4): API wrapper with 401 refresh, auth store, all screens, Uploader/PhotoGrid/Lightbox/Comments, SSE client with reconnect + REST catch-up, three themes + system via data-theme. Deploy without Docker: Makefile builds the frontend into backend/static and embeds it (go:embed) into a single statically linked binary; systemd unit and nginx SSE example included. Use gen2brain/webp (-tags nodynamic) instead of cgo kolesa-team/go-webp to keep CGO_ENABLED=0 static builds; design.md/proposal.md updated accordingly.
90 lines
7.2 KiB
Markdown
90 lines
7.2 KiB
Markdown
# Design
|
||
|
||
## Context
|
||
|
||
Greenfield-сервис «многопользовательский фотоальбом». Существующая инфраструктура:
|
||
PostgreSQL (`pg.localnet:5432`) и Garage S3 (`s3.localnet`, path-style, регион `garage`,
|
||
ключ `GK...`). Жёсткое ограничение — без Docker: один статичный Go-бинарник со вшитой
|
||
статикой SvelteKit через `go:embed`, запуск через systemd. Мотивация — в proposal.md.
|
||
|
||
## Goals / Non-Goals
|
||
|
||
**Goals:**
|
||
- Один артефакт (Go-бинарник), `CGO_ENABLED=0`, self-hosted поверх Garage + PostgreSQL.
|
||
- Чёткая модель прав, проверяемая на уровне сервиса, а не только UI.
|
||
- Live-обновления через SSE без внешних зависимостей.
|
||
|
||
**Non-Goals:**
|
||
- Горизонтальное масштабирование (один инстанс, in-memory SSE-хаб).
|
||
- Web push (браузерные push) — отложено; уведомления только in-app через SSE.
|
||
- Публичный каталог альбомов — доступ вне состава только по share-ссылке.
|
||
- Zip-скачивание альбомов.
|
||
|
||
## Decisions
|
||
|
||
### 1. Обработка изображений: `imaging` + `gen2brain/webp` + `goexif`
|
||
Resize — `disintegration/imaging`, WebP-кодирование — `gen2brain/webp` (libwebp,
|
||
транспилированный в чистый Go/WASM; `kolesa-team/go-webp` отклонён, т.к. это cgo-биндинг,
|
||
несовместимый с `CGO_ENABLED=0`), EXIF — `rwcarlsen/goexif`. Всё чистый Go, совместимо с `CGO_ENABLED=0`.
|
||
- **Альтернатива (отклонена):** `bimg`/libvips — быстрее и нативный WebP/EXIF, но требует
|
||
CGO и libvips на сборочной и прод-машинах, ломает «статичный бинарник».
|
||
- **Альтернатива (запасная):** `imaging` + JPEG-превью — проще, но превью тяжелее ~25–35%.
|
||
- Конвейер: stream original в S3 → EXIF+resize → encode WebP → thumb/preview в S3.
|
||
|
||
### 2. Аутентификация: JWT access + refresh с хранением refresh-токена
|
||
Access 15 мин, refresh 30 дней. Refresh-токены хранятся в отдельной таблице
|
||
`refresh_tokens` (хэшированными) — это даёт отзыв при logout и ротацию при refresh.
|
||
- **Альтернатива:** stateless refresh-JWT без хранения — проще, но logout не может
|
||
отозвать токен (противоречит спец `auth`).
|
||
- SSE: `EventSource` не умеет заголовки, поэтому access-токен в `?token=`; короткий TTL
|
||
компенсирует риск. (Cookie+CSRF — отложено.)
|
||
|
||
### 3. Права: «нет эскалации», владелец из `albums.owner_id`
|
||
Роли `admin/editor/member/viewer` хранятся в `album_members`. Владелец — создатель,
|
||
выводится из `albums.owner_id`, не дублируется в составе. Правило: никто не может выдать
|
||
роль выше своей; админ управляет `editor/member/viewer`, владелец — админами; редактор
|
||
приглашает только `member/viewer`. Проверка на уровне сервиса.
|
||
|
||
### 4. Скачивание: только одиночное фото
|
||
`GET /api/photos/:id/download` — presigned URL оригинала. Zip альбома исключён из MVP.
|
||
|
||
### 5. Доступ по ссылке: таблица `share_links` вместо `privacy`
|
||
`privacy` на `albums` убран. `share_links` (id, album_id, token, created_by, created_at,
|
||
expires_at, revoked_at) — несколько ссылок, срок при создании, точечный отзыв. Доступ по
|
||
токену — view-only (сетка, лайтбокс, одиночное скачивание), без комментариев/лайков/загрузки.
|
||
Публичные эндпоинты `/api/share/:token/*` отдают свежие presigned URL.
|
||
|
||
### 6. Presigned URL: TTL 1 час + выдача по требованию
|
||
Клиент не хранит URL надолго: сетка получает свежие thumb/preview при каждом запросе списка,
|
||
лайтбокс дёргает `GET /api/photos/:id` перед показом оригинала. SSE-событие несёт `thumb_url`
|
||
только для мгновенного рендера. Короткий TTL сохраняет малое окно утечки для приватных фото.
|
||
|
||
### 7. SSE: in-memory hub на каналах
|
||
`map[albumID]map[clientID]chan Event`, неблокирующая публикация, отключение при переполнении
|
||
буфера, heartbeat 25 сек, отдельная горутина на клиента. Догон пропущенного — REST
|
||
(`photos?since=`). Один инстанс (см. Non-Goals); на рестарте клиенты переподключаются.
|
||
|
||
### 8. Фреймворк и структура
|
||
Backend — Gin + pgx + AWS SDK v2 (path-style, регион `garage`). Frontend — SvelteKit 2 +
|
||
Svelte 5, adapter-static, статика в `backend/static` через `go:embed`. Миграции —
|
||
golang-migrate. Логи — slog. Конфиг — env (12-factor).
|
||
|
||
## Risks / Trade-offs
|
||
|
||
- **[Чистый Go медленнее libvips]** → Приемлемо для MVP; превью генерируются асинхронно,
|
||
не в пути запроса. При росте нагрузки — миграция на bimg с отказом от `CGO_ENABLED=0`.
|
||
- **[In-memory SSE-хаб теряет события при рестарте]** → Клиенты переподключаются и догоняют
|
||
через REST; окно потери ограничено.
|
||
- **[Токен в query-параметре]** → Короткий TTL access-токена и refresh в httpOnly-хранилище
|
||
на фронте; Cookie+CSRF как дальнейшее улучшение.
|
||
- **[Presigned URL в 1 час]** → Может «протухать» на долгоживущих вкладках → компенсируется
|
||
выдачей по требованию (лайтбокс, свежий список).
|
||
- **[Несколько share-ссылок]** → Небольшой оверхед на управление, но даёт точечный отзыв
|
||
(важно для сценария «фотограф ↔ несколько клиентов»).
|
||
|
||
## Migration Plan
|
||
|
||
Greenfield: golang-migrate применяет `up` к `pg.localnet` при деплое. Таблицы: users,
|
||
albums (без `privacy`), album_members, invites, photos, comments, likes, notifications,
|
||
share_links, refresh_tokens. Откат — `down`-миграции или переустановка предыдущего бинарника.
|