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

108 lines
8.0 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.
# 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.