Files
ds-galo/openspec/changes/archive/2026-10-11-local-fs-storage-backend/design.md
T
iu 8638d1bbfc chore(openspec): archive completed changes and sync main specs
- archive local-fs-storage-backend, sqlite-metadata-backend,
  storage-orphan-reconciliation, metadata-managed-jwt-secret, default-local-backends
- sync specs: storage-backends, metadata-backends, storage-reconciliation,
  app-secrets; update photos/share-links
- defaults reflected in specs: SQLite and local FS
2026-10-11 10:19:44 +03:00

8.0 KiB
Raw Blame History

Design

Context

См. proposal.md — Why. Текущее состояние, определяющее подход:

  • storage.Backend уже введён; storage.S3 возвращает presigned URL на Garage, а storage.FS отдаёт блобы через приложение. Presign уводит браузер напрямую в S3.
  • В проде Garage (s3.localnet) недоступен извне, поэтому presigned URL не работают: отдача обязана идти через бэкенд для обоих бэкендов.
  • Подпись URL сейчас живёт внутри FS (fs.go: sign/verify/URL).
  • Маршрут /api/objects/*key и app.serveObject уже есть и диспетчеризуются по необязательному интерфейсу storage.ObjectServer.
  • Ключи объектов непрозрачны (objects/{id}/{role}{ext}) и одинаковы для всех бэкендов.
  • Сборка CGO_ENABLED=0, один бинарник + systemd, перед Go стоит nginx.

Goals / Non-Goals

Goals:

  • Единая модель доставки: любой блоб клиент получает только через бэкенд.
  • Убрать presigned URL полностью; S3 отдаёт байты через прокси бэкенда.
  • Сохранить выбор бэкенда хранилища (s3|fs) и непрозрачные ключи.
  • Сохранить работу анонимных share-ссылок без пользовательской сессии.

Non-Goals:

  • Миграция существующих блобов между бэкендами.
  • Переключение бэкенда в рантайме.
  • Кэширующий CDN и раздача через внешний веб-сервер.
  • Изменение слоя метаданных (см. sqlite-metadata-backend).

Decisions

D1. ServeObject — обязательная часть storage.Backend

Отдача через бэкенд нужна всем бэкендам, поэтому ServeObject(w, r, key) входит в интерфейс Backend, а не остаётся опциональным ObjectServer. Компилятор не даст собрать бэкенд без отдачи, а app.serveObject диспетчеризуется без type-assert.

  • Альтернатива: оставить ObjectServer опциональным — теперь это всегда реализуется, опциональность только маскирует ошибку.

D2. URL всегда возвращает подписанный URL приложения

Backend.URL больше не зависит от бэкенда в способе доставки: и s3, и fs возвращают ${APP_BASE_URL}/api/objects/<key>?e=<exp>&s=<sig>. Presign-клиент AWS удаляется.

  • Альтернатива (dev): оставить presign для S3 — отвергнута по требованию «только через бэкенд» и чтобы не иметь двух режимов доставки.

D3. Общий Signer

HMAC-SHA256 от key + exp выносится в общий Signer (секрет URL_SECRET с fallback на JWT_SECRET, срок OBJECT_URL_TTL). Его используют и FS, и S3, и маршрут проверяет подпись одинаково. Внутри FS дублирующая подпись удаляется.

  • Альтернатива: дублировать HMAC в каждом бэкенде — риск рассинхрона проверки.

D4. Отдача S3: ranged GetObject и стриминг

S3-бэкенд в ServeObject определяет Range из запроса, вызывает GetObject с тем же диапазоном (Garage отдаёт Content-Range) и стримит тело в ответ через io.Copy (200/206/416). HEAD обслуживается HeadObject. Память не растёт, размер берётся из Content-Length ответа S3.

  • Альтернатива: http.ServeContent требует io.ReadSeeker; для S3 это ranged-GET-адаптер и лишний size-probe — отвергнуто ради простоты нативного passthrough.
  • S3.Get (в память) сохраняется только для воркера/backfill, отдачу не использует.

D5. Отдача FS: без изменений

FS.ServeObject остаётся на http.ServeContent (Range и Content-Type бесплатно). Меняется лишь источник подписи — общий Signer.

D6. Публичный маршрут и доступ

/api/objects/*key вне группы авторизации; подпись и срок — единственный контроль доступа, поэтому он обслуживает и авторизованных, и анонимные share-ссылки. Наружу не уходят ни S3-endpoint, ни AWS-подпись, ни ключ с секретом.

D7. Конфигурация

STORAGE_BACKEND=s3|fs без изменений (дефолт s3). FS_ROOT — для fs. S3_PRESIGN_TTL переименовывается в OBJECT_URL_TTL (семантика: срок подписи URL). S3_PUBLIC_URL удаляется как неиспользуемая. APP_BASE_URL становится определяющим для корректности ссылок; пустое значение даёт относительный /api/objects/... (полагается на nginx того же origin).

D8. nginx для прокси-отдачи

location /api/objects/ обслуживается как location /, но с proxy_buffering off и X-Accel-Buffering: no, чтобы стриминг и Range не буферизовались. SSE-правило остаётся.

Risks / Trade-offs

  • [Все байты медиа идут через Go] → стриминг без чтения в память; proxy_buffering off; для крупных инсталляций это принятая плата за требование «только через бэкенд».
  • [Подписанный URL действует как bearer до истечения] → срок OBJECT_URL_TTL (1 час), отдельный URL_SECRET для отзыва.
  • [Range/HEAD через прокси] → passthrough Range в S3 и HeadObject для HEAD; покрыто httptest-тестами (200/206/416/404).
  • [Ломающее переименование S3_PRESIGN_TTL] → задокументировать в .env.example и project.md; при откате вернуть имя.
  • [Обход каталога через ключ FS] → нормализация пути и проверка префикса FS_ROOT.
  • [Смена бэкенда на существующих данных не переносит блобы] → документируем; миграция вне рамок.

Migration Plan

  1. Вынести Signer, сделать ServeObject обязательным, URL — всегда app-signed.
  2. Реализовать S3 ServeObject (ranged GetObject + стриминг, HEAD).
  3. Удалить presign-путь и S3_PUBLIC_URL; переименовать TTL; обновить доки и nginx.
  4. Существующие развёртывания: дефолт s3 сохраняется, но доставка теперь проксируется — внешний доступ к S3 больше не нужен.
  5. Перенос блобов между бэкендами не автоматизируется.

Open Questions

  • Нужно ли выставлять Cache-Control на ответы /api/objects/ помимо браузерного кэша по уникальному URL.