# Проект: Многопользовательский фотоальбом (galo) ## 1. Цель Веб-приложение, где несколько пользователей создают общие фотоальбомы, загружают, просматривают, комментируют и организуют фотографии в реальном времени с разграничением прав доступа. **Целевая аудитория:** семьи и друзья (отпуск, свадьбы, праздники), рабочие команды (мероприятия, стройка), фотографы и их клиенты. **Проблема, которую решаем:** фото разбросаны по мессенджерам и галереям участников, теряется авторство и контекст, нет единого пространства с понятными правами. ## 2. Инфраструктура (уже существует) - **S3:** `s3.localnet` — **Garage** (S3-совместимое хранилище, self-hosted) - **PostgreSQL:** `pg.localnet:5432` — БД, пользователь, пароль в env - Локальные Postgres и MinIO **не поднимаем** — только подключаемся к существующим ### Ключевые особенности Garage - **Path-style обязателен** — `UsePathStyle: true`, виртуальный хостинг (`{bucket}.{host}`) не используется - **Регион:** `garage` (не стандартный `us-east-1`) - **Access Key ID** начинается с `GK...` - **Presigned URL** поддерживаются и являются предпочтительным способом отдачи файлов ## 3. Стек ### Backend: Go 1.22+ - HTTP: **Gin** (или Fiber) - БД: **PostgreSQL** через `pgx` (подключение к `pg.localnet`) - Хранилище: **S3 через AWS SDK v2** (подключение к Garage на `s3.localnet`, path-style) - Real-time: **Server-Sent Events (SSE)** — нативный `net/http` + `http.Flusher` - Аутентификация: **JWT** (access 15 мин + refresh 30 дней) - Миграции: **golang-migrate** - Обработка изображений: **imaging** или **bimg** (превью + EXIF) - Логирование: **slog** (структурированные логи) ### Frontend: SvelteKit 2 + Svelte 5 - **adapter-static** — сборка в статику, раздаётся Go-бинарником через `embed.FS` - **TypeScript** - **TailwindCSS** — utility-first, семантические токены через CSS-переменные - Real-time: **нативный `EventSource`** с обёрткой для reconnect и backoff - Только UI — вся логика на Go, обращение через `fetch` к `/api/*` ### Обоснование выбора - **Svelte** оправдан: маленькие бандлы (важно для галереи фото), простота кода, зрелость (Thoughtworks перевёл в «Adopt»), идеально как UI-слой над Go API - **Tailwind** оправдан: скорость разработки UI, автоматическая консистентность, 5–20 КБ gzip в продакшене, отлично гасится компонентным подходом Svelte - **SSE вместо WebSocket**: односторонний поток server→client полностью закрывает задачу (новые фото, комментарии, лайки); работает поверх HTTP/1.1 и HTTP/2, проходит через прокси, встроен в `net/http` без внешних либ ## 4. Запуск и деплой (без Docker) ### Принципы - **Никаких контейнеров.** Backend — нативный Go-бинарник. Frontend — статика, вшитая в бинарник через `embed.FS`. Никакого Docker, docker-compose, Kubernetes. - **Процесс-менеджер:** systemd (unit для backend). - **Сборка:** Go-тулчейн на сборочной машине, Node.js только на этапе сборки фронтенда (в CI или локально), в продакшене Node.js не нужен. - **Статика фронтенда** копируется в `backend/static/` и попадает в бинарник через `//go:embed static`. ### Сборка backend ```bash cd backend CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o ../bin/galo ./cmd/server ``` ### Сборка frontend ```bash cd frontend npm ci npm run build # adapter-static → frontend/build/ rm -rf ../backend/static/* cp -r build/* ../backend/static/ ``` ### Порядок сборки 1. Собрать frontend (`npm run build`) 2. Скопировать `build/*` в `backend/static/` 3. Собрать backend (`go build`) — статика вшивается в бинарник 4. Положить бинарник на сервер и запустить через systemd ### systemd unit `/etc/systemd/system/galo.service` ```ini [Unit] Description=Galo backend After=network-online.target Wants=network-online.target [Service] Type=simple User=album Group=album WorkingDirectory=/opt/galo EnvironmentFile=/opt/galo/.env ExecStart=/opt/galo/galo Restart=on-failure RestartSec=5s # Безопасность NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict ProtectHome=true ReadWritePaths=/opt/galo [Install] WantedBy=multi-user.target ``` Активация: ```bash sudo systemctl daemon-reload sudo systemctl enable --now galo sudo journalctl -u galo -f ``` ### Прокси (опционально) Если перед Go стоит nginx — критично выставить для SSE: ```nginx location /api/sse/ { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_buffering off; # иначе SSE буферизуется proxy_cache off; proxy_read_timeout 1h; chunked_transfer_encoding on; } ``` ## 5. Переменные окружения (.env) ```env # PostgreSQL PG_HOST=pg.localnet PG_PORT=5432 PG_USER=album PG_PASSWORD=change_me PG_DATABASE=galo PG_SSLMODE=disable # Metadata backend DB_BACKEND=sqlite # sqlite (default, embedded single instance) | postgres SQLITE_PATH=./galo.db # path when DB_BACKEND=sqlite # S3 (Garage) - внутреннее хранилище, клиенту не раскрывается S3_ENDPOINT=http://s3.localnet:3900 S3_REGION=garage S3_ACCESS_KEY=GKchange_me S3_SECRET_KEY=change_me S3_BUCKET=albums S3_USE_PATH_STYLE=true # Storage backend STORAGE_BACKEND=fs # fs (default, local filesystem) | s3 FS_ROOT=./data # root directory for object blobs (fs) URL_SECRET=change_me # signs object URLs; falls back to the metadata-layer secret OBJECT_URL_TTL=1h # lifetime of signed object URLs # Auth. Token signing secret is generated on first start and stored in the # metadata layer (app_settings) — not configured via env. JWT_ACCESS_TTL=15m JWT_REFRESH_TTL=720h # Server HTTP_ADDR=:8080 APP_BASE_URL=http://localhost:8080 # SSE SSE_HEARTBEAT_INTERVAL=25s SSE_CLIENT_BUFFER=64 ``` ### Параметры клиента S3 для Garage ```go cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion("garage"), config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( os.Getenv("S3_ACCESS_KEY"), os.Getenv("S3_SECRET_KEY"), "", )), ) client := s3.NewFromConfig(cfg, func(o *s3.Options) { o.BaseEndpoint = aws.String(os.Getenv("S3_ENDPOINT")) o.UsePathStyle = true // обязательно для Garage }) ``` **Важно:** - `UsePathStyle = true` — без этого Garage вернёт ошибку DNS или SignatureDoesNotMatch - Регион должен совпадать со значением `s3_region` в конфиге Garage (обычно `garage`) - Access Key ID начинается с `GK...` ## 6. Роли и права | Действие | Владелец | Админ | Редактор | Участник | Наблюдатель | |---|---|---|---|---|---| | Создать/удалить альбом | ✅ | ❌ | ❌ | ❌ | ❌ | | Приглашать участников | ✅ | ✅ | ⚠️ | ❌ | ❌ | | Загружать фото | ✅ | ✅ | ✅ | ✅ | ❌ | | Удалять чужие фото | ✅ | ✅ | ❌ | ❌ | ❌ | | Удалять свои фото | ✅ | ✅ | ✅ | ✅ | ❌ | | Комментировать | ✅ | ✅ | ✅ | ✅ | ❌ | | Менять роли | ✅ | ⚠️ | ❌ | ❌ | ❌ | | Скачивать | ✅ | ✅ | ✅ | ✅ | ⚠️ | Права проверяются **на уровне сервиса**, не только UI. ## 7. Функционал MVP 1. **Аутентификация:** регистрация, вход, refresh-токен 2. **Альбомы:** создание, редактирование, удаление, список моих альбомов 3. **Участники:** инвайт по ссылке с токеном, принятие, роли 4. **Фото:** загрузка (по одному и массово), генерация 3 превью (thumb 200px / preview 1200px / original), извлечение EXIF 5. **Просмотр:** сетка, полноэкранный лайтбокс, свайпы 6. **Социальное:** лайки, комментарии 7. **Real-time через SSE:** новые фото, комментарии, лайки, подключение участников 8. **Уведомления:** web push о новых событиях 9. **Скачивание:** фото по одному и альбомом в zip 10. **Три темы оформления:** светлая, тёмная, нейтральная + системная ## 8. Темы оформления Три темы + режим «системная», переключение через `data-theme` на ``. ### Токены в `src/app.css` ```css @import "tailwindcss"; /* Светлая */ :root, [data-theme="light"] { --bg: 0 0% 100%; --bg-elevated: 0 0% 98%; --bg-muted: 0 0% 95%; --border: 0 0% 88%; --text: 0 0% 9%; --text-muted: 0 0% 45%; --text-subtle: 0 0% 60%; --primary: 221 83% 53%; --primary-fg: 0 0% 100%; --danger: 0 84% 60%; --success: 142 71% 45%; --shadow: 0 0% 0% / 0.08; } /* Тёмная */ [data-theme="dark"] { --bg: 0 0% 7%; --bg-elevated: 0 0% 11%; --bg-muted: 0 0% 16%; --border: 0 0% 22%; --text: 0 0% 96%; --text-muted: 0 0% 65%; --text-subtle: 0 0% 45%; --primary: 217 91% 60%; --primary-fg: 0 0% 100%; --danger: 0 84% 65%; --success: 142 65% 50%; --shadow: 0 0% 0% / 0.4; } /* Нейтральная (тёплая серо-бежевая) */ [data-theme="neutral"] { --bg: 40 20% 96%; --bg-elevated: 40 22% 92%; --bg-muted: 40 18% 87%; --border: 40 14% 78%; --text: 30 10% 15%; --text-muted: 30 8% 40%; --text-subtle: 30 6% 55%; --primary: 25 90% 50%; --primary-fg: 0 0% 100%; --danger: 0 72% 55%; --success: 142 55% 42%; --shadow: 30 20% 20% / 0.12; } html, body { background: hsl(var(--bg)); color: hsl(var(--text)); transition: background-color 200ms ease, color 200ms ease; } @media (prefers-reduced-motion: reduce) { html, body { transition: none; } } ``` ### Маппинг в `tailwind.config.ts` ```ts colors: { bg: { DEFAULT: 'hsl(var(--bg) / )', elevated: 'hsl(var(--bg-elevated) / )', muted: 'hsl(var(--bg-muted) / )', }, border: 'hsl(var(--border) / )', text: { DEFAULT: 'hsl(var(--text) / )', muted: 'hsl(var(--text-muted) / )', subtle: 'hsl(var(--text-subtle) / )', }, primary: { DEFAULT: 'hsl(var(--primary) / )', fg: 'hsl(var(--primary-fg) / )', }, danger: 'hsl(var(--danger) / )', success: 'hsl(var(--success) / )', } ``` В компонентах — только семантические классы: `bg-bg-elevated text-text border-border`, никаких `dark:` префиксов. ### Защита от вспышки Инлайн-скрипт в `src/app.html` ставит `data-theme` **до** отрисовки: читает `localStorage.theme` (или `system`), разрешает через `matchMedia` и устанавливает атрибут на ``. ### Store темы `src/lib/stores/theme.svelte.ts` — Svelte 5 runes: `$state` для текущей темы, `initTheme()` в `onMount` в `+layout.svelte`, `setTheme()` пишет в localStorage и обновляет `data-theme`. Слушает `matchMedia` при режиме `system`. ### Переключатель `ThemeSwitcher.svelte` — 4 кнопки (☀️ Light / 🌙 Dark / 🌾 Neutral / 💻 System) с активным состоянием на семантических токенах. ### Нюансы для фотоальбома - В тёмной теме под фото использовать `bg-bg-muted` и лёгкую рамку `border-border` — так фото не «режет» глаз - Подложка лайтбокса зависит от темы: тёмная — почти чёрная, светлая — белая полупрозрачная, нейтральная — тёплый тёмный - Контраст `text-muted` на `bg-muted` в нейтральной теме ≥ 4.5:1 (WCAG AA) — важно для чтения подписей и комментариев - Presigned URL от Garage не зависят от темы ## 9. Схема БД (в pg.localnet, схема public) - **users** (id, email, password_hash, name, avatar_url, created_at) - **albums** (id, owner_id, title, description, cover_photo_id, privacy, created_at) - **album_members** (album_id, user_id, role, invited_by, joined_at) - **invites** (id, album_id, token, created_by, expires_at, max_uses, uses) - **photos** (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif, deleted_at, created_at) - **objects** (id, storage_key, mime_type, size, width, height, checksum, state, created_at) - **photo_objects** (photo_id, object_id, role) - **user_avatar_objects** (user_id, object_id, role) - **variant_jobs** (photo_id, attempts, next_attempt_at, last_error) - **comments** (id, photo_id, user_id, text, created_at, updated_at) - **likes** (photo_id, user_id, created_at) - **notifications** (id, user_id, type, payload_json, read_at, created_at) ## 10. Слой хранения и слой метаданных **Слой хранения выбирается параметром `STORAGE_BACKEND`:** `s3` (S3-совместимое хранилище, по умолчанию) или `fs` (локальная файловая система, `FS_ROOT`). Оба бэкенда реализуют единый интерфейс `storage.Backend` и используют одни и те же непрозрачные ключи, поэтому переключение не меняет формат данных. **S3 (Garage) — непрозрачное объектное хранилище.** Ключи не содержат сущностей метаданных (альбома, фото). Каждый физический файл адресуется своим `object_id`: ``` {bucket}/ └── objects/{object_id}/ ├── original.{ext} ├── thumb.webp └── preview.webp ``` При `STORAGE_BACKEND=fs` те же ключи отображаются в файлы под `FS_ROOT`. Отдача идёт через сам сервис: `GET /api/objects/` с HMAC-подписью (`e`, `s`), поддержкой Range и Content-Type. Один и тот же маршрут обслуживает авторизованных пользователей и анонимные share-ссылки. **Postgres — журнал истины.** Таблица `objects` описывает каждый блоб (ключ, mime, размер, габариты, checksum, состояние `pending|ready|deleting`), таблица `photo_objects` связывает фото с объектами по ролям (`original|thumb|preview`), таблица `user_avatar_objects` — аватар пользователя с его нарезками (`avatar|avatar_small`), таблица `variant_jobs` — очередь генерации вариантов. Альбом, принадлежность и представление живут только в `photos`/`albums`. Следствия: - **Копирование** между альбомами — только новые строки `photos`+`photo_objects`, ссылающиеся на те же объекты; операции с хранилищем не выполняются. - **Перенос** — только `UPDATE photos.album_id`; объекты не трогаются. - **Удаление** фото удаляет строки; фоновый воркер удаляет блоб лишь когда на объект не осталось ни одной ссылки (фото или аватар) — сборка мусора по ledger-состояниям. Доступ: **только через бэкенд** — подписанный URL со сроком 1 час на `/api/objects/`, независимо от бэкенда хранилища. Для S3 (Garage) байты стримятся из внутренней сети через бэкенд (ranged `GetObject`), presigned URL наружу не выдаются; для ФС — чтение с диска. Переключение бэкенда не переносит существующие блобы — перенос выполняется вручную. **Реконсиляция хранилища.** Сборка мусора управляется журналом `objects` и не сканирует хранилище, поэтому осиротевшие блобы (нет строки в журнале) не удаляются автоматически. Ручная команда `cmd/reconcile` сверяет хранилище с журналом: по умолчанию только отчёт, `--delete` удаляет сироты старше `--min-age` (по умолчанию 1h) в пределах префикса `objects/`; пропавшие ключи только сообщаются. ## 11. API-эндпоинты ### Auth - `POST /api/auth/register` - `POST /api/auth/login` - `POST /api/auth/refresh` - `POST /api/auth/logout` ### Profile - `GET|PATCH|DELETE /api/auth/me` - `POST /api/auth/me/password` - `POST /api/auth/me/identifiers` - `PATCH /api/auth/me/preferences` - `POST /api/auth/me/avatar` — загрузка готового квадрата (multipart `file`) - `DELETE /api/auth/me/avatar` — сброс к заглушке - `GET /api/avatars/:userID/:version?size=large` — публичная отдача аватара (без авторизации) ### Albums - `GET /api/albums` - `POST /api/albums` - `GET /api/albums/:id` - `PATCH /api/albums/:id` - `DELETE /api/albums/:id` ### Members - `GET /api/albums/:id/members` - `POST /api/albums/:id/invites` - `POST /api/invites/:token/accept` - `PATCH /api/albums/:id/members/:user_id` - `DELETE /api/albums/:id/members/:user_id` ### Photos - `GET /api/albums/:id/photos?cursor=&limit=` - `POST /api/albums/:id/photos` (multipart, поддержка batch) - `GET /api/photos/:id` - `DELETE /api/photos/:id` - `GET /api/photos/:id/download` ### Social - `POST /api/photos/:id/likes` - `DELETE /api/photos/:id/likes` - `GET /api/photos/:id/comments` - `POST /api/photos/:id/comments` - `DELETE /api/comments/:id` ### Real-time (SSE) - `GET /api/sse/albums/:id` — события открытого альбома (`photo.*`, `like.*`, `comment.created`, `member.*`, `album.updated`, `album.deleted`) - `GET /api/sse/me` — персональный поток: `notification` и события жизненного цикла альбома `album.created`, `album.updated`, `album.deleted` ## 12. SSE — детали реализации ### Формат события ``` event: photo.created data: {"id":"...","album_id":"...","thumb_url":"...","uploader":{...}} ``` ### Персональный поток (`/api/sse/me`) Несёт события, адресованные конкретному пользователю: | Событие | Когда | payload | |---|---|---| | `notification` | создано уведомление для пользователя | объект уведомления | | `album.created` | альбом появился в списке пользователя (он его создал или принял инвайт) | объект альбома | | `album.updated` | изменены название/описание/обложка, состав участников или состав фото | объект альбома | | `album.deleted` | альбом удалён владельцем или доступ отозван (исключение из участников) | `{"id":"..."}` | Объект альбома в `album.created` и `album.updated` совпадает по полям с элементом ответа `GET /api/albums`: `id`, `owner_id`, `title`, `description`, `cover_photo_id`, `cover_thumb_url`, `cover_medium_url`, `photos_count`, `members_count`, `created_at`, `updated_at`. Событие рассылается веером по личным каналам владельца и каждого участника альбома; `album.deleted` при исключении участника получает только исключённый. Публикация аддитивна: клиенты, которые не знают новый тип события, его игнорируют, а пер-альбомный контракт (`album.updated`, `album.deleted`) сохранён. Состав фото отражается в персональном потоке: загрузка, копирование, перемещение, восстановление, перевод в корзину и окончательное удаление публикуют `album.updated` получателям (в пер-альбомный канал `album.updated` по этим поводам не идёт — открытая страница узнаёт об изменениях из `photo.*`). `photos_count` считает только фото вне корзины и совпадает с числом фото в сетке альбома. ### Backend: hub на каналах - В памяти `map[albumID]map[clientID]chan Event` - Регистрация клиента при подключении, удаление при отключении - Публикация — неблокирующая запись в канал каждого клиента - При переполнении буфера — отключение медленного клиента (backpressure) ### Обязательные заголовки ``` Content-Type: text/event-stream Cache-Control: no-cache Connection: keep-alive X-Accel-Buffering: no ``` `X-Accel-Buffering: no` критичен, если перед Go стоит nginx. ### Heartbeat Каждые 25 сек — комментарий `: heartbeat\n\n`, чтобы прокси не рвали соединение. ### Авторизация `EventSource` не умеет кастомные заголовки, поэтому access-токен передаётся в query-параметре `?token=...`. Компенсация: короткий TTL (15 мин) и переподключение с новым токеном. Альтернатива — refresh в HttpOnly-cookie (с CSRF-защитой). ### Догон пропущенного `EventSource` не отправляет `Last-Event-ID` надёжно. Поэтому при переподключении клиент догоняет пропущенное через REST: `GET /api/albums/:id/photos?since=` для открытого альбома и `GET /api/albums` для списка альбомов (персональный поток). Догон списка восстанавливает изменения, пропущенные за время разрыва, в том числе удаления: удалённый альбом в ответе уже не приходит. ### Ограничения SSE - Односторонний поток (server → client), действия клиента — обычные POST/PATCH/DELETE - Работает поверх HTTP/1.1 и HTTP/2, проходит через большинство прокси - Лимит 6 одновременных SSE-соединений на домен в HTTP/1.1 (в HTTP/2 нет) - Для чатов и игр не подходит — только для ленты событий ## 13. Структура проекта ``` galo/ ├── backend/ │ ├── cmd/server/main.go │ ├── internal/ │ │ ├── config/ # чтение env │ │ ├── auth/ # JWT, middleware │ │ ├── albums/ # handlers, service, repo │ │ ├── photos/ # upload, resize, storage │ │ ├── comments/ │ │ ├── likes/ │ │ ├── invites/ │ │ ├── sse/ # hub, broker, publisher │ │ ├── storage/ # S3 клиент (Garage на s3.localnet) │ │ └── db/ # подключение (pg.localnet), миграции │ ├── migrations/ │ ├── static/ # сюда копируется build SvelteKit (go:embed) │ └── go.mod ├── frontend/ │ ├── src/ │ │ ├── app.html # inline-скрипт для data-theme │ │ ├── app.css # CSS-переменные трёх тем │ │ ├── routes/ │ │ │ ├── +layout.svelte # initTheme() │ │ │ ├── +page.svelte # список альбомов │ │ │ ├── login/ │ │ │ ├── albums/[id]/ │ │ │ │ ├── +page.svelte # сетка фото + SSE │ │ │ │ ├── members/ │ │ │ │ └── settings/ │ │ │ └── invites/[token]/ │ │ ├── lib/ │ │ │ ├── api.ts # fetch-обёртка │ │ │ ├── sse.ts # EventSource с reconnect │ │ │ ├── stores/ │ │ │ │ ├── auth.svelte.ts │ │ │ │ └── theme.svelte.ts │ │ │ └── components/ │ │ │ ├── PhotoGrid.svelte │ │ │ ├── Lightbox.svelte │ │ │ ├── Uploader.svelte │ │ │ ├── Comments.svelte │ │ │ └── ThemeSwitcher.svelte │ ├── static/ │ ├── svelte.config.js # adapter-static │ ├── vite.config.ts │ └── package.json ├── deploy/ │ ├── galo.service # systemd unit │ └── nginx-sse.conf # пример прокси для SSE ├── tailwind.config.ts ├── .env.example ├── Makefile # build, run, migrate, deploy └── README.md ``` ## 14. Makefile (пример) ```makefile .PHONY: build frontend backend run migrate frontend: cd frontend && npm ci && npm run build rm -rf backend/static/* cp -r frontend/build/* backend/static/ backend: cd backend && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" \ -o ../bin/galo ./cmd/server build: frontend backend run: backend ./bin/galo migrate: cd backend && migrate -path migrations -database \ "postgres://$(PG_USER):$(PG_PASSWORD)@$(PG_HOST):$(PG_PORT)/$(PG_DATABASE)?sslmode=$(PG_SSLMODE)" up ``` ## 15. Требования к качеству - Все эндпоинты защищены JWT-мидлварой, кроме `/auth/*` - Права проверяются на уровне сервиса, не только UI - Загрузка фото: стриминг в Garage (`s3.localnet`), генерация 3 превью асинхронно - SSE: авторизация через токен, подписка только на доступные альбомы, heartbeat каждые 25 сек, backpressure для медленных клиентов - Пагинация через cursor (id или created_at), не offset - Ошибки API — единый формат `{error: {code, message}}` - Логирование через `slog` (структурированные логи) - Конфиг через env (12-factor) - Подключение к `pg.localnet` и Garage (`s3.localnet`) — с ретраями и health-check - **Garage:** обязательно `UsePathStyle: true` и регион `garage` - **Без Docker:** один Go-бинарник со вшитой статикой, запуск через systemd - SSE-эндпоинт не блокирует остальные запросы — отдельная горутина на клиента - Темы: никаких `dark:` префиксов в компонентах — только семантические токены ## 16. Порядок работы 1. Структура проекта, `.env.example`, `Makefile`, systemd unit 2. Подключение к `pg.localnet`, миграции 3. Клиент S3 к Garage (`s3.localnet`), создание бакета если нет 4. Auth: register/login/refresh 5. CRUD альбомов + инвайты + роли 6. Загрузка фото в Garage + превью + EXIF 7. Лайки, комментарии 8. SSE-хаб и публикация событий 9. SvelteKit UI: все экраны + SSE-подписка + три темы 10. Сборка: frontend → static → go:embed → бинарник 11. README с инструкцией запуска (build, systemd, nginx) ## 17. Что НЕ делать в MVP - Распознавание лиц, ML-теги - Мобильные приложения, кроме Android-клиента (см. `android/`); iOS не планируется - Платные тарифы и биллинг - Печать фотокниг, экспорт в сторонние сервисы - Публичный API для внешних интеграций - Поднимать локальные Postgres и MinIO — используем `pg.localnet` и Garage - WebSocket — real-time только через SSE - Виртуальный хостинг для S3 (`{bucket}.s3.localnet`) — только path-style - **Docker, docker-compose, Kubernetes** — только нативный Go-бинарник + systemd ## 18. Пошаговые промпты для итеративной генерации ### Шаг 1 — Скелет и подключения Создай структуру проекта galo: backend (Go + Gin) и frontend (SvelteKit 2 + Svelte 5 + adapter-static + Tailwind). Без Docker — нативная сборка Go-бинарника, статика вшивается через go:embed. Используй существующие pg.localnet и Garage на s3.localnet. Добавь .env.example со всеми переменными (PG_*, S3_*, JWT_*, HTTP_*, SSE_*), config-пакет на Go, подключение к pg.localnet через pgx с ретраями, клиент S3 к Garage (AWS SDK v2, region=garage, UsePathStyle=true), health-check /api/health (проверяет БД и S3). Добавь Makefile: build (frontend → static → backend), run, migrate. Добавь systemd unit deploy/galo.service. ### Шаг 2 — Миграции Добавь golang-migrate и миграции для таблиц: users, albums, album_members, invites, photos, comments, likes, notifications. Применяются к pg.localnet. Реализуй репозитории с базовыми CRUD на pgx. ### Шаг 3 — Аутентификация Реализуй регистрацию и вход по email/паролю с bcrypt, выдачу JWT access (15 мин) и refresh (30 дней). Middleware для проверки токена. Эндпоинты: POST /api/auth/register, /login, /refresh, /logout. На фронтенде — страницы login и register, store для токена, автообновление при 401. ### Шаг 4 — Альбомы и участники Реализуй CRUD альбомов и систему инвайтов по токену. Роли: owner, admin, editor, member, viewer. Проверка прав на уровне сервиса. Эндпоинты согласно ТЗ. На фронтенде — список альбомов, страница альбома, страница участников, принятие инвайта. ### Шаг 5 — Загрузка и просмотр фото Реализуй загрузку фото в Garage с генерацией трёх превью (thumb 200px, preview 1200px, original) и извлечением EXIF. Структура ключей: albums/{album_id}/{original|preview|thumb}/{photo_id}.{ext|webp}. Отдавай фото через бэкенд: подписанный URL `/api/objects/` (срок 1 час), S3-байты стримятся из внутренней сети через бэкенд. Эндпоинты загрузки, списка с cursor-пагинацией, удаления. На фронтенде — Uploader с drag&drop и прогрессом, PhotoGrid с ленивой загрузкой, Lightbox со свайпами. ### Шаг 6 — SSE real-time Реализуй SSE-хаб на Go: реестр клиентов по album_id, неблокирующая публикация, heartbeat каждые 25 сек, backpressure. Эндпоинт GET /api/sse/albums/:id с авторизацией через ?token=. Заголовки: text/event-stream, no-cache, X-Accel-Buffering: no. События: photo.created, photo.deleted, comment.created, like.added, like.removed, member.joined, member.left, album.updated. Публикуй события из сервисов. На фронтенде — обёртка sse.ts над EventSource с экспоненциальным backoff и догоном пропущенного через REST. Обновляй сетку и комментарии в live-режиме. ### Шаг 7 — Темы оформления Реализуй три темы (light, dark, neutral) + system через data-theme на . CSS-переменные в app.css, маппинг в tailwind.config.ts на семантические токены (bg, bg-elevated, bg-muted, border, text, text-muted, text-subtle, primary, danger, success). Инлайн-скрипт в app.html ставит data-theme до отрисовки (защита от вспышки). Store theme.svelte.ts на Svelte 5 runes: initTheme() в onMount в +layout.svelte, setTheme() пишет в localStorage, слушает matchMedia для system. ThemeSwitcher.svelte — 4 кнопки с активным состоянием. В компонентах — только семантические классы, никаких dark: префиксов. ### Шаг 8 — Сборка и деплой без Docker Настрой Makefile: frontend собирается в статику, копируется в backend/static, backend собирается с go:embed. Финальный артефакт — один бинарник. Добавь systemd unit в deploy/galo.service с EnvironmentFile=.env и базовым харденингом (NoNewPrivileges, ProtectSystem, PrivateTmp). Добавь пример nginx-конфига с proxy_buffering off для SSE. Напиши README: как собрать, как положить бинарник, как запустить через systemd.