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