Новый алгоритм редактирования: обработка идёт только на клиенте. - Фильтры «Свет» считает WASM-модуль на Rust (frontend/wasm/light, 1.5 КБ): он строит тональные кривые и обрабатывает пиксели. При недоступности модуля используется JS-реализация с тем же результатом — расхождение проверяется тестом. - Просмотр рисует видимую область фото с правками на canvas поверх исходной версии, поэтому изменения видны сразу, без ожидания рендера. - Правки уходят на сервер не сразу: через десять секунд без изменений или при переключении фото. Сервер сохраняет параметры и готовит варианты сетки и полноразмерную версию в фоне, о готовности сообщает через SSE — клиент подтягивает готовую версию.
18 KiB
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,
service worker с офлайн-кешем оболочки, списков и эскизов/превью фотографий.
Фильтры правок считает WASM-модуль на Rust (
frontend/wasm/light): пересборка нужна только при правке самого модуля, в репозитории лежит готовыйlight.wasm. - Обработка изображений:
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 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 учитывают это и не пересобирают неизменившийся фронтенд.
Проверка:
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
-
Соберите бинарник:
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(email и/или phone),POST /api/auth/login(identifier— email или E.164-телефон),POST /api/auth/refresh|logoutGET|PATCH|DELETE /api/auth/me,POST /api/auth/me/password,POST /api/auth/me/identifiers,PATCH /api/auth/me/preferencesPOST|DELETE /api/auth/me/avatar(круглый аватар: загрузка готового квадрата),GET /api/avatars/:userID/:version?size=large(публичная отдача аватара)GET|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