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
This commit is contained in:
iu
2026-10-11 10:19:44 +03:00
parent cb272398ce
commit 8638d1bbfc
33 changed files with 392 additions and 13 deletions
@@ -0,0 +1,107 @@
# 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.