Files
ds-galo/openspec/changes/shared-album/design.md
T
iu 2b2b342492 feat: implement shared-album multi-user photo album
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.
2026-10-10 12:50:33 +03:00

90 lines
7.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`-миграции или переустановка предыдущего бинарника.