Цель run зависела только от backend, а статика фронтенда попадает в бинарник через go:embed из backend/static, который обновляла лишь цель frontend. Поэтому make run пересобирал Go-код со старым UI. - run теперь зависит от frontend backend; - frontend стала инкрементальной: npm ci только при изменении package.json/ package-lock.json, npm run build и копирование в backend/static — только когда исходники или конфиги новее, чем backend/static/index.html (он же stamp); - проверки вынесены в shell-recipe, т.к. имена SvelteKit-роутов вида (app) ломают разбор имён файлов в make.
328 lines
18 KiB
Markdown
328 lines
18 KiB
Markdown
# Galo — многопользовательский фотоальбом
|
||
|
||
Self-hosted веб-приложение: общие фотоальбомы с ролями, загрузкой фото в S3,
|
||
live-обновлениями через SSE и доступом по ссылке без аккаунта.
|
||
|
||
Без Docker: один статичный Go-бинарник (`CGO_ENABLED=0`) со вшитой статикой
|
||
SvelteKit через `go:embed`, запуск через systemd.
|
||
|
||
## Стек
|
||
|
||
- **Backend:** Go, Gin, pgx (PostgreSQL на `pg.localnet`), AWS SDK v2 (Garage на
|
||
`s3.localnet`, path-style), нативный SSE, golang-migrate, slog.
|
||
- **Frontend:** SvelteKit 2 + Svelte 5, adapter-static, TypeScript, TailwindCSS.
|
||
- **Обработка изображений:** `disintegration/imaging` + `gen2brain/webp` +
|
||
`rwcarlsen/goexif` (всё чистый Go).
|
||
|
||
## Требования
|
||
|
||
- Go 1.22+ (сборка проверена на 1.26)
|
||
- Node.js 20+ (только для сборки фронтенда)
|
||
- Доступ к `pg.localnet:5432` (PostgreSQL) и `s3.localnet:3900` (Garage)
|
||
- `ffmpeg` и `ffprobe` на хосте — только для видео; без них приём видео отключается,
|
||
а изображения продолжают работать (см. «Видео»)
|
||
- `make`
|
||
|
||
## Конфигурация
|
||
|
||
Скопируйте `.env.example` в `.env` и заполните значения:
|
||
|
||
```bash
|
||
cp .env.example .env
|
||
```
|
||
|
||
Секрет подписи токенов (`JWT_SECRET`) в окружении не задаётся: он генерируется
|
||
при первом старте и хранится в слое метаданных (`app_settings`).
|
||
|
||
Все параметры окружения (регистр имён учитывается):
|
||
|
||
| Переменная | Значение по умолчанию | Описание |
|
||
| --- | --- | --- |
|
||
| `HTTP_ADDR` | `:8080` | Адрес прослушивания HTTP-сервера |
|
||
| **База данных (метаданные)** | | |
|
||
| `DB_BACKEND` | `sqlite` | Слой метаданных: `sqlite` или `postgres` |
|
||
| `SQLITE_PATH` | `./galo.db` | Путь к файлу SQLite (при `DB_BACKEND=sqlite`) |
|
||
| `PG_HOST` | `pg.localnet` | Хост PostgreSQL |
|
||
| `PG_PORT` | `5432` | Порт PostgreSQL |
|
||
| `PG_USER` | `album` | Пользователь PostgreSQL |
|
||
| `PG_PASSWORD` | *(пусто)* | Пароль PostgreSQL (обязателен при `DB_BACKEND=postgres`) |
|
||
| `PG_DATABASE` | `galo` | Имя БД PostgreSQL |
|
||
| `PG_SSLMODE` | `disable` | SSL-режим PostgreSQL |
|
||
| **Блочное хранилище** | | |
|
||
| `STORAGE_BACKEND` | `fs` | Бэкенд блобов: `fs` или `s3` |
|
||
| `FS_ROOT` | `./data` | Корень ФС для блобов (при `STORAGE_BACKEND=fs`) |
|
||
| `S3_ENDPOINT` | `http://s3.localnet:3900` | Endpoint S3/Garage |
|
||
| `S3_REGION` | `garage` | Регион S3 |
|
||
| `S3_ACCESS_KEY` | *(пусто)* | Access key S3 |
|
||
| `S3_SECRET_KEY` | *(пусто)* | Secret key S3 |
|
||
| `S3_BUCKET` | `albums` | Имя бакета S3 |
|
||
| `S3_USE_PATH_STYLE` | `true` | Использовать path-style запросы (обязательно для Garage) |
|
||
| `OBJECT_URL_TTL` | `1h` | Время жизни подписанных URL объектов |
|
||
| `URL_SECRET` | *(пусто)* | Секрет подписи URL объектов; при пустом — секрет из `app_settings` |
|
||
| **Auth / JWT** | | |
|
||
| `JWT_ACCESS_TTL` | `15m` | Время жизни access-токена |
|
||
| `JWT_REFRESH_TTL` | `720h` | Время жизни refresh-токена |
|
||
| **SSE** | | |
|
||
| `SSE_HEARTBEAT_INTERVAL` | `25s` | Период heartbeat-сообщений каналов SSE |
|
||
| `SSE_CLIENT_BUFFER` | `64` | Размер буфера клиента SSE (количество сообщений) |
|
||
| **Фоновый воркер** (генерация вариантов + GC объектов) | | |
|
||
| `WORKER_INTERVAL` | `5s` | Период проверки воркером очереди задач |
|
||
| `WORKER_BATCH` | `10` | Размер батча обрабатываемых задач |
|
||
| `WORKER_PENDING_TTL` | `10m` | TTL задачи в статусе `pending` до повторной попытки |
|
||
| **Загрузка и видео** | | |
|
||
| `IMAGE_MAX_UPLOAD_SIZE` | `100MB` | Максимальный размер изображения (байты или суффикс `KB`/`MB`/`GB`) |
|
||
| `VIDEO_MAX_UPLOAD_SIZE` | `2GB` | Максимальный размер видео |
|
||
| `FFMPEG_PATH` | `ffmpeg` | Путь к `ffmpeg` (нужен для приёма видео) |
|
||
| `FFPROBE_PATH` | `ffprobe` | Путь к `ffprobe` |
|
||
| `VIDEO_TRANSCODE_CONCURRENCY` | `1` | Параллельных транскодов (отдельно от воркера изображений) |
|
||
| `VIDEO_TEMP_DIR` | *(системный tmp)* | Каталог для временных файлов загрузки/транскода |
|
||
| **Логирование** | | |
|
||
| `LOG_LEVEL` | `info` | Уровень логов: `debug`, `info`, `warn`, `error` |
|
||
| `LOG_FORMAT` | `json` | Формат логов: `json` или `text` |
|
||
|
||
На уровне `debug` сервер пишет по строке на каждый HTTP-запрос (метод, маршрут,
|
||
статус, длительность, пользователь, `request_id`) и логирует ожидаемые отказы 4xx.
|
||
Внутренние ошибки (неожиданные и 5xx) логируются на уровне `error` с исходной причиной
|
||
и `request_id`; сам `request_id` возвращается клиенту в заголовке ответа
|
||
`X-Request-ID`. Маршрут `/api/health` в access-логи не попадает, а `/api/objects/*` и
|
||
`/api/sse/*` — только на уровне `debug`. Query-строка, заголовок `Authorization` и тела
|
||
запросов в логи не пишутся.
|
||
|
||
Слои данных выбираются параметрами (по умолчанию — локальный режим):
|
||
|
||
- `STORAGE_BACKEND=fs|s3` — где лежат блобы: `fs` (по умолчанию, `FS_ROOT=./data`)
|
||
или `s3` (S3/Garage); наружу всегда отдаётся бэкенд (`/api/objects/<key>`).
|
||
- `DB_BACKEND=sqlite|postgres` — где лежат метаданные: `sqlite` (по умолчанию,
|
||
`SQLITE_PATH=./galo.db`) или `postgres`. SQLite — встроенный режим без внешней БД,
|
||
**только для одного экземпляра приложения на файл** (без горизонтального
|
||
масштабирования).
|
||
|
||
### Видео
|
||
|
||
Видео загружается в те же альбомы, что и фото (`kind=video`), и готовится для
|
||
воспроизведения в браузере фоновым воркером:
|
||
|
||
- **Постер**: из видео извлекается кадр, из которого генерируются обычные варианты
|
||
`thumb`/`medium`/`preview` (WebP) — сетка и обложка показывают постер.
|
||
- **Воспроизведение**: web-совместимый источник (h264/yuv420p, профиль Baseline/Main/High,
|
||
звук aac или без звука, контейнер mp4/mov/mkv, разрешение ≤ 1080p) ремуксится без
|
||
перекодирования; иначе выполняется транскод в h264/yuv420p/aac с `+faststart`. Готовый
|
||
вариант хранится как объект роли `playback` и отдаётся с поддержкой `Range` (перемотка).
|
||
- **Оригинал** сохраняется отдельно и используется для скачивания.
|
||
- Транскод идёт в отдельном малом пуле (`VIDEO_TRANSCODE_CONCURRENCY`) с длинным лизингом и
|
||
не вытесняет генерацию вариантов изображений. Роль `playback` появляется по готовности и
|
||
транслируется подписчикам альбома (`photo.updated`).
|
||
|
||
Без `ffmpeg`/`ffprobe` приём видео отклоняется, а `/api/health` сообщает
|
||
`"video": "unavailable"`; изображения работают как прежде.
|
||
|
||
## Сборка
|
||
|
||
```bash
|
||
make build # frontend -> backend/static -> go:embed -> bin/galo
|
||
```
|
||
|
||
Отдельные шаги:
|
||
|
||
```bash
|
||
make frontend # npm run build, копирует статику в backend/static (инкрементально)
|
||
make backend # CGO_ENABLED=0 go build -tags nodynamic -o bin/galo
|
||
```
|
||
|
||
> `-tags nodynamic` обязателен: он заставляет `gen2brain/webp` использовать
|
||
> транспилированный в Go libwebp вместо динамической загрузки через purego,
|
||
> благодаря чему бинарник остаётся полностью статическим.
|
||
|
||
`make frontend` собирает только при изменении исходников `frontend/`; `npm ci`
|
||
запускается лишь когда менялись `package.json`/`package-lock.json`. `make run`
|
||
и `make build` учитывают это и не пересобирают неизменившийся фронтенд.
|
||
|
||
Проверка:
|
||
|
||
```bash
|
||
file bin/galo # => statically linked
|
||
```
|
||
|
||
## Миграции
|
||
|
||
Миграции встроены в бинарник и применяются автоматически при старте сервера.
|
||
Отдельно:
|
||
|
||
```bash
|
||
make migrate # go run ./cmd/migrate — применяет up-миграции к pg.localnet
|
||
```
|
||
|
||
## Локальный запуск
|
||
|
||
```bash
|
||
make run # доберёт изменившийся фронтенд, соберёт backend и запустит ./bin/galo
|
||
# или напрямую:
|
||
./bin/galo
|
||
```
|
||
|
||
Сервер слушает `HTTP_ADDR` (по умолчанию `:8080`). Health-check:
|
||
|
||
```bash
|
||
curl -s localhost:8080/api/health # {"status":"ok","db":"ok","s3":"ok"}
|
||
```
|
||
|
||
Разработка фронтенда с HMR:
|
||
|
||
```bash
|
||
cd frontend && npm run dev # Vite проксирует /api на :8080
|
||
```
|
||
|
||
## Наполнение тестовыми данными
|
||
|
||
Скрипт `scripts/seed.py` наполняет запущенный инстанс тестовыми данными через публичный
|
||
API: создаёт альбомы, генерирует фотографии и, по флагам, добавляет теги, комментарии,
|
||
лайки, share-ссылки и инвайты. Работает только на добавление — существующие данные не
|
||
удаляются и не изменяются.
|
||
|
||
```bash
|
||
GALO_EMAIL=owner@example.com GALO_PASSWORD=secret \
|
||
python3 scripts/seed.py --albums 2 --photos-per-album 10 --tags --likes
|
||
```
|
||
|
||
Ту же операцию можно выполнить через Make-цель (параметры — переменные сборки):
|
||
|
||
```bash
|
||
GALO_EMAIL=owner@example.com GALO_PASSWORD=secret \
|
||
make testdata ALBUMS=3 PHOTOS=5 TAGS=1 LIKES=1 SEED=42
|
||
```
|
||
|
||
Значения по умолчанию: `ALBUMS=2`, `PHOTOS=10`, `SEED=0`, `TIMEOUT=30`. Булевы
|
||
параметры включаются значением `1`: `TAGS`, `COMMENTS`, `LIKES`, `SHARE`, `INVITES`,
|
||
`WAIT`, `DRY`, `VIDEO`. Дополнительные флаги передаются через `ARGS`.
|
||
|
||
Переменные окружения:
|
||
|
||
| Переменная | По умолчанию | Описание |
|
||
| --- | --- | --- |
|
||
| `GALO_BASE_URL` | `http://localhost:8080` | Базовый URL инстанса |
|
||
| `GALO_EMAIL` | — | Email владельца (нужен один из email/телефона) |
|
||
| `GALO_PHONE` | — | Телефон владельца в E.164 (нужен один из email/телефона) |
|
||
| `GALO_PASSWORD` | — | Пароль владельца (обязательно) |
|
||
|
||
Вход выполняется по единому идентификатору: при заданном `GALO_EMAIL` используется он,
|
||
иначе — `GALO_PHONE`.
|
||
|
||
Параметры:
|
||
|
||
| Флаг | По умолчанию | Описание |
|
||
| --- | --- | --- |
|
||
| `--albums` | `2` | Число альбомов |
|
||
| `--photos-per-album` | `10` | Фотографий в каждом альбоме |
|
||
| `--seed` | `0` | Seed генератора (воспроизводимость) |
|
||
| `--tags` | выкл. | Добавлять теги |
|
||
| `--comments` | выкл. | Добавлять комментарии |
|
||
| `--likes` | выкл. | Добавлять лайки |
|
||
| `--share-links` | выкл. | Создавать share-ссылки на альбомы |
|
||
| `--invites` | выкл. | Создавать инвайты (роль `member`) |
|
||
| `--wait-variants` | выкл. | Дожидаться генерации превью каждого фото |
|
||
| `--video` | выкл. | Загружать видео-фикстуры (требует `ffmpeg`; иначе пропускаются) |
|
||
| `--timeout` | `30` | Таймаут ожидания и сетевых запросов, сек |
|
||
| `--dry-run` | выкл. | Показать план и не отправлять изменяющих запросов |
|
||
|
||
Изображения генерируются на лету: при установленной Pillow — JPEG с EXIF (дата съёмки и
|
||
GPS), иначе — PNG без EXIF на стандартной библиотеке. Ядро скрипта не требует сторонних
|
||
пакетов; Pillow — необязательная зависимость.
|
||
|
||
## Тесты
|
||
|
||
```bash
|
||
make test
|
||
# интеграционные тесты репозиториев требуют тестовую БД:
|
||
TEST_DATABASE_URL='postgres://user:pass@pg.localnet:5432/galo?sslmode=disable' \
|
||
cd backend && go test ./internal/repo/ -v
|
||
```
|
||
|
||
## Реконсиляция хранилища
|
||
|
||
Сборка мусора воркера управляется журналом `objects` и не сканирует хранилище, поэтому
|
||
блобы без строки в журнале (после сброса БД в обход журнала и т.п.) накапливаются.
|
||
Ручная сверка:
|
||
|
||
```bash
|
||
go run ./cmd/reconcile # отчёт: сироты (есть в хранилище, нет в журнале) и пропавшие
|
||
go run ./cmd/reconcile --delete # удалить сироты старше --min-age (по умолчанию 1h)
|
||
```
|
||
|
||
- По умолчанию ничего не удаляется; удаление только по явному `--delete`.
|
||
- Область — префикс `objects/`; блобы старого формата `albums/` не затрагиваются.
|
||
- Пропавшие ключи (в журнале есть, в хранилище нет) только сообщаются.
|
||
|
||
## Деплой через systemd
|
||
|
||
1. Соберите бинарник: `make build`.
|
||
2. Создайте пользователя и каталог:
|
||
|
||
```bash
|
||
sudo useradd --system --home /opt/galo album
|
||
sudo mkdir -p /opt/galo
|
||
sudo cp bin/galo /opt/galo/
|
||
sudo cp .env /opt/galo/.env
|
||
sudo chown -R album:album /opt/galo
|
||
```
|
||
|
||
3. Установите unit:
|
||
|
||
```bash
|
||
sudo cp deploy/galo.service /etc/systemd/system/
|
||
sudo systemctl daemon-reload
|
||
sudo systemctl enable --now galo
|
||
sudo journalctl -u galo -f
|
||
```
|
||
|
||
## nginx (опционально)
|
||
|
||
Пример в `deploy/nginx-sse.conf`. Для SSE критично `proxy_buffering off` и
|
||
`X-Accel-Buffering: no` (последнее сервер выставляет сам).
|
||
|
||
## Структура
|
||
|
||
```
|
||
backend/
|
||
cmd/server/ # точка входа
|
||
cmd/migrate/ # применение миграций
|
||
internal/config # чтение env
|
||
internal/db # pgx + golang-migrate
|
||
internal/storage # S3 (Garage) / локальная ФС, отдача через бэкенд
|
||
internal/store # фабрика репозиториев (postgres / sqlite)
|
||
internal/auth # JWT, bcrypt, refresh
|
||
internal/albums # альбомы
|
||
internal/members # участники, роли, инвайты
|
||
internal/photos # загрузка, варианты, отдача через бэкенд
|
||
internal/social # лайки, комментарии
|
||
internal/notifications # персональные уведомления
|
||
internal/sharelinks # ссылки для просмотра
|
||
internal/sse # SSE-хаб
|
||
internal/app # маршруты и статика
|
||
migrations/ # SQL-миграции
|
||
static/ # сюда копируется build SvelteKit
|
||
frontend/ # SvelteKit 2 + Svelte 5
|
||
deploy/ # systemd unit, пример nginx
|
||
```
|
||
|
||
## API
|
||
|
||
Полный список эндпоинтов: `project.md`, раздел 11. Кратко:
|
||
|
||
- `POST /api/auth/register` (email и/или phone), `POST /api/auth/login`
|
||
(`identifier` — email или E.164-телефон), `POST /api/auth/refresh|logout`
|
||
- `GET|PATCH|DELETE /api/auth/me`, `POST /api/auth/me/password`,
|
||
`POST /api/auth/me/identifiers`, `PATCH /api/auth/me/preferences`
|
||
- `POST|DELETE /api/auth/me/avatar` (круглый аватар: загрузка готового квадрата),
|
||
`GET /api/avatars/:userID/:version?size=large` (публичная отдача аватара)
|
||
- `GET|POST /api/albums`, `GET|PATCH|DELETE /api/albums/:id`
|
||
- `GET /api/albums/:id/members`, `POST /api/albums/:id/invites`,
|
||
`POST /api/invites/:token/accept`, `PATCH|DELETE /api/albums/:id/members/:user_id`
|
||
- `GET|POST /api/albums/:id/photos`, `GET|DELETE /api/photos/:id`,
|
||
`GET /api/photos/:id/download`
|
||
- `POST|DELETE /api/photos/:id/likes`, `GET|POST /api/photos/:id/comments`,
|
||
`DELETE /api/comments/:id`
|
||
- `GET /api/sse/albums/:id`, `GET /api/sse/me`
|
||
- `POST|GET /api/albums/:id/share`, `DELETE /api/albums/:id/share/:link_id`,
|
||
`GET /api/share/:token[/photos[/:photo_id[/download]]]`
|
||
- `GET /api/notifications`, `POST /api/notifications/:id/read`
|