Files
ds-galo/openspec/changes/archive/2026-10-10-shared-album/design.md
T
iu 84ce4d1930 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.
2026-10-10 12:58:34 +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`-миграции или переустановка предыдущего бинарника.