feat: implement shared-album multi-user photo album
Backend (Go/Gin/pgx): env config, pgx pool with retries, Garage S3 client (path-style, region garage), embedded golang-migrate migrations, JWT auth with refresh rotation, albums CRUD, members/roles/invites with no-escalation matrix, photo upload + async imaging/goexif WebP variants, cursor pagination, presigned URLs, likes/comments, SSE hub (album + personal), notifications, share links. Frontend (SvelteKit 2 + Svelte 5 + Tailwind v4): API wrapper with 401 refresh, auth store, all screens, Uploader/PhotoGrid/Lightbox/Comments, SSE client with reconnect + REST catch-up, three themes + system via data-theme. Deploy without Docker: Makefile builds the frontend into backend/static and embeds it (go:embed) into a single statically linked binary; systemd unit and nginx SSE example included. Use gen2brain/webp (-tags nodynamic) instead of cgo kolesa-team/go-webp to keep CGO_ENABLED=0 static builds; design.md/proposal.md updated accordingly.
This commit is contained in:
+625
@@ -0,0 +1,625 @@
|
||||
# Проект: Многопользовательский фотоальбом (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/shared-album ./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/shared-album.service`
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Shared Album backend
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=album
|
||||
Group=album
|
||||
WorkingDirectory=/opt/shared-album
|
||||
EnvironmentFile=/opt/shared-album/.env
|
||||
ExecStart=/opt/shared-album/shared-album
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
|
||||
# Безопасность
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/opt/shared-album
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Активация:
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now shared-album
|
||||
sudo journalctl -u shared-album -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=shared_album
|
||||
PG_SSLMODE=disable
|
||||
|
||||
# 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
|
||||
S3_PUBLIC_URL=http://s3.localnet:3900/albums
|
||||
|
||||
# Auth
|
||||
JWT_SECRET=change_me_long_random
|
||||
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, storage_key, thumb_key, preview_key,
|
||||
original_filename, size, width, height, mime_type,
|
||||
taken_at, latitude, longitude, caption, created_at)
|
||||
- **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. Структура в S3 (Garage)
|
||||
|
||||
```
|
||||
{bucket}/
|
||||
└── albums/{album_id}/
|
||||
├── original/{photo_id}.{ext}
|
||||
├── preview/{photo_id}.webp
|
||||
└── thumb/{photo_id}.webp
|
||||
```
|
||||
|
||||
Доступ: **presigned URL** со сроком 1 час (бакет приватный).
|
||||
Garage поддерживает presigned URL через SigV4 query-string signatures.
|
||||
|
||||
## 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. Структура проекта
|
||||
|
||||
```
|
||||
shared-album/
|
||||
├── 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/
|
||||
│ ├── shared-album.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/shared-album ./cmd/server
|
||||
|
||||
build: frontend backend
|
||||
|
||||
run: backend
|
||||
./bin/shared-album
|
||||
|
||||
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 — Скелет и подключения
|
||||
Создай структуру проекта shared-album: 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/shared-album.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}.
|
||||
Отдавай фото через presigned URL (срок 1 час).
|
||||
Эндпоинты загрузки, списка с 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/shared-album.service с EnvironmentFile=.env
|
||||
и базовым харденингом (NoNewPrivileges, ProtectSystem, PrivateTmp).
|
||||
Добавь пример nginx-конфига с proxy_buffering off для SSE.
|
||||
Напиши README: как собрать, как положить бинарник, как запустить через systemd.
|
||||
Reference in New Issue
Block a user