Files
ds-galo/README.md
T
iu 520886a11d feat(web): правки применяются на фронте, фильтры в WASM
Новый алгоритм редактирования: обработка идёт только на клиенте.

- Фильтры «Свет» считает WASM-модуль на Rust (frontend/wasm/light, 1.5 КБ):
  он строит тональные кривые и обрабатывает пиксели. При недоступности модуля
  используется JS-реализация с тем же результатом — расхождение проверяется
  тестом.
- Просмотр рисует видимую область фото с правками на canvas поверх
  исходной версии, поэтому изменения видны сразу, без ожидания рендера.
- Правки уходят на сервер не сразу: через десять секунд без изменений или при
  переключении фото. Сервер сохраняет параметры и готовит варианты сетки и
  полноразмерную версию в фоне, о готовности сообщает через SSE — клиент
  подтягивает готовую версию.
2026-10-21 00:06:46 +03:00

331 lines
18 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,
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` и заполните значения:
```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 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` учитывают это и не пересобирают неизменившийся фронтенд.
Проверка:
```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 владельца (нужен один из 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 — необязательная зависимость.
## Тесты
```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` (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`