iu 44cb7c7d68 fix(realtime): доставлять обложку альбома после готовности вариантов
Карточка альбома не получала ссылку на обложку: она появляется в объекте
альбома только после генерации thumb/medium, а событие об этом шаге в
персональные потоки не публиковалось — счётчик обновлялся, обложка нет.

- albums.Service.NotifyCoverReady публикует album.updated получателям, когда
  варианты готовы у фото, являющегося текущей обложкой (иначе no-op, чтобы
  массовая загрузка не рассылала событие на каждый файл)
- photos: порт AlbumAccess расширен, worker вызывает публикацию после
  photo.updated
- project.md §11/§12: триггер добавлен в контракт персонального потока
2026-10-16 12:47:50 +03:00

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-сервера
База данных (метаданные)
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 владельца (нужен один из 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 — необязательная зависимость.

Тесты

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

  1. Соберите бинарник: make build.

  2. Создайте пользователя и каталог:

    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:

    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
S
Description
No description provided
Readme
4.5 MiB
0.2.0
Latest
2026-10-23 20:09:06 +00:00
Languages
Kotlin 44.2%
Go 32.7%
Svelte 12.1%
TypeScript 9%
Python 0.9%
Other 0.9%