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,30 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
Слои данных были по умолчанию внешними (`postgres` и `s3`), поэтому самодостаточный
|
||||
локальный запуск без внешних сервисов требовал явной настройки. Нужно сделать локальные
|
||||
бэкенды значением по умолчанию.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Сменить значения по умолчанию: `DB_BACKEND=sqlite` (`SQLITE_PATH=./galo.db`) и
|
||||
`STORAGE_BACKEND=fs` (`FS_ROOT=./data`).
|
||||
- Обновить `.env.example`, `project.md`, `README.md`.
|
||||
- Поведение при явном выборе `postgres`/`s3` не меняется.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
<!-- нет -->
|
||||
|
||||
### Modified Capabilities
|
||||
- `metadata-backends`: значением по умолчанию становится встроенная SQLite (ранее PostgreSQL).
|
||||
- `storage-backends`: значением по умолчанию становится локальная ФС (ранее S3-совместимая).
|
||||
|
||||
## Impact
|
||||
|
||||
- `backend/internal/config` (значения по умолчанию) и тесты.
|
||||
- `.env.example`, `project.md`, `README.md`.
|
||||
- **BREAKING (поведение):** развёртывания без явных `DB_BACKEND`/`STORAGE_BACKEND`
|
||||
переключатся на локальные бэкенды; для прежнего режима задайте `postgres`/`s3` явно.
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Spec Delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Выбор бэкенда метаданных
|
||||
Система SHALL поддерживать выбор бэкенда слоя метаданных параметром конфигурации,
|
||||
при этом встроенная SQLite SHALL быть значением по умолчанию.
|
||||
|
||||
#### Scenario: Выбор по умолчанию
|
||||
- **WHEN** бэкенд метаданных не задан в конфигурации
|
||||
- **THEN** система использует встроенную SQLite с путём по умолчанию
|
||||
|
||||
#### Scenario: Выбор PostgreSQL
|
||||
- **WHEN** в конфигурации задан бэкенд PostgreSQL
|
||||
- **THEN** метаданные читаются и пишутся во внешнюю PostgreSQL
|
||||
|
||||
#### Scenario: Выбор встроенной SQLite
|
||||
- **WHEN** в конфигурации задан бэкенд SQLite
|
||||
- **THEN** метаданные читаются и пишутся в локальный файл SQLite без внешней службы
|
||||
|
||||
#### Scenario: Неполная конфигурация
|
||||
- **WHEN** конфигурация выбранного бэкенда неполна
|
||||
- **THEN** система отказывается стартовать с явной ошибкой конфигурации
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Spec Delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Выбор бэкенда хранилища
|
||||
Система SHALL поддерживать выбор бэкенда объектного хранилища параметром конфигурации,
|
||||
при этом локальная файловая система SHALL быть значением по умолчанию.
|
||||
|
||||
#### Scenario: Выбор по умолчанию
|
||||
- **WHEN** бэкенд хранилища не задан в конфигурации
|
||||
- **THEN** система хранит и отдаёт блобы из локальной файловой системы с корнем по умолчанию
|
||||
|
||||
#### Scenario: Выбор S3-совместимого бэкенда
|
||||
- **WHEN** в конфигурации задан S3-совместимый бэкенд
|
||||
- **THEN** система хранит и отдаёт блобы через S3-совместимое хранилище
|
||||
|
||||
#### Scenario: Выбор локальной ФС
|
||||
- **WHEN** в конфигурации задан бэкенд локальной ФС
|
||||
- **THEN** система хранит и отдаёт блобы из каталога локальной файловой системы
|
||||
|
||||
#### Scenario: Неполная конфигурация
|
||||
- **WHEN** конфигурация выбранного бэкенда неполна
|
||||
- **THEN** система отказывается стартовать с явной ошибкой конфигурации
|
||||
@@ -0,0 +1,6 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Дефолты и документация
|
||||
|
||||
- [x] 1.1 Сменить значения по умолчанию в `config` (`DB_BACKEND=sqlite`, `SQLITE_PATH=./galo.db`, `STORAGE_BACKEND=fs`, `FS_ROOT=./data`) и обновить тесты конфига; проверка — `go test ./internal/config` зелёный.
|
||||
- [x] 1.2 Обновить `.env.example`, `project.md`, `README.md`; проверка — запуск без переменных окружения стартует на SQLite+ФС и health показывает `db=ok, fs=ok`.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-11
|
||||
@@ -0,0 +1,76 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
См. `proposal.md` — Why. Текущее состояние:
|
||||
|
||||
- `JWT_SECRET` обязателен в env (`config.Load`), используется в `auth.NewTokenManager`
|
||||
(HS256-подпись access-токенов) и как fallback для `URL_SECRET` в `storage.Open`.
|
||||
- Refresh-токены не подписываются секретом (случайные, хранятся хэшем).
|
||||
- Есть два бэкенда метаданных (`postgres`, `sqlite`) за интерфейсом `Repository` и
|
||||
фабрикой `store.NewPostgres/NewSQLite`; миграции раздельные (pg-набор и `migrations/sqlite`).
|
||||
- Порядок старта в `cmd/server`: миграции → метаданные → storage → app.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Хранить секрет приложения в слое метаданных; генерировать при первом использовании.
|
||||
- Убрать обязательный `JWT_SECRET` из окружения.
|
||||
- Единый стабильный секрет на инсталляцию; корректное поведение при конкурентном старте.
|
||||
|
||||
**Non-Goals:**
|
||||
- Шифрование секрета в БД (БД доверенная).
|
||||
- UI/команда ротации секрета.
|
||||
- Миграция существующих env-секретов в БД.
|
||||
- Изменение формата токенов или наблюдаемого поведения аутентификации для клиента.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1. Таблица `app_settings`
|
||||
`app_settings(key TEXT PRIMARY KEY, value TEXT NOT NULL, created_at, updated_at)` в обоих
|
||||
бэкендах. Ключ `jwt_secret`. Отдельные миграции: PostgreSQL `000010_app_settings`,
|
||||
SQLite `000002_app_settings` (консолидированная 000001 уже применена на существующих БД).
|
||||
|
||||
### D2. Репозиторий `internal/settings`
|
||||
Интерфейс `Repository { Get(ctx,key) (value string, ok bool, err error); Set(ctx,key,value) error; Ensure(ctx,key, generate func() (string,error)) (string, error) }`.
|
||||
Реализации pg и sqlite. `Ensure`: `SELECT` → если есть, вернуть; иначе сгенерировать,
|
||||
`INSERT ... ON CONFLICT(key) DO NOTHING`, затем `SELECT` (единый секрет при гонке).
|
||||
Добавляется в `store.Repos`.
|
||||
|
||||
### D3. Генерация секрета
|
||||
`crypto/rand` 32 байта → base64url (без padding). Значение хранится как строка; в
|
||||
TokenManager и Signer передаётся как есть.
|
||||
|
||||
### D4. Порядок старта и проводка
|
||||
`cmd/server`: миграции → метаданные → `secret := repos.Settings.Ensure("jwt_secret", generate)`
|
||||
→ `tm := auth.NewTokenManager(secret, accessTTL, refreshTTL)` → `storage.Open(ctx, cfg, secret)`
|
||||
(где `URL_SECRET`, если задан, имеет приоритет, иначе используется `secret`) → `app.New`.
|
||||
`app.New` перестаёт строить TokenManager из конфига и принимает готовый `*auth.TokenManager`
|
||||
(или секрет).
|
||||
|
||||
### D5. Конфиг
|
||||
Поле `Auth.JWTSecret` и проверка обязательности удаляются; `JWT_ACCESS_TTL`/`JWT_REFRESH_TTL`
|
||||
остаются. `URL_SECRET` остаётся необязательным. `storage.Open` получает секрет параметром
|
||||
вместо чтения из конфига.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Смена источника секрета у существующих деплоев] → access-токены инвалидируются, но
|
||||
refresh-токены остаются валидными (не подписаны секретом), клиент молча обновляет access;
|
||||
в худшем случае — перелогин. Отмечаем в доках.
|
||||
- [Конкурентный первый старт на общей БД] → `INSERT ... ON CONFLICT DO NOTHING` + повторный
|
||||
`SELECT` (D2).
|
||||
- [Секрет в открытом виде в БД] → БД доверенная; шифрование — вне рамок.
|
||||
- [Тихая смена поведения] → при `URL_SECRET` не задан подпись URL зависит от секрета из БД;
|
||||
явный `URL_SECRET` снимает зависимость.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Добавить таблицу и репозиторий секретов; сид отсутствует — генерируется при старте.
|
||||
2. Переключить `cmd/server`/`app`/`storage` на секрет из метаданных; убрать `JWT_SECRET`.
|
||||
3. Обновить доки и `.env.example`.
|
||||
4. Откат: вернуть чтение `JWT_SECRET` из env (код), данные не меняются.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Нужна ли операция ротации секрета (сброс значения и инвалидация access-токенов).
|
||||
@@ -0,0 +1,36 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
`JWT_SECRET` сейчас обязателен в окружении: его нужно задавать вручную и одинаково на
|
||||
всех инстансах. Хранение секрета в слое метаданных с автогенерацией убирает обязательный
|
||||
секрет из env и даёт стабильное значение на инсталляцию.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Убрать обязательный `JWT_SECRET` из конфига и окружения.
|
||||
- Добавить таблицу `app_settings` (key/value) в оба бэкенда метаданных (PostgreSQL и
|
||||
SQLite) и репозиторий чтения/создания секрета.
|
||||
- При старте после открытия метаданных читать секрет или генерировать его
|
||||
(`crypto/rand`) и сохранять; использовать для `TokenManager` и как fallback при подписи
|
||||
URL объектов.
|
||||
- `URL_SECRET` остаётся необязательным env-оверрайдом.
|
||||
- Обновить `.env.example`, `project.md`, `README.md`.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `app-secrets`: хранение секретов приложения (JWT) в слое метаданных с автогенерацией при первом использовании.
|
||||
|
||||
### Modified Capabilities
|
||||
<!-- Нет изменений требований существующих спек: для клиента поведение аутентификации не меняется. -->
|
||||
|
||||
## Impact
|
||||
|
||||
- `backend/internal/config` (убрать `JWTSecret` и его обязательность).
|
||||
- `backend/internal/app`, `backend/cmd/server` (порядок старта: метаданные → секрет →
|
||||
storage/app).
|
||||
- `backend/internal/storage` (fallback-секрет для подписи URL).
|
||||
- Новая settings-репозиция (`internal/settings`), миграции PostgreSQL и SQLite.
|
||||
- Тесты (контрактные, e2e), `.env.example`, `project.md`, `README.md`.
|
||||
- Не затрагивает формат данных клиента.
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# Spec Delta
|
||||
|
||||
## Purpose
|
||||
|
||||
Хранение секретов приложения в слое метаданных с автогенерацией: единый стабильный
|
||||
секрет на инсталляцию без обязательной настройки окружения.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Хранение секрета приложения
|
||||
Слой метаданных SHALL хранить секрет подписи токенов приложения и SHALL возвращать один
|
||||
и тот же секрет при последующих запусках.
|
||||
|
||||
#### Scenario: Первый запуск
|
||||
- **WHEN** секрет ещё не сохранён
|
||||
- **THEN** система генерирует случайный секрет и сохраняет его в слое метаданных
|
||||
|
||||
#### Scenario: Повторный запуск
|
||||
- **WHEN** секрет уже сохранён
|
||||
- **THEN** система использует сохранённый секрет и не генерирует новый
|
||||
|
||||
### Requirement: Отсутствие ручного секрета
|
||||
Система SHALL работать без секрета, заданного в окружении, и SHALL не требовать его
|
||||
наличия при старте.
|
||||
|
||||
#### Scenario: Секрет не задан в окружении
|
||||
- **WHEN** переменная секрета не задана
|
||||
- **THEN** система запускается, используя секрет из слоя метаданных
|
||||
|
||||
### Requirement: Конкурентный первый запуск
|
||||
При одновременном первом запуске нескольких инстансов на общей базе метаданных система
|
||||
SHALL обеспечивать единый сохранённый секрет для всех инстансов.
|
||||
|
||||
#### Scenario: Гонка первого запуска
|
||||
- **WHEN** два инстанса одновременно обнаруживают отсутствие секрета
|
||||
- **THEN** сохраняется ровно один секрет, и оба инстанса используют его
|
||||
|
||||
### Requirement: Использование секрета для подписи URL
|
||||
При отсутствии отдельного секрета подписи URL система SHALL использовать секрет из слоя
|
||||
метаданных.
|
||||
|
||||
#### Scenario: URL_SECRET не задан
|
||||
- **WHEN** отдельный секрет подписи URL не задан
|
||||
- **THEN** подпись URL объектов использует секрет приложения из слоя метаданных
|
||||
|
||||
#### Scenario: URL_SECRET задан
|
||||
- **WHEN** отдельный секрет подписи URL задан
|
||||
- **THEN** он используется независимо от секрета приложения
|
||||
@@ -0,0 +1,15 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Репозиторий секретов и миграции
|
||||
|
||||
- [x] 1.1 Добавить таблицу `app_settings` миграциями: PostgreSQL `000010_app_settings` и SQLite `000002_app_settings`; проверка — применение на временных БД и идемпотентность.
|
||||
- [x] 1.2 Реализовать `internal/settings` (`Get`/`Set`/`Ensure`) для pg и sqlite и добавить в `store.Repos`; проверка — контрактный тест: `Ensure` идемпотентен и возвращает одно значение.
|
||||
|
||||
## 2. Проводка и конфиг
|
||||
|
||||
- [x] 2.1 Убрать `Auth.JWTSecret` и его обязательность из конфига; `app.New` принимает готовый `*auth.TokenManager`; `storage.Open` принимает секрет-fallback; проверка — `go build ./...`.
|
||||
- [x] 2.2 В `cmd/server` после открытия метаданных получить/сгенерировать секрет и собрать `TokenManager` и storage; проверка — e2e: перезапуск процесса даёт тот же секрет (токен, выпущенный до перезапуска, валиден после).
|
||||
|
||||
## 3. Документация
|
||||
|
||||
- [x] 3.1 Обновить `.env.example`, `project.md`, `README.md`: убрать `JWT_SECRET`, описать автогенерацию секрета в слое метаданных и роль `URL_SECRET`; проверка — документированный запуск без `JWT_SECRET` стартует.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-11
|
||||
@@ -0,0 +1,120 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
См. `proposal.md` — Why. Текущее состояние, определяющее подход:
|
||||
|
||||
- Каждый домен имеет конкретный `Store` с полем `*pgxpool.Pool` и SQL диалекта Postgres.
|
||||
Всего ~72 метода: `auth(9)`, `albums(7)`, `members(8)`, `photos(20)`,
|
||||
`photos/objects(15)`, `social(6)`, `notifications(3)`, `sharelinks(4)`.
|
||||
- Postgres-специфика: `FOR UPDATE SKIP LOCKED`, `ANY($1)`, `make_interval`/`power`/`least`,
|
||||
`now()`, `timestamptz`, `jsonb`/`::jsonb`, `ON CONFLICT`, `RETURNING`.
|
||||
- Кросс-доменные зависимости уже выражены интерфейсами (`AlbumAccess`, `PhotoLookup`,
|
||||
`Notifier`, `MemberLookup`, `AlbumReader`); свои store сервисы берут конкретными.
|
||||
- Сборка `CGO_ENABLED=0`; model: один экземпляр приложения на инсталляцию.
|
||||
- Ограничение: SQLite допускает один писатель, много читателей (WAL); один файл на
|
||||
один экземпляр приложения.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Ввести интерфейсы репозиториев по доменам и вторую реализацию на SQLite с паритетом.
|
||||
- Сохранить текущее поведение PostgreSQL по умолчанию и не менять сервисную логику.
|
||||
- Обеспечить атомарность объектов, GC и очереди вариантов на обоих бэкендах.
|
||||
- Гарантировать паритет общим контрактным набором тестов.
|
||||
|
||||
**Non-Goals:**
|
||||
- Горизонтальное масштабирование и несколько экземпляров на один файл SQLite.
|
||||
- Миграция данных между PostgreSQL и SQLite.
|
||||
- Изменение наблюдаемого поведения API/спек (см. `proposal.md`, изменений требований нет).
|
||||
- Изменение слоя хранения (см. `local-fs-storage-backend`).
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1. Параллельные реализации, а не слой диалекта
|
||||
Выбрано (по решению пользователя): Postgres-store не трогаем, пишем отдельные
|
||||
SQLite-store. Интерфейс `Repository` становится швом между доменом и хранилищем.
|
||||
- Альтернатива: единая логика + `Dialect` — отвергнута: правки во всех существующих
|
||||
store и риск регрессии Postgres ради экономии дублирования.
|
||||
- Цена: каждая будущая фича реализуется дважды; снижается контрактными тестами (D9).
|
||||
|
||||
### D2. Именование и размещение
|
||||
Интерфейс домена — `Repository`; существующий pgx-тип остаётся `Store` (`NewStore(pool)`),
|
||||
SQLite-тип — `SQLiteStore` и живёт в том же пакете (`photos/sqlite_store.go` и т.п.).
|
||||
- В том же пакете — доступ к `models` и sentinel-ошибкам без циклов импорта и без
|
||||
перемещения существующего кода.
|
||||
- Сервисы типизируются интерфейсом (`albums.Repository` и т.д.); кросс-доменные
|
||||
интерфейсы удовлетворяются любой реализацией.
|
||||
|
||||
### D3. Драйвер SQLite — `modernc.org/sqlite`
|
||||
Чистый Go, совместим с `CGO_ENABLED=0`.
|
||||
- Альтернатива `mattn/go-sqlite3` (CGO) — отвергнута, ломает модель сборки.
|
||||
|
||||
### D4. Роль `$1`-плейсхолдеров
|
||||
Плейсхолдеры остаются `$1`: и pgx, и `modernc` понимают именованные параметры.
|
||||
Поскольку реализации раздельные, каждая пишет свой SQL; отдельный слой диалекта не нужен.
|
||||
|
||||
### D5. Отдельный набор миграций для SQLite
|
||||
`migrations/sqlite/` рядом с Postgres-набором, драйвер `golang-migrate/database/sqlite`.
|
||||
Отображение типов: `timestamptz`→TEXT, `jsonb`→TEXT, `bigint`→INTEGER,
|
||||
`double precision`→REAL, `now()`→`strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
|
||||
`REFERENCES ... ON DELETE CASCADE` и `CHECK` сохраняются.
|
||||
- Альтернатива: переносимый единый набор — отвергнута, тянет переписывание обоих.
|
||||
|
||||
### D6. Настройка соединения и конкурентность
|
||||
`journal_mode=WAL`, `busy_timeout=5000`, `foreign_keys=ON` через DSN-параметры
|
||||
`_pragma`. Писатель один; читатели — параллельно.
|
||||
- `ANY($1)` переписывается в `IN (?, ...)` с построением списка в Go; батчи малы.
|
||||
- `FOR UPDATE`/`SKIP LOCKED` заменяются транзакцией с немедленной блокировкой записи
|
||||
(`BEGIN IMMEDIATE`); порядок «проверить состояние — вставить/заявку» сохраняется.
|
||||
|
||||
### D7. Атомарность жизненного цикла объектов
|
||||
`BindObject` и claim-методы (`ClaimGarbage`, `ClaimStalePending`, `ClaimVariantJobs`)
|
||||
выполняются в `BEGIN IMMEDIATE`-транзакции, что сериализует запись и воспроизводит
|
||||
инварианты Postgres-версии (нельзя привязать объект, уходящий в GC; нельзя удалить
|
||||
используемый; одинаковая роль без дублей).
|
||||
|
||||
### D8. Backoff и `now()`
|
||||
`FailVariantJob` считает задержку в Go (`min(300, 10*2^attempts)`) вместо
|
||||
`make_interval/power/least`. Время пишется/читается через хелперы преобразования
|
||||
`time.Time` <-> RFC3339 TEXT; запросы сравнения используют `datetime('now')`/значения Go,
|
||||
а не неявное приведение драйвера.
|
||||
|
||||
### D9. Контрактные тесты на интерфейс
|
||||
Один набор тестов на каждый `Repository`, прогоняемый на обоих бэкендах: Postgres под
|
||||
`TEST_DATABASE_URL` (пропуск без него), SQLite на временном файле (всегда). Это механизм
|
||||
удержания паритета при параллельных реализациях.
|
||||
|
||||
### D10. Фабрика и сборка зависимостей
|
||||
`store.NewPostgres(pool)` и `store.NewSQLite(db)` возвращают бандл репозиториев;
|
||||
`app.New` принимает бандл и `storage.Backend`. Выбор бэкенда — на старте по
|
||||
`DB_BACKEND=postgres|sqlite` (дефолт `postgres`), путь — `SQLITE_PATH`.
|
||||
- Альтернатива: ветвление внутри `app.New` по типу пула — отвергнута, размазывает выбор.
|
||||
|
||||
### D11. Маппинг ошибок и `RETURNING`
|
||||
`schema`-уровневые sentinel-ошибки домена сохраняются; каждая реализация сама
|
||||
отображает `pgx.ErrNoRows`/`sql.ErrNoRows` в sentinel. `RETURNING` используется и в
|
||||
SQLite (>=3.35), поэтому вставки с возвратом `created_at` переносятся один-в-один.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Дрейф параллельных реализаций] → общий контрактный набор тестов на интерфейс (D9).
|
||||
- [Пропускная способность записи SQLite под многими пользователями] → WAL,
|
||||
`busy_timeout`, один писатель; документируем как режим малой инсталляции.
|
||||
- [Потеря валидации `jsonb`] → хранение как TEXT, проверка JSON при необходимости в Go.
|
||||
- [Ошибки преобразования времени/JSON между бэкендами] → хелперы + контрактные тесты.
|
||||
- [Расхождение миграций] → симметричные наборы, прогон `up` на обоих в тестах.
|
||||
- [Лимит параметров SQLite в `IN (...)`] → малые батчи (`WORKER_BATCH`), построение
|
||||
списка в Go.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Ввести интерфейсы, не меняя Postgres-реализации и дефолт `postgres`.
|
||||
2. Добавить SQLite-реализации, миграции и фабрику; включение — `DB_BACKEND=sqlite`.
|
||||
3. Существующие развёртывания не меняются; откат — вернуть `DB_BACKEND=postgres`.
|
||||
4. Перенос данных между бэкендами не автоматизируется и вне рамок изменения.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Нужен ли SQLite-специфичный поиск меток (например, FTS) или достаточно текущих
|
||||
запросов с точным совпадением/префиксом.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
Слой метаданных жёстко привязан к PostgreSQL: каждый store держит `*pgxpool.Pool` и
|
||||
пишет SQL диалекта Postgres. Инсталляциям без внешней БД нужен встроенный,
|
||||
самодостаточный режим на SQLite с паритетом поведения для одного экземпляра приложения.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Ввести интерфейсы репозиториев по доменам (auth, albums, members, photos, social,
|
||||
notifications, sharelinks); существующие pgx-типы становятся одной из реализаций.
|
||||
- Добавить SQLite-реализации (~72 метода, включая жизненный цикл объектов, сборку
|
||||
мусора и очередь вариантов) на чистом Go (`modernc.org/sqlite`, `CGO_ENABLED=0`).
|
||||
- Добавить отдельный набор миграций для SQLite.
|
||||
- Ввести `DB_BACKEND=postgres|sqlite` и `SQLITE_PATH`; валидировать условно; настроить
|
||||
WAL, `busy_timeout`, `foreign_keys`.
|
||||
- Добавить фабрику сборки репозиториев по выбранному бэкенду.
|
||||
- Обеспечить единый контрактный набор тестов, прогоняемый на обоих бэкендах.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `metadata-backends`: выбор и работа слоя метаданных — PostgreSQL и встроенная SQLite с паритетным поведением для одного экземпляра.
|
||||
|
||||
### Modified Capabilities
|
||||
<!-- Нет изменений требований существующих спек: поведение системы для клиента не меняется, меняется способ хранения метаданных. -->
|
||||
|
||||
## Impact
|
||||
|
||||
- `backend/internal/*/store.go` и `backend/internal/photos/objects.go` (интерфейсы +
|
||||
SQLite-реализации), `internal/db`, `internal/app`, `cmd/server`, `config`, `migrations`.
|
||||
- Тесты: общий контрактный набор для Postgres и SQLite.
|
||||
- `.env.example`, `project.md`.
|
||||
- Ограничение: один экземпляр приложения на один SQLite-файл (без горизонтального
|
||||
масштабирования); Postgres остаётся поведением по умолчанию.
|
||||
- Не затрагивает слой хранения.
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
# Spec Delta
|
||||
|
||||
## Purpose
|
||||
|
||||
Выбор и работа слоя метаданных: внешняя PostgreSQL или встроенная SQLite, с
|
||||
паритетным поведением для одного экземпляра приложения.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Выбор бэкенда метаданных
|
||||
Система SHALL поддерживать выбор бэкенда слоя метаданных параметром конфигурации,
|
||||
при этом PostgreSQL SHALL оставаться значением по умолчанию.
|
||||
|
||||
#### Scenario: Выбор PostgreSQL
|
||||
- **WHEN** в конфигурации задан бэкенд PostgreSQL
|
||||
- **THEN** метаданные читаются и пишутся во внешнюю PostgreSQL
|
||||
|
||||
#### Scenario: Выбор встроенной SQLite
|
||||
- **WHEN** в конфигурации задан бэкенд SQLite
|
||||
- **THEN** метаданные читаются и пишутся в локальный файл SQLite без внешней службы
|
||||
|
||||
#### Scenario: Неполная конфигурация
|
||||
- **WHEN** конфигурация выбранного бэкенда неполна
|
||||
- **THEN** система отказывается стартовать с явной ошибкой конфигурации
|
||||
|
||||
### Requirement: Паритет поведения бэкендов
|
||||
Оба бэкенда метаданных SHALL обеспечивать одинаковое наблюдаемое поведение системы:
|
||||
аутентификацию, альбомы, роли и доступ, фото, объектный журнал, сборку мусора и
|
||||
очередь генерации вариантов.
|
||||
|
||||
#### Scenario: Одинаковое поведение API
|
||||
- **WHEN** один и тот же сценарий выполняется на PostgreSQL и на SQLite
|
||||
- **THEN** наблюдаемые результаты и ошибки API совпадают
|
||||
|
||||
#### Scenario: Соблюдение прав доступа
|
||||
- **WHEN** на любом бэкенде участник без прав выполняет запрещённое действие
|
||||
- **THEN** система отклоняет действие одинаковым образом
|
||||
|
||||
### Requirement: Встроенный одиночный экземпляр
|
||||
Бэкенд SQLite SHALL работать как самодостаточный режим для одного экземпляра приложения,
|
||||
и система SHALL отвергать или явно документировать использование одного файла SQLite
|
||||
несколькими экземплярами.
|
||||
|
||||
#### Scenario: Один экземпляр
|
||||
- **WHEN** приложение запущено в единственном экземпляре
|
||||
- **THEN** SQLite корректно обслуживает всех пользователей этого экземпляра
|
||||
|
||||
#### Scenario: Совместная работа с файлом
|
||||
- **WHEN** предполагается совместная работа нескольких экземпляров с одним файлом SQLite
|
||||
- **THEN** конфигурация считает такой режим неподдерживаемым
|
||||
|
||||
### Requirement: Атомарность жизненного цикла объектов
|
||||
На обоих бэкендах привязка готового объекта к фото и заявка объекта на удаление SHALL
|
||||
быть атомарными и SHALL исключать привязку объекта, который собирается быть удалён, и
|
||||
удаление используемого объекта.
|
||||
|
||||
#### Scenario: Конкурентная привязка и сборка мусора
|
||||
- **WHEN** привязка объекта и заявка на его удаление происходят одновременно
|
||||
- **THEN** система либо привязывает объект без удаления, либо удаляет его без привязки
|
||||
|
||||
#### Scenario: Повторная привязка
|
||||
- **WHEN** объект уже привязан к фото в роли
|
||||
- **THEN** повторная привязка приводит к согласованному состоянию без дублей
|
||||
|
||||
### Requirement: Устойчивость к перезапуску
|
||||
Очередь генерации вариантов и сборка мусора SHALL восстанавливаться из состояния слоя
|
||||
метаданных после перезапуска на обоих бэкендах, и система SHALL применять схему
|
||||
метаданных при старте.
|
||||
|
||||
#### Scenario: Незавершённые задания
|
||||
- **WHEN** процесс перезапускается с незавершёнными заданиями генерации вариантов
|
||||
- **THEN** обработка заданий продолжается
|
||||
|
||||
#### Scenario: Применение схемы
|
||||
- **WHEN** приложение стартует с выбранным бэкендом
|
||||
- **THEN** схема метаданных выбранного бэкенда приведена к актуальному состоянию
|
||||
@@ -0,0 +1,32 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Интерфейсы репозиториев и контрактный набор
|
||||
|
||||
- [x] 1.1 Ввести `Repository` в доменах auth, albums, members, photos, social, notifications, sharelinks и перевести сервисы на интерфейсы; проверка — `go build ./...` проходит, PostgreSQL-реализации не менялись.
|
||||
- [x] 1.2 Поднять общий контрактный набор тестов на каждый `Repository`; проверка — набор зелёный на PostgreSQL под `TEST_DATABASE_URL` (пропуск без него).
|
||||
- [x] 1.3 Зафиксировать маппинг sentinel-ошибок домена и `created_at` через `RETURNING` как часть контракта; проверка — тесты ошибок «не найдено» на Postgres проходят.
|
||||
|
||||
## 2. SQLite: драйвер, миграции, фабрика
|
||||
|
||||
- [x] 2.1 Добавить `modernc.org/sqlite` и подключение с `journal_mode=WAL`, `busy_timeout`, `foreign_keys=ON`; проверка — тест открытия, `Ping` и применения прагм проходит.
|
||||
- [x] 2.2 Добавить набор `migrations/sqlite/` и `db.MigrateSQLite`; проверка — тест применяет схему на временном файле и повторный запуск идемпотентен.
|
||||
- [x] 2.3 Реализовать фабрику `store.NewPostgres(pool)` / `store.NewSQLite(db)`, возвращающую бандл `Repository`; проверка — тест собирает бандл для обоих бэкендов и `go build ./...` проходит.
|
||||
|
||||
## 3. SQLite-реализации доменов
|
||||
|
||||
- [x] 3.1 Реализовать SQLite-store для auth, members, albums; проверка — контрактные тесты этих доменов зелёные на SQLite.
|
||||
- [x] 3.2 Реализовать SQLite-store для photos (фото, метки, корзина, backfill EXIF); проверка — контрактные тесты photos зелёные на SQLite.
|
||||
- [x] 3.3 Реализовать SQLite-логику объектов (`BindObject`, `ClaimGarbage`, `ClaimStalePending`, `ClaimVariantJobs`, `FailVariantJob`) в `BEGIN IMMEDIATE`; проверка — тесты на атомарность привязки/GC и на backoff зелёные.
|
||||
- [x] 3.4 Реализовать SQLite-store для social, notifications, sharelinks; проверка — контрактные тесты этих доменов зелёные на SQLite.
|
||||
|
||||
## 4. Конфигурация, проводка, health, документация
|
||||
|
||||
- [x] 4.1 Ввести `DB_BACKEND=postgres|sqlite` и `SQLITE_PATH` с условной валидацией; проверка — юнит-тесты `config.Load` для обоих бэкендов и неполной конфигурации.
|
||||
- [x] 4.2 Перевести `app.New` на бандл репозиториев и выбрать бэкенд в `cmd/server`; проверка — сервер стартует с обоими значениями `DB_BACKEND`, db-метка в health динамическая.
|
||||
- [x] 4.3 Обновить `.env.example`, `project.md` и README с ограничением «один экземпляр на файл SQLite»; проверка — документированный запуск с `DB_BACKEND=sqlite` стартует.
|
||||
|
||||
## 5. Интеграционные проверки
|
||||
|
||||
- [x] 5.1 Прогнать полный контрактный набор на обоих бэкендах и сравнить результаты; проверка — одинаковые исходы на Postgres и SQLite.
|
||||
- [x] 5.2 End-to-end на SQLite (регистрация → альбом → загрузка → превью → удаление → GC) с перезапуском процесса; проверка — очередь вариантов и GC продолжаются, сирот нет.
|
||||
- [x] 5.3 Убедиться, что дефолт `postgres` не меняет текущее развёртывание; проверка — запуск без новых переменных проходит как раньше.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-11
|
||||
@@ -0,0 +1,82 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
См. `proposal.md` — Why. Текущее состояние, определяющее подход:
|
||||
|
||||
- `storage.Backend` умеет Put/Delete/Get/URL/ServeObject/Ping/EnsureBucket/Bucket, но не
|
||||
умеет перечислять объекты.
|
||||
- Сборка мусора (`photos.Worker`) управляется журналом `objects`: `ClaimGarbage` выбирает
|
||||
строки без привязок и удаляет блоб. Блобы без строки в журнале воркеру невидимы.
|
||||
- Ключи объектов — `objects/<object_id>/<role><ext>`, слой метаданных хранит их в
|
||||
`objects.storage_key`.
|
||||
- Есть два бэкенда хранения (`s3`, `fs`) и два бэкенда метаданных (`postgres`, `sqlite`)
|
||||
за интерфейсами `storage.Backend` и доменными `Repository`.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Ручная сверка хранилища и журнала: инвентаризация обеих сторон, отчёт, безопасное удаление.
|
||||
- Работа с любой комбинацией бэкендов хранения и метаданных.
|
||||
- Безопасное поведение по умолчанию: ничего не удаляется без явного флага, свежие блобы
|
||||
защищены.
|
||||
|
||||
**Non-Goals:**
|
||||
- Автоматическая периодическая реконсиляция в воркере.
|
||||
- Восстановление пропавших блобов (в журнале есть, в хранилище нет).
|
||||
- Миграция блобов между бэкендами.
|
||||
- Сверка префиксов, отличных от объектов.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1. Перечисление в `storage.Backend`
|
||||
Добавляем `List(ctx context.Context, prefix string) ([]ObjectInfo, error)` где
|
||||
`ObjectInfo{Key string; Size int64; ModTime time.Time}`.
|
||||
- S3: `ListObjectsV2` с пагинацией по `ContinuationToken`, `Prefix`.
|
||||
- ФС: обход корня (`filepath.WalkDir`), ключ — путь относительно корня, размер/время из
|
||||
`os.Stat`.
|
||||
- Альтернатива: не трогать интерфейс и добавить отдельный тип для реконсиляции — отвергнуто,
|
||||
перечисление логично принадлежит бэкенду хранения.
|
||||
|
||||
### D2. Ключи журнала в метаданных
|
||||
Добавляем `AllStorageKeys(ctx) ([]string, error)` в `photos.Repository`; реализации:
|
||||
`SELECT storage_key FROM objects` (pg и sqlite). Контрактный набор покрывает метод.
|
||||
|
||||
### D3. Команда `cmd/reconcile`
|
||||
Отдельная maintenance-команда, читает конфиг, открывает выбранные бэкенды
|
||||
(`storage.Open` + бандл репозиториев), строит множества и печатает отчёт.
|
||||
Флаги: `--delete` (bool), `--min-age` (duration, по умолчанию `1h`), `--sample` (число
|
||||
примеров в отчёте). Область — фиксированный префикс `objects/`.
|
||||
|
||||
### D4. Классификация расхождений
|
||||
- **Сироты** = ключи хранилища под `objects/`, которых нет в журнале; кандидаты на удаление.
|
||||
- **Пропавшие** = ключи журнала, которых нет в хранилище; только отчёт.
|
||||
- Сироты моложе `--min-age` не удаляются (защита от гонки с идущей загрузкой: блоб уже
|
||||
записан, строка ещё не создана).
|
||||
|
||||
### D5. Безопасность
|
||||
По умолчанию — только отчёт и нулевое воздействие. Удаление только по `--delete`, только
|
||||
под `objects/`, только старше `--min-age`. Пропавшие никогда не «исправляются».
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Гонка с загрузкой] → `--min-age` пропускает молодые блобы; рекомендуем запускать в
|
||||
спокойный период.
|
||||
- [Большие бакеты: память/время] → пагинация S3 и потоковый обход ФС; в отчёте лишь
|
||||
примеры, не полный список.
|
||||
- [Несогласованность формата ключей между бэкендами] → оба бэкенда отдают ключи
|
||||
относительно корня/перфикса без ведущего слэша; контракт фиксируется тестами.
|
||||
- [Удаление используемого блоба] → сверка выполняется вручную и в спокойный период; журнал
|
||||
— источник истины, поэтому «сирота» по определению не имеет строки.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Добавить `List` в интерфейс — S3 и ФС реализуют его (компилятор требует оба).
|
||||
2. Добавить `AllStorageKeys` в `photos.Repository` — обе реализации.
|
||||
3. Добавить `cmd/reconcile` (аддитивно, ничего не меняет в работающем сервисе).
|
||||
4. Данные не мигрируются; команда запускается вручную по необходимости.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Нужно ли со временем добавить восстановление пропавших блобов или отметку повреждённых
|
||||
записей журнала.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
Сборка мусора воркера управляется журналом `objects` и никогда не сканирует хранилище.
|
||||
Поэтому блобы, у которых нет строки в журнале (после сброса БД в обход журнала,
|
||||
исторических миграций или ошибок), накапливаются и не удаляются никогда: воркер их не
|
||||
видит. Нужна ручная сверка «хранилище ∖ журнал».
|
||||
|
||||
## What Changes
|
||||
|
||||
- Добавить в `storage.Backend` перечисление объектов под префиксом (ключ, размер, время
|
||||
изменения); реализовать для S3 (`ListObjectsV2`) и локальной ФС (обход каталога).
|
||||
- Добавить в слой метаданных чтение всех ключей журнала `objects` (Postgres и SQLite).
|
||||
- Добавить maintenance-команду `cmd/reconcile`: по умолчанию — только отчёт (сироты и
|
||||
пропавшие), флаг `--delete` удаляет сироты с учётом минимального возраста
|
||||
(`--min-age`), область ограничена префиксом `objects/`.
|
||||
- Пропавшие ключи (есть в журнале, нет в хранилище) только сообщаются — автоматически не
|
||||
«исправляются».
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `storage-reconciliation`: ручная сверка объектного хранилища с журналом метаданных и удаление осиротевших блобов.
|
||||
|
||||
### Modified Capabilities
|
||||
<!-- Нет изменений требований существующих спек: поведение системы для клиента не меняется, добавляется отдельная maintenance-операция. -->
|
||||
|
||||
## Impact
|
||||
|
||||
- `backend/internal/storage` (интерфейс + перечисление для S3 и ФС).
|
||||
- `backend/internal/photos` (`Repository.AllStorageKeys`, реализации pg и sqlite).
|
||||
- Новый `backend/cmd/reconcile`.
|
||||
- `README.md`, `project.md`.
|
||||
- Не меняет API и существующие спеки; дополняет `media-objects` ручной сверкой к
|
||||
журнальной сборке мусора.
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
# Spec Delta
|
||||
|
||||
## Purpose
|
||||
|
||||
Ручная сверка объектного хранилища с журналом метаданных: инвентаризация обеих сторон,
|
||||
отчёт о расхождениях и безопасное удаление осиротевших блобов.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Инвентаризация хранилища
|
||||
Слой хранения SHALL перечислять объекты под заданным префиксом, возвращая ключ, размер и
|
||||
время изменения, независимо от бэкенда (S3-совместимое или локальная ФС).
|
||||
|
||||
#### Scenario: Перечисление в S3-совместимом хранилище
|
||||
- **WHEN** запрашивается список объектов под префиксом
|
||||
- **THEN** возвращаются все ключи с этим префиксом с их размером и временем изменения
|
||||
|
||||
#### Scenario: Перечисление в локальной ФС
|
||||
- **WHEN** хранилищем выбрана локальная ФС
|
||||
- **THEN** возвращаются ключи всех блобов под префиксом с их размером и временем изменения
|
||||
|
||||
### Requirement: Инвентаризация журнала
|
||||
Слой метаданных SHALL возвращать все ключи, зарегистрированные в журнале `objects`, на
|
||||
обоих бэкендах (PostgreSQL и SQLite).
|
||||
|
||||
#### Scenario: Ключи журнала
|
||||
- **WHEN** запрашиваются ключи журнала
|
||||
- **THEN** возвращается множество ключей всех строк `objects`
|
||||
|
||||
### Requirement: Отчёт о расхождениях
|
||||
По умолчанию сверка SHALL выполняться в режиме отчёта и SHALL не изменять хранилище;
|
||||
отчёт SHALL различать сироты (в хранилище есть, в журнале нет) и пропавшие (в журнале
|
||||
есть, в хранилище нет).
|
||||
|
||||
#### Scenario: Только отчёт
|
||||
- **WHEN** сверка запущена без флага удаления
|
||||
- **THEN** система выводит число и примеры сирот и пропавших, ничего не удаляя
|
||||
|
||||
#### Scenario: Пропавшие не восстанавливаются
|
||||
- **WHEN** ключ есть в журнале, но отсутствует в хранилище
|
||||
- **THEN** он сообщается как пропавший и не создаётся и не удаляется
|
||||
|
||||
### Requirement: Удаление сирот по явному запросу
|
||||
Удаление сирот SHALL выполняться только по явному флагу, только для ключей под префиксом
|
||||
объектов и только для блобов старше заданного минимального возраста.
|
||||
|
||||
#### Scenario: Удаление включено
|
||||
- **WHEN** сверка запущена с флагом удаления
|
||||
- **THEN** сироты старше минимального возраста удаляются из хранилища, а более молодые пропускаются
|
||||
|
||||
#### Scenario: Защита от гонки с загрузкой
|
||||
- **WHEN** блоб создан недавно (моложе минимального возраста)
|
||||
- **THEN** он не удаляется, даже если строки в журнале ещё нет
|
||||
|
||||
#### Scenario: Ограничение области
|
||||
- **WHEN** удаление включено
|
||||
- **THEN** удаляются только ключи под префиксом объектов, остальные не затрагиваются
|
||||
|
||||
### Requirement: Независимость от бэкендов
|
||||
Сверка SHALL работать с любой комбинацией бэкенда хранения (S3 или ФС) и бэкенда
|
||||
метаданных (PostgreSQL или SQLite).
|
||||
|
||||
#### Scenario: Комбинации бэкендов
|
||||
- **WHEN** сверка запускается на выбранной конфигурации
|
||||
- **THEN** она использует выбранные бэкенды хранения и метаданных без изменений кода
|
||||
@@ -0,0 +1,21 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Перечисление в хранилище
|
||||
|
||||
- [x] 1.1 Добавить `List(ctx, prefix)` (`[]ObjectInfo{Key,Size,ModTime}`) в `storage.Backend` и реализовать в `storage.S3` через `ListObjectsV2` с пагинацией; проверка — тест перечисления под префиксом (под `S3_ENDPOINT`, иначе пропуск).
|
||||
- [x] 1.2 Реализовать `List` в `storage.FS` обходом корня с относительными ключами; проверка — юнит-тест на временном каталоге.
|
||||
|
||||
## 2. Ключи журнала в метаданных
|
||||
|
||||
- [x] 2.1 Добавить `AllStorageKeys` в `photos.Repository` и реализовать в pg- и sqlite-store; проверка — контрактный тест на обоих бэкендах.
|
||||
|
||||
## 3. Команда reconcile
|
||||
|
||||
- [x] 3.1 Реализовать `cmd/reconcile`: инвентаризация обеих сторон, классификация сирот/пропавших, отчёт по умолчанию; проверка — юнит-тесты классификации.
|
||||
- [x] 3.2 Добавить флаги `--delete`, `--min-age`, `--sample` с ограничением области `objects/` и защитой свежих блобов; проверка — тест: молодой блоб не удаляется, старый сирота удаляется, пропавшие не трогаются.
|
||||
- [x] 3.3 Собрать бэкенды через конфиг (`storage.Open` + бандл репозиториев) для postgres/sqlite и s3/fs; проверка — запуск на fs и s3.
|
||||
|
||||
## 4. Документация и интеграция
|
||||
|
||||
- [x] 4.1 Обновить `README.md` и `project.md` с описанием команды и её безопасного использования; проверка — документированный запуск работает.
|
||||
- [x] 4.2 Интеграционная проверка: создать сироту, прогнать отчёт, затем `--delete`, убедиться в удалении и в том, что журнальные записи не затронуты.
|
||||
Reference in New Issue
Block a user