- backend: user_avatar_objects binding (PG+SQLite), GC preserves avatar blobs, avatar service (EXIF-stripping WebP 128/512), public avatar read route - web: avatar upload/crop on profile, round avatar in header/members/comments/author - android: avatar upload/crop, round avatar on profile and albums header - openspec: add-user-avatar proposal/design/specs/tasks
684 lines
33 KiB
Markdown
684 lines
33 KiB
Markdown
# Проект: Многопользовательский фотоальбом (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)
|
||
- **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/<key>` с 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/<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`
|
||
|
||
### 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` — события альбома
|
||
- `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-теги
|
||
- Мобильные приложения, кроме 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/<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. |