# 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/?e=&s=`. 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.