# 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/`). - `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`