Kotlin + Jetpack Compose client for a self-hosted instance: manual server setup and health check, auth with Keystore-backed tokens, album and photo browsing with SSE live updates, upload queue in WorkManager with HEIC-to-JPEG conversion, and Room/Coil offline cache. Add an android Makefile target and revise project.md §17.
33 KiB
Проект: Многопользовательский фотоальбом (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
cd backend
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o ../bin/galo ./cmd/server
Сборка frontend
cd frontend
npm ci
npm run build # adapter-static → frontend/build/
rm -rf ../backend/static/*
cp -r build/* ../backend/static/
Порядок сборки
- Собрать frontend (
npm run build) - Скопировать
build/*вbackend/static/ - Собрать backend (
go build) — статика вшивается в бинарник - Положить бинарник на сервер и запустить через systemd
systemd unit /etc/systemd/system/galo.service
[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
Активация:
sudo systemctl daemon-reload
sudo systemctl enable --now galo
sudo journalctl -u galo -f
Прокси (опционально)
Если перед Go стоит nginx — критично выставить для SSE:
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)
# 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
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
- Аутентификация: регистрация, вход, refresh-токен
- Альбомы: создание, редактирование, удаление, список моих альбомов
- Участники: инвайт по ссылке с токеном, принятие, роли
- Фото: загрузка (по одному и массово), генерация 3 превью (thumb 200px / preview 1200px / original), извлечение EXIF
- Просмотр: сетка, полноэкранный лайтбокс, свайпы
- Социальное: лайки, комментарии
- Real-time через SSE: новые фото, комментарии, лайки, подключение участников
- Уведомления: web push о новых событиях
- Скачивание: фото по одному и альбомом в zip
- Три темы оформления: светлая, тёмная, нейтральная + системная
8. Темы оформления
Три темы + режим «системная», переключение через data-theme на <html>.
Токены в src/app.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
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/registerPOST /api/auth/loginPOST /api/auth/refreshPOST /api/auth/logout
Albums
GET /api/albumsPOST /api/albumsGET /api/albums/:idPATCH /api/albums/:idDELETE /api/albums/:id
Members
GET /api/albums/:id/membersPOST /api/albums/:id/invitesPOST /api/invites/:token/acceptPATCH /api/albums/:id/members/:user_idDELETE /api/albums/:id/members/:user_id
Photos
GET /api/albums/:id/photos?cursor=&limit=POST /api/albums/:id/photos(multipart, поддержка batch)GET /api/photos/:idDELETE /api/photos/:idGET /api/photos/:id/download
Social
POST /api/photos/:id/likesDELETE /api/photos/:id/likesGET /api/photos/:id/commentsPOST /api/photos/:id/commentsDELETE /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 (пример)
.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. Порядок работы
- Структура проекта,
.env.example,Makefile, systemd unit - Подключение к
pg.localnet, миграции - Клиент S3 к Garage (
s3.localnet), создание бакета если нет - Auth: register/login/refresh
- CRUD альбомов + инвайты + роли
- Загрузка фото в Garage + превью + EXIF
- Лайки, комментарии
- SSE-хаб и публикация событий
- SvelteKit UI: все экраны + SSE-подписка + три темы
- Сборка: frontend → static → go:embed → бинарник
- 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.