chore(openspec): archive shared-album and sync main specs

Move the completed shared-album change to
openspec/changes/archive/2026-10-10-shared-album/ and create the 8 main
specs (albums, auth, members, notifications, photos, realtime, share-links,
social) from the delta ADDED requirements.
This commit is contained in:
iu
2026-10-10 12:58:34 +03:00
parent 2b2b342492
commit 84ce4d1930
20 changed files with 373 additions and 0 deletions
@@ -0,0 +1,89 @@
# 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`-миграции или переустановка предыдущего бинарника.