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

7.2 KiB
Raw Blame History

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.

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-миграции или переустановка предыдущего бинарника.