- 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
8.0 KiB
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
- Вынести
Signer, сделатьServeObjectобязательным,URL— всегда app-signed. - Реализовать S3
ServeObject(rangedGetObject+ стриминг, HEAD). - Удалить presign-путь и
S3_PUBLIC_URL; переименовать TTL; обновить доки и nginx. - Существующие развёртывания: дефолт
s3сохраняется, но доставка теперь проксируется — внешний доступ к S3 больше не нужен. - Перенос блобов между бэкендами не автоматизируется.
Open Questions
- Нужно ли выставлять
Cache-Controlна ответы/api/objects/помимо браузерного кэша по уникальному URL.