Files
ds-galo/project.md
T
iu 50dff63ece feat: rename service to galo, add halo branding and release build
- rename Go module, binary, systemd unit, PG_DATABASE default, frontend package and docs from shared-album to galo
- add theme-aware halo emblem and favicon (new ui-branding capability)
- add make build-release (versioned, stripped, static binary) and run the binary from the repo root
2026-10-11 11:36:56 +03:00

672 lines
33 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.
# Проект: Многопользовательский фотоальбом (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` на `<html>`.
### Токены в `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) / <alpha-value>)',
elevated: 'hsl(var(--bg-elevated) / <alpha-value>)',
muted: 'hsl(var(--bg-muted) / <alpha-value>)',
},
border: 'hsl(var(--border) / <alpha-value>)',
text: {
DEFAULT: 'hsl(var(--text) / <alpha-value>)',
muted: 'hsl(var(--text-muted) / <alpha-value>)',
subtle: 'hsl(var(--text-subtle) / <alpha-value>)',
},
primary: {
DEFAULT: 'hsl(var(--primary) / <alpha-value>)',
fg: 'hsl(var(--primary-fg) / <alpha-value>)',
},
danger: 'hsl(var(--danger) / <alpha-value>)',
success: 'hsl(var(--success) / <alpha-value>)',
}
```
В компонентах — только семантические классы: `bg-bg-elevated text-text border-border`,
никаких `dark:` префиксов.
### Защита от вспышки
Инлайн-скрипт в `src/app.html` ставит `data-theme` **до** отрисовки:
читает `localStorage.theme` (или `system`), разрешает через `matchMedia` и
устанавливает атрибут на `<html>`.
### 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)
- **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/<key>` с HMAC-подписью (`e`, `s`),
поддержкой Range и Content-Type. Один и тот же маршрут обслуживает авторизованных
пользователей и анонимные share-ссылки.
**Postgres — журнал истины.** Таблица `objects` описывает каждый блоб (ключ, mime,
размер, габариты, checksum, состояние `pending|ready|deleting`), таблица
`photo_objects` связывает фото с объектами по ролям (`original|thumb|preview`),
таблица `variant_jobs` — очередь генерации вариантов. Альбом, принадлежность и
представление живут только в `photos`/`albums`.
Следствия:
- **Копирование** между альбомами — только новые строки `photos`+`photo_objects`,
ссылающиеся на те же объекты; операции с хранилищем не выполняются.
- **Перенос** — только `UPDATE photos.album_id`; объекты не трогаются.
- **Удаление** фото удаляет строки; фоновый воркер удаляет блоб лишь когда на
объект не осталось ни одной ссылки (сборка мусора по ledger-состояниям).
Доступ: **только через бэкенд** — подписанный URL со сроком 1 час на
`/api/objects/<key>`, независимо от бэкенда хранилища. Для 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`
### 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` — события альбома
- `GET /api/sse/me` — персональный поток (приглашения, уведомления)
## 12. SSE — детали реализации
### Формат события
```
event: photo.created
data: {"id":"...","album_id":"...","thumb_url":"...","uploader":{...}}
```
### 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=<last_event_time>`.
### Ограничения 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-теги
- Мобильные приложения (только адаптивный веб)
- Платные тарифы и биллинг
- Печать фотокниг, экспорт в сторонние сервисы
- Публичный 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/<key>` (срок 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 на <html>.
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.