The panorama viewer inverted horizontal drag relative to vertical: the yaw sign was opposite, so dragging right turned the view right instead of moving the scene right. Flip yaw to match the pitch convention (drag the world) and lock the behavior with a ui-workspace scenario.
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 и заполните значения:
cp .env.example .env
Секрет подписи токенов (JWT_SECRET) в окружении не задаётся: он генерируется
при первом старте и хранится в слое метаданных (app_settings).
Все параметры окружения (регистр имён учитывается):
| Переменная | Значение по умолчанию | Описание |
|---|---|---|
HTTP_ADDR |
:8080 |
Адрес прослушивания HTTP-сервера |
APP_BASE_URL |
http://localhost:8080 |
Внешний базовый URL приложения (для ссылок и SSE) |
| База данных (метаданные) | ||
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"; изображения работают как прежде.
Сборка
make build # frontend -> backend/static -> go:embed -> bin/galo
Отдельные шаги:
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, благодаря чему бинарник остаётся полностью статическим.
Проверка:
file bin/galo # => statically linked
Миграции
Миграции встроены в бинарник и применяются автоматически при старте сервера. Отдельно:
make migrate # go run ./cmd/migrate — применяет up-миграции к pg.localnet
Локальный запуск
make run # соберёт backend и запустит ./bin/galo
# или напрямую:
./bin/galo
Сервер слушает HTTP_ADDR (по умолчанию :8080). Health-check:
curl -s localhost:8080/api/health # {"status":"ok","db":"ok","s3":"ok"}
Разработка фронтенда с HMR:
cd frontend && npm run dev # Vite проксирует /api на :8080
Наполнение тестовыми данными
Скрипт scripts/seed.py наполняет запущенный инстанс тестовыми данными через публичный
API: создаёт альбомы, генерирует фотографии и, по флагам, добавляет теги, комментарии,
лайки, share-ссылки и инвайты. Работает только на добавление — существующие данные не
удаляются и не изменяются.
GALO_EMAIL=owner@example.com GALO_PASSWORD=secret \
python3 scripts/seed.py --albums 2 --photos-per-album 10 --tags --likes
Ту же операцию можно выполнить через Make-цель (параметры — переменные сборки):
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 — необязательная зависимость.
Тесты
make test
# интеграционные тесты репозиториев требуют тестовую БД:
TEST_DATABASE_URL='postgres://user:pass@pg.localnet:5432/galo?sslmode=disable' \
cd backend && go test ./internal/repo/ -v
Реконсиляция хранилища
Сборка мусора воркера управляется журналом objects и не сканирует хранилище, поэтому
блобы без строки в журнале (после сброса БД в обход журнала и т.п.) накапливаются.
Ручная сверка:
go run ./cmd/reconcile # отчёт: сироты (есть в хранилище, нет в журнале) и пропавшие
go run ./cmd/reconcile --delete # удалить сироты старше --min-age (по умолчанию 1h)
- По умолчанию ничего не удаляется; удаление только по явному
--delete. - Область — префикс
objects/; блобы старого форматаalbums/не затрагиваются. - Пропавшие ключи (в журнале есть, в хранилище нет) только сообщаются.
Деплой через systemd
-
Соберите бинарник:
make build. -
Создайте пользователя и каталог:
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 -
Установите unit:
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/meGET|POST /api/albums,GET|PATCH|DELETE /api/albums/:idGET /api/albums/:id/members,POST /api/albums/:id/invites,POST /api/invites/:token/accept,PATCH|DELETE /api/albums/:id/members/:user_idGET|POST /api/albums/:id/photos,GET|DELETE /api/photos/:id,GET /api/photos/:id/downloadPOST|DELETE /api/photos/:id/likes,GET|POST /api/photos/:id/comments,DELETE /api/comments/:idGET /api/sse/albums/:id,GET /api/sse/mePOST|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