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:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-11
|
||||
@@ -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.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
S3-совместимое хранилище (Garage на `s3.localnet`) в проде недоступно извне, поэтому
|
||||
отдача медиа не может идти напрямую из хранилища в браузер. Нужен слой хранения,
|
||||
который всегда отдаёт блобы через бэкенд, и локальный бэкенд для инсталляций без S3.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Ввести интерфейс `storage.Backend`: Put/Delete/Get/Ping/EnsureBucket/Bucket, `URL`
|
||||
(всегда подписанный URL приложения) и обязательный `ServeObject`.
|
||||
- Реализовать бэкенд локальной ФС: блобы под корневым каталогом по непрозрачным ключам
|
||||
`objects/{id}/{role}{ext}`, защита от обхода пути, отдача через `ServeContent`.
|
||||
- Реализовать отдачу S3 через бэкенд: `ServeObject` тянет объект ranged `GetObject` из
|
||||
Garage (внутренняя сеть) и стримит браузеру; presigned URL больше не используются.
|
||||
- Публичный маршрут `/api/objects/<key>` с HMAC-подписью, сроком, Range и Content-Type
|
||||
обслуживает и авторизованных, и анонимные share-ссылки.
|
||||
- Ввести `STORAGE_BACKEND=s3|fs` и `FS_ROOT`; убрать `S3_PUBLIC_URL`; переименовать
|
||||
`S3_PRESIGN_TTL` → `OBJECT_URL_TTL`; дефолт — `s3`.
|
||||
- Сделать метку хранилища в health динамической.
|
||||
- Сделать формулировки отдачи файлов нейтральными к бэкенду в спеках `photos` и `share-links`.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `storage-backends`: выбор и работа объектного хранилища — S3-совместимое (Garage) и локальная ФС; блобы всегда отдаются через бэкенд по подписанному URL, хранилище клиенту не раскрывается.
|
||||
|
||||
### Modified Capabilities
|
||||
- `photos`: получение фото и скачивание не требуют presigned URL и обслуживаются бэкендом (подписанный URL).
|
||||
- `share-links`: просмотр по ссылке отдаёт подписанный URL, обслуживаемый бэкендом.
|
||||
|
||||
## Impact
|
||||
|
||||
- `backend/internal/storage` (интерфейс, общий `Signer`, FS-бэкенд, S3-proxy-отдача),
|
||||
сервисы `photos` и `albums`, `app` (маршрут отдачи, health), `cmd/*`, `config`.
|
||||
- Удаление presign-пути AWS SDK из `storage`.
|
||||
- `.env.example`, `project.md`, `deploy/nginx-sse.conf`.
|
||||
- Спеки `photos`, `share-links`, новая `storage-backends`.
|
||||
- Не затрагивает слой метаданных.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Spec Delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Получение фото по требованию
|
||||
Клиент SHALL иметь возможность получить актуальные данные фото с подписанным URL,
|
||||
обслуживаемым бэкендом, независимо от бэкенда хранения.
|
||||
|
||||
#### Scenario: Выдача свежего URL
|
||||
- **WHEN** клиент запрашивает фото по id
|
||||
- **THEN** система возвращает свежий подписанный URL бэкенда (срок до 1 часа) для просмотра
|
||||
|
||||
### Requirement: Скачивание одиночного фото
|
||||
Любой участник, включая viewer, SHALL иметь возможность скачать одиночное фото.
|
||||
|
||||
#### Scenario: Скачивание фото
|
||||
- **WHEN** участник запрашивает скачивание фото
|
||||
- **THEN** система возвращает подписанный URL оригинала, обслуживаемый бэкендом
|
||||
|
||||
### Requirement: Перенос и копирование фото между альбомами
|
||||
Пользователь с правом удаления в исходном альбоме и правом загрузки в целевом SHALL иметь
|
||||
возможность перенести или скопировать фото в другой альбом. Операции SHALL изменять только
|
||||
метаданные и SHALL не изменять объекты хранилища.
|
||||
|
||||
#### Scenario: Перенос
|
||||
- **WHEN** пользователь переносит фото в другой альбом
|
||||
- **THEN** фото исчезает из исходного альбома и появляется в целевом, объекты хранилища не меняются
|
||||
|
||||
#### Scenario: Копирование
|
||||
- **WHEN** пользователь копирует фото в другой альбом
|
||||
- **THEN** в целевом альбоме появляется новая запись, ссылающаяся на те же объекты хранилища
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
# Spec Delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Просмотр альбома по ссылке
|
||||
Пользователь без авторизации SHALL иметь возможность просматривать альбом по действующему токену (сетка, лайтбокс, скачивание одиночного фото).
|
||||
|
||||
#### Scenario: Просмотр по действующей ссылке
|
||||
- **WHEN** аноним открывает ссылку с действующим токеном
|
||||
- **THEN** система возвращает фото альбома со свежими подписанными URL, обслуживаемыми бэкендом
|
||||
|
||||
#### Scenario: Истёкшая или отозванная ссылка
|
||||
- **WHEN** аноним открывает ссылку с истёкшим или отозванным токеном
|
||||
- **THEN** система отклоняет доступ
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
# Spec Delta
|
||||
|
||||
## Purpose
|
||||
|
||||
Выбор и работа объектного хранилища: S3-совместимое (Garage) или локальная файловая
|
||||
система. Блобы всегда отдаются через бэкенд по подписанному URL, хранилище клиенту не
|
||||
раскрывается.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Выбор бэкенда хранилища
|
||||
Система SHALL поддерживать выбор бэкенда объектного хранилища параметром конфигурации,
|
||||
при этом S3-совместимый бэкенд SHALL оставаться значением по умолчанию.
|
||||
|
||||
#### Scenario: Выбор S3-совместимого бэкенда
|
||||
- **WHEN** в конфигурации задан S3-совместимый бэкенд
|
||||
- **THEN** система хранит и отдаёт блобы через S3-совместимое хранилище
|
||||
|
||||
#### Scenario: Выбор локальной ФС
|
||||
- **WHEN** в конфигурации задан бэкенд локальной ФС
|
||||
- **THEN** система хранит и отдаёт блобы из каталога локальной файловой системы
|
||||
|
||||
#### Scenario: Неполная конфигурация
|
||||
- **WHEN** конфигурация выбранного бэкенда неполна
|
||||
- **THEN** система отказывается стартовать с явной ошибкой конфигурации
|
||||
|
||||
### Requirement: Единая абстракция хранилища
|
||||
Операции записи, чтения, удаления и проверки доступности SHALL выполняться через единый
|
||||
интерфейс хранилища, одинаковый для всех бэкендов; ключи объектов SHALL оставаться
|
||||
непрозрачными и независимыми от бэкенда.
|
||||
|
||||
#### Scenario: Ключи переносимы между бэкендами
|
||||
- **WHEN** один и тот же ключ объекта используется на разных бэкендах
|
||||
- **THEN** он адресует тот же логический блоб без изменения формата
|
||||
|
||||
#### Scenario: Удаление объекта
|
||||
- **WHEN** система удаляет объект через интерфейс хранилища
|
||||
- **THEN** блоб удаляется выбранным бэкендом
|
||||
|
||||
### Requirement: Отдача блобов через бэкенд
|
||||
Клиент SHALL получать любой блоб по URL, обслуживаемому бэкендом, с ограниченным сроком
|
||||
действия; ни один бэкенд SHALL не отдавать клиенту ссылку напрямую на объектное
|
||||
хранилище. Для анонимного доступа по share-ссылке подписанный URL SHALL быть действителен
|
||||
без пользовательской сессии.
|
||||
|
||||
#### Scenario: Свежий URL просмотра
|
||||
- **WHEN** клиент запрашивает фото
|
||||
- **THEN** система возвращает подписанный URL бэкенда со сроком действия до 1 часа
|
||||
|
||||
#### Scenario: Доступ по share-ссылке
|
||||
- **WHEN** аноним открывает альбом по действующей share-ссылке
|
||||
- **THEN** полученные подписанные URL позволяют загрузить фото без сессии
|
||||
|
||||
#### Scenario: Истёкшая подпись
|
||||
- **WHEN** клиент обращается к URL с истёкшей или неверной подписью
|
||||
- **THEN** система отказывает в доступе
|
||||
|
||||
### Requirement: Хранилище не раскрывается клиенту
|
||||
Адрес и учётные данные объектного хранилища SHALL оставаться внутренними; клиент SHALL
|
||||
взаимодействовать с медиа только через бэкенд, независимо от бэкенда хранения.
|
||||
|
||||
#### Scenario: S3-совместимый бэкенд
|
||||
- **WHEN** хранилищем выбран S3-совместимый бэкенд
|
||||
- **THEN** выдаваемый клиенту URL указывает на бэкенд, а не на S3-эндпоинт
|
||||
|
||||
#### Scenario: Локальная ФС
|
||||
- **WHEN** хранилищем выбрана локальная ФС
|
||||
- **THEN** выдаваемый клиенту URL указывает на бэкенд
|
||||
|
||||
#### Scenario: Хранилище недоступно извне
|
||||
- **WHEN** объектное хранилище недоступно из внешней сети
|
||||
- **THEN** просмотр и скачивание медиа продолжают работать
|
||||
|
||||
### Requirement: Локальное хранение на ФС
|
||||
Бэкенд локальной ФС SHALL хранить блобы в пределах заданного корневого каталога по
|
||||
непрозрачным ключам объектов и SHALL отвергать ключи, выводящие за пределы корня.
|
||||
|
||||
#### Scenario: Запись и чтение блоба
|
||||
- **WHEN** система сохраняет объект с ключом
|
||||
- **THEN** блоб размещается под корневым каталогом и позже читается по тому же ключу
|
||||
|
||||
#### Scenario: Попытка выхода из корня
|
||||
- **WHEN** ключ содержит элементы обхода каталога
|
||||
- **THEN** система отвергает операцию и не обращается за пределы корня
|
||||
|
||||
### Requirement: Диапазонные запросы и тип содержимого
|
||||
При отдаче блоба система SHALL возвращать корректный тип содержимого и SHALL
|
||||
поддерживать диапазонные запросы для докачки и больших файлов.
|
||||
|
||||
#### Scenario: Запрос диапазона
|
||||
- **WHEN** клиент запрашивает часть блоба через диапазон
|
||||
- **THEN** система возвращает запрошенный диапазон и соответствующий статус
|
||||
|
||||
#### Scenario: Тип содержимого
|
||||
- **WHEN** клиент загружает блоб
|
||||
- **THEN** ответ содержит тип содержимого, соответствующий объекту
|
||||
|
||||
### Requirement: Доступность выбранного хранилища
|
||||
Система SHALL сообщать о доступности выбранного бэкенда хранилища и отражать его в
|
||||
состоянии здоровья сервиса.
|
||||
|
||||
#### Scenario: Хранилище доступно
|
||||
- **WHEN** выбранный бэкенд отвечает на проверку
|
||||
- **THEN** состоянии здоровья отражает хранилище как доступное
|
||||
|
||||
#### Scenario: Хранилище недоступно
|
||||
- **WHEN** выбранный бэкенд не отвечает на проверку
|
||||
- **THEN** состояние здоровья отражает деградацию
|
||||
@@ -0,0 +1,39 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Интерфейс хранилища и перевод S3
|
||||
|
||||
- [x] 1.1 Ввести интерфейс `storage.Backend` (Put/Delete/Get/Ping/EnsureBucket/Bucket/URL) и сделать S3-клиент его реализацией; проверка — `go build ./...` проходит.
|
||||
- [x] 1.2 Заменить `PresignGet` на `URL(ctx, key)` во всех вызовах; проверка — `go build ./...` и `go vet ./...` без ошибок.
|
||||
- [x] 1.3 Перевести `photos.Service`, `albums.Service`, `app.Server` и `cmd/*` на тип `storage.Backend`; проверка — сборка `cmd/server` и `cmd/backfill-exif` успешна.
|
||||
- [x] 1.4 Прогнать существующие S3-тесты (под `S3_ENDPOINT`); проверка — тесты зелёные или корректно пропущены без env.
|
||||
|
||||
## 2. Бэкенд локальной ФС
|
||||
|
||||
- [x] 2.1 Реализовать `fs.Backend` (Put/Get/Delete/Ping/EnsureBucket/Bucket) с раскладкой `<root>/<key>` и нормализацией пути; проверка — юнит-тесты записи, чтения, удаления и отказа на `..`/абсолютный ключ проходят.
|
||||
- [x] 2.2 Реализовать `URL` с HMAC-SHA256 подписью (`key`+`exp`, `URL_SECRET` с fallback на `JWT_SECRET`) и сроком `OBJECT_URL_TTL`; проверка — юнит-тесты валидной, истёкшей и подделанной подписи проходят.
|
||||
|
||||
## 3. Маршрут отдачи объектов
|
||||
|
||||
- [x] 3.1 Добавить публичный `GET /api/objects/<key>` с проверкой подписи и срока и стримингом через `http.ServeContent`; проверка — httptest-тесты: валидный доступ (200), истёкшая подпись (403), Range (206), отсутствующий ключ (404).
|
||||
- [x] 3.2 Проверить вывод `Content-Type` по расширению с fallback `application/octet-stream`; проверка — тест на `.jpg`/`.webp` и неизвестное расширение.
|
||||
- [x] 3.3 Убедиться, что share-ссылки отдают рабочие подписанные URL на обоих бэкендах; проверка — интеграционный тест `share/*` с `fs` и `s3`.
|
||||
|
||||
## 4. Конфигурация, health, проводка, документация
|
||||
|
||||
- [x] 4.1 Ввести `STORAGE_BACKEND=s3|fs`, `FS_ROOT`, `URL_SECRET` с условной валидацией; проверка — юнит-тесты `config.Load` для `s3`, `fs` и неполной конфигурации.
|
||||
- [x] 4.2 Сделать метку хранилища в health динамической по выбранному бэкенду; проверка — тест `/api/health` для `s3` и `fs`.
|
||||
- [x] 4.3 Обновить `.env.example` и `project.md`; проверка — документированный запуск с `STORAGE_BACKEND=fs` стартует и отдаёт файл.
|
||||
|
||||
## 5. Интеграционные проверки
|
||||
|
||||
- [x] 5.1 Прогнать полный поток (загрузка → превью → просмотр → скачивание → share) на `fs`; проверка — наблюдаемые результаты совпадают с `s3`.
|
||||
- [x] 5.2 Убедиться, что дефолт `s3` не меняет текущее развёртывание; проверка — запуск без новых переменных проходит как раньше.
|
||||
|
||||
## 6. Доставка только через бэкенд (без presign)
|
||||
|
||||
- [x] 6.1 Вынести подпись URL в общий `Signer` и использовать его в `FS` и `S3`; проверка — юнит-тесты подписи и `go build ./...` проходят.
|
||||
- [x] 6.2 Сделать `ServeObject` обязательной частью `storage.Backend`; `app.serveObject` диспетчеризуется без type-assert; проверка — `go build ./...`.
|
||||
- [x] 6.3 Реализовать `S3.ServeObject` через ranged `GetObject` + стриминг и HEAD через `HeadObject`; проверка — httptest-тесты 200/206/416/404.
|
||||
- [x] 6.4 Удалить presign-путь (`PresignClient`/`PresignGetObject`), вернуть `S3.URL` как app-signed; проверка — код presign отсутствует, тесты отдачи проходят.
|
||||
- [x] 6.5 Конфиг: переименовать `S3_PRESIGN_TTL` → `OBJECT_URL_TTL`, удалить `S3_PUBLIC_URL`; обновить `.env.example`, `project.md`, `deploy/nginx-sse.conf` (`proxy_buffering off` для `/api/objects/`); проверка — юнит-тесты `config.Load` и документированный запуск.
|
||||
- [x] 6.6 Интеграция: полный поток и share на `s3` в proxy-режиме при недоступном извне хранилище; проверка — тест зелёный, наружу уходит URL бэкенда, не S3.
|
||||
Reference in New Issue
Block a user