Files
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

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