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