Files
ds-galo/README.md
T
iu eef7c6c5d0 fix(storage): emit origin-relative object URLs
Build signed object URLs as path-only (/api/objects/...) instead of
prefixing APP_BASE_URL, so media loads from whatever host or reverse
proxy served the app. Remove the now-unused base URL from the signer and
drop the APP_BASE_URL / Server.BaseURL config.
2026-10-12 12:14:46 +03:00

315 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Galo — многопользовательский фотоальбом
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 ci && npm run build, копирует статику в backend/static
make backend # CGO_ENABLED=0 go build -tags nodynamic -o bin/galo
```
> `-tags nodynamic` обязателен: он заставляет `gen2brain/webp` использовать
> транспилированный в Go libwebp вместо динамической загрузки через purego,
> благодаря чему бинарник остаётся полностью статическим.
Проверка:
```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 владельца (обязательно) |
| `GALO_PASSWORD` | — | Пароль владельца (обязательно) |
Параметры:
| Флаг | По умолчанию | Описание |
| --- | --- | --- |
| `--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|login|refresh|logout`, `GET /api/auth/me`
- `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`