diff --git a/.av-dev.toml b/.av-dev.toml index 8147e84..c48c47a 100644 --- a/.av-dev.toml +++ b/.av-dev.toml @@ -5,7 +5,7 @@ version = 4 # версия раскладки; обратной совмест [docs] # каталог миграций: по нему docs.py сверяет схему с database.md -migrations = "internal/adapter/repo/pocketbase/migrations" +migrations = "internal/adapter/repo/sqlite/migrations" [tasks] # каталог задач от корня репозитория; имена частей — умолчания скрипта diff --git a/.gitignore b/.gitignore index b3d584a..7dd4067 100644 --- a/.gitignore +++ b/.gitignore @@ -20,14 +20,9 @@ transcriber # Go workspace file go.work -# Database files -data/transcriber.db -data/transcriber.db-shm -data/transcriber.db-wal - -# Uploaded files -data/files/* -!data/files/.gitkeep +# Каталог данных: файл базы, её журнал упреждающей записи, замок наката схемы и +# подкаталоги с файлами записей. Раскладку задаёт сервис. +data/ # IDE files .vscode/ diff --git a/.golangci.yml b/.golangci.yml index 5423abb..adae1a9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -155,6 +155,19 @@ linters: - (*os.File).Close - (io.ReadCloser).Close - os.Remove + # Закрытие выборки отложенным вызовом: строки к этому моменту прочитаны, + # а их отказ уже спрошен у `rows.Err()` — отдельного смысла у отказа + # закрытия нет. + - (*database/sql.Rows).Close + # Откат транзакции отложенным вызовом. Успешно завершённая транзакция + # отвечает на него «уже закончена», и проверка этого отказа означала бы + # разбор штатного исхода. + - (*database/sql.Tx).Rollback + # Запись тела ответа. Отказ здесь значит оборванное соединение, и + # сказать о нём некому: код ответа уже ушёл, а строка о каждом закрытом + # браузере наполняла бы журнал ничем. + - (*encoding/json.Encoder).Encode + - (net/http.ResponseWriter).Write exclusions: rules: diff --git a/CLAUDE.md b/CLAUDE.md index ec383aa..37e8f70 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,10 +11,11 @@ Сервис расшифровки аудио в текст. Принимает запись одним входом — HTTP API, — конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание Yandex -SpeechKit и отдаёт текст тому, кто запись загрузил, по опросу готовности. -Состояние записей, метаданные и сами файлы лежат во встроенной PocketBase, и она -же даёт владельцу панель администратора. Вход Telegram убран 2026-08-14 — -временно, до задачи, которая свяжет чат с учётной записью. +SpeechKit и отдаёт текст тому, кто запись загрузил, карточкой записи. +Состояние записей и метаданные лежат в SQLite, файлы записей — своим каталогом +рядом с базой. Панели администратора у сервиса нет: встроенное хранилище, +дававшее её, убрано 2026-08-22. Вход Telegram убран 2026-08-14 — временно, до +задачи, которая свяжет чат с учётной записью. Чего **не** делает: сам речь не распознаёт и своих моделей не держит, текст руками не правит и в форматы документов не экспортирует, учётных записей не @@ -25,8 +26,10 @@ SpeechKit и отдаёт текст тому, кто запись загруз ## Стек -Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), встроенная PocketBase — хранилище, файлы записей и -панель администратора, — `aws-sdk-go-v2` для Object +Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), SQLite через +`modernc.org/sqlite` — база, — шаги схемы библиотекой `pressly/goose/v3`, +маршруты и слои на `net/http`, файлы записей своим каталогом, +`aws-sdk-go-v2` для Object Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Приложение — Vue 3 с роутером пятой версии и сборкой Vite; собранное вшито в бинарник, проверяют его Biome и юнит-тесты Vue. Сборка — @@ -60,7 +63,8 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project- Изъятия у инварианта нет. Оно было — секрет клиента OIDC жил ещё и в настройках коллекции пользователей хранилища, — и снято 2026-08-22 вместе с самим секретом: вход переехал на доверенный заголовок, обменивать код стало не - на что. Чтение файла базы больше не равносильно чтению секрета. + на что. Чтение файла базы больше не равносильно чтению секрета. Секретов в + базе не осталось вовсе: пароль владельца от панели ушёл вместе с панелью. - **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла пользователя и его сообщение в лог не пишутся — только длина и идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера. @@ -85,20 +89,31 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project- логируется, не считается в метрику и не поднимает уровень. Нарушение даёт запись раз в секунду на каждый воркер. **major** - **Миграция, уехавшая на сервер, не переписывается.** Изменение — только новым - файлом шага. Необратимо: хранилище считает применённое по имени файла. + файлом шага. Необратимо: учёт применённого ведёт сама база. **critical** -- **Имя файла в хранилище задаёт сервис, а в журнал не идёт.** Умолчание - PocketBase строит имя из имени, данного отправителем, — оно не применяется. - Само имя — последняя часть ссылки `/api/files/...`, поэтому в журнал пишется + *Снятие было, разовое:* 2026-08-22 решением владельца весь каталог шагов + встроенного хранилища удалён и заменён одним шагом начальной схемы. Причина — + стройка: на сервере данных нет, сервис остановлен, выкладка идёт с чистого + листа, а новая база ведёт учёт применённого своей таблицей, которой отметки + прежнего каталога не годятся вовсе. Граница названа: снятие кончилось этим + изменением, и шаг начальной схемы подпадает под инвариант как всякий прежний. +- **Имя файла на диске задаёт сервис, а в журнал не идёт.** Ни имя файла, ни имя + подкаталога записи не строятся из имени, данного отправителем: подкаталог зовётся + идентификатором записи, файл — идентификатором с расширением. В журнал пишется расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой записи. **critical** -- **Колонки записи правятся в двух местах** пакета хранилища — - `applyOwnedByPipeline` вместе с `applyToRecord` и `recordToAudioRecord`, — - плюс шаг схемы. Компилятор не видит ни одного: колонка, забытая в одном из - них, теряется молча — запись сохранится без поля либо приедет с нулевым. +- **Колонки записи правятся в трёх местах** пакета хранилища — + `writeOwnedByPipeline` вместе с `writeRecord`, `readRecordColumns` и + `rowToAudioRecord`, — плюс шаг схемы. Компилятор не видит ни одного: колонка, + забытая в одном из них, теряется молча — запись сохранится без поля, приедет с + нулевым либо доедет до сущности пустой, и ближайшее сохранение запишет этот + ноль поверх сохранённого. Мест было четыре, пока захват перечислял колонки поимённо; теперь он возвращает идентификатор и признак своего захвата, и перечень перестал расти - с моделью. Сверку держат правила `internal/archrules`. **major** + с моделью. Отображение при этом идёт **по имени колонки**: именованные + параметры запроса и место назначения, найденное по имени, — позиционный список + дал бы сдвиг на одно поле, который компилируется молча. Сверку держат правила + `internal/archrules`. **major** - **Рубеж объявляется одним дескриптором** — `internal/entity/stage.go`. Из него выводятся выбор шага, отбор захвата, срок протухания захвата и предел простоя; перечислять рубежи порознь в каждом потребителе нельзя. Рубеж, забытый в @@ -108,15 +123,16 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project- - **Результат пишет только держатель захвата, и держатель узнаётся значением.** Признак захвата уникален для каждого захвата, и запись результата условна по нему, а не по занятости записи. Шаг, чей захват за время работы достался - другому — по протуханию срока или после того, как человек снял признак - остановки в панели, — завершается без записи результата. Условие + другому — по протуханию срока или после того, как человек вернул запись в + работу подкомандой оснастки, — завершается без записи результата. Условие по непустоте признака пропустило бы обоих: два воркера писали бы в одну запись по очереди, портя её результат. **major** - **У записи есть владелец, и колонка пустого значения не принимает.** Ничья - запись не заводится ничем — ни приёмом, ни конвейером, ни рукой в панели, — и - держит это схема хранилища, а не договорённость. Пока обязательность жила в - одном приёме, ничью запись заводили в панели, она уходила в конвейер, стоила - денег на распознавание и не доставалась потом никому. Правило со стороны + запись не заводится ничем — ни приёмом, ни конвейером, ни запросом к базе, — и + держит это схема, а не договорённость: колонка объявлена внешним ключом на + учётную запись и обязательна. Пока обязательность жила в одном приёме, ничью + запись заводили руками мимо него, она уходила в конвейер, стоила денег на + распознавание и не доставалась потом никому. Правило со стороны спрашивающего при этом остаётся: пустой владелец не совпадает ни с одной записью, потому что схема запрещает **заводить** ничью, а это правило — **спрашивать** ничьим именем. **major** @@ -137,6 +153,7 @@ gofmt -l . golangci-lint run go run ./cmd/transcriber -c config.toml # флаг -c или --config, по умолчанию config.toml go run ./cmd/devtools proxy # подставной прокси: ставит заголовок входа локально +go run ./cmd/devtools resume -c config.toml # вернуть остановленную запись в работу task front # приложение: зависимости, Biome, юнит-тесты, сборка task image # docker-образ; тег и раскладка — docs/architecture.md task gate # весь набор проверок разом @@ -247,8 +264,8 @@ Node на машину **не ставится**: шаг сборки прило ## Запреты - **Боевой каталог данных не трогать.** `data/` на сервере целиком: под ним и - база (`data/data.db`), и записи живых людей - (`data/storage/<коллекция>/<запись>/`). На стройке под ним пусто и сервис + база (`data/transcriber.db`), и записи живых людей + (`data/records/<запись>/`). На стройке под ним пусто и сервис остановлен — запрет от этого не снимается: каталог принадлежит серверу, и выкладка с чистого листа наполнит его снова. Локальный каталог данных — свой, его ронять и пересоздавать можно свободно. diff --git a/README.md b/README.md index 9e914ef..23c9b71 100644 --- a/README.md +++ b/README.md @@ -8,16 +8,17 @@ - Конвертация в ogg через ffmpeg - Распознавание речи через Yandex SpeechKit - Отслеживание статуса задач расшифровки -- Встроенная PocketBase для метаданных, файлов и панели владельца; метрики Prometheus +- Своё хранилище: SQLite для метаданных и каталог файлов записей рядом с ним; метрики Prometheus ## Технологии - **Язык**: Go 1.26, CGO не нужен -- **Веб-фреймворк**: gin-gonic/gin +- **HTTP**: стандартная библиотека, `net/http` - **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3) - **Конвертация**: ffmpeg -- **Хранилище, файлы и панель**: встроенная PocketBase -- **База данных**: SQLite внутри PocketBase (через modernc.org/sqlite, CGO не нужен) +- **База данных**: SQLite через modernc.org/sqlite, CGO не нужен +- **Шаги схемы**: pressly/goose/v3, библиотекой — накат при старте +- **Файлы записей**: свой каталог, подкаталог на запись - **Метрики**: prometheus/client_golang ## Установка и запуск @@ -75,13 +76,13 @@ inv pl -- transcriber Адреса приложения живут под корнем `/app`: `POST /app/audiorecords` — приём записи, `GET /app/audiorecords` — страница своих записей, `GET /app/audiorecords/{id}` — карточка, `GET /app/audiorecords/{id}/text` — -текст названного вида, `GET /app/me` — кто пришёл, `GET /app/config` — пределы, -которые сервис объявляет приложению. Отдельными адресами стоят `GET /metrics` — -метрики Prometheus с префиксом `transcriber_` — и `GET /health` — проверка -живости. Своего входа у сервиса нет: кто пришёл, называет заголовок обратного -прокси ([access](openspec/specs/access/spec.md)). Сверх этого тем же портом -отдаётся собственная поверхность встроенного хранилища и панель `/_/` — -[docs/security.md](docs/security.md), «Из чего строятся пути и ключи». +текст названного вида, `GET /app/audiorecords/{id}/file` — файл записи названной +копии, `GET /app/me` — кто пришёл, `GET /app/config` — пределы, которые сервис +объявляет приложению. Отдельными адресами стоят `GET /metrics` — метрики +Prometheus с префиксом `transcriber_` — и `GET /health` — проверка живости. +Своего входа у сервиса нет: кто пришёл, называет заголовок обратного прокси +([access](openspec/specs/access/spec.md)). Больше на этом порту не отвечает +ничего: всякий прочий путь получает разметку приложения. Контракт приёма и опроса нормативен и живёт в [openspec/specs/intake/spec.md](openspec/specs/intake/spec.md): поля запроса и @@ -91,7 +92,7 @@ inv pl -- transcriber ## Состояния задач Перечень состояний, переходы между ними и число воркеров — -[docs/database.md](docs/database.md), разделы «Коллекции» и «Представление +[docs/database.md](docs/database.md), разделы «Таблицы» и «Представление данных»; как сложен конвейер целиком — [docs/architecture.md](docs/architecture.md). ## Структура проекта @@ -100,9 +101,10 @@ inv pl -- transcriber transcriber/ ├── cmd/ │ ├── transcriber/ # Точка входа сервиса: конфиг, миграции, сборка зависимостей, запуск -│ └── devtools/ # Оснастка разработчика: подставной прокси для локального входа +│ └── devtools/ # Оснастка разработчика: подставной прокси и возврат записи в работу ├── internal/ -│ ├── entity/ # Модели: задача, файл, результат распознавания +│ ├── entity/ # Модели: запись, файл, результат распознавания +│ ├── ident/ # Выдача и разбор идентификаторов строк (ULID) │ ├── contract/ # Интерфейсы адаптеров и репозиториев, типы ошибок │ ├── config/ # Разбор config.toml │ ├── metrics/ # Метрики Prometheus @@ -114,26 +116,32 @@ transcriber/ │ ├── converter/ffmpeg/ # Конвертация аудио │ ├── metaviewer/ffmpeg/ # Длительность аудио │ ├── recognizer/yandex/ # SpeechKit + Object Storage -│ └── repo/pocketbase/ # Репозитории, схема коллекций, правила панели +│ └── repo/sqlite/ # Репозитории, подключение к базе, шаги схемы, каталог файлов └── data/ # Каталог данных: база и файлы записей вместе - ├── data.db # База хранилища (создаётся автоматически) - └── storage/ # Файлы записей в раскладке хранилища + ├── transcriber.db # База (создаётся автоматически) + ├── migrate.lock # Замок наката схемы + └── records/ # Файлы записей: подкаталог на запись ``` ## Хранилище -Коллекции хранилища — аудиозапись и её приложения. Поля, ключи, правило времени и -идентификаторов, а также механика захвата задачи воркером — -[docs/database.md](docs/database.md). Панель владельца — по адресу `/_/` того же -порта; пароль от неё задаёт сам владелец по приглашению, которое сервис печатает -в журнал при первом запуске. +Таблицы базы — аудиозапись и её приложения. Поля, ключи, правило времени и +идентификаторов, раскладка файлов записи и механика захвата задачи воркером — +[docs/database.md](docs/database.md). Панели владельца у сервиса нет: единственное +его действие вне экранов — возврат остановленной записи в работу подкомандой +оснастки. + +```bash +go run ./cmd/devtools resume -c config.toml <идентификатор записи> +``` ## Разработка -Схему двигают шаги миграций PocketBase на Go — -`internal/adapter/repo/pocketbase/migrations`, файл на шаг. Непринятые шаги -накатываются при подъёме хранилища, прежде чем стартуют воркеры и сервер. -Применённый шаг не переписывается: изменение — только новым файлом шага. +Схему двигают шаги `pressly/goose/v3` — +`internal/adapter/repo/sqlite/migrations`, файл на шаг, версия шага — число в +начале имени файла. Непринятые шаги накатываются при старте, прежде чем поднимутся +входы и стартуют воркеры; отказ шага роняет старт. Применённый шаг не +переписывается: изменение — только новым файлом шага. Проверки перед коммитом — одной командой: diff --git a/cmd/devtools/main.go b/cmd/devtools/main.go index 01b3ddb..5938892 100644 --- a/cmd/devtools/main.go +++ b/cmd/devtools/main.go @@ -7,8 +7,9 @@ // тихо кладёт инструмент разработчика в боевой образ. Один пакет платит эти // четыре места **однажды**, сколько бы подкоманд в нём ни завелось. // -// Сегодня подкоманда одна — `proxy`. Заведение владельца панели придёт -// подкомандой `admin` вместе с задачей о запуске одной командой. +// Подкоманд две: `proxy` — подставной обратный прокси, `resume` — возврат +// остановленной записи в работу. Вторая встала на место панели владельца: +// панели у сервиса больше нет, а экраны правки записи приносят отдельные задачи. // // Имена заголовков берутся **константами транспорта**, а не литералами: они // нормативны, и второй список разошёлся бы с первым молча — локальный вход @@ -43,6 +44,8 @@ func main() { switch os.Args[1] { case "proxy": runProxy(os.Args[2:]) + case "resume": + runResume(os.Args[2:]) default: fmt.Fprintf(os.Stderr, "неизвестная подкоманда: %s\n\n", os.Args[1]) usage() @@ -54,7 +57,8 @@ func usage() { fmt.Fprint(os.Stderr, `Оснастка разработчика. Подкоманды: - proxy подставной обратный прокси: ставит заголовок и шлёт запрос сервису + proxy подставной обратный прокси: ставит заголовок и шлёт запрос сервису + resume вернуть остановленную запись в работу `) } diff --git a/cmd/devtools/resume.go b/cmd/devtools/resume.go new file mode 100644 index 0000000..993a568 --- /dev/null +++ b/cmd/devtools/resume.go @@ -0,0 +1,100 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + + sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite" + "git.vakhrushev.me/av/transcriber/internal/config" + "git.vakhrushev.me/av/transcriber/internal/entity" + "git.vakhrushev.me/av/transcriber/internal/ident" +) + +// stepResume — чем возврат в работу подписывается в журнале событий записи. +const stepResume = "resume" + +// runResume возвращает остановленную запись в работу. +// +// Подкоманда встала на место панели владельца: панели у сервиса больше нет, а +// экраны правки записи приносят отдельные задачи. Из всего, что владелец делал +// панелью, отложить до экранов нельзя было одно — возврат остановленной записи. +// +// **Колонок подкоманда не пишет.** Перечень полей, которые возврат обязан +// сбросить — признак остановки, признак захвата и срок его протухания, число +// отказов, паузу и время входа в рубеж, — исполняет домен одним действием. +// Рука, забывшая любое из них, оставила бы запись либо невидимой для захвата, +// либо останавливаемой снова первым же захватом — молча, без единой строки. +// +// Событие журнала записи пишется с происхождением «человек»: иначе запись, +// побывавшая остановленной и вернувшаяся в работу, неотличима в журнале от +// записи, которую конвейер вёл без остановок, а происхождение события перестаёт +// различать что-либо. +func runResume(args []string) { + flags := flag.NewFlagSet("resume", flag.ExitOnError) + configPath := flags.String("c", "config.toml", "путь к файлу настроек") + if err := flags.Parse(args); err != nil { + os.Exit(2) + } + + if flags.NArg() != 1 { + fmt.Fprint(os.Stderr, "укажи идентификатор записи: devtools resume [-c config.toml] \n") + os.Exit(2) + } + + recordID, ok := ident.Parse(flags.Arg(0)) + if !ok { + log.Fatalf("идентификатор записи не читается: %q", flags.Arg(0)) + } + + cfg, err := config.LoadConfig(*configPath) + if err != nil { + log.Fatalf("настройки не читаются: %v", err) + } + if err := cfg.Storage.Validate(); err != nil { + log.Fatalf("настройки хранилища негодны: %v", err) + } + + db, err := sqliterepo.Open(cfg.Storage.DataDir, sqliterepo.Settings{ + BusyTimeoutMs: cfg.Storage.BusyTimeoutMs, + ReadConnections: cfg.Storage.ReadConnections, + }) + if err != nil { + log.Fatalf("база не открывается: %v", err) + } + defer func() { + if err := db.Close(); err != nil { + log.Printf("база закрылась с отказом: %v", err) + } + }() + + records := sqliterepo.NewAudioRecordRepository(db) + events := sqliterepo.NewRecordEventRepository(db) + + record, err := records.Get(recordID) + if err != nil { + log.Fatalf("запись не читается: %v", err) + } + if !record.IsHalted() { + log.Fatalf("запись %s не остановлена: возвращать в работу нечего", recordID) + } + + // Захват снимает сам домен, поэтому сохранение идёт **безусловным**: держателя + // у остановленной записи нет, и сверять признак захвата не с чем. + record.Resume() + if err := records.Save(record, ""); err != nil { + log.Fatalf("запись не сохраняется: %v", err) + } + + if err := events.Append(&entity.RecordEvent{ + RecordID: recordID, + Origin: entity.EventOriginHuman, + Step: stepResume, + Outcome: entity.EventOutcomeResumed, + }); err != nil { + log.Fatalf("событие журнала записи не сохраняется: %v", err) + } + + log.Printf("запись %s возвращена в работу с рубежа %s", recordID, record.State) +} diff --git a/cmd/transcriber/journal_route_test.go b/cmd/transcriber/journal_route_test.go deleted file mode 100644 index 233056b..0000000 --- a/cmd/transcriber/journal_route_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package main - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http" -) - -// Перечень адресного пространства для проверок журнала. Обработчиков он здесь -// не вешает: журналу нужны только границы, а не то, что стоит за ними. -func journalMounts() []httpcontroller.Mount { - return []httpcontroller.Mount{ - {Path: httpcontroller.StorageRoot}, - {Path: httpcontroller.PanelRoot}, - {Path: httpcontroller.AppRoot}, - {Path: httpcontroller.HealthPath, Exact: true}, - {Path: httpcontroller.MetricsPath, Exact: true}, - } -} - -// Имя файла в хранилище в журнал не идёт: оно последняя часть ссылки -// `/api/files/...`, и строка журнала вместе с идентификатором записи собрала бы -// ссылку целиком. Инвариант проекта, critical. -func TestJournalRouteHidesStoredFileName(t *testing.T) { - cases := []struct { - name string - path string - want string - }{ - { - name: "ссылка на файл теряет имя", - path: "/api/files/files/abc123def456ghi/9f1c-3b2a.mp3", - want: "/api/files/files/abc123def456ghi/<имя>", - }, - { - name: "маршрут остаётся различимым", - path: "/api/files/files/abc123def456ghi/запись.ogg", - want: "/api/files/files/abc123def456ghi/<имя>", - }, - { - name: "прочие пути не трогаются", - path: "/app/audiorecords/abc123def456ghi", - want: "/app/audiorecords/abc123def456ghi", - }, - { - name: "приём не трогается", - path: "/app/audiorecords", - want: "/app/audiorecords", - }, - { - name: "сам префикс без имени не портится", - path: "/api/files/", - want: "/api/files/", - }, - } - - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - assert.Equal(t, c.want, journalRoute(c.path, journalMounts())) - }) - } -} - -// Отдельно и прямо: имени в готовой строке нет. Проверка судит результат, а не -// устройство — переписанная реализация обязана остаться зелёной. -func TestJournalRouteDropsNameEntirely(t *testing.T) { - const stored = "0f7b8dd3-d1cc-424c.mp3" - - route := journalRoute("/api/files/files/rec0000000000000/"+stored, journalMounts()) - - assert.NotContains(t, route, stored, "имя файла в хранилище не доезжает до журнала") - assert.Contains(t, route, "rec0000000000000", "идентификатор записи остаётся: по нему прослеживается путь") -} - -// Путь, не принадлежащий сервису, уходит приложению и в журнал дословно не -// идёт: множеством его значений распоряжается спрашивающий. -func TestJournalRouteHidesWebappPath(t *testing.T) { - cases := []string{ - "/", - "/records/abc123def456ghi", - "/" + strings.Repeat("a", 1024), - } - - for _, path := range cases { - route := journalRoute(path, journalMounts()) - - assert.Equal(t, webappRoute, route) - } -} diff --git a/cmd/transcriber/main.go b/cmd/transcriber/main.go index 5afb81e..39a30b0 100644 --- a/cmd/transcriber/main.go +++ b/cmd/transcriber/main.go @@ -9,29 +9,31 @@ import ( "net/http" "os" "os/signal" - "strings" "sync" "syscall" "time" + "github.com/joho/godotenv" + "github.com/prometheus/client_golang/prometheus/promhttp" + ffmpegconv "git.vakhrushev.me/av/transcriber/internal/adapter/converter/ffmpeg" ffmpegmv "git.vakhrushev.me/av/transcriber/internal/adapter/metaviewer/ffmpeg" "git.vakhrushev.me/av/transcriber/internal/adapter/recognizer/yandex" - pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" + sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite" "git.vakhrushev.me/av/transcriber/internal/config" httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http" "git.vakhrushev.me/av/transcriber/internal/controller/worker" "git.vakhrushev.me/av/transcriber/internal/metrics" "git.vakhrushev.me/av/transcriber/internal/service" "git.vakhrushev.me/av/transcriber/web" - "github.com/joho/godotenv" - "github.com/pocketbase/pocketbase/apis" - "github.com/pocketbase/pocketbase/core" - "github.com/prometheus/client_golang/prometheus/promhttp" - - "git.vakhrushev.me/av/transcriber/internal/clock" ) +// main держит одну обязанность: отказ подъёма пишется **одной** строкой и +// кончается ненулевым кодом выхода. +// +// Работа вынесена в run, чтобы уборка шла отложенными вызовами: `os.Exit` +// посреди подъёма оставил бы за собой открытые пулы базы и незакрытого клиента +// распознавания. func main() { // Создаем структурированный логгер logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ @@ -39,34 +41,36 @@ func main() { })) slog.SetDefault(logger) + if err := run(logger); err != nil { + logger.Error("Transcriber service failed to start", "error", err) + os.Exit(1) + } +} + +func run(logger *slog.Logger) error { // Parse command line flags configPath := flag.String("c", "config.toml", "Path to config file") flag.StringVar(configPath, "config", "config.toml", "Path to config file (alias for -c)") flag.Parse() - // Load configuration cfg, err := config.LoadConfig(*configPath) if err != nil { - logger.Error("Unable to load configuration", "config_path", *configPath, "error", err) - os.Exit(1) - } else { - logger.Info("Configuration loaded successfully", "config_path", *configPath) + return fmt.Errorf("unable to load configuration from %s: %w", *configPath, err) } + logger.Info("Configuration loaded successfully", "config_path", *configPath) // Пустой перечень доверенных адресов роняет старт: он значит «не верить // никому», то есть сервис, поднявшийся никого не узнающим, — и узнать об // этом было бы неоткуда. if err := cfg.Auth.Validate(); err != nil { - logger.Error("Unable to start without trusted proxies", "error", err) - os.Exit(1) + return err } // Перечень разобран один раз, при старте: разбирать строки на каждом запросе // значило бы платить за настройку, которая не меняется. trustedNetworks, err := cfg.Auth.TrustedNetworks() if err != nil { - logger.Error("Unable to read trusted proxies", "error", err) - os.Exit(1) + return err } // Перечень называется строкой журнала: сервис, никого не узнающий из-за // неверного перечня, иначе неотличим от сервиса, до которого заголовок не @@ -77,8 +81,10 @@ func main() { // отрицательное число и нулевой предел простоя — опечатка, и подниматься с // ней значит остановить всякую запись первым же захватом. if err := cfg.Pipeline.Validate(); err != nil { - logger.Error("Unable to start with incorrect pipeline settings", "error", err) - os.Exit(1) + return err + } + if err := cfg.Storage.Validate(); err != nil { + return err } // Загружаем переменные окружения из .env файла @@ -86,32 +92,43 @@ func main() { logger.Warn("Warning: .env file not found, using system environment variables") } - // Хранилище поднимается библиотекой, а не её набором команд: разбор флагов - // и мягкая остановка остаются нашими. Схему накатывает Serve — он гоняет - // непринятые шаги прежде, чем поднять сервер. - storage, err := pbrepo.New(cfg.Storage.DataDir) + db, err := sqliterepo.Open(cfg.Storage.DataDir, sqliterepo.Settings{ + BusyTimeoutMs: cfg.Storage.BusyTimeoutMs, + ReadConnections: cfg.Storage.ReadConnections, + }) if err != nil { - logger.Error("Failed to open storage", "error", err) - os.Exit(1) + return err } defer func() { - if err := storage.ResetBootstrapState(); err != nil { - logger.Error("Failed to close storage", "error", err) + if err := db.Close(); err != nil { + logger.Error("Failed to close the database", "error", err) } }() - pbrepo.BindPanelRules(storage) + // Создаем контекст для graceful shutdown + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() - // Создаем репозитории - recordRepo := pbrepo.NewAudioRecordRepository(storage) + // Схема накатывается **до** подъёма входов и до старта воркеров, а её отказ + // роняет старт: сервис, поднявшийся на неприведённой схеме, отвечает отказом + // на каждый запрос и на каждый прогон воркера — вместо одной строки о + // причине их становятся сотни. + if err := sqliterepo.Migrate(ctx, db, cfg.Storage.DataDir, logger); err != nil { + return err + } + + store := sqliterepo.NewStore(cfg.Storage.DataDir) + recordRepo := sqliterepo.NewAudioRecordRepository(db) + fileRepo := sqliterepo.NewFileRepository(db, store) repos := service.Repositories{ Records: recordRepo, - Files: pbrepo.NewFileRepository(storage), - Texts: pbrepo.NewTextRepository(storage), - Structures: pbrepo.NewStructureRepository(storage), - Recognitions: pbrepo.NewRecognitionRepository(storage), - Events: pbrepo.NewRecordEventRepository(storage), + Files: fileRepo, + Texts: sqliterepo.NewTextRepository(db), + Structures: sqliterepo.NewStructureRepository(db), + Recognitions: sqliterepo.NewRecognitionRepository(db, store), + Events: sqliterepo.NewRecordEventRepository(db), } + users := sqliterepo.NewUserRepository(db) // Создаем адаптеры metaviewer := ffmpegmv.NewFfmpegMetaViewer() @@ -127,8 +144,7 @@ func main() { FolderID: cfg.Yandex.FolderID, }) if err != nil { - logger.Error("failed to create audio recognizer", "error", err) - os.Exit(1) + return fmt.Errorf("failed to create audio recognizer: %w", err) } // Отдавать отказ закрытия некому — процесс заканчивается, — поэтому он идёт // в журнал владельца. Что он означает: gRPC-клиент отдаёт здесь отказ лишь @@ -140,7 +156,6 @@ func main() { } }() - // Создаем сервисы transcribeService := service.NewTranscribeService( repos, metaviewer, @@ -150,10 +165,6 @@ func main() { logger, ) - // Создаем контекст для graceful shutdown - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - // Создаем WaitGroup для ожидания завершения всех воркеров var wg sync.WaitGroup @@ -166,113 +177,41 @@ func main() { pool.Start(ctx) }() - // Вход у сервиса один — приём по HTTP, — и метка ставится только ему. Метки - // убранного входа Telegram здесь нет намеренно: ноль читался бы как поломка, - // а признак существует ради того дня, когда входов снова станет больше. + // Вход у сервиса один — приём по HTTP, — и метка ставится только ему. metrics.IntakeUpGauge.WithLabelValues("http").Set(1) - // Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом, - // и второму серверу на нём взяться неоткуда. - appHandler := httpcontroller.NewAppHandler(recordRepo, repos.Texts, repos.Structures, transcribeService, logger) + appHandler := httpcontroller.NewAppHandler( + recordRepo, repos.Texts, repos.Structures, fileRepo, transcribeService, logger, + ) // Адресное пространство сервиса объявлено одним перечнем, и он порождает // регистрацию, а не описывает её: корень, заведённый мимо перечня, не // получит обработчика вовсе. Отсюда же уровень журнала для адресов - // наблюдения и правило неизвестного пути у раздачи приложения. - mounts := httpcontroller.ServiceMounts(appHandler, promhttp.Handler()) + // наблюдения, правило неизвестного пути у раздачи приложения и область + // действия узнавания. + mounts := httpcontroller.ServiceMounts( + httpcontroller.AppChain(appHandler.Routes(), users, trustedNetworks, logger), + promhttp.Handler(), + ) dist, appBuilt := web.Dist() - webappHandler := httpcontroller.NewWebappHandler(dist, appBuilt, mounts, logger) + webappHandler := httpcontroller.NewWebappHandler(dist, appBuilt, logger) - // Сервер приезжает каналом, а не общей переменной: хук исполняется в - // горутине сервера, а читает его горутина остановки, и связи «произошло - // раньше» между ними иначе нет. - srvCh := make(chan *http.Server, 1) - storage.OnServe().BindFunc(func(se *core.ServeEvent) error { + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Server.Port), + Handler: httpcontroller.BuildHandler(mounts, webappHandler, logger), // Шесть часов записи по медленному каналу переживают любой фиксированный - // таймаут чтения, а умолчание хранилища — пять минут. Стойкость к - // целенаправленной нагрузке объявлена вне модели угроз проекта. - se.Server.ReadTimeout = 0 - srvCh <- se.Server + // таймаут чтения. Стойкость к целенаправленной нагрузке объявлена вне + // модели угроз проекта. + ReadTimeout: 0, + } - // Журнал входящих запросов вернулся своим слоем: вместе с gin ушёл - // `sloggin`, а хранилище пишет запросы в свою таблицу, которой в - // журнале контейнера не видно. Поля — те, что просит конвенция. - se.Router.BindFunc(func(e *core.RequestEvent) error { - start := clock.Start() - err := e.Next() - - level := slog.LevelInfo - if httpcontroller.IsObservationAddress(mounts, e.Request.URL.Path) { - // Опрос здоровья и метрик идёт постоянно и полезного не несёт. - level = slog.LevelDebug - } - - attrs := []any{ - "http.method", e.Request.Method, - "http.route", journalRoute(e.Request.URL.Path, mounts), - "http.status_code", e.Status(), - "duration_ms", time.Since(start).Milliseconds(), - "transport", "http", - } - - // Путь, отданный приложению, в журнал не идёт — вместо него исход - // и длина: по ним видно, что происходит, а множеством значений - // самого пути распоряжается спрашивающий. - if outcome := httpcontroller.WebappOutcome(e); outcome != "" { - attrs = append(attrs, - "webapp.outcome", outcome, - "http.path_length", len(e.Request.URL.Path)) - } - - logger.Log(e.Request.Context(), level, "Incoming request", attrs...) - - return err - }) - - // Слой узнавания вешается на **корневой** роутер, а не под корнем - // приложения: он накрывает ещё и адрес выдачи файлового токена, который - // принадлежит роутеру хранилища и группой не накрывается. Область его - // действия при этом выводится из перечня адресного пространства — см. - // `underIdentifiedArea`, — а не из места привязки. - se.Router.Bind(httpcontroller.TrustedHeaderIdentity(storage, mounts, trustedNetworks, logger)) - - // Своё правило ограничителя частоты под корень приложения. Правило - // хранилища настроено на его собственный корень и наших адресов больше не - // покрывает: вместе с переездом приложения ограничитель перестал бы - // существовать для него вовсе, и заметить это было бы нечем. - if err := httpcontroller.ApplyAppRateLimit(storage); err != nil { - return fmt.Errorf("failed to apply app rate limit: %w", err) - } - - // Хранилищу называется заголовок, из которого брать адрес - // спрашивающего. Без этого счётчик ограничителя ключуется адресом пира, - // а пир теперь всегда один — прокси, и бюджет становится общим на всех. - if err := httpcontroller.ApplyTrustedProxyHeaders(storage); err != nil { - return fmt.Errorf("failed to apply trusted proxy headers: %w", err) - } - - httpcontroller.RegisterServiceRoutes(se.Router, mounts) - - // Раздача приложения вешается последней: она занимает корень, и всё, - // что не совпало ни с одним адресом сервиса, доходит до неё. - webappHandler.Register(se.Router) - - return se.Next() - }) - - // Запускаем HTTP сервер в отдельной горутине serveErr := make(chan error, 1) wg.Add(1) go func() { defer wg.Done() logger.Info("Starting HTTP server", "port", cfg.Server.Port) - err := apis.Serve(storage, apis.ServeConfig{ - HttpAddr: fmt.Sprintf(":%d", cfg.Server.Port), - ShowStartBanner: false, - }) - if err != nil && !errors.Is(err, http.ErrServerClosed) { - logger.Error("HTTP server error", "error", err) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { serveErr <- err } }() @@ -285,41 +224,36 @@ func main() { logger.Info("Press Ctrl+C to stop...") // Ждем сигнал завершения либо отказ сервера + var startupErr error select { case <-sigChan: logger.Info("Received shutdown signal, initiating graceful shutdown...") - case <-serveErr: - logger.Error("HTTP server stopped unexpectedly, shutting down") + case err := <-serveErr: + logger.Error("HTTP server stopped unexpectedly, shutting down", "error", err) + startupErr = err } - // Создаем контекст с таймаутом для graceful shutdown HTTP сервера - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), time.Duration(cfg.Server.ShutdownTimeout)*time.Second) + // Останавливаем HTTP сервер + shutdownCtx, shutdownCancel := context.WithTimeout( + context.Background(), time.Duration(cfg.Server.ShutdownTimeout)*time.Second) defer shutdownCancel() - // Останавливаем HTTP сервер - select { - case srv := <-srvCh: - logger.Info("Shutting down HTTP server...") - if err := srv.Shutdown(shutdownCtx); err != nil { - logger.Error("HTTP server forced to shutdown", "error", err) - } else { - logger.Info("HTTP server stopped gracefully") - } - default: - logger.Info("HTTP server was not started, nothing to shut down") + logger.Info("Shutting down HTTP server...") + if err := srv.Shutdown(shutdownCtx); err != nil { + logger.Error("HTTP server forced to shutdown", "error", err) + } else { + logger.Info("HTTP server stopped gracefully") } // Отменяем контекст для остановки воркеров cancel() - // Создаем канал для уведомления о завершении всех воркеров done := make(chan struct{}) go func() { wg.Wait() close(done) }() - // Ждем завершения всех воркеров или таймаута select { case <-done: logger.Info("All workers stopped gracefully") @@ -328,43 +262,6 @@ func main() { } logger.Info("Transcriber service stopped") -} -// filesPathPrefix — начало пути, которым хранилище отдаёт файл записи. Последний -// сегмент такого пути и есть имя файла в хранилище. -const filesPathPrefix = "/api/files/" - -// webappRoute — чем в журнале обозначается всякий путь, отданный приложению. -const webappRoute = "<приложение>" - -// journalRoute готовит путь запроса к записи в журнал. -// -// Инвариант проекта запрещает имени файла в хранилище попадать в журнал: имя — -// последняя часть ссылки `/api/files/...`, и строка журнала вместе с -// идентификатором записи собирала бы ссылку целиком. Слой журнала пишет путь -// всякого запроса, поэтому имя срезается здесь — иначе оно уезжало бы в -// собранные логи при каждом скачивании записи. -// -// Срезается только имя: маршрут остаётся различимым, и наблюдаемость от этого не -// теряется. -// -// Путь, не принадлежащий сервису, в журнал не идёт вовсе. До появления раздачи -// приложения такой путь ловил отказ маршрутизатора, а теперь получает разметку -// с кодом `200`: множеством его значений распоряжается спрашивающий, и -// дословная строка сделала бы журнал местом, куда аноним пишет свой текст. -func journalRoute(path string, mounts []httpcontroller.Mount) string { - if !httpcontroller.IsServiceAddress(mounts, path) { - return webappRoute - } - - if !strings.HasPrefix(path, filesPathPrefix) { - return path - } - - cut := strings.LastIndex(path, "/") - if cut < len(filesPathPrefix) { - return path - } - - return path[:cut+1] + "<имя>" + return startupErr } diff --git a/config.example.toml b/config.example.toml index e4f7bdf..ba219c6 100644 --- a/config.example.toml +++ b/config.example.toml @@ -4,11 +4,33 @@ port = 8080 shutdown_timeout = 5 force_shutdown_timeout = 20 -# Storage configuration -# Единственный каталог данных: под ним лежат и база, и файлы записей. +# Хранилище: каталог данных и числа его базы. +# +# Каталог единственный: под ним лежат и файл базы, и подкаталог с файлами +# записей. Двух путей у хранилища не бывает. [storage] data_dir = "data" +# Сколько ждать занятую базу, миллисекунды. Положительное число. +# +# База принимает **одного** писателя: драйвер пишет единственным соединением, и +# несколько воркеров, пришедших писать разом, встают в очередь. Это число — +# сколько ждущий готов простоять, прежде чем получить отказ «база занята». +# Крутят его при таком отказе под несколькими воркерами; ноль означает «отказать +# сразу» и потому не принимается. +busy_timeout_ms = 5000 + +# Сколько соединений держит читающий пул. Положительное число. +# +# Чтение идёт отдельно от записи: в журнале упреждающей записи читатели не +# мешают писателю, и список записей не ждёт, пока конвейер сохранит свой шаг. +# Число выводят из числа воркеров плюс запас под запросы приложения. +# +# Пишущее соединение при этом всегда одно и настройкой не делается: второе +# означало бы отказы по занятости на записи результата шага, то есть после +# оплаченной работы. +read_connections = 4 + # Конвейер расшифровки. [pipeline] # Число рабочих потоков. Специализации у них нет: каждый берёт любую пригодную к diff --git a/docs/adr/ADR-2026-08-22-storage-without-pocketbase.md b/docs/adr/ADR-2026-08-22-storage-without-pocketbase.md index 46fbfe8..5328d8f 100644 --- a/docs/adr/ADR-2026-08-22-storage-without-pocketbase.md +++ b/docs/adr/ADR-2026-08-22-storage-without-pocketbase.md @@ -13,6 +13,25 @@ PocketBase уходит из проекта целиком: состояние ничем**: пока идёт стройка, остановленную запись возвращает в работу запрос к базе. +**Два решения абзаца выше сменились при разметке изменения**, и заменившее +названо здесь. + +Шаги схемы двигает библиотека `github.com/pressly/goose/v3`, а не свой раннер. +Инструмент выбрал владелец 2026-08-22: библиотека уже была в этом проекте и ушла +вместе с PocketBase, а из трёх норм, которые накат обязан выполнять, две +выполняет сама. + +Остановленную запись возвращает в работу подкоманда `cmd/devtools resume`, а не +запрос к базе руками. Возврат сбрасывает не одно поле записи и пишет событие +журнала с происхождением `entity.EventOriginHuman`; рука за клавиатурой не делает +ни того, ни другого. Последствие ниже — «возврат остановленной в работу […] +делает запрос к базе руками» — читается этой сменой. + +Доводы обоих решений записаны в +[design.md](../../openspec/changes/archive/2026-08-23-storage-without-pocketbase/design.md), +разделы «Шаги схемы двигает `goose`, а не свой раннер» и «Панель не заменяется +ничем, а возврат в работу делает подкоманда оснастки». + Запись заменяет три: [ADR-2026-08-11-pocketbase-storage-with-admin-panel](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md), [ADR-2026-08-11-queue-as-pocketbase-collection](ADR-2026-08-11-queue-as-pocketbase-collection.md) и diff --git a/docs/architecture.md b/docs/architecture.md index e7bc721..949dbc7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -21,18 +21,20 @@ `http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11, `pocketbase-storage` и `oidc-login` 2026-08-12, `local-run-without-telegram-token` 2026-08-13, `remove-telegram-intake` - 2026-08-14; + 2026-08-14, `storage-without-pocketbase` 2026-08-22; - [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват задачи и срок его протухания, число попыток, остановка признаком, пауза перед повтором и молчание конвейера наружу: задачи `errors-as-instead-of-typecast` 2026-08-11, `pocketbase-storage` 2026-08-12, - `local-run-without-telegram-token` 2026-08-13 и `remove-telegram-intake` - 2026-08-14. Переходы состояний и отмена + `local-run-without-telegram-token` 2026-08-13, `remove-telegram-intake` + 2026-08-14 и `storage-without-pocketbase` 2026-08-22. Переходы состояний и отмена контекста посреди шага остаются долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки; - [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные - и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage` - 2026-08-12; + и её файл, как файл отдаётся и что видит владелец: задачи `pocketbase-storage` + 2026-08-12 и `storage-without-pocketbase` 2026-08-22. Последняя убрала + встроенное хранилище целиком: база стала своей, файлы — своим каталогом, + панель владельца исчезла и не заменена ничем; - [recognition](../openspec/specs/recognition/spec.md) — **попытка распознавания у внешнего провайдера**: что о ней хранится, почему сырой ответ сохраняется целиком и вложением, как из сохранённого строится структура реплик без @@ -66,13 +68,15 @@ - **Один процесс.** HTTP-сервер и фоновые воркеры живут в одном бинарнике и делят одну базу. Отдельного воркер-процесса нет намеренно. -- **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища; неделимость +- **Очередь таблицей.** Состояние задачи лежит таблицей базы; неделимость захвата и порядок выборки нормирует [pipeline](../openspec/specs/pipeline/spec.md), «Захват задачи неделим». Внешний брокер не заводим: нагрузка — единицы записей в день (оценка владельца, не замер). Готовую библиотеку очереди тоже не заводим — решено 2026-08-11, [ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение - кандидатов в [research/job-queue.md](research/job-queue.md). + кандидатов в [research/job-queue.md](research/job-queue.md). Решение пережило + уход встроенного хранилища: замер снят на том же драйвере, и отменилось у него + одно слово — таблица перестала быть коллекцией. - **Шаг конвейера идемпотентен по повтору.** Что делает срок захвата и когда задача возвращается в работу, нормирует [pipeline](../openspec/specs/pipeline/spec.md), «Брошенная задача возвращается @@ -83,11 +87,10 @@ `cmd/transcriber`. Слои, их дома и словарь модели — раздел «Слои и модель домена» ниже. Правило механизировано тестами-сканерами `internal/archrules`, и они же держат обратные направления: транспорты не знают друг о друге, адаптер - не знает ни ядра, ни транспортов. - *Изъятие:* транспорт **вправе** знать адаптер хранилища — `controller/http` - импортирует `adapter/repo/pocketbase`, потому что HTTP-поверхность и есть - роутер этого хранилища, а не наш сервер поверх него. Правила на это - направление нет намеренно. + не знает ни ядра, ни транспортов, транспорт не знает адаптеров. Изъятие, + разрешавшее транспорту знать адаптер хранилища, снято 2026-08-22 вместе с + предметом: HTTP-поверхность была роутером встроенного хранилища, а стала своей, + и правило на это направление заведено впервые. ## Слои и модель домена @@ -133,10 +136,10 @@ наблюдаем: правило о записи, записанное в `internal/service` условием над её полями, принадлежит `internal/entity`. -Место, где подход нарушен сегодня, названо изъятием в «Принципах»: транспорт -знает адаптер хранилища, потому что HTTP-поверхность и есть роутер этого -хранилища. Изъятие снимает задача `storage-without-pocketbase` — своя отдача -файла и свои маршруты возвращают транспорту независимость от инфраструктуры. +Изъятий у подхода сегодня нет: последнее — транспорт знал адаптер хранилища — +снято задачей `storage-without-pocketbase` 2026-08-22. Своя отдача файла и свои +маршруты вернули транспорту независимость от инфраструктуры, а узнавание +пришедшего приходит ему интерфейсом `contract.UserRepository`. ## Компоненты @@ -146,14 +149,15 @@ | Компонент | Где | Что делает | | --- | --- | --- | -| HTTP API | `internal/controller/http` | Адреса приложения под корнем `/app/`: приём записи, страница своих записей, карточка, текст названного вида, пределы сервера и «кто вошёл» | +| HTTP API | `internal/controller/http` | Адреса приложения под корнем `/app/` на `net/http`: приём записи, страница своих записей, карточка, текст названного вида, файл записи, пределы сервера и «кто вошёл». Слои — свои: журнал, восстановление после паники, ограничитель частоты, узнавание, требование учётной записи | | Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен | | Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи | | Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности | | Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit; разбор ответа в реплики со временем | -| Репозитории | `internal/adapter/repo/pocketbase` | Записи, файлы, тексты, структура, попытки распознавания и журнал событий — коллекциями хранилища; захват — сырым запросом | -| Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций | -| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки записи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» | +| Репозитории | `internal/adapter/repo/sqlite` | Учётные записи, записи, файлы, тексты, структура, попытки распознавания и журнал событий — таблицами базы; захват — одним запросом с `RETURNING` по пишущему соединению | +| Файлы записей | `internal/adapter/repo/sqlite`, `store.go` | Подкаталог на запись под её идентификатором; укладка атомарна — временное имя рядом и переименование | +| Шаги схемы | `internal/adapter/repo/sqlite/migrations` | Файл на шаг, версия — число в начале имени; накатывает `pressly/goose/v3` под своим замком | +| Оснастка владельца | `cmd/devtools` | Подставной прокси для местного запуска и возврат остановленной записи в работу. Панели у сервиса нет и не будет: экраны правки приносят отдельные задачи | | Приложение | `web/` | Vue 3, роутер пятой версии, сборка Vite. Собранное лежит в `web/embed/dist` и вшивается в бинарник; в git его нет | | Раздача приложения | `internal/controller/http`, `webapp.go` | Корневой маршрут: разметка вне корней сервиса, отказ внутри, срок хранения по каталогу сборщика | @@ -174,6 +178,12 @@ асинхронное: запрос возвращает идентификатор операции, готовность опрашивается через `operation.api.cloud.yandex.net:443`, текст читается потоком. - **ffmpeg и ffprobe.** Внешние процессы, ищутся в `PATH`. +- **SQLite через `modernc.org/sqlite`.** Драйвер на чистом Go: CGO сборке не + нужен. База и файлы записей лежат под одним каталогом данных. +- **`github.com/pressly/goose/v3`.** Шаги схемы — библиотекой, а не командной + строкой: перечень шагов приходит провайдеру доводом, накат идёт при старте. + Исключающей блокировки под SQLite библиотека не даёт, и замок каталога данных + берём сами. - **Node и его установщик пакетов.** Нужны только сборке приложения и на машину не ставятся: шаг зовёт их контейнером, а образ берёт из ступени `Dockerfile`. Требованием к машине разработчика поэтому становится docker. Реестр пакетов — @@ -191,34 +201,23 @@ Секцию `[telegram]` и ключ `server.users_while_list` человек убирает из боевого файла после выкладки: незнакомые ключи разбор настроек не судит, и файл с ними сервис поднимает молча. -- **Откат образа через шаг схемы `202608140002` не работает и не говорит об - этом.** Шаг удаляет прежнюю коллекцию задач, а библиотека накатывает только - те шаги, которые знает сам бинарь: прежний образ шагов новее не видит, - поднимается **без единой ошибки** и отвечает зелёной пробой здоровья — после - чего всякое обращение к очереди отказывает «коллекции нет». Проверено прогоном - двух бинарей на одном каталоге данных. +- **Откат образа на версию до 2026-08-22 не работает вовсе.** Каталог данных + сменил раскладку целиком: база зовётся другим файлом, файлы записей лежат + другими путями, а учёт применённых шагов ведёт другая таблица. Прежний образ на + таком каталоге поднимется, накатит **свои** шаги в пустое место и заведёт + вторую, чужую схему рядом. Лечится повторной выкладкой вперёд; обратного шага + схемы нет и не планируется. - Значит штатное средство владельца на инциденте — «вернём прошлый образ» — с - этого шага делает хуже и молчит. Лечится повторной выкладкой нового образа; - обратного шага схемы нет и не планируется. Порог перехода назван прямо: до - выкладки `record-centric-model` откат образа работает, после — нет. -- **Откат образа через шаг схемы `202608220001` обрывает вход.** Шаг закрывает - правила коллекции пользователей наглухо, а прежний образ заводил учётную - запись внутренним запросом обмена кода — и этот запрос закрытое правило - отвергает. Проверено прогоном прежнего кода поверх нового каталога данных: - вход отвечает `401`, в журнале «storage rejected the exchange with code 403». - Порог тот же по форме, что и у `202608140002`: до выкладки - `trusted-header-login` откат работает, после — нет, и лечится он повторной - выкладкой вперёд. Обратного шага схемы нет и не планируется. - - Окно этого порога сегодня пусто: сервис не выложен, а откат уже не работает с - шага `202608140002`. Строка стоит здесь потому, что порог принято называть - прямо, а не потому, что риск сегодня чем-то грозит. + Прежние два порога — шаги `202608140002` и `202608220001` — этим поглощены: до + выкладки `record-centric-model` откат работал, после перестал, а с уходом + встроенного хранилища перестал окончательно. Окно порога сегодня пусто: сервис + не выложен. Строка стоит здесь потому, что порог принято называть прямо, а не + потому, что риск сегодня чем-то грозит. - **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает медленно» читается вместе с тем, что таймаута нет ни у одного обращения наружу — [database.md](database.md), «Настройки с числовым значением»: - + | Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор | | --- | --- | --- | --- | --- | @@ -226,7 +225,7 @@ | ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — | | Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции | | ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании | - | Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — | + | База (файл на диске) | Старт кончается отказом с именем шага схемы либо шаг падает на каждом запросе | Ожидание занятой базы задано числом; исчерпав его, операция отказывает, и запись остаётся пригодной к повтору | — | — | | Диск | Запись файла падает, задача не заводится | — | — | — | - **Кто заметит отказ и когда:** тот, кто загрузил запись, — карточкой записи: @@ -237,8 +236,8 @@ «падает приведение» отличается от «падает распознавание». Плюс логи контейнера. Отдельного оповещения нет. - **Журнал событий записи** — второй канал наблюдения, `record_events`. Пишется - на смену рубежа, на остановку и на снятие остановки; читает его человек в - панели, ни один шаг конвейера на него не смотрит. Экрана у него пока нет. + на смену рубежа, на остановку и на возврат в работу; ни один шаг конвейера на + него не смотрит. Читается запросом к базе: ни панели, ни экрана у него нет. - **Характер потока:** непрерывный, но разреженный. Воркеры опрашивают базу вхолостую с паузой из [database.md](database.md), «Настройки с числовым значением». @@ -248,7 +247,11 @@ | Что | Где | | --- | --- | | Приём аудио и заведение записи | `TranscribeService.createRecord` — единственный путь, которым запись появляется в хранилище | -| Правка записи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает | +| Возврат остановленной записи в работу | `cmd/devtools resume` — зовёт домен и пишет событие журнала записи с происхождением «человек»; колонок сама не пишет | +| Выдача идентификатора строки | `internal/ident` — ULID в нижнем регистре, монотонный внутри миллисекунды; разбор пришедшего снаружи — там же | +| Подключение к базе | `internal/adapter/repo/sqlite.Open` — пишущее соединение одно, чтение своим пулом, настройки строкой подключения обоих | +| Накат схемы | `internal/adapter/repo/sqlite.Migrate` — до подъёма входов и до старта воркеров, под замком каталога данных | +| Раскладка файлов записи | `internal/adapter/repo/sqlite.Store` — подкаталог на запись; путь на диске за её пределы не выходит | | Захват записи воркером | `AudioRecordRepository.FindAndAcquire` — один запрос с `RETURNING`, отдаёт идентификатор и признак захвата | | Объявление рубежа | `internal/entity/stage.go` — выбор шага, отбор захвата, срок протухания и предел простоя выводятся отсюда | | Выбор шага по рубежу | `TranscribeService.stepFor` — таблица, а не привязка к воркеру | @@ -264,13 +267,17 @@ | Состояния отбора списка | `internal/entity.ListFilter` вместе с `WorkingStages` и `TerminalStages` — предикаты выводятся из дескриптора рубежа, а не пишутся строкой запроса | | Уборка имени файла отправителя | `internal/entity.SanitizeOriginalFilename` — режет по пределу и убирает управляющие знаки; зовёт её приём | | Адресное пространство сервиса | `internal/controller/http.ServiceMounts` — перечень корней и адресов наблюдения. Он **порождает** регистрацию наших маршрутов, а не описывает её, и из него же выводятся правило неизвестного пути, уровень журнала и область действия узнавания | -| Узнавание предъявителя | `pbrepo.EnsureUser` — поиск учётной записи по логину у провайдера и заведение при первом обращении. Дом правила один и лежит в хранилище, а не в транспорте: второй способ представиться (личные токены) возьмёт этот же метод, а уложенное куском в слой оно разошлось бы двумя копиями. Транспорт читает заголовок, судит адрес пира и зовёт метод — `internal/controller/http.TrustedHeaderIdentity` | +| Узнавание предъявителя | `sqlite.UserRepository.EnsureUser` — поиск учётной записи по логину у провайдера и заведение при первом обращении. Дом правила один и лежит в хранилище, а не в транспорте: второй способ представиться (личные токены) возьмёт этот же метод, а уложенное куском в слой оно разошлось бы двумя копиями. Транспорт читает заголовок, судит адрес пира и зовёт метод интерфейсом `contract.UserRepository` — `internal/controller/http.TrustedHeaderIdentity` | +| Приём значения заголовка | `internal/entity.AcceptProviderLogin`, `AcceptDisplayName`, `AcceptEmail` — правило одно на все способы представиться | +| Ограничитель частоты | `internal/controller/http.RateLimit` — бюджет по адресу спрашивающего под корнем приложения; из его чисел выводится объявляемая частота опроса | -Единых точек, которых **нет** и которые ожидались бы: идентификаторы -генерируются вызовом `uuid.NewString()` по месту. Время из этого перечня ушло -2026-08-13: его читает `internal/clock`, и запрет держит линтер; отображение -доменной ошибки — 2026-08-15 задачей `json-api-for-spa`, и до неё обработчик -решал сам: опрос отвечал `404` на упавшую базу, а приём — `500` на негодный файл. +Единых точек, которых **нет** и которые ожидались бы, сегодня не осталось. +Время ушло из перечня отсутствий 2026-08-13 — его читает `internal/clock`, и +запрет держит линтер; отображение доменной ошибки — 2026-08-15 задачей +`json-api-for-spa`, и до неё обработчик решал сам: опрос отвечал `404` на упавшую +базу, а приём — `500` на негодный файл; выдача идентификаторов — 2026-08-22 +задачей `storage-without-pocketbase`, и до неё их выдавало встроенное хранилище +своим алфавитом, а сервис звал `uuid.NewString()` по месту. ## Деплой @@ -320,8 +327,9 @@ DNS-сервер, молчащий на `AAAA`, оставляет устано [ADR-2026-08-22-login-by-trusted-header](adr/ADR-2026-08-22-login-by-trusted-header.md). **Не решено одно:** как связать чат Telegram с учётной записью — от этого зависит возвращение убранного входа. - Панель администратора при этом Authelia не закрывает: у неё свой пароль - суперпользователя. + Второго периметра на порту сервиса при этом не осталось: панель администратора + ушла вместе со встроенным хранилищем 2026-08-22, и закрывать её на прокси + больше нечего. - **Приложение.** Каркас поставлен `spa-skeleton` 2026-08-15: приложение открывается, показывает вошедшего и вшито в бинарник. Экранов загрузки и списка нет — их делают `upload-and-status-screen` и `records-list-screen`. @@ -343,7 +351,8 @@ DNS-сервер, молчащий на `AAAA`, оставляет устано шесть часов нормирует [storage](../openspec/specs/storage/spec.md), «Файл записи живёт в хранилище»; откуда взято число — [research/pocketbase-defaults.md](research/pocketbase-defaults.md), «Чего эта - записка не узнала». + записка не узнала». Записка описывает умолчания ушедшей библиотеки, и живой + она осталась только этим числом. - **Приём большого файла.** Форма читается целиком, предел памяти под multipart задан числом в [database.md](database.md), «Настройки с числовым значением»; обрыв начинает загрузку заново. @@ -358,8 +367,8 @@ DNS-сервер, молчащий на `AAAA`, оставляет устано - **Резервные копии.** Копии делает сервер своими средствами, и приложение о них ничего не знает. Не решено, хватит ли копировать каталог данных файлами, или приложению нужна команда выгрузки: база под нагрузкой копируется файлом не - всегда целой. Своё копирование по расписанию у PocketBase есть — берём мы его - или нет, тоже не решено. + всегда целой. Готового копирования по расписанию у сервиса нет вовсе: оно + ушло вместе со встроенным хранилищем, и заводить своё пока не решено. - **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли сервис определяет содержимое сам, то ли часть записей теряется на этом. @@ -374,12 +383,15 @@ DNS-сервер, молчащий на `AAAA`, оставляет устано паузы, а не замер ([research/job-queue.md](research/job-queue.md), «Как снималось»), — при нагрузке в единицы записей в день, и во что это обходится, никто не мерил. + Хранилище при этом сменилось задачей `storage-without-pocketbase` 2026-08-22 + ([ADR](adr/ADR-2026-08-22-storage-without-pocketbase.md)), а модель очереди + пережила смену: отменилось одно слово — таблица перестала быть коллекцией. - **Наблюдаемость.** `/metrics` остаётся и развивается. Чем — дописывать счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой — решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в выкладке сегодня нет. - **Выводы из текста.** Литературный текст, заголовок, темы и пересказ решено считать внешним сервисом с OpenAI-совместимым интерфейсом за шлюзом bifrost. - Появляется пятая внешняя зависимость, платная, и текст расшифровки начинает + Появляется ещё одна внешняя зависимость, платная, и текст расшифровки начинает уходить ещё на одну сторону — сдвиг периметра [security.md](security.md). Не решено, отдельный это шаг конвейера или продолжение шага распознавания. diff --git a/docs/conventions/config.md b/docs/conventions/config.md index 313cb07..d2728af 100644 --- a/docs/conventions/config.md +++ b/docs/conventions/config.md @@ -5,8 +5,8 @@ **Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту. Главные: комментариями снабжена половина полей; единого места проверки на старте -нет: у секций `[auth]` и `[pipeline]` свой `Validate()` в точке входа, а пустые -ключи `[yandex]` ловит конструктор распознавателя. +нет: у секций `[auth]`, `[pipeline]` и `[storage]` свой `Validate()` в точке +входа, а пустые ключи `[yandex]` ловит конструктор распознавателя. **Механизировано:** запрет `os.Getenv` — `forbidigo` в `.golangci.yml` ([go-linters.md](go-linters.md), «Механизировано»). Он держит правило «настройки @@ -137,9 +137,12 @@ TOML. Пустые ключи Yandex ловятся в конструкторе Два ключа секции `[telegram]`, стоявшие здесь исключением, ушли вместе с самим входом 2026-08-14: секции больше нет, и своей проверки у неё тоже. -Секция `[auth]` — первая, у которой проверка своя и стоит на старте: -`AuthConfig.Validate()` зовётся из `cmd/transcriber` сразу после загрузки и роняет -процесс с именем незаполненного ключа. Причина в цене умолчания: поднявшись с +Секции `[auth]`, `[pipeline]` и `[storage]` проверяют себя сами, и проверка стоит +на старте: `Validate()` каждой зовётся из `cmd/transcriber` сразу после загрузки +и роняет процесс с именем незаполненного ключа. У `[storage]` это ожидание занятой +базы и число соединений читающего пула: ноль у первого отдаёт «база занята» +первому же воркеру, ноль у второго означает пул без предела — то есть настройку, +которой не управляют. Причина в цене умолчания: поднявшись с пустым перечнем доверенных адресов, сервис не узнавал бы никого, а узнать об этом было бы неоткуда — все адреса приложения просто отвечали бы отказом. Сообщение называет **имя ключа**; правило «значения в отказ не идут» остаётся в diff --git a/docs/conventions/database.md b/docs/conventions/database.md index 38eb4cd..a4abb5b 100644 --- a/docs/conventions/database.md +++ b/docs/conventions/database.md @@ -2,11 +2,11 @@ Как мы устраиваем таблицы и ключи. Актуальная схема — [../database.md](../database.md). -**Взято из проекта jellybit целиком.** Сегодняшний код transcriber следует -этому частью: ключи — UUID v4, а не ULID, и единой точки их генерации нет. Время -единой точкой читается с 2026-08-13 — `internal/clock`, метка в UTC, — и правило -держит линтер. Правила действуют на новый код; переписывание существующего — -отдельная работа, и до неё расхождение читается как долг, а не как нарушение. +**Взято из проекта jellybit целиком.** Сегодняшний код transcriber следует этому +целиком: ключи — ULID в нижнем регистре, выдаёт их единая точка `internal/ident` +(с 2026-08-22, задача `storage-without-pocketbase`), время читает единая точка +`internal/clock` (с 2026-08-13), и правило времени держит линтер. Расхождений у +записи не осталось. **Механизировано:** сверка изменённого шага схемы с [../database.md](../database.md) (`docs.py check`), чтение времени единой точкой @@ -17,17 +17,17 @@ ## Первичные ключи — ULID, не автоинкремент - **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется - **приложением** в момент создания записи. - *Расхождение:* идентификаторы записей выдаёт хранилище — 15 знаков - собственного алфавита. Своей точки генерации у приложения нет, и `ORDER BY id` - хронологией не является: порядок берут по колонке времени с ключом. + **приложением** в момент создания записи. Выдача монотонна внутри одной + миллисекунды: колонка времени несёт секунды, и порядок записей одной секунды + задаёт ключ. Порядок ленты берут парой «время заведения и ключ» — одного + времени мало. - Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология), компактен и удобен в URL и логах (без дефисов — grep и двойной клик берут id целиком), глобально уникален между таблицами — поиск по голому id находит все записи сущности в логах. -- **Точка генерации и разбора одна**: создание — при вставке записи в - репозитории, разбор — на входных границах. Самодельных генераторов по месту - вызова не заводим. +- **Точка генерации и разбора одна** — `internal/ident`: `New` выдаёт, `Parse` + разбирает пришедшее снаружи. Самодельных генераторов по месту вызова не + заводим. ## Канонический вид — lowercase @@ -47,31 +47,28 @@ ## Прочее -- Enum-поля (`state`, `source`, …) — обычный `TEXT` без `CHECK`; допустимые - значения держит код. - *Расхождение:* перечни, по которым панель владельца правит запись руками, - закрыты схемой (`SelectField`), а не кодом: правка руками не должна заводить - значение, которого сервис не знает. Закрыты рубеж записи, причина её - остановки, вид текста, источник и исход события журнала. Цена названа: новое - значение любого из них потребует нового шага схемы, а применённый шаг не - переписывается. Прочие перечни остаются обычным `TEXT`. +- Enum-поля (`state`, `halt_reason`, …) — обычный `TEXT` без `CHECK`; допустимые + значения держит код. Прежде часть перечней закрывала схема — правку руками вела + панель владельца, и она вправе была завести значение, которого сервис не + знает. Панели нет с 2026-08-22, правка идёт только нашим кодом, и закрытый + перечень в схеме остался бы ценой — новое значение стоило бы нового шага — без + покупателя. - Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например `2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`). Единая точка генерации — приложение, а не умолчание в схеме: так забытая вставка падает громко. Измерение длительности — не метка времени. - *Расхождение:* вид времени задаёт хранилище — `2006-01-02 15:04:05.000Z`, - пробел вместо `T` и доли секунды ([../database.md](../database.md), «Время»). - Правило RFC 3339 действует на то, что пишем мы сами мимо хранилища; вид - хранилища не меняем — сравнение строк в сыром запросе побайтово, и - разошедшийся вид молча обращает условие срока захвата в константу. -- Миграции — шаги PocketBase на Go - (`internal/adapter/repo/pocketbase/migrations`, файл на шаг): коллекции и их - поля заводятся кодом. При изменении структуры обновляем схему - [../database.md](../database.md) тем же изменением. -- Время в **сыром запросе** кладётся и сравнивается тем же видом, каким - хранилище пишет свои `created`/`updated`. Сравнение строк побайтово, и - разошедшийся вид обращает условие в постоянную истину или ложь — молча. + Умолчаний вида `CURRENT_TIMESTAMP` в схеме нет ни у одной колонки, и вид один + на все — включая те, что пишет только сам сервис: своего типа времени у SQLite + нет, а колонка, заполненная то одним видом, то другим, молча обращает условие + срока захвата в константу. +- Миграции — шаги `pressly/goose/v3` на Go + (`internal/adapter/repo/sqlite/migrations`, файл на шаг, версия — число в + начале имени): таблицы, их колонки и индексы заводятся кодом. При изменении + структуры обновляем схему [../database.md](../database.md) тем же изменением. +- Время в запросе кладётся и сравнивается тем же видом, каким оно лежит в + колонке. Сравнение строк побайтово, и разошедшийся вид обращает условие в + постоянную истину или ложь — молча. - Выборка «следующей» записи с `LIMIT 1` дополняется ключом в `ORDER BY`: сравнение по неуникальному значению делает порядок обработки невоспроизводимым. diff --git a/docs/conventions/errors.md b/docs/conventions/errors.md index 38e5b98..3b8a888 100644 --- a/docs/conventions/errors.md +++ b/docs/conventions/errors.md @@ -100,15 +100,19 @@ transcriber — **приложение, а не библиотека**: внеш | Доменная ошибка | Статус | `error_code` | Сообщение | | --- | --- | --- | --- | - | сессии нет | 401 | `unauthorized` | «требуется вход» | - | предъявитель узнан, учётной записи пользователя нет | 403 | `forbidden` | «у вашей сессии нет учётной записи» | + | пришедший не узнан | 401 | `unauthorized` | «сервис вас не узнал» | | запись не найдена, чужая либо ничья | 404 | `not_found` | «запись не найдена» | - | файл не приложен, формат не распознан, негодное значение параметра | 400 | `bad_request` | «некорректный ввод» | + | файл не приложен, формат не распознан, негодное значение параметра, негодный диапазон | 400 | `bad_request` | «некорректный ввод» | | запись сверх потолка размера | 413 | `too_large` | «запись больше допустимого размера», плюс предел числом | | запросов слишком много подряд | 429 | `too_many_requests` | «слишком много запросов подряд, попробуйте позже» | - | текста запрошенного вида ещё нет | 409 | `not_ready` | «действие недоступно в текущем состоянии» | + | текста или копии файла запрошенного вида ещё нет | 409 | `not_ready` | «действие недоступно в текущем состоянии» | | прочее | 500 | `internal` | «внутренняя ошибка» | + Ветвь `403`/`forbidden` ушла отсюда 2026-08-22 вместе со своим единственным + случаем: им был владелец панели, предъявивший собственный токен хранилища. + Ни панели, ни токенов у сервиса не осталось, а узнавание по заголовку + учётную запись заводит само. + Новую штатную ветвь отказа заводим sentinel'ом и добавляем сюда — иначе ветвь по умолчанию отдаст 500 «внутренняя ошибка» на обычный конфликт, а логирующая граница спишет его в `ERROR` вместо `DEBUG`. @@ -124,10 +128,13 @@ transcriber — **приложение, а не библиотека**: внеш `json-api-for-spa` 2026-08-15. **Часть отказов рождается не в обработчике** — предел тела, ограничитель - частоты, неизвестный путь под корнем приложения — и до этой точки не доходит - вовсе. Их приводит к той же форме слой `OneErrorForm`, стоящий снаружи всех - прочих. Без него формы отказа было бы две, и отказ у человека на мобильной сети - приходил бы телом библиотеки. + частоты, неизвестный путь под корнем приложения, негодный диапазон в запросе + файла — и до этой точки не доходит вовсе. С 2026-08-22 отдельного слоя + перевода им не нужно: маршрутизатор и слои написаны нами, и каждый из них + отвечает **своей доменной ошибкой** через ту же точку. Прежде их приводил к + общей форме слой `OneErrorForm`, стоявший снаружи всех прочих и переводивший + тело чужой библиотеки; библиотеки не осталось, и второй формы отказа взяться + неоткуда. ### Разовый ответ и сохранённая диагностика @@ -135,7 +142,7 @@ transcriber — **приложение, а не библиотека**: внеш - **Разовый ответ на действие** (тело HTTP-ответа) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт, полная ошибка остаётся в логах по идентификатору задачи. -- **Сохранённая диагностика состояния** — колонка `error_text` задачи. Это +- **Сохранённая диагностика состояния** — колонка `error_text` аудиозаписи. Это **поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим и полезен. Но: - **секреты запрещены** — токены, ключи, пароли, заголовок @@ -146,8 +153,8 @@ transcriber — **приложение, а не библиотека**: внеш числом рядом**: без этого непонятно, насколько сокращать. *Расхождение:* `error_text` пишется целиком, без вычистки и без усечения. - Наружу он при этом не выходит: опрос готовности отдаёт признак остановки без - машинного текста — эту часть правила держит спека `intake`. + Наружу он при этом не выходит: карточка записи отдаёт причину остановки без + машинного текста — эту часть правила держит спека `archive`. ## panic @@ -156,11 +163,11 @@ transcriber — **приложение, а не библиотека**: внеш - Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) — это значения `error`. - `recover` — на верхней границе обработчика, чтобы один паникующий запрос не - ронял процесс. В transcriber его вешает роутер хранилища сам - (`apis.panicRecover`, слой с идентификатором `DefaultPanicRecoverMiddlewareId` - на каждом роутере PocketBase): паникующий обработчик отдаёт `500`, процесс - живёт. Своего слоя мы не пишем. У воркеров такой границы **нет**: паника в - шаге конвейера роняет процесс целиком. + ронял процесс. В transcriber его ставит свой слой `http.Recover`: паникующий + обработчик отдаёт `500` нашей формой тела, а строка о панике идёт в журнал + владельца. Слой стал своим 2026-08-22 вместе с роутером — прежде его вешала + чужая библиотека. У воркеров такой границы **нет**: паника в шаге конвейера + роняет процесс целиком. ## Несколько ошибок diff --git a/docs/conventions/go-linters.md b/docs/conventions/go-linters.md index e85c6d9..22b02c5 100644 --- a/docs/conventions/go-linters.md +++ b/docs/conventions/go-linters.md @@ -78,10 +78,11 @@ | --- | --- | | Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml` → `errorlint` | | Ошибка не узнаётся сравнением текста сообщения (`strings.Contains(err.Error(), …)`, `err.Error() == …`) | `internal/archrules` → `TestОшибкаНеУзнаётсяПоТексту` | -| Непроверенное возвращаемое значение ошибки | `.golangci.yml` → `errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close` и `os.Remove` | +| Непроверенное возвращаемое значение ошибки | `.golangci.yml` → `errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close`, `os.Remove`, отложенные `(*sql.Rows).Close` и `(*sql.Tx).Rollback` и запись тела ответа (`json.Encoder.Encode`, `http.ResponseWriter.Write`) | | Непроверенное приведение типа (`v := x.(T)`) | `.golangci.yml` → `errcheck` с `check-type-assertions`. Отдельная настройка, потому что такое приведение паникует, а не возвращает ошибку, и `check-blank` его не видит | | Проверенный отказ не оборачивается в `return nil` | `.golangci.yml` → `nilerr`. Механизирует половину инварианта «принятая запись не теряется молча»: молчаливый успех после отказа | -| Отказ выборки из хранилища не теряется (`rows.Err()`), а сама выборка закрывается | `.golangci.yml` → `rowserrcheck`, `sqlclosecheck`. **Профилактические: предмета в коде сегодня нет** — выборки идут через `dbx` хранилища, а из `database/sql` употребляются только `sql.NullString` и `sql.ErrNoRows`. Правила заведены на будущий сырой запрос; мутацией проверены на пробе, а не на своём коде | +| Отказ выборки из базы не теряется (`rows.Err()`), а сама выборка закрывается | `.golangci.yml` → `rowserrcheck`, `sqlclosecheck`. Предмет у правил появился 2026-08-22: выборки идут своим `database/sql`, и обе ветви ловятся на живом коде | +| Обращение к базе идёт с контекстом (`ExecContext`, `QueryContext`, `BeginTx`) | `.golangci.yml` → `noctx`. Контекст у репозиториев свой — почему, названо в [../database.md](../database.md), «Представление данных» | | Ошибки — только stdlib, без сторонних пакетов | `.golangci.yml` → `depguard` | ### Структура и границы @@ -90,8 +91,9 @@ | --- | --- | | Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules` → `TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` | | Транспорты (`controller/http`, `controller/worker`) не знают друг о друге | `internal/archrules` → `TestТранспортыНеЗнаютДругОДруге` | +| Транспорты не знают адаптеров | `internal/archrules` → `TestТранспортыНеЗнаютАдаптеров`. Правило заведено 2026-08-22: изъятие, разрешавшее транспорту знать адаптер хранилища, снято вместе с предметом | | Адаптер не знает ни ядра, ни транспортов | `internal/archrules` → `TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` | -| Колонки записи согласованы: что пишет отображение ↔ что читает обратное ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках. Закрывает инвариант «колонки записи правятся в двух местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций, а не в файле целиком | +| Колонки записи согласованы: что пишет отображение ↔ что спрошено чтением ↔ что доезжает до сущности ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках (`TestКолонкиЗаписиПишутсяИЧитаются`, `TestПрочитанныеКолонкиДоезжаютДоСущности`, `TestКолонкиЗаписиЗаведеныШагомСхемы`). Закрывает инвариант «колонки записи правятся в трёх местах» (CLAUDE.md, major), которого компилятор не держит. Имя колонки ищется в телах нужных функций, а не в файле целиком | | Рубежи согласованы: дескриптор ↔ таблица выбора шага, в обе стороны | `internal/archrules` → правила о рубежах. Закрывает инвариант «рубеж объявляется одним дескриптором» (CLAUDE.md, major). Рубеж без шага останавливает запись, не начав работы; шаг без рубежа недостижим — захват такую запись не выдаст никогда | ### Отмена и внешний собеседник @@ -138,7 +140,7 @@ | Правило | Где механизировано | | --- | --- | -| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `A` | `Taskfile.yml` → шаг `migrations`. Закрывает инвариант CLAUDE.md (critical), которого не держит ни компилятор, ни хранилище: применённое считается по имени файла. Баз диффа две — `BASE` и `HEAD`: первая отвечает на «шаг уже уехал» ровно настолько, насколько свежа `origin/master`, вторая ловит правку закоммиченного шага независимо от неё. Каталог берётся из ключа `migrations` секции `[docs]` в `.av-dev.toml`, чтобы у факта не было второго дома. Исходы шага и их коды — [CLAUDE.md](../../CLAUDE.md), «Гейт». `migrations.go` под правило не подпадает: строка `Register` нового шага прибавляется именно там | +| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `A` | `Taskfile.yml` → шаг `migrations`. Закрывает инвариант CLAUDE.md (critical), которого не держит ни компилятор, ни база: применённое считается своей таблицей учёта. Баз диффа две — `BASE` и `HEAD`: первая отвечает на «шаг уже уехал» ровно настолько, насколько свежа `origin/master`, вторая ловит правку закоммиченного шага независимо от неё. Каталог берётся из ключа `migrations` секции `[docs]` в `.av-dev.toml`, чтобы у факта не было второго дома. Исходы шага и их коды — [CLAUDE.md](../../CLAUDE.md), «Гейт». `migrations.go` под правило не подпадает: строка `Register` нового шага прибавляется именно там | | Раскладка документов, битые ссылки, изменённый шаг схемы без правки `database.md` | `docs.py check`; каталог шагов задаёт ключ `migrations` секции `[docs]` в `.av-dev.toml` | | Согласованность каталога задач, форма `openspec/config.yaml` | `tasks.py check`, `openspec.py check` | | Секреты в коммите | `lefthook.yml` → `gitleaks git --staged` | @@ -188,11 +190,9 @@ ещё никуда не уехал. Отсюда следствие: при отставшей `origin/master` правило молчит на всём каталоге, и на подозрении база задаётся руками (`task migrations BASE=`); -- направление «транспорт не знает адаптера»: сегодня оно нарушено осознанно — - `controller/http` импортирует адаптер хранилища, потому что HTTP-поверхность и - есть роутер этого хранилища. Изъятие названо в - [../architecture.md](../architecture.md), «Принципы», и правила на это направление - нет. +- чистота домена: правила смотрят ядро, входы и адаптеры, а импорт внешней + библиотеки в `internal/entity` сегодня пройдёт молча. Названо в + [../architecture.md](../architecture.md), «Слои и модель домена». Отдельно названы **правила, чей подъём отклонён**: diff --git a/docs/conventions/logging.md b/docs/conventions/logging.md index 70beeff..dd5a6c9 100644 --- a/docs/conventions/logging.md +++ b/docs/conventions/logging.md @@ -105,12 +105,12 @@ stdlib-логом в поток ошибок. Это выбор, а не дол | Когда добавляем | Поля | | --- | --- | -| на входящий HTTP-запрос | `transport` (`http`), `http.method`, `http.route`, `http.status_code`, `duration_ms` | +| на входящий HTTP-запрос | `transport` (`http`), `http.method`, `http.route`, `http.status_code`, `duration_ms`, `http.path_length`. **Запрошенного пути в строке нет ни под каким корнем**: его выбирает спрашивающий, и дословная запись сделала бы журнал местом, куда аноним пишет свой текст. В `http.route` идёт маршрут из закрытого перечня — точный адрес наблюдения либо образец адреса приложения, — а всё прочее обозначается одним общим значением | | на узнавание пришедшего | `http.peer_addr` — адрес того, кто открыл соединение; плюс `account_id` на заведении учётной записи. **Значения заголовка в строке нет**: им довольно назваться, чтобы стать этим человеком, а с недоверенного адреса его пишет аноним | | на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `record_id`, `file_id`, `source` | | на запись об ошибке | `error` | | на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` | -| на запрос, отданный приложению | `webapp.outcome` (`markup`, `asset`, `failure` — перечень закрыт), `http.path_length`. Самого пути в строке нет: его выбирает спрашивающий, и дословная запись сделала бы журнал местом, куда аноним пишет свой текст. Вместо пути в `http.route` стоит `<приложение>` | +| на запрос, отданный приложению | `webapp.outcome` (`markup`, `asset`, `failure` — перечень закрыт). Правило о пути — строкой выше, общее: вместо пути в `http.route` стоит `<приложение>` | | на подъёме сервиса | `webapp.build` — отпечаток вшитой сборки; им «не та сборка» отличается от «той» | Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум. @@ -205,7 +205,7 @@ Object Storage и опрос операции не логируются ника ## HTTP и проверка здоровья - Входящие HTTP-запросы логируем с полями `http.method`, `http.route`, - `http.status_code`, `duration_ms`, `transport`. + `http.status_code`, `duration_ms`, `http.path_length`, `transport`. - **Поле, которое уже даёт логгер с подставленным ключом, руками не доклеиваем.** Иначе в JSON получается дублирующийся ключ, и строгий потребитель молча оставит одно из значений. Правило проверяется чтением, @@ -214,13 +214,12 @@ Object Storage и опрос операции не логируются ника периодически, на `INFO` они забивают разбор шумом. В продакшене при базовом `INFO` они не пишутся. -Расхождения здесь больше нет: слой журналирования запросов свой, -`cmd/transcriber`, хук `OnServe` — вместе с gin ушёл и `sloggin`. `/health` и `/metrics` -идут на `DEBUG`, то есть при боевом `INFO` не пишутся вовсе. +Расхождения здесь больше нет: слой журналирования запросов свой — +`internal/controller/http`, `journal.go`. `/health` и `/metrics` идут на `DEBUG`, +то есть при боевом `INFO` не пишутся вовсе. -Хранилище ведёт **свой** журнал запросов в собственной таблице, и он виден -владельцу в панели. Заменой потоку процесса он не служит: в журнал контейнера, -по которому разбирают отказы, эта таблица не попадает. +**Журнал у сервиса один.** Второй, куда встроенное хранилище клало путь целиком +вместе с адресом отправителя, ушёл вместе с самим хранилищем 2026-08-22. ## Безопасность: что не логируем @@ -257,7 +256,7 @@ Object Storage и опрос операции не логируются ника (`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост расширением. В журнал оно идёт **собственным полем** строки приёма — это объявленное изъятие инварианта приватности ([CLAUDE.md](../../CLAUDE.md), -«Инварианты»); ни имени файла в хранилище, ни пути к нему в журнале нет вовсе +«Инварианты»); ни имени файла на диске, ни пути к нему в журнале нет вовсе (норма — `openspec/specs/intake`). Наружу — в метку метрики — хвост не выходит: там расширение приводится к перечню известных форматов. Остаток описан в [../security.md](../security.md). diff --git a/docs/conventions/web-ui.md b/docs/conventions/web-ui.md index 25bcbfc..b6538d8 100644 --- a/docs/conventions/web-ui.md +++ b/docs/conventions/web-ui.md @@ -74,15 +74,16 @@ [webapp](../../openspec/specs/webapp/spec.md). - **Адреса обычные, а не после решётки** (`createWebHistory`). Отсюда требование к серверу: неизвестный путь **вне корней сервиса** отдаёт `index.html`, а не - `404`; путь внутри корня в приложение не проваливается никогда. Корней - сегодня три — `/api/` у хранилища, `/app/` у приложения, `/_/` у панели, — - плюс `/health` и `/metrics` отдельными адресами. Корень `/auth/` снят - 2026-08-22 вместе с собственным входом, и пути под ним стали обычными путями - вне корней. Приложение - уехало из общего `/api/` решением владельца 2026-08-15: пространство - принадлежит хранилищу, и обновление библиотеки вправе занять там имя рядом с - нашим. Перечень корней сервису не описывают, а из него **порождают** - регистрацию маршрутов: описанный порознь, он разошёлся бы с ними молча. + `404`; путь внутри корня в приложение не проваливается никогда. Корень + сегодня **один** — `/app/` у приложения, — плюс `/health` и `/metrics` + отдельными адресами. Корни `/auth/`, `/api/` и `/_/` сняты 2026-08-22: первый + ушёл с собственным входом, два других — со встроенным хранилищем и его + панелью, и пути под ними стали обычными путями вне корней. Приложение уехало + из общего `/api/` решением владельца 2026-08-15, и корень свой сохранило: + соседа, ради которого выбирался, больше нет, а формы запросов и ответов от + смены хранилища не изменились ни одним полем. Перечень корней сервису не + описывают, а из него **порождают** регистрацию маршрутов: описанный порознь, + он разошёлся бы с ними молча. - **Несовпавший ресурс разметкой не подменяется.** Путь под каталогом сборщика, которому не нашлось файла, отвечает `404`. Правило — вторая половина предыдущего: разметка прежней сборки называет ресурсы прежней сборки, и diff --git a/docs/database.md b/docs/database.md index 8d83ca8..a5b78a0 100644 --- a/docs/database.md +++ b/docs/database.md @@ -1,62 +1,118 @@ # Схема хранилища -Хранилище, коллекции, правило времени и идентификаторов. +База, таблицы, раскладка файлов, правило времени и идентификаторов. -Хранилище — **встроенная PocketBase 0.39.10**: она держит и базу, и файлы -записей под одним каталогом данных. Ключ конфигурации — `[storage] data_dir`, -умолчание `data`. В SQLite библиотека ходит через `modernc.org/sqlite`, поэтому -CGO сборке не нужен. +Хранилище **своё**: база SQLite через `modernc.org/sqlite` (CGO сборке не нужен) +и файлы записей своим каталогом рядом с ней. Ключ конфигурации один — +`[storage] data_dir`, умолчание `data`. Встроенная PocketBase, державшая до +2026-08-22 и базу, и файлы, и панель, и маршрутизатор, ушла из проекта целиком — +задача `storage-without-pocketbase`, +[ADR](adr/ADR-2026-08-22-storage-without-pocketbase.md). -Схему двигают **шаги миграций PocketBase** на Go, каталог -`internal/adapter/repo/pocketbase/migrations`, файл на шаг и имя файла — имя -шага. Шаг регистрируется при загрузке пакета, а накатывается при подъёме -хранилища (`pocketbase.New`), прежде чем стартуют воркеры и сервер. Применённый -шаг не переписывается — изменение только новым шагом: применённое хранилище -считает по имени шага. +**База принимает одного писателя.** Пишущий пул держит одно соединение — драйвер +пишет единственным, и несколько воркеров, пришедших писать разом мимо этого +правила, получают отказ по занятости на записи результата шага, то есть после +оплаченной работы. Чтение идёт отдельным пулом: в журнале упреждающей записи +читатели не мешают писателю. + +Журнал упреждающей записи, соблюдение внешних ключей и ожидание занятой базы +задаются **строкой подключения обоих пулов**, а не запросом после открытия: две +из трёх настроек в SQLite принадлежат соединению, а не базе, а пул заводит новые +соединения по мере надобности — запрос настроил бы одно из многих. Операция, +которая читает и следом пишет, идёт целиком по пишущему соединению: читающую +транзакцию SQLite до пишущей не повышает и отказывает по занятости немедленно. + +Схему двигают **шаги `github.com/pressly/goose/v3`** — библиотекой, а не +командной строкой. Каталог `internal/adapter/repo/sqlite/migrations`, файл на +шаг, версия шага — число в начале имени файла. Перечень шагов приходит +провайдеру доводом, провайдер заводится в точке входа и получает пишущий пул, +накат идёт **до подъёма входов и до старта воркеров**, а отказ шага роняет старт. +Применённый шаг не переписывается — изменение только новым шагом. + +Шаг и отметка о нём идут одной транзакцией: библиотека открывает её на том же +соединении. Порядок шагов детерминирован и выводится из версии, а не из порядка +чтения каталога; две одинаковых версии дают отказ сбора. + +**Исключающую блокировку наката держим сами.** Библиотека под SQLite её не +поставляет вовсе — её запиратели объявлены только для PostgreSQL, а провайдер без +запирателя накатывает без всякой блокировки. Замок берётся на файле +`data/migrate.lock` (`syscall.Flock`, `LOCK_EX`) и снимается закрытием +дескриптора; с умершим процессом его снимает ядро, поэтому просроченного замка, +который надо чистить руками, не остаётся. Каталог у шагов свой, а не файл внутри пакета репозитория, и причина внешняя: шаг гейта сверяет изменённые шаги схемы с правкой этого документа по **префиксу пути**, а префикс наводится только на каталог. Где этот префикс задан — -[conventions/go-linters.md](conventions/go-linters.md), «Механизировано». Имена коллекций живут там же, рядом с шагом, который их заводит; пакет -репозитория берёт их оттуда. +[conventions/go-linters.md](conventions/go-linters.md), «Механизировано». -**Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита. -Свои UUID остались только в **именах файлов**: имя, под которым запись ложится в -хранилище, задаёт сервис, и это `<расширение>`. +**Идентификаторы** — ULID в нижнем регистре, `TEXT`, 26 знаков алфавита +Crockford. Выдаёт их приложение единой точкой `internal/ident`; внутри одной +миллисекунды выдача монотонна, потому что колонка времени несёт секунды и +порядок записей одной секунды задаёт ключ. Идентификатор, пришедший снаружи, +разбирается на границе: разбор проверяет вид и приводит регистр, а негодный +считается несуществующей записью и до базы не доходит. -**Время** — вид хранилища: строка `2006-01-02 15:04:05.000Z` в UTC. Колонки -`created` и `updated` проставляет само хранилище; те же поля в сыром запросе -захвата кладёт наш код — **тем же видом**, потому что сравнение строк в SQLite -побайтово, и разошедшийся вид обратил бы условие срока в постоянную истину или -постоянную ложь молча. +Тем же идентификатором зовётся **подкаталог записи** в каталоге данных, а имя +файла внутри него — `<расширение>`. -Того, что единой точки генерации идентификатора и времени нет, здесь не -повторяем: перечень единых точек и их отсутствий держит -[architecture.md](architecture.md), «Единые точки проекта». +**Время** — `TEXT` в RFC 3339, UTC, суффикс `Z`, секундная точность: +`2006-01-02T15:04:05Z`. Ширина записи постоянная, поэтому лексикографический +порядок совпадает с хронологией. Вид один на **все** колонки времени, включая +те, что пишет только сам сервис: своего типа времени у SQLite нет, колонка +хранит то, что в неё положили, и колонка, заполненная то одним видом, то другим, +обратила бы условие срока протухания захвата в постоянную истину или ложь молча. -## Коллекции +Время ставит приложение единой точкой `internal/clock`. **Умолчаний вида +`CURRENT_TIMESTAMP` в схеме нет**: умолчание писало бы свой вид времени, а +вставка, забывшая проставить время, при нём прошла бы молча. + +## Таблицы + +**Перечни значений держит код, а не схема.** Прежде рубеж, причина остановки и +вид текста были закрыты `CHECK`-подобным типом хранилища, потому что панель +владельца правила запись руками и вправе была завести значение, которого сервис +не знает. Панели нет, правка идёт только нашим кодом, и закрытый перечень в схеме +остался бы ценой — новое значение стоило бы нового шага — без покупателя. + +### `users` + +| Поле | Тип | Что | +| --- | --- | --- | +| `id` | TEXT PK | ULID, выдаёт приложение | +| `provider_login` | TEXT, уникален | Логин человека **у провайдера**: то значение, которым его называет обратный прокси заголовком `Remote-User`. Ключ учётной записи | +| `name` | TEXT | Имя, пригодное к показу; берётся при заведении и вторым обращением не переписывается | +| `email` | TEXT | Адрес почты; необязателен | +| `created_at`, `updated_at` | TEXT | Время | + +Уникальность почты держится **частичным** индексом (`WHERE email <> ''`), поэтому +записи без почты уживаются друг с другом. Уникальность логина — обычным. + +Ключом почта не служит вовсе: адрес меняется, и первое обращение с чужим адресом +досталось бы чужой записи. ### `files` -Одна запись на одну физическую копию. Копий у аудиозаписи ровно две: принятая и +Одна строка на одну физическую копию. Копий у аудиозаписи ровно две: принятая и приведённая к рабочему формату. Копия во внешнем хранилище файлом записи не считается — она существует только потому, что провайдер распознавания читает аудио по адресу, и её ключ живёт в строке попытки распознавания. | Поле | Тип | Что | | --- | --- | --- | -| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище | -| `file` | file | Сам файл | -| `owner` | relation → `users` | Владелец файла; пустого значения не принимает | -| `location` | select | `local` или `s3` | -| `object_key` | TEXT | Ключ объекта; заведён прежним шагом и новым путём не заполняется | -| `size` | INTEGER | Размер в байтах | +| `id` | TEXT PK | ULID | +| `owner_id` | TEXT → `users(id)` | Владелец копии; пустого значения не принимает | +| `record_id` | TEXT | Запись, которой копия принадлежит: имя её подкаталога | +| `file_name` | TEXT | Имя файла в этом подкаталоге; задаёт сервис | +| `size_bytes` | INTEGER | Размер копии в байтах | | `format` | TEXT | Расширение без точки, в нижнем регистре | | `duration_ms` | INTEGER | Длительность, если её удалось прочитать | -| `created`, `updated` | DATETIME | Проставляет хранилище | +| `created_at` | TEXT | Время | -Поле названо `location`, а не `storage`: последним словом зовут само хранилище и -capability, и третий смысл развёл бы одно слово по разным вещам. +**Внешнего ключа на аудиозапись у `record_id` нет намеренно.** Приём заводит +файл **до** самой записи — подкаталог назван её идентификатором, и знать его надо +раньше, — и обязательная связь отвергала бы первую же принятую запись. Владелец +при этом лежит своей колонкой, а не выводится через запись: файл переживает свою +запись, и заведённый шагом до её сохранения остаётся с владельцем и без ссылки. ### `audio_records` @@ -65,36 +121,37 @@ capability, и третий смысл развёл бы одно слово п | Поле | Тип | Что | | --- | --- | --- | -| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище | -| `owner` | relation → `users` | Владелец записи; пустого значения не принимает | -| `source` | select | `api`, `unknown`; значение `telegram` осталось историческим — вход убран, новых записей с ним не появляется | +| `id` | TEXT PK | ULID | +| `owner_id` | TEXT → `users(id)` | Владелец записи; пустого значения не принимает | | `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком | -| `original_filename` | TEXT ≤ 255 | Имя файла, данное отправителем; кладёт приём, обрезав по пределу и убрав управляющие знаки | -| `duration_ms` | INTEGER ≥ 0 | Длительность **принятого**, миллисекунды; ставит приём и всегда | -| `size_bytes` | INTEGER ≥ 0 | Размер **принятого**, байты | -| `state` | select | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done`; перечень закрыт схемой | -| `state_entered_at` | DATETIME | Время входа в рубеж — сторож застревания | -| `halted_at` | DATETIME | Признак остановки; рубеж при ней не стирается | -| `halt_reason` | select | `step_failed`, `attempts_exhausted`, `stuck` | +| `original_filename` | TEXT | Имя файла, данное отправителем; кладёт приём, обрезав по пределу и убрав управляющие знаки | +| `duration_ms` | INTEGER, обязателен | Длительность **принятого**, миллисекунды; ставит приём и всегда | +| `size_bytes` | INTEGER, обязателен | Размер **принятого**, байты | +| `state` | TEXT | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done` | +| `state_entered_at` | TEXT | Время входа в рубеж — сторож застревания | +| `halted_at` | TEXT | Признак остановки; рубеж при ней не стирается | +| `halt_reason` | TEXT | `step_failed`, `attempts_exhausted`, `stuck` | | `error_text` | TEXT | Текст ошибки, машинный | | `acquisition_id` | TEXT | Признак **этого** захвата, уникальный для каждого | -| `acquire_expires_at` | DATETIME | Срок протухания захвата; приезжает с рубежом | -| `delay_time` | DATETIME | Не брать запись раньше этого времени | -| `attempts` | INTEGER ≥ 0 | Число **отказов**: растёт при захвате, обнуляется на шаге без отказа и на откладывании | -| `original_file` | relation → `files` | Принятая копия | -| `normalized_file` | relation → `files` | Копия, приведённая к рабочему формату | -| `transcript_text`, `literary_text` | relation → `texts` | Тексты записи | -| `structure` | relation → `structures` | Структура реплик | -| `recognition` | relation → `recognitions` | Попытка распознавания | -| `topics` | relation → `topics`, до 5 | Темы записи | -| `tg_chat_id` | INTEGER | Адресат ответа у записи убранного входа; кодом не читается | -| `tg_reply_message_id` | INTEGER | Ответное сообщение у неё же; кодом не читается | -| `created`, `updated` | DATETIME | Проставляет хранилище | +| `acquire_expires_at` | TEXT | Срок протухания захвата; приезжает с рубежом | +| `delay_time` | TEXT | Не брать запись раньше этого времени | +| `attempts` | INTEGER | Число **отказов**: растёт при захвате, обнуляется на шаге без отказа и на откладывании | +| `original_file_id` | TEXT → `files(id)` | Принятая копия | +| `normalized_file_id` | TEXT → `files(id)` | Копия, приведённая к рабочему формату | +| `transcript_text_id`, `literary_text_id` | TEXT | Тексты записи | +| `structure_id` | TEXT | Структура реплик | +| `recognition_id` | TEXT | Попытка распознавания | +| `created_at`, `updated_at` | TEXT | Время | -Индексов три. Первый — по паре «рубеж и признак остановки»: по ним, паузе и -сроку протухания идёт выборка захвата. Два других завела страница списка приложения: -`(owner, created DESC, id DESC)` под страницу «новыми сверху» и -`(owner, state, halted_at)` под отбор тремя состояниями. +Индексов два. `idx_audio_records_acquire` — `(state, halted_at, created_at, id)`: +по нему идёт отбор захвата, и по нему же он берёт запись в определённом порядке. +`idx_audio_records_owner_page` — `(owner_id, created_at, id)`: под страницу +списка, сужаемую владельцем и режущуюся полным ключом сортировки. + +Оба индекса заведены **начальным шагом**, а не отложены: применённый шаг схемы не +переписывается, и добавление индекса стоило бы отдельного шага. Проверено +`EXPLAIN QUERY PLAN`: ни отбор захвата, ни страница списка не показывают полного +сканирования таблицы. **Ведущая колонка у ленты — владелец, и потому индекс захвата ей не помогает ничем.** Замер на задаче `json-api-for-spa` 2026-08-15: без своего индекса @@ -109,21 +166,20 @@ capability, и третий смысл развёл бы одно слово п узнаёт свою запись, пока заголовка нет. Одной колонкой на оба смысла посчитанное название затирало бы имя, и вернуть затёртое было бы неоткуда. Имя приходит извне, поэтому приём режет его по пределу и убирает управляющие знаки; в имя -файла в хранилище и в журнал оно по-прежнему не идёт. +файла на диске и в журнал оно по-прежнему не идёт. **Длительность и размер лежат и на записи, и на её файле, и равенство между ними не поддерживается никем — намеренно.** На записи снимок **принятого**, взятый -приёмом один раз; на файле — величины нынешней копии файла. -Уточнение длительности меняет вторые и не трогает первые: это разные вопросы — -«что человек прислал» и «что лежит сейчас». Колонками записи они нужны потому, -что показываются в списке, а список читается без содержимого. Решение владельца -от 2026-08-15. +приёмом один раз; на файле — величины нынешней копии. Уточнение длительности +меняет вторые и не трогает первые: это разные вопросы — «что человек прислал» и +«что лежит сейчас». Колонками записи они нужны потому, что показываются в списке, +а список читается без содержимого. Решение владельца от 2026-08-15. -**«Неизвестно» эти колонки не выражают, и это решение владельца от 2026-08-15.** -Числовая колонка хранилища пустого значения не держит: пустое она кладёт нулём. -Платить за отличимость четвёртой колонкой-признаком или текстовым типом у чисел -не за что — обе величины ставит приём и ставит всегда, а запись с непрочитанными -метаданными отвергается отказом и не заводится вовсе. +**«Неизвестно» эти колонки не выражают**, и это то же решение владельца: обе +величины ставит приём и ставит всегда — запись с непрочитанными метаданными +отвергается отказом и не заводится вовсе. Обе объявлены обязательными: пустое +значение, которое схема теперь допустить может, завело бы третий смысл, которого +никто не читает. **Ссылки на файлы две и порознь.** Прежняя модель держала одну и переставляла её каждым шагом: у прошедшей конвейер записи она вела на копию во внешнем @@ -131,20 +187,32 @@ capability, и третий смысл развёл бы одно слово п **Остановка — признак, а не рубеж.** Прежние состояния `failed` и `dead` схлопнуты в `halted_at` с причиной: обе восстанавливаются одинаково — снятием -признака, — и различие между ними перестало быть структурным. Рубеж при -остановке сохраняется, поэтому запись продолжает с места остановки. +признака, — и различие между ними перестало быть структурным. **Сторожей двое.** `attempts` ограничивает повторы внутри шага, `state_entered_at` — застревание. Прежде обе обязанности несло одно число, и не справлялось ни с одной. +### `record_topics` + +| Поле | Тип | Что | +| --- | --- | --- | +| `record_id` | TEXT → `audio_records(id)` | Запись | +| `topic_id` | TEXT → `topics(id)` | Тема | + +Первичный ключ — пара целиком. Потолок в пять тем на запись держит **триггер**: +без него часовой разговор даёт два десятка тем, и словарь распухает за неделю. +Число берётся у домена — то же самое, которое сервис объявляет приложению. + ### `texts` | Поле | Тип | Что | | --- | --- | --- | -| `record` | relation → `audio_records` | Чья это расшифровка | -| `kind` | select | `transcript` или `literary` | -| `contents` | editor | Сам текст | +| `id` | TEXT PK | ULID | +| `record_id` | TEXT → `audio_records(id)` | Чья это расшифровка | +| `kind` | TEXT | `transcript` или `literary` | +| `contents` | TEXT | Сам текст | +| `created_at`, `updated_at` | TEXT | Время | Пара «запись и вид» уникальна: повтор прерванного шага не заводит второй строки. Поле зовётся `kind`, а не `format`: словом `format` в этой же схеме зовут формат @@ -154,9 +222,11 @@ capability, и третий смысл развёл бы одно слово п | Поле | Тип | Что | | --- | --- | --- | -| `record` | relation → `audio_records` | Чья это структура | +| `id` | TEXT PK | ULID | +| `record_id` | TEXT → `audio_records(id)` | Чья это структура | | `version` | INTEGER | Версия вида разбора | -| `contents` | JSON | Реплики со временем | +| `contents` | TEXT | Реплики со временем, JSON | +| `created_at`, `updated_at` | TEXT | Время | Пара «запись и версия разбора» уникальна. Номер версии нужен потому, что разбор сохранённого ответа изменится раньше, чем архив пересчитают. @@ -167,92 +237,86 @@ capability, и третий смысл развёл бы одно слово п | Поле | Тип | Что | | --- | --- | --- | -| `record` | relation → `audio_records` | Чья это попытка | +| `id` | TEXT PK | ULID | +| `record_id` | TEXT → `audio_records(id)` | Чья это попытка | | `provider`, `model` | TEXT | Кем и какой моделью считано | | `external_id` | TEXT | Идентификатор операции у провайдера | | `source_uri` | TEXT | Адрес, по которому провайдер читает аудио | -| `payload` | file, **защищённое** | Сырой ответ провайдера целиком | -| `started_at`, `finished_at` | DATETIME | Границы операции | +| `payload_file` | TEXT | Имя файла с сохранённым ответом провайдера | +| `started_at`, `finished_at` | TEXT | Границы операции | +| `created_at`, `updated_at` | TEXT | Время | -**Сырой ответ лежит вложением, а не колонкой.** Шаг опроса читает эту строку раз -в несколько секунд, а хранилище читает запись целиком: ответ на многочасовую -запись ехал бы в память при каждом опросе. Хранится он потому, что результат -операции у провайдера не переспрашивается. - -Поле вложения помечено защищённым: сырой ответ — это полный текст речи, и -умолчание библиотеки отдавало бы его по ссылке любому, кто её знает. +**Сохранённый ответ лежит третьим файлом в подкаталоге записи, а не колонкой.** +Шаг опроса читает эту строку раз в несколько секунд, а репозиторий читает строку +целиком: ответ на многочасовую запись, положенный колонкой, ехал бы в память при +каждом опросе. Хранится он потому, что результат операции у провайдера не +переспрашивается. Копией аудио он при этом не считается — их у записи по-прежнему +две, — и адреса, которым его читают снаружи, у сервиса нет вовсе. ### `record_events` | Поле | Тип | Что | | --- | --- | --- | -| `record` | relation → `audio_records` | Чьё это событие | -| `origin` | select | `pipeline` или `human` | +| `id` | TEXT PK | ULID | +| `record_id` | TEXT → `audio_records(id)` | Чьё это событие | +| `origin` | TEXT | `pipeline` или `human` | | `step` | TEXT | Имя шага | -| `outcome` | select | `done`, `failed`, `halted`, `resumed` | +| `outcome` | TEXT | `done`, `failed`, `halted`, `resumed` | | `outcome_text` | TEXT | Причина, если она есть | | `duration_ms` | INTEGER | Сколько шаг занял | +| `created_at` | TEXT | Время | Колонка текста зовётся `outcome_text`, а не `error_text`: последнее имя названо поимённо инвариантом о секрете, и две колонки с этим именем сделали бы инвариант двусмысленным. -Журнал пишется на смену рубежа, на остановку и на снятие остановки — не на +Журнал пишется на смену рубежа, на остановку и на возврат в работу — не на каждое откладывание опроса. Ни один шаг конвейера его не читает, чтобы решить, -что делать дальше. +что делать дальше. Происхождение `human` пишет сегодня подкоманда оснастки, +возвращающая остановленную запись в работу: другого писателя, кроме конвейера, у +журнала не осталось. ### `topics` | Поле | Тип | Что | | --- | --- | --- | -| `owner` | relation → `users` | Чей это словарь | +| `id` | TEXT PK | ULID | +| `owner_id` | TEXT → `users(id)` | Чей это словарь | | `name` | TEXT | Название темы | +| `created_at`, `updated_at` | TEXT | Время | Пара «владелец и название» уникальна: словарь тем свой у каждого человека. -Коллекцией, а не набором строк в записи, потому что перечень тем нужен целиком -перед каждым обращением к языковой модели. Ни один шаг сегодняшнего сервиса тем -не пишет и не читает — место заведено вперёд, чтобы задача, считающая темы, не -платила вторым необратимым шагом схемы. +Отдельной таблицей, а не набором строк в записи, потому что перечень тем нужен +целиком перед каждым обращением к языковой модели. Ни один шаг сегодняшнего +сервиса тем не пишет и не читает — место заведено вперёд, чтобы задача, +считающая темы, не платила вторым необратимым шагом схемы. ### Чего в схеме больше нет -Коллекция `transcribe_jobs` удалена шагом `202608140002`. Данных под ней не было: -сервис на сервере остановлен, а прежние записи удалены решением владельца -2026-08-14 — переноса это изменение не делало. Оставленная пустая коллекция -висела бы в панели вторым домом для понятия, которого больше нет. +**Каталог шагов PocketBase удалён целиком, и на его месте стоит один шаг +начальной схемы** — `202608220002_init.go`. Это разовое снятие инварианта +«применённая миграция не переписывается», решением владельца от 2026-08-22: +стадия проекта — стройка, на сервере данных нет, сервис остановлен, а новая база +ведёт учёт применённого своей таблицей, которой отметки прежнего каталога не +годятся вовсе. Снятие кончается этим шагом. -**Владелец записи** заведён шагом `202608140001` — связью с коллекцией `users` в -обеих таблицах, — и шагом `202608140003` пустого значения больше не принимает. -Прежде принимал, и цену за это платили записи входа Telegram: связи чата с -учётной записью сервис не вёл. Вход убран 2026-08-14, ничью запись заводить стало -некому, и обязательность переехала из приёма в схему — туда, где её держит -хранилище, а не договорённость. +**Колонок `location` и `source` в новой схеме нет.** Обе писались одним значением +и не читались никем: в `location` уходило `local`, второго значения (`s3`) не +писал ни один шаг; в `source` всякий приём писал `api`, а второе значение +(`telegram`) держалось ссылкой из применённого шага, а не потребителем. Шаги +ушли, и держать их стало нечем. Поле, у которого появится читатель, вернётся +одним новым шагом схемы. -**Колонки `tg_chat_id` и `tg_reply_message_id`** остались от убранного входа и -кодом больше не читаются. Из схемы они не убираются: заводили их применённые -шаги `202608110001` и `202608140002`, а применённый шаг не переписывается. +**Колонок `tg_chat_id`, `tg_reply_message_id` и `object_key` нет по той же +причине:** их держал применённый шаг, которого больше не существует. -Выборка по владельцу сужает **чтение записи**: чужая, ничья и несуществующая -дают один и тот же отказ. Выборку воркера владелец не сужает — конвейер -обрабатывает записи всех. Тот же шаг сузил правило просмотра коллекции `files` -владельцем: прежнее правило пускало всякого вошедшего, и знание идентификатора -файловой записи равнялось праву скачать чужое аудио. - -**Учётная запись с записями не удаляется.** Каскадное удаление у связи выключено, -но одного этого мало: при выключенном каскаде хранилище снимает ссылку и -сохраняет запись без проверок — записи остались бы, но стали бы ничьими, а ничья -запись не достаётся никому. Отказ ставит слой приложения `GuardOwnerDeletion`, -а не правило коллекции: панель ходит правами суперпользователя, и правило её не -судит. Считаются все коллекции с колонкой владельца — `audio_records`, `files` и -`topics`, — и перечень живёт одним местом: пропущенная коллекция пропускает -удаление вперёд, а наружу приезжает подсказка библиотеки про обязательную связь -вместо нашего отказа с причиной. - -**Правила доступа новых коллекций пусты**, то есть перечислять и читать их может -только владелец панели. Содержимое записи отдаёт собственный адрес сервиса, а не -поверхность хранилища; непустое правило открыло бы перечисление коллекции впрок. -Проверено прогоном: анонимный запрос к `/api/collections/*/records` отвечает -`403`, к `/api/logs`, `/api/backups`, `/api/settings` и `/api/crons` — `401`. +**Учётная запись с записями не удаляется**, и держит это схема обязательной +связью, а не проверка вызывающего: `audio_records`, `files` и `topics` ссылаются +на `users(id)` без каскада, а соблюдение внешних ключей включено на каждом +соединении обоих пулов. Прежде запрет ставил слой приложения — сборка, забывшая +его позвать, теряла защиту молча, и теряла. Адреса, которым учётную запись +удаляют, у сервиса нет вовсе; способа удалить записи тоже нет, и это осознанный +тупик до задачи про удаление записи. ## Представление данных @@ -260,70 +324,63 @@ capability, и третий смысл развёл бы одно слово п - **Расшифровка лежит отдельной строкой `texts`**, а не колонкой записи. Захват её не тянет вовсе: он возвращает **идентификатор и признак своего захвата**, а - колонки шаг читает отдельным чтением. Прежде расшифровка стояла колонкой той - же строки и читалась при каждом опросе очереди. -- **Аудио лежит в раскладке хранилища:** - `data/storage/<коллекция>/<запись>/<имя>` рядом с файлом атрибутов. Имя задаёт - сервис — `<расширение>`; собственного суффикса хранилище не дописывает, - потому что умолчание, строящее имя из имени отправителя, не применяется. Ни - файлы, ни объекты в Object Storage не удаляются после завершения задачи: - каталог и бакет растут неограниченно. -- **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла - помечено защищённым шагом `202608120001`, а правило просмотра коллекции - пускает всякого вошедшего: пройти по ссылке можно только с коротким токеном - файла, который берёт узнанный. Прежнее решение — «право прочитать запись даёт - знание её идентификатора» — отменено задачей `oidc-login` 2026-08-12. Имя файла - в хранилище **в журнал не пишется** по-прежнему: оно последняя часть ссылки. -- **Коллекция `users`** заводится самой библиотекой, а два наших шага её сужают. - `202608120001` выключил вход по паролю и одноразовый код; `202608220001` - довершил: снял настройки OAuth2 и **все пять правил доступа** — перечисление, - чтение, создание, правку и удаление, — оставив их пустыми, что у хранилища - означает «только владелец панели». - - Правку и удаление умолчание библиотеки открывало владельцу записи - (`id = @request.auth.id`), и до переезда входа это ничему не мешало: слой - предъявления жил под корнем приложения, и браузер до поверхности хранилища не - дотягивался. С узнаванием по заголовку она достижима, а ключ учётной записи - лежит теперь обычной колонкой — правка своей записи была бы присвоением чужого - имени. Наш код читает и заводит запись мимо правил, панель работает - суперпользователем, своих экранов профиля сервис не заводит. - -- **Ключ учётной записи — колонка `provider_login`** с уникальным индексом, - заведена шагом `202608220001`. В ней логин человека **у провайдера** — то - значение, которым его называет обратный прокси заголовком. По нему запись - ищется и по нему же заводится при первом обращении. - - Почта в той же коллекции переведена в необязательную тем же шагом: провайдер - не обязан её приносить, а ключом она не служит. Уникальность почты держится - **частичным** индексом (`WHERE email != ''`), поэтому записи без почты - уживаются друг с другом; уникальность логина — обычным, поэтому двух записей с - пустым ключом схема не примет вовсе. -- **Захват записи — один запрос с `RETURNING`**, мимо записей коллекции. - `app.DB()` направляет всё, кроме выборок, в пул с единственным соединением, - поэтому захваты выстраиваются в очередь. Порядок выборки — по времени - заведения **и по ключу**: время неуникально, и без ключа порядок обработки - невоспроизводим. Отбор идёт по рубежам из дескриптора, паузе, сроку протухания - захвата и отсутствию признака остановки; срок протухания выбирается по рубежу - самой записи прямо в запросе — воркер, ещё не знающий, что вытянет, подставить - его не может. + колонки шаг читает отдельным чтением. +- **Файлы записи лежат подкаталогом на запись:** + `data/records//<имя>`. Внутри — принятая копия, приведённая копия + и сохранённый ответ провайдера. Так копии одной записи лежат вместе, а запись + убирается целиком одним движением; плоский каталог, где копии различаются + приставкой в имени, обращал бы уборку в перебор по маске. Имя, данное + отправителем, не попадает ни в имя файла, ни в путь к нему. Ни файлы, ни + объекты в Object Storage не удаляются после завершения записи: каталог и бакет + растут неограниченно. +- **Укладка атомарна:** содержимое пишется во временное имя **в том же + подкаталоге записи** и переименовывается в рабочее только после того, как поток + дочитан до конца без отказа. Строка о файле заводится **после** этого; + содержимое легло, а строка не сохранилась — уложенный файл убирается. +- **Файл отдаётся адресом приложения** — + `GET /app/audiorecords/{id}/file?copy=original|normalized`, — и право пройти по + нему даёт узнавание пришедшего и владение записью. Значений на предъявителя + сервис не выдаёт вовсе: ни короткого токена файла, ни подписанной ссылки со + сроком. Отзыв доступа доходит до файла сразу, а не через срок жизни выданного + значения. Имя файла на диске в журнал не пишется и в ответ не идёт. +- **Учётная запись заводится первым обращением** — поиск по `provider_login` и + вставка идут одной транзакцией на пишущем соединении. Два отказа уникальности + различаются повторным поиском по ключу: нашёлся — гонка двух первых обращений + одним логином, не нашёлся — занятая почта, и запись заводится без неё. +- **Захват записи — один запрос `UPDATE … RETURNING`** по пишущему соединению: + выбор подходящей записи и пометка её захваченной идут вместе. Порядок выборки — + по времени заведения **и по ключу**: время неуникально, и без ключа порядок + обработки невоспроизводим. Отбор идёт по рубежам из дескриптора, паузе, сроку + протухания захвата и отсутствию признака остановки; срок протухания выбирается + по рубежу самой записи прямо в запросе — воркер, ещё не знающий, что вытянет, + подставить его не может. - **Запись результата условна по признаку захвата** — инвариант «Результат пишет только держатель захвата» в [CLAUDE.md](../CLAUDE.md), «Инварианты» (major); - норма — [pipeline](../openspec/specs/pipeline/spec.md). Здесь названо потому, - что условие проверяется тем же запросом, что и сам захват. -- **Список колонок задан двумя местами** — `applyOwnedByPipeline` вместе с - `applyToRecord` и `recordToAudioRecord`, — плюс шагом схемы. Мест было четыре, - пока захват перечислял колонки поимённо; теперь он возвращает идентификатор, и - перечень перестал расти с моделью. Правило правки и его серьёзность — - инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты»; сверку держат правила + норма — [pipeline](../openspec/specs/pipeline/spec.md). Условие стоит в самом + запросе правки, поэтому между проверкой и записью не остаётся окна. +- **Колонки записи отображаются по имени**: именованные параметры запроса и место + назначения, найденное по имени колонки. У аудиозаписи поля одного типа идут + длинным непрерывным рядом, и позиционный список дал бы сдвиг на одно поле, + который компилируется молча и кладёт идентификатор файла в колонку текста. + Перечень задан двумя местами — `writeOwnedByPipeline` вместе с `writeRecord` и + `readRecordColumns`, — плюс шагом схемы; правило правки и его серьёзность — + инвариант в [CLAUDE.md](../CLAUDE.md), сверку держат правила `internal/archrules`. - **Перечень рубежей объявлен одним дескриптором** — `internal/entity/stage.go`. Из него выводятся выбор шага, отбор захвата, срок протухания и предел простоя: рубеж, забытый в отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту проекта не пишется в журнал и не считается в метрику. -- **Отказ хранилища наружу не выходит дословно.** Он несёт ключ файла целиком, а - ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают - свой текст с идентификатором записи, а цепочку `%w` обрывают. То же у выгрузки - в Object Storage: отказ SDK несёт полный URL объекта. +- **Отказ базы наружу не выходит дословно.** Отказы чтения и укладки называют + запись её идентификатором и не несут ни имени файла, ни пути к нему: имя — + часть пути к чужому аудио. То же у выгрузки в Object Storage: отказ SDK несёт + полный URL объекта. +- **Обращения к базе идут с собственным контекстом**, а не с контекстом запроса. + Отменять там нечего: операции местные и короткие, а единственное ожидание — + занятая база — задано числом. За отмену платили бы дважды: шаг, прерванный + остановкой сервиса, перестал бы освобождать захват и писать причину остановки — + то есть отмена ломала бы ровно ту уборку, ради которой она и делается. Отмена, + которой сервис распоряжается по-настоящему, доходит до `ffmpeg` и до платного + распознавания. ## Настройки с числовым значением @@ -336,41 +393,46 @@ capability, и третий смысл развёл бы одно слово п | Срок захвата, опрос операции | 1 час | там же | опрос идёт секунды | | Срок захвата, завершение | 1 час | там же | запись текста и ответ идут секунды | | Число воркеров конвейера | 3 | конфиг, `[pipeline] workers` | решение владельца; ноль — законное значение | +| Ожидание занятой базы | 5000 миллисекунд | конфиг, `[storage] busy_timeout_ms` | выведено из числа воркеров, а не замерено: пишет сервис короткими операциями, и очередь из трёх воркеров укладывается в него с запасом | +| Соединений в читающем пуле | 4 | конфиг, `[storage] read_connections` | число воркеров плюс запас под запросы приложения; пишущее соединение при этом всегда одно и настройкой не делается | | Предел простоя, своя работа | 60 минут | конфиг, `[pipeline] own_work_limit_minutes` | решение владельца 2026-08-14: сторож ловит зависание, а не долгую работу. Число **меньше** времени приведения многочасовой записи, и цена названа прямо — остановка обратима. Предел этот работает только по записи, вернувшейся в выборку: см. строку ниже | | Предел простоя, чужая операция | 1440 минут | конфиг, `[pipeline] foreign_work_limit_minutes` | сколько идёт распознавание долгой записи, никто не мерил: ошибаемся в сторону долгого | | Версия вида структуры реплик | 1 | `entity.StructureVersion` | первая | | Умолчание размера страницы списка | 30 | `controller/http.DefaultPageLimit` | столько помещается на экран телефона без прокрутки в два экрана | | Потолок размера страницы списка | 100 | `controller/http.MaxPageLimit` | против того, чтобы попросить весь архив одним запросом и тем обойти постраничность её же параметром | | Ограничитель частоты под `/app/` | 120 запросов за 60 секунд | `controller/http.appRateMaxRequests`, `appRateWindowSec` | сервисом пользуются единицы человек; бюджет считается по адресу спрашивающего, а не по учётной записи | - -**Адрес спрашивающего берётся из `X-Forwarded-For`, и это назначается кодом при -подъёме** — `controller/http.ApplyTrustedProxyHeaders`. Без этого хранилище -ключует счётчик адресом пира, а пир с переездом входа на заголовок всегда один и -тот же — обратный прокси; бюджет тогда становится общим на весь сервис, и восемь -одновременно открытых карточек выбирают его целиком. Требование к контуру, -которое отсюда следует, записано в [security.md](security.md), «Периметр»: -`X-Forwarded-For` прокси обязан перезаписывать, а не дописывать. +| Срок жизни неиспользуемого счётчика ограничителя | 10 минут | `controller/http.staleBudgetAge` | карта счётчиков растёт с числом адресов, и без уборки она стала бы местом, куда спрашивающий кладёт по строке на каждый свой адрес | | Доля бюджета под опрос карточки | 1/8 | `controller/http.pollBudgetShare` | опрос идёт не один: в ту же секунду приложение листает список и грузит новую запись. Из этой доли **выводится** объявляемая частота опроса, и своей константы у неё нет | | Потолок длины имени файла отправителя | 255 знаков | `entity.MaxOriginalFilenameLen` | предел длины имени в распространённых файловых системах: длиннее системный диалог выбора файла не даёт | | Потолок длины расширения | 32 знака | `service/transcribe.go`, `maxExtLen` | сторож от патологии, а не перечень: расширения известных форматов укладываются в пять знаков, а `x.` с четырьмястами знаками роняет заведение временного файла | | Потолок тем на запись | 5 | `entity.MaxTopicsPerRecord` | решение владельца: без него часовой разговор даёт два десятка тем | | Срок хранения ресурса приложения | 1 год | `controller/http.assetMaxAgeSeconds` | имена ресурсов несут отпечаток содержимого, поэтому ответ устареть не может; срок ставится только файлам из каталога сборщика, всё прочее браузер спрашивает заново | -| Потолок сохранённого ответа провайдера | 256 МиБ | шаг `202608140002` | ответ многословнее расшифровки: несёт альтернативы, время каждого слова и разбор говорящих | -| Потолок структуры реплик | 16 МиБ | там же | шестичасовой разговор даёт порядка мегабайта текста с временем | | Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | как было | | Задержка между проверками операции | 5 секунд | там же | как было | | Пауза воркера между прогонами | 1 секунда | `controller/worker/worker.go` | как было | | Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — | | Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — | | Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — | -| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — | | Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео | -| Предел длины логина у провайдера | 255 знаков | `pbrepo.MaxProviderLoginLength` и колонка `provider_login` | значение приходит заголовком, то есть задаётся тем, кто шлёт запрос; число то же, что у имени в умолчании библиотеки | +| Предел длины логина у провайдера | 255 знаков | `entity.MaxProviderLoginLength` | значение приходит заголовком, то есть задаётся тем, кто шлёт запрос; число то же, что у имени, пригодного к показу | +| Предел длины имени, пригодного к показу | 255 знаков | `entity.MaxDisplayNameLength` | то же | +| Длина идентификатора | 26 знаков | `ident.Len` | ширина записи ULID | -Три числа отсюда ушли 2026-08-22 вместе с собственным входом: срок жизни сессии, -потолок времени на вход у провайдера и таймаут обмена кода. Сессия не выдаётся -вовсе, обменивать код не на что, а отзыв доступа судит провайдер на каждом -запросе — задержке, которую измерял срок сессии, теперь неоткуда взяться. +**Адрес спрашивающего ограничитель берёт из `X-Forwarded-For` — и только тогда, +когда соединение пришло с адреса из объявленного перечня доверенных.** Без этого +счётчик ведётся по адресу пира, а пир с переездом входа на заголовок всегда один +и тот же — обратный прокси; бюджет тогда становится общим на весь сервис, и +восемь одновременно открытых карточек выбирают его целиком. Обратная ошибка — +верить заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого он +ограничивает. Сама цепочка читается справа налево с отбрасыванием доверенных +адресов, поэтому дописывающий прокси правилом покрыт; почему так — +[security.md](security.md), «Периметр». + +Числа, ушедшие отсюда со встроенным хранилищем: потолок сохранённого ответа +провайдера и потолок структуры реплик — их держало поле коллекции, а теперь ответ +лежит файлом, а структура текстовой колонкой; жизнь приглашения завести владельца +панели — панели нет. Прежде, вместе с собственным входом, ушли срок жизни сессии, +потолок времени на вход у провайдера и таймаут обмена кода. **У сторожа простоя есть второй потолок, и он не тот, что в настройке.** Предел простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок @@ -381,15 +443,12 @@ capability, и третий смысл развёл бы одно слово п остановка «застряла» наступает только после него. Мягкая остановка сюда не подпадает: она снимает захват сама. -**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у -тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля -библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело -на 32 МиБ раньше обработчика. Оставленные умолчания отвергали бы всё длиннее -примерно пяти минут. Таймаут чтения запроса снят: шесть часов записи по -медленному каналу переживают любой фиксированный, а стойкость к целенаправленной -нагрузке объявлена вне модели угроз. +**Потолок размера назван числом там, где иначе действует умолчание** — у тела +запроса приёма, и назван дважды: объявленная длина судится заранее, а +необъявленная и солгавшая ловятся на чтении. Умолчания здесь не «без предела», а +величины на два-три порядка меньше нужного. Таймаут чтения запроса снят: шесть +часов записи по медленному каналу переживают любой фиксированный, а стойкость к +целенаправленной нагрузке объявлена вне модели угроз. -Чего среди настроек **нет**: режим журналирования, таймаут занятости и размер -пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения -файлов и объектов нет вовсе. Таймаутов у -обращений к S3 и SpeechKit тоже нет — ни одного. +Чего среди настроек **нет**: срока хранения файлов и объектов нет вовсе. +Таймаутов у обращений к S3 и SpeechKit тоже нет — ни одного. diff --git a/docs/passport.md b/docs/passport.md index f7ec0f0..f9efb46 100644 --- a/docs/passport.md +++ b/docs/passport.md @@ -65,9 +65,9 @@ Telegram. записи у сервиса при этом есть, и границы это не двигает: сервис **зеркалит** имя, названное провайдером, — заводит строку при первом обращении под новым именем и связывает с ней записи владельца. Кто этот человек и пускать ли его, - сервис не решает никогда. Одно исключение появилось 2026-08-11 вместе с - решением про PocketBase: в панель администратора владелец входит своим - паролем, потому что подпустить к ней внешнего провайдера PocketBase не даёт. + сервис не решает никогда. Исключений у этого больше нет: панель администратора + со своим паролем владельца жила здесь с 2026-08-11 по 2026-08-22 и ушла вместе + со встроенным хранилищем — своего входа сервис не ведёт вовсе. - **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не обрабатываем. - **Диктофон.** Запись звука делает телефон, а приложение принимает готовый @@ -117,10 +117,11 @@ Telegram. которой пользуемся: она и задаёт потолок по длине записи и формату. - **Whisper и его серверные обёртки** — запасной путь, если внешний сервис перестанет устраивать по цене или по качеству русской речи. -**PocketBase** из референсов ушла: она больше не кандидат — в стек её перевела -задача `pocketbase-storage` 2026-08-12 -([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)); там она -держит хранилище, файлы и панель владельца. Схема и -раскладка — [database.md](database.md). Учётные записи она хранит, а заводит их -сервис по имени, названному Authelia; источником людей она при этом не -становится: заводит и проверяет их по-прежнему Authelia. +**PocketBase** побывала и референсом, и стеком, и ушла из проекта целиком. +Референсом она быть перестала 2026-08-12, когда задача `pocketbase-storage` +перевела её в стек; стеком — 2026-08-22, когда задача +`storage-without-pocketbase` +([adr](adr/ADR-2026-08-22-storage-without-pocketbase.md)) убрала её вместе с +панелью владельца и собственным адресным пространством. Хранилище у сервиса своё: +SQLite напрямую и файлы записей своим каталогом. Схема и раскладка — +[database.md](database.md). diff --git a/docs/research/README.md b/docs/research/README.md index 5d6c780..268f863 100644 --- a/docs/research/README.md +++ b/docs/research/README.md @@ -20,6 +20,11 @@ SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, чт ## Записи +Две записи о PocketBase — [pocketbase.md](pocketbase.md) и +[pocketbase-defaults.md](pocketbase-defaults.md) — описывают библиотеку, ушедшую +из проекта 2026-08-22. Они остаются записями о прошлом, и строкой в каждой это +сказано. + | Дата | Запись | О чём | | --- | --- | --- | | 2026-08-22 | [Хранилище: PocketBase против голого SQLite с каталогом файлов](storage-without-pocketbase.md) | Шесть ролей библиотеки в этом коде, отпавший довод перевода, объём кода на её типах, шесть модулей только через неё | diff --git a/docs/research/pocketbase-defaults.md b/docs/research/pocketbase-defaults.md index 897057c..cc84bbd 100644 --- a/docs/research/pocketbase-defaults.md +++ b/docs/research/pocketbase-defaults.md @@ -1,5 +1,13 @@ # PocketBase: умолчания, которые ломают штатный сценарий +**Записка о прошлом.** PocketBase ушла из проекта целиком 2026-08-22 — +[ADR-2026-08-22-storage-without-pocketbase](../adr/ADR-2026-08-22-storage-without-pocketbase.md). +Умолчания ниже принадлежат ушедшей библиотеке и ни на что в сервисе не влияют. +Живое из записки переехало в [../database.md](../database.md), «Настройки с +числовым значением», — потолок размера одной записи, потолок тела запроса и +снятый таймаут чтения, — и в +[ADR-2026-08-15-owner-required-by-schema](../adr/ADR-2026-08-15-owner-required-by-schema.md). + Наблюдения, снятые по ходу задачи `pocketbase-storage` уже на своём коде. От [записки разведки](pocketbase.md) отличаются предметом: та мерила, **что даёт панель**, эта — **что библиотека делает молча**, если её не переубедить. diff --git a/docs/research/pocketbase.md b/docs/research/pocketbase.md index cc3986c..f5cc068 100644 --- a/docs/research/pocketbase.md +++ b/docs/research/pocketbase.md @@ -1,5 +1,11 @@ # PocketBase: что даёт панель администратора +**Записка о прошлом.** PocketBase ушла из проекта целиком 2026-08-22 — +[ADR-2026-08-22-storage-without-pocketbase](../adr/ADR-2026-08-22-storage-without-pocketbase.md). +Панели у сервиса нет, и ничто из описанного ниже сегодня не работает. Записка +остаётся затем, что ею мерили цену потери: возврат остановленной записи в работу +делает подкоманда `cmd/devtools resume`, а остальное приносят отдельные задачи. + Отвечает на вопрос разведки `pocketbase-admin-fit`: что панель показывает и правит по трём частям — записи, пользователи, файлы, — и хватает ли этого, чтобы держать перевод хранилища в планах. diff --git a/docs/review.md b/docs/review.md index 1c59f6a..f782849 100644 --- a/docs/review.md +++ b/docs/review.md @@ -72,11 +72,11 @@ - вшито то, что собрано этим прогоном, а не то, что осталось от прошлого; - шаг следует словарю кодов: отказ сети и реестра — 3, красная сборка — 1, и он **отказывает, а не висит**; -- путь, выбранный анонимом, не уходит ни меткой метрики, ни строкой журнала — и - журналов **два**: свой, в вывод контейнера, и журнал хранилища, куда - библиотека кладёт путь целиком вместе с адресом отправителя. Второй молчит - только на успехе и только потому, что признак отказа от записи поставлен - руками: готовая раздача статики ставит его сама, своя — нет. +- путь, выбранный анонимом, не уходит ни меткой метрики, ни строкой журнала. + Журнал у сервиса с 2026-08-22 **один** — свой, в вывод контейнера: второй + ушёл вместе со встроенным хранилищем, которое клало путь целиком вместе с + адресом отправителя. Правило при этом расширилось, а не сузилось: путь не + пишется дословно ни под каким корнем, включая корень приложения. **Клиент внешнего сервиса** (`adapter/recognizer/yandex`): @@ -86,17 +86,20 @@ - вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в успех молча. -**Репозиторий хранилища** (`internal/adapter/repo/pocketbase`; шаги схемы — +**Репозиторий хранилища** (`internal/adapter/repo/sqlite`; шаги схемы — подпакетом `migrations`): -- список колонок совпадает в обоих местах — `applyOwnedByPipeline` вместе с - `applyToRecord` и `recordToAudioRecord` — и в шаге схемы (инвариант - [CLAUDE.md](../CLAUDE.md), «Инварианты»); +- список колонок совпадает во всех трёх местах — `writeOwnedByPipeline` вместе с + `writeRecord`, `readRecordColumns` и `rowToAudioRecord` — и в шаге схемы + (инвариант [CLAUDE.md](../CLAUDE.md), «Инварианты»). Колонки называются + **именами**: именованный параметр запроса и место назначения по имени, а не + позиция в списке; - захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только - держатель захвата; -- репозиторий кладёт время в сыром запросе тем же видом, каким хранилище пишет - свои `created`/`updated` ([database.md](database.md), «Представление данных»); -- отказ хранилища не выходит наружу дословно: он несёт ключ файла целиком. + держатель захвата, и держатель узнаётся значением признака; +- репозиторий кладёт время тем же видом, каким его кладут остальные, и берёт его + из единой точки ([database.md](database.md), «Представление данных»); +- отказ хранилища не выходит наружу дословно: он несёт ключ файла и путь к нему + целиком. **Обёртка над внешним процессом** (`adapter/converter/ffmpeg`, `adapter/metaviewer/ffmpeg`): @@ -256,7 +259,7 @@ - изменение, трогающее конвейер задач целиком: состояние, воркер, шаг сервиса и колонку разом; -- замена хранилища или переход на PocketBase — любой её кусок; +- замена хранилища — любой её кусок; - смена модели очереди: захват, повторы и воркеры разом; - каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом; - изменение, убирающее или возвращающее вход приёма целиком. @@ -301,9 +304,10 @@ API и имя не откатываются обратной правкой по **весь** барьер: он обязан заголовки `Remote-*` перезаписывать, а не пропускать пришедшие. Проверить это отсюда нечем — правило живёт в `pet-project-server` ([adr/ADR-2026-08-12-access-delegated-to-provider.md](adr/ADR-2026-08-12-access-delegated-to-provider.md)); -- `security`: поведение браузера с куками. Своих кук сервис больше не ставит - (2026-08-22), и класс сузился до кук, которые ставит панель хранилища; браузера - в прогоне нет, и находки этого рода остаются гипотезами. +- `security`: поведение браузера с куками. Своих кук сервис не ставит с + 2026-08-22, а вместе со встроенным хранилищем ушли и те, что ставила его + панель. Класс опустел, и строка стоит здесь затем, чтобы возврат кук читался + как возврат недоступного проверке, а не как обычная работа. **Перестали проверять сознательно:** @@ -315,7 +319,8 @@ API и имя не откатываются обратной правкой по - **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять можно: он встаёт своим единственным входом на выдуманных непустых ключах секций `[auth]` и `[yandex]` — наружу они на старте не ходят. Живой прогон — - осмотр HTTP, панели, журнала, метрик и остановки — доступен любой задаче. + осмотр HTTP, журнала, метрик и остановки — доступен любой задаче; панели среди + предметов осмотра нет с 2026-08-22. Прежняя формулировка «всё, что требует поднять сервис целиком» снята задачей `local-run-without-telegram-token` 2026-08-13; рецепт прогона менялся дважды — с пустого ключа доступа на выключенный вход (`telegram-enabled-flag` того же @@ -341,6 +346,69 @@ API и имя не откатываются обратной правкой по истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не оракул, и выдумывать оракул задним числом нельзя. +## 2026-08-23 — путь, выбранный анонимом, уезжал в журнал под корнем приложения [пойман ревью] + +- **Где:** `internal/controller/http/journal.go`, `JournalRoute`; задача + `storage-without-pocketbase` +- **Симптом:** неузнанный писал в журнал владельца свой текст произвольной длины. + Путь под корнем приложения уходил в строку дословно — в том числе при ответе + `401`, потому что слой журнала стоит снаружи ограничителя частоты +- **Причина:** правило «путь спрашивающего в журнал не идёт» было записано только + для запроса, отданного приложению. Путь вида `/app/<текст>` принадлежит + сервису, под то правило не подпадал и уезжал целиком, хотя множеством значений + под корнем распоряжается тот же аноним +- **Чем воспроизведён:** прогон враждебного прохода — путь в 1 044 480 знаков дал + прирост журнала в 1 044 632 байта; одно соединение за 1,003 с дало 122 запроса + и 121,5 МиБ журнала; 120 отказов ограничителя оставили 240 строк +- **Почему не поймали раньше:** правило записали по месту, где его впервые + понадобилось применить, а не по признаку «значением распоряжается спрашивающий». + Зазор был ровно шириной в корень приложения +- **Что меняем:** `JournalRoute` обобщает всё, что накрыто корнем приложения, а + длину отдаёт полем `http.path_length`; дословно пишутся только адреса из + закрытого перечня. Правило в [conventions/logging.md](conventions/logging.md) + переписано на все корни разом — оно стоит теперь у строки о всяком входящем + запросе, а не у строки о раздаче приложения + +## 2026-08-23 — ключ бюджета ограничителя выбирал тот, кого ограничивают [пойман ревью] + +- **Где:** `internal/controller/http/rate_limit.go`, `clientAddress`; задача + `storage-without-pocketbase` +- **Симптом:** ограничитель пропустил 1200 запросов одного спрашивающего при + бюджете 120 за окно. Заодно карта счётчиков росла линейно от числа выдуманных + адресов +- **Причина:** адрес брался из **левого** значения `X-Forwarded-For`, а прокси + заголовок дописывает, а не заменяет. Левым значением распоряжается сам + спрашивающий, значит он же выбирает и ключ карты — и меняет его на каждом + запросе +- **Чем воспроизведён:** прогон враждебного прохода — 1200 пропущенных запросов + при бюджете 120; 200 000 ключей в карте дали прирост кучи в 19 810 376 байт +- **Почему не поймали раньше:** слой писался заново вместе с транспортом, а + свойство «ключ бюджета не выбирает тот, кого ограничивают» не стояло ни в + конвенции, ни в типовом узле — его держала прежде чужая библиотека +- **Что меняем:** цепочка читается справа налево, доверенные адреса + отбрасываются, ключом становится первый недоверенный, а заголовок читается + всеми строками, а не одной. Требование к контуру этим снято: дописывающий + прокси правилом покрыт — [security.md](security.md), «Периметр» + +## 2026-08-23 — инвариант о колонках записи потерял предмет [пойман ревью] + +- **Где:** [CLAUDE.md](../CLAUDE.md), «Инварианты»; + `internal/adapter/repo/sqlite/record_mapping.go`, `internal/archrules` +- **Симптом:** инвариант называл поимённо `applyOwnedByPipeline`, `applyToRecord` + и `recordToAudioRecord` — функций с такими именами в коде уже не было. Сослаться + на инвариант как на оракул стало нельзя +- **Причина:** сторож и отображение переписаны под новую форму хранилища, а текст + инварианта остался от прежней. Мест при этом стало три: что спрошено + (`readRecordColumns`), куда лягут (`recordRow`) и что доедет до сущности + (`rowToAudioRecord`), — а сверялось правилом одно +- **Чем воспроизведён:** `grep` по трём прежним именам — пусто; `grep` по + `rowToAudioRecord` в `internal/archrules` — пусто +- **Почему не поймали раньше:** инвариант проверяется правилом, а имена в его + тексте — ничем. Текст и сторож разошлись молча +- **Что меняем:** инвариант назван действующими именами и действительным числом + мест; правило `internal/archrules` расширено на `rowToAudioRecord` — перечень + колонок чтения сверяется с перечнем присвоений в сущность + ## 2026-08-15 — короткая форма рецепта входа не работала, а проверяли длинную [пойман ревью] - **Где:** `cmd/oidcstub` — подставной провайдер OIDC для локального входа; diff --git a/docs/security.md b/docs/security.md index 5993495..0295127 100644 --- a/docs/security.md +++ b/docs/security.md @@ -43,27 +43,22 @@ Telegram — связи чата с учётной записью сервис сделало сервис архивом. Оба сдвига описаны ниже разделами «Куда уходит содержимое записи» и «Что вне модели». -**Третий сдвиг — панель администратора.** Решением от 2026-08-11 -([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)) хранилищем -становится PocketBase, и вместе с ним на том же порту появляется панель по -адресу `/_/`: доступ ко всем записям, всем файлам и всем пользователям разом. -Порт опубликован в интернет через обратный прокси, а сама PocketBase вход в -панель через Authelia не пускает — у неё свой пароль суперпользователя. -**Закрывает панель контур, а не приложение:** решением владельца от 2026-08-11 -адрес `/_/` закрывает Authelia на обратном прокси, пропуская только группу -администраторов. Задачи в беклоге у этого нет — работа принадлежит выкладке, а -она вне модели («Что вне модели», строка про контур). +**Третьего сдвига — панели администратора — больше нет, и это снятие.** Решением +от 2026-08-11 хранилищем становилась PocketBase, и вместе с ней на том же порту +появлялась панель `/_/`: доступ ко всем записям, всем файлам и всем пользователям +разом, закрываемый не приложением, а правилом обратного прокси. 2026-08-22, +задачей `storage-without-pocketbase`, встроенное хранилище убрано целиком: +панели не существует, второго периметра на порту сервиса не осталось, и правилу +прокси нечего закрывать. -**И этот барьер обходится подменой одного знака.** Маршрутизатор сравнивает -сегменты пути **после** раскодирования, поэтому `/%5f/` попадает в ту же группу, -что и `/_/`, а правило прокси написано на литерал и такой формы не видит. -Проверено прогоном 2026-08-15 ревью задачи `spa-skeleton`: обе формы отвечают -байт в байт, и весь клиент панели грузится анониму. Вход в приложение при этом -не обходится — `/%61pp/me` отвечает `401`. Дефект старше задачи, которая его -нашла, и **сегодня не закрыт**: лечение — приведение пути к канонической форме на -стороне сервиса, и глухая проверка тут не годится, потому что сломает скачивание -файлов с пробелами и не-латиницей в имени. Половину пути проверить нечем: правило -прокси живёт в `pet-project-server`, вне этого репозитория. +**Вместе с панелью снят и дефект подменённого знака.** Маршрутизатор сравнивал +сегменты пути после раскодирования, поэтому `/%5f/` попадал в ту же группу, что и +`/_/`, а правило прокси, написанное на литерал, такой формы не видело — весь +клиент панели грузился анониму (проверено прогоном 2026-08-15 ревью задачи +`spa-skeleton`). Лечится он теперь тем, что за обоими адресами не стоит ничего: +оба попадают под общее правило неизвестного пути и отдают разметку приложения. +Проверено прогоном 2026-08-22: `/_/`, `/%5f/` и всякий путь под `/api/` отвечают +байт в байт тем же, чем отвечает выдуманный путь вне корней сервиса. **Четвёртый сдвиг был — секрет клиента в базе, — и он снят.** Задача `oidc-login` 2026-08-12 клала адреса провайдера, идентификатор клиента и его @@ -82,14 +77,17 @@ Telegram — связи чата с учётной записью сервис `Remote-*` прокси обязан перезаписывать, а не пропускать**. Выкладку запускает человек. -**То же требование распространяется на `X-Forwarded-For`, и по другой причине.** -С 2026-08-22 сервис называет этот заголовок хранилищу источником адреса -спрашивающего — иначе счётчик ограничителя частоты ключуется адресом пира, а -пир теперь всегда один, и бюджет становится общим на весь сервис. Прокси, -дописывающий `X-Forwarded-For` к присланному вместо замены, отдаёт ключ счётчика -самому спрашивающему: тот меняет значение и обходит ограничитель. Барьером -узнавания этот заголовок при этом не служит — кто пришёл, решает адрес самого -соединения. +**`X-Forwarded-For` сервис читает сам, и правило чтения закрывает дописывание.** +С 2026-08-22 адрес спрашивающего ограничитель частоты берёт из этого заголовка: +иначе счётчик ведётся по адресу пира, а пир теперь всегда один — прокси, — и +бюджет становится общим на весь сервис. Цепочка читается **справа налево**, +доверенные адреса отбрасываются, и ключом становится первый недоверенный: левым +значением распоряжается сам спрашивающий, а правое приписал ближайший к нам +прокси. Заголовок читается всеми строками, а не одной: цепочка законно приходит +несколькими. Прокси, дописывающий `X-Forwarded-For` к присланному, этим правилом +покрыт, и требования «перезаписывать, а не дописывать» у сервиса к нему нет — в +отличие от `Remote-*`. Барьером узнавания заголовок при этом не служит: кто +пришёл, решает адрес самого соединения. **Ширина перечня доверенных адресов — тоже цена, и она принимается сознательно.** Перечень задаёт, чьему `Remote-User` верить, и всякий, кто дотянулся до сервиса @@ -150,7 +148,7 @@ Telegram — связи чата с учётной записью сервис Сегодня запись покидает наш сервер двумя путями: файл уезжает в Yandex Object Storage, оттуда его читает SpeechKit. Третий путь — ответ в Telegram — исчез 2026-08-14 вместе с убранным входом: текст теперь достаётся только своим адресом -и в панели владельца. +приложения. Целевой периметр добавляет три пути, каждый — своей задачей: @@ -170,44 +168,40 @@ Storage, оттуда его читает SpeechKit. Третий путь — Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда путь не предполагался. -- **Путь на диске** выбирает хранилище: - `data/storage/<коллекция>/<запись>/<имя>`. **Имя задаёт сервис** — - `<расширение>`, — а умолчание PocketBase, строящее имя из имени - отправителя, не применяется: имя отправителя в хранилище не попадает. - Расширение берётся из имени отправителя через `filepath.Ext` без проверки +- **Путь на диске** выбирает сервис: `data/records//<имя>`. Обе + части задаёт он сам — подкаталог назван идентификатором записи, имя файла это + `<расширение>`, — и имя, данное отправителем, не попадает ни в одну из + них. Расширение берётся из имени отправителя через `filepath.Ext` без проверки списком; `filepath.Ext` режет по последней точке и не пропускает разделитель - каталогов, но это единственное, что стоит между входом и именем файла. + каталогов, но это единственное, что стоит между входом и именем файла. Длина + расширения при этом ограничена числом — иначе `x.` с четырьмястами знаками + роняет заведение временного файла. - **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с расширением. Бакет один на все записи, префикса по пользователю нет. С 2026-08-14 копия там файлом записи не считается: она существует лишь потому, что провайдер читает аудио по адресу, и её ключ живёт в строке попытки распознавания. -- **Вторая раскладка файла на диске** появилась 2026-08-14 вместе с сохранённым - ответом провайдера: `data/storage//<попытка>/<имя>.payload`. Имя - задаёт сервис, как и у аудио. Содержимое там — **полный текст речи**, а не - метаданные, поэтому поле помечено защищённым, правило просмотра коллекции - оставлено пустым, и ссылка на вложение подпадает под тот же запрет, что и - ссылка на аудио: в журнал она не пишется. Проверено прогоном: без сессии, с - чужим и со своим токеном файла ссылка отвечает «не найдено». -- **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла - помечено защищённым задачей `oidc-login` 2026-08-12: пройти по ссылке теперь - можно только с коротким токеном файла, который выдаётся по сессии, и запрос - без него получает «не найдено». Сама ссылка отзыва по-прежнему не имеет — - токен сужает круг и живёт недолго, но выданное не отзывается. Отсюда запрет - остаётся: **имя файла в хранилище в журнал не пишется** - — иначе строка журнала вместе с идентификатором записи собирала бы ссылку - целиком и работала бы бессрочно. В журнал идёт расширение своим полем. -- **Идентификатор записи** — 15 знаков, выдаёт хранилище. Он же единственное, - что защищает карточку записи и её текст. -- **Поверхность самого хранилища.** Вместе с переводом наружу выходят - `/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`, - `/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то - есть доступны они только владельцу панели, — и коллекции, заведённые - 2026-08-14, тоже: содержимое записи отдаёт собственный адрес сервиса, а не - поверхность хранилища. Коды, снятые прогоном, — - [database.md](database.md), «Коллекции», норма — - [storage](../openspec/specs/storage/spec.md), «Наружу хранилище отдаёт только - то, что заказано». +- **Сохранённый ответ провайдера** лежит третьим файлом в том же подкаталоге + записи, под именем, которое задаёт сервис. Содержимое там — **полный текст + речи**, а не метаданные, поэтому закрыт он наравне с расшифровкой: адреса, + которым его читают снаружи, у сервиса нет вовсе, а путь к нему не пишется ни в + журнал, ни в метку метрики, ни в ответ. +- **Адрес файла** — `GET /app/audiorecords/{id}/file?copy=original|normalized`. + Право пройти по нему даёт **узнавание пришедшего и владение записью**, и + судится оно там же, где отдаётся файл. Значений на предъявителя сервис не + выдаёт вовсе: короткий токен файла ушёл 2026-08-22 вместе со встроенным + хранилищем, и отзыв доступа доходит до файла сразу, а не через срок жизни + выданного значения. Запрет при этом остаётся: **имя файла на диске в журнал не + пишется** — строка журнала стала бы бессрочным ключом к чужой записи. В журнал + идёт расширение своим полем. +- **Идентификатор записи** — ULID, 26 знаков, выдаёт приложение. Он же + единственное, что защищает карточку записи, её текст и её файл сверх владения. +- **Чужой поверхности на порту сервиса нет.** Адреса `/api/collections/...`, + `/api/logs`, `/api/backups`, `/api/settings`, `/api/crons` и панель `/_/` ушли + вместе со встроенным хранилищем 2026-08-22. Отвечает сервис только своими + адресами, а всё прочее идёт общим правилом неизвестного пути — норму держит + [webapp](../openspec/specs/webapp/spec.md). Что содержимое записи закрыто + везде, где лежит, нормирует [storage](../openspec/specs/storage/spec.md). Целевой периметр добавляет сюда три вещи, и все три — от новых задач: @@ -227,33 +221,34 @@ Storage, оттуда его читает SpeechKit. Третий путь — заголовка: пересылаемым распоряжается тот, кто шлёт запрос. Значения, переживающего запрос, сервис не выдаёт вовсе — ни куки, ни токена, — и потому отзыв доступа у Authelia действует со следующего обращения. - Предъявленный собственный токен хранилища побеждает заголовок: им работает - владелец панели, и подмена его учётной записью пользователя отобрала бы у него - панель. Протухший и негодный токен предъявленными не считаются. - **Область узнавания сужена** до корня приложения и адреса выдачи файлового - токена: собственная поверхность хранилища под неё не подпадает, иначе узнанный - переписал бы себе ключ учётной записи на чужое имя. + Собственных токенов сервис не принимает вовсе: значения, предъявленного + запросом и дающего доступ помимо заголовка, у него не существует. Прежде такое + значение било заголовок — им работал владелец панели; панели нет, и правило + приоритета осталось бы правилом без предмета. + **Область узнавания — корень приложения**, и выводится она из объявленного + адресного пространства сервиса: слои одеты на корень целиком, вторым списком + адресов область не описывается. Проба здоровья, метрики и ресурсы приложения + под неё не подпадают — иначе запрос за каждой картинкой стоил бы обращения к + базе, а первый такой запрос с новым именем — записи в неё. - **Учётная запись** — заводится первым обращением с новым логином и находится по нему же дальше. Ключ — колонка `provider_login`, уникальная; править её - снаружи нельзя, все пять правил доступа коллекции пользователей закрыты шагом - схемы `202608220001`. -- **Файл записи** — короткий токен файла, который берёт узнанный. Поле файла - помечено защищённым, правило просмотра коллекции пускает только владельца - файла, и ссылка `/api/files/...` перестала быть правом пройти по ней. Одного - заголовка мало: порядок здесь «узнавание → токен файла → ссылка». **Это - единственное значение, переживающее запрос**, и на его срок отзыв доступа до - файловой ссылки не доходит. + снаружи нельзя, потому что адреса правки учётной записи у сервиса нет вовсе: + своих экранов профиля он не заводит, а поверхности хранилища, правившей запись + библиотечным правилом, не осталось. +- **Файл записи** — узнавание пришедшего и владение записью, судимые в самом + обработчике отдачи. Отказ наступает **на обращении за файлом**: другого места, + где он мог бы наступить, у сервиса не осталось. Значений, переживающих запрос, + сервис не выдаёт ни одного, поэтому отзыв доступа доходит и до файла. - **Кто допущен** — **решает Authelia, а не сервис.** Своей проверки группы приложение не делает: кого пускать, определяет правило провайдера на этого клиента. Правило живёт **вне репозитория**, в настройках выкладки, и по коду его не проверить. Клиент, настроенный слишком широко, открывает сервис всякому, у кого есть учётная запись в общей Authelia. Решение владельца от 2026-08-12. -- **Собственный вход хранилища закрыт целиком.** Создание записи, вход по - паролю, одноразовый код, обмен кода у внешнего провайдера, восстановление - доступа и продление — ни один не даёт доступа и не меняет учётной записи: - хранилище заводит коллекцию пользователей открытой, и без этого закрытия - узнавание обходилось бы двумя запросами. +- **Собственного входа у сервиса нет вовсе.** Создание записи, вход по паролю, + одноразовый код, обмен кода у внешнего провайдера, восстановление доступа и + продление принадлежали встроенному хранилищу и ушли вместе с ним: закрывать + больше нечего, и адресов этих не существует. - **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты неузнанному: учётной записи нет ни у пробы, ни у сборщика. Заголовок их ответа не меняет и учётной записи на них не заводит. Наружу их закрывает правило обратного @@ -284,23 +279,27 @@ Storage, оттуда его читает SpeechKit. Третий путь — Откуда он берётся — из группы OIDC или из конфигурации — не решено (`admin-stats-screen`). -**Панель администратора в эту таблицу не входит и разграничению не подчиняется.** -Суперпользователь PocketBase видит все записи, все файлы и всех пользователей -мимо любого из четырёх механизмов, а пускает его свой пароль, а не Authelia. -Замер показал, что закрыть панель провайдером OIDC или вторым фактором нельзя: -обе настройки у коллекции суперпользователей отклоняются. Остаётся ограничение -по списку адресов (`superuserIPs`), и оно же запирает владельца, если список -задан неверно: сброса в наборе команд нет. +**Панели администратора в этой таблице нет, и это снятие, а не пропуск.** До +2026-08-22 суперпользователь встроенного хранилища видел все записи, все файлы и +всех пользователей мимо любого из механизмов разграничения, а пускал его свой +пароль, а не Authelia. Хранилище ушло, панели не существует, и разграничение у +сервиса осталось одно — владение записью. + +Владелец сервиса взамен получил одно действие и один инструмент: подкоманда +`cmd/devtools resume` возвращает остановленную запись в работу. Она ходит **в тот +же каталог данных**, то есть требует доступа к файлам сервера, а не к сети: +поверхности, открытой в интернет, у неё нет вовсе. ## Что чувствительнее чего 1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка; это самое чувствительное, что здесь есть. С 2026-08-14 оно живёт не одной - колонкой, а шестью коллекциями: сама запись (заголовок и краткое описание), + колонкой, а шестью таблицами: сама запись (заголовок и краткое описание), `texts` (расшифровка и вычитанный текст), `structures` (реплики со временем), - `recognitions` (**сырой ответ провайдера вложением — полный текст речи**), + `recognitions` (попытка распознавания; **сохранённый ответ провайдера — + полный текст речи — лежит файлом в подкаталоге записи**), `record_events` (журнал событий, содержимого не несёт) и `topics` (словарь - тем человека). Всякая новая коллекция, куда содержимое переезжает, закрывается + тем человека). Всякая новая таблица, куда содержимое переезжает, закрывается наравне с записью — норму держит спека `storage`. 2. **Ключи Yandex Cloud** — `speech_kit_api_key` и пара ключей Object Storage. Утечка оплачивается деньгами и доступом к бакету. @@ -325,14 +324,10 @@ Storage, оттуда его читает SpeechKit. Третий путь — 5. **Статистика потребления** (`usage-accounting`). Текста записей не содержит, но говорит, кто и когда пользовался сервисом и сколько; страница расхода открыта только владельцу. -6. **Пароль владельца от панели.** Открывает все записи, все файлы и всех - пользователей разом, то есть стоит вровень с самым чувствительным из списка - выше. Второй секрет после токенов пользователей, который лежит **не в - конфигурации**: его отпечаток хранит сама база, а задаёт пароль сам владелец - по приглашению, которое сервис печатает в журнал при первом запуске. У - приглашения тридцать минут жизни, и после того как владелец заведён, оно не - печатается вовсе — иначе строка журнала отдавала бы панель всякому его - читателю навсегда. +Пароля владельца от панели в этом списке больше нет: он ушёл 2026-08-22 вместе с +самой панелью. Секрет, появившийся только ради перевода на встроенное хранилище, +пропал, и ключа под него в конфигурации не заводится по той простой причине, что +заводить нечего. Тексты расшифровок в логи не пишутся — логируется длина текста и идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано @@ -355,7 +350,7 @@ Storage, оттуда его читает SpeechKit. Третий путь — Это закрыто задачей `no-user-filename-in-log` 2026-08-11 вместе с самим именем. Заодно у метки размера принятой записи пропала ведущая точка (`.mp3` стало `mp3`) — форма выровнялась с меткой конвертации, которая точку не носила -никогда. Ряды, собранные до выкладки, перестают пополняться: панель, отобранная +никогда. Ряды, собранные до выкладки, перестают пополняться: график, отобранный по старому значению, покажет пустоту, и это не поломка. Требование важно тем, что `GET /metrics` открыт вместе с остальным: без приведения хвост читал бы кто угодно из интернета, а множеством значений метки @@ -422,14 +417,13 @@ Storage, оттуда его читает SpeechKit. Третий путь — Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а строки потребления текста не содержат. - **Руками запись сегодня не удаляется, и прежняя строка об этом была неверна.** - Проверено прогоном 2026-08-14: содержимое живёт в коллекциях, перечисленных - выше («Что чувствительнее чего»), связи приложений с записью обязательны и - каскада не имеют, поэтому удаление самой - строки записи отвергается хранилищем, а удаление её файлов проходит молча. - Владелец, выполнивший прежнюю процедуру, стирает аудио и **оставляет полный - текст речи** — расшифровку, разбивку по репликам и сырой ответ провайдера - файлом на диске. Порядок, которым запись убирается на самом деле: сперва - строки приложений — журнал событий, попытка распознавания вместе с её - вложением, структура, тексты, — потом сама запись, потом её файлы. До + **Руками запись сегодня убирается только запросом к базе, и порядок в нём + несущий.** Содержимое живёт в таблицах, перечисленных выше («Что чувствительнее + чего»), связи приложений с записью обязательны и каскада не имеют, поэтому + удаление самой строки отвергается базой, пока живы приложения. Порядок такой: + сперва строки приложений — журнал событий, попытка распознавания, структура, + тексты, связи с темами, — потом сама запись, потом её файлы. Файлы при этом + убираются **одним движением**: подкаталог записи под её идентификатором. Тот, + кто убрал только файлы, стирает аудио и **оставляет полный текст речи** — + расшифровку, разбивку по репликам и сохранённый ответ провайдера. До `delete-record` это единственный способ, и он ручной целиком. diff --git a/go.mod b/go.mod index b0020d6..795d6c3 100644 --- a/go.mod +++ b/go.mod @@ -12,18 +12,16 @@ require ( github.com/aws/smithy-go v1.27.7 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 - github.com/pocketbase/dbx v1.12.0 - github.com/pocketbase/ozzo-validation/v4 v4.3.0 - github.com/pocketbase/pocketbase v0.39.10 + github.com/pressly/goose/v3 v3.27.3 github.com/prometheus/client_golang v1.23.0 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.11.1 github.com/yandex-cloud/go-genproto v0.17.0 google.golang.org/grpc v1.82.1 google.golang.org/protobuf v1.36.11 + modernc.org/sqlite v1.57.0 ) require ( - github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect @@ -40,41 +38,28 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/disintegration/imaging v1.6.2 // indirect - github.com/domodwyer/mailyak/v3 v3.6.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/fatih/color v1.19.0 // indirect - github.com/fsnotify/fsnotify v1.10.1 // indirect - github.com/gabriel-vasile/mimetype v1.4.13 // indirect - github.com/ganigeorgiev/fexpr v0.6.0 // indirect - github.com/go-sql-driver/mysql v1.9.2 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mattn/go-colorable v0.1.15 // indirect - github.com/mattn/go-isatty v0.0.23 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.65.0 // indirect - github.com/prometheus/procfs v0.16.1 // indirect + github.com/prometheus/procfs v0.21.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/spf13/cast v1.10.0 // indirect - github.com/spf13/cobra v1.10.2 // indirect - github.com/spf13/pflag v1.0.10 // indirect - golang.org/x/crypto v0.54.0 // indirect - golang.org/x/image v0.45.0 // indirect + github.com/sethvargo/go-retry v0.4.0 // indirect + go.uber.org/multierr v1.11.0 // indirect golang.org/x/net v0.57.0 // indirect - golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - modernc.org/libc v1.74.1 // indirect + modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.55.0 // indirect ) diff --git a/go.sum b/go.sum index 814351b..07144ea 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,5 @@ -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= -github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= -github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= @@ -47,147 +42,99 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= -github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= -github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8= -github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= -github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= -github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= -github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= -github.com/ganigeorgiev/fexpr v0.6.0 h1:Fza3O/QMBKEudUvxV862qe6GjxM60GJjjKytdp+VQus= -github.com/ganigeorgiev/fexpr v0.6.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= -github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= -github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= -github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= -github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw= -github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= -github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= -github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA= -github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= -github.com/pocketbase/ozzo-validation/v4 v4.3.0 h1:uKBDVma7bZqgR2a6AwE+k9hkuDFfiZMpBHQdZ1z3iQs= -github.com/pocketbase/ozzo-validation/v4 v4.3.0/go.mod h1:6XNjSTw/Jb2F8LOkKO3oyzIWExbrGiYoS4uVxVwz90g= -github.com/pocketbase/pocketbase v0.39.10 h1:2j8TDJRuo3aAC8Y8F9WFux0SwYcxeDCgEYQxxdWkwGE= -github.com/pocketbase/pocketbase v0.39.10/go.mod h1:tSX3anHQ7Ul6dPV9WhlEc6No1DtklGF69iwnVNW3BEE= +github.com/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA= +github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY= github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= -github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= -github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= -github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw= +github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yandex-cloud/go-genproto v0.17.0 h1:uQ5Lr8B/xIyY1KrOm7pItYY3YT/DL1O8gVaY03ouYKM= github.com/yandex-cloud/go-genproto v0.17.0/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= -golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= -golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= -golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= -golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a h1:qI/YMH1ep2qQtqcp00gMQyoU7mjvbhg88GJKCvfoLj0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= @@ -195,11 +142,10 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= -modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= @@ -210,8 +156,8 @@ modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= -modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= @@ -220,8 +166,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM= -modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= +modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg= +modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/internal/adapter/repo/pocketbase/app.go b/internal/adapter/repo/pocketbase/app.go deleted file mode 100644 index 165b211..0000000 --- a/internal/adapter/repo/pocketbase/app.go +++ /dev/null @@ -1,65 +0,0 @@ -// Package pocketbase — хранилище задач и файлов поверх встроенной PocketBase. -// -// Приложение поднимается библиотекой, а не её набором команд: разбор флагов и -// мягкая остановка остаются нашими, а ключ `-c config.toml` — объявленный -// контракт запуска. -package pocketbase - -import ( - "fmt" - - pb "github.com/pocketbase/pocketbase" - "github.com/pocketbase/pocketbase/core" - - // Шаги схемы регистрируются загрузкой своего пакета, а накатывает их - // `RunAllMigrations` ниже. Импорт здесь пустой и явный, хотя соседние файлы - // пакета и так берут оттуда имена коллекций: день, когда имена перестанут - // читаться отсюда, унёс бы вместе с последней ссылкой и регистрацию — список - // шагов остался бы пустым, `RunAllMigrations` вернул бы `nil`, и приложение - // поднялось бы здоровым, но без коллекций. Отказ вылез бы не на старте, а на - // первом приёме записи. - _ "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" -) - -// New создаёт приложение хранилища на заданном каталоге данных и приводит его в -// рабочее состояние: открывает базу, читает настройки и накатывает непринятые -// шаги схемы. -// -// Схема накатывается **здесь**, а не оставляется серверу, хотя тот и гоняет -// непринятые шаги сам. Причина в порядке: воркеры стартуют раньше сервера, и на -// чистом каталоге их первые опросы приходились бы на несуществующую таблицу — -// отказ в журнале и в счётчике на каждую секунду до конца накатки. -func New(dataDir string) (*pb.PocketBase, error) { - app := pb.NewWithConfig(pb.Config{ - DefaultDataDir: dataDir, - HideStartBanner: true, - }) - - if err := app.Bootstrap(); err != nil { - return nil, fmt.Errorf("failed to bootstrap storage: %w", err) - } - - if err := app.RunAllMigrations(); err != nil { - return nil, fmt.Errorf("failed to apply storage schema: %w", err) - } - - // Страж владельца вешается здесь, а не вызывающим: он защищает архив от - // удаления учётной записи, и сборка, забывшая его позвать, теряет защиту - // молча. Так это уже и было — окружение проверок его не ставило, и всё - // разграничение проверялось на приложении, где архив сносится одним - // запросом. - GuardOwnerDeletion(app) - - return app, nil -} - -// MustFindCollection достаёт коллекцию по имени. Отсутствие коллекции здесь — -// не отказ окружения, а несделанный шаг схемы: сервис до этой точки не доходит, -// потому что Serve накатывает схему прежде, чем поднять сервер. -func findCollection(app core.App, name string) (*core.Collection, error) { - collection, err := app.FindCollectionByNameOrId(name) - if err != nil { - return nil, fmt.Errorf("failed to find collection %s: %w", name, err) - } - return collection, nil -} diff --git a/internal/adapter/repo/pocketbase/file_repo.go b/internal/adapter/repo/pocketbase/file_repo.go deleted file mode 100644 index d9353f7..0000000 --- a/internal/adapter/repo/pocketbase/file_repo.go +++ /dev/null @@ -1,261 +0,0 @@ -package pocketbase - -import ( - "errors" - "fmt" - "io" - "os" - "path/filepath" - - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tools/filesystem" - - "git.vakhrushev.me/av/transcriber/internal/contract" - "git.vakhrushev.me/av/transcriber/internal/entity" - - "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" -) - -// workFile — рабочая копия файла на диске. Живёт во временном каталоге -// системы, а не в каталоге данных: последний смонтирован на сервере, и -// временному там не место. -type workFile struct { - path string -} - -func (w *workFile) Path() string { return w.path } - -func (w *workFile) Size() (int64, error) { - info, err := os.Stat(w.path) - if err != nil { - return 0, fmt.Errorf("failed to stat work file: %w", err) - } - return info.Size(), nil -} - -// Close убирает копию. Отсутствие файла отказом не считается: шаг мог не дойти -// до его создания, и повторный Close тоже законен. -func (w *workFile) Close() error { - if err := os.Remove(w.path); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to remove work file: %w", err) - } - return nil -} - -type FileRepository struct { - app core.App -} - -func NewFileRepository(app core.App) *FileRepository { - return &FileRepository{app: app} -} - -// newWorkFile заводит пустую копию во временном каталоге. Расширение сохраняется -// в имени: `ffprobe` и `ffmpeg` по нему выбирают разбор. -func newWorkFile(ext string) (*workFile, error) { - f, err := os.CreateTemp("", "transcriber-*"+ext) - if err != nil { - return nil, fmt.Errorf("failed to create work file: %w", err) - } - path := f.Name() - if err := f.Close(); err != nil { - _ = os.Remove(path) - return nil, fmt.Errorf("failed to close work file: %w", err) - } - return &workFile{path: path}, nil -} - -func (repo *FileRepository) StageEmpty(ext string) (contract.WorkFile, error) { - return newWorkFile(ext) -} - -func (repo *FileRepository) Stage(ext string, content io.Reader) (contract.WorkFile, error) { - work, err := newWorkFile(ext) - if err != nil { - return nil, err - } - - if err := writeTo(work.path, content); err != nil { - // Отказ уборки не подменяет отказ записи, но и не теряется. - return nil, errors.Join(err, work.Close()) - } - - return work, nil -} - -func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) { - record, err := repo.app.FindRecordById(migrations.FilesCollection, fileID) - if err != nil { - return nil, fmt.Errorf("failed to find file %s: %w", fileID, err) - } - - name := firstFileName(record) - if name == "" { - return nil, fmt.Errorf("file %s has no content in storage", fileID) - } - - work, err := newWorkFile(filepath.Ext(name)) - if err != nil { - return nil, err - } - - src, err := repo.openStored(record, name) - if err != nil { - return nil, errors.Join(err, work.Close()) - } - defer src.Close() - - if err := writeTo(work.path, src); err != nil { - return nil, errors.Join(err, work.Close()) - } - - return work, nil -} - -// Create кладёт рабочую копию в хранилище. Имя задаём мы: умолчание библиотеки -// строит его из имени, данного отправителем, а имя отправителя в хранилище не -// попадает — путь к файлу читается в журнале, и инвариант приватности этого не -// допускает. Свой суффикс хранилище допишет само. -// -// Копий у записи ровно две — принятая и приведённая, — и обе местные. Прежний -// путь заведения записи о копии во внешнем хранилище отсюда ушёл: та копия -// файлом записи не считается, а её ключ живёт в строке попытки распознавания. -func (repo *FileRepository) Create(name string, work contract.WorkFile, meta contract.FileMeta, ownerID string) (*entity.File, error) { - collection, err := findCollection(repo.app, migrations.FilesCollection) - if err != nil { - return nil, err - } - - stored, err := filesystem.NewFileFromPath(work.Path()) - if err != nil { - return nil, fmt.Errorf("failed to read work file: %w", err) - } - stored.Name = name - - record := core.NewRecord(collection) - record.Set("file", stored) - record.Set("location", entity.LocationLocal) - record.Set("size", stored.Size) - record.Set("format", meta.Format) - record.Set("duration_ms", meta.DurationMs) - // Владелец файла — владелец записи, которой файл принадлежит. Пустой значит - // «файл без владельца»: таков всякий файл записи, принятой ботом. Правило - // просмотра коллекции сужено этой колонкой, и без неё чужое аудио осталось - // бы доступным всякому вошедшему. - record.Set("owner", ownerID) - - if err := repo.app.Save(record); err != nil { - // Отказ укладки называет имя файла — то самое, из которого строится - // ссылка на скачивание. В цепочку оно не идёт по той же причине, что и - // ключ при чтении. - return nil, errors.New("failed to store file") - } - - return recordToFile(record), nil -} - -func (repo *FileRepository) GetByID(id string) (*entity.File, error) { - record, err := repo.app.FindRecordById(migrations.FilesCollection, id) - if err != nil { - return nil, fmt.Errorf("failed to get file: %w", err) - } - return recordToFile(record), nil -} - -func (repo *FileRepository) Open(fileID string) (io.ReadCloser, error) { - record, err := repo.app.FindRecordById(migrations.FilesCollection, fileID) - if err != nil { - return nil, fmt.Errorf("failed to find file %s: %w", fileID, err) - } - - name := firstFileName(record) - if name == "" { - return nil, fmt.Errorf("file %s has no content in storage", fileID) - } - - return repo.openStored(record, name) -} - -// openStored открывает содержимое файла в хранилище потоком. -func (repo *FileRepository) openStored(record *core.Record, name string) (io.ReadCloser, error) { - fsys, err := repo.app.NewFilesystem() - if err != nil { - return nil, fmt.Errorf("failed to open storage filesystem: %w", err) - } - - reader, err := fsys.GetReader(record.BaseFilesPath() + "/" + name) - if err != nil { - // Отказ хранилища несёт ключ файла целиком, а ключ — последняя часть - // ссылки `/api/files/...`, по которой запись скачивают. Наружу отдаётся - // идентификатор записи, и только он: цепочка `%w` уехала бы в журнал и - // стала бы там бессрочным ключом к чужому аудио. - return nil, errors.Join( - fmt.Errorf("failed to read stored file of record %s", record.Id), - fsys.Close(), - ) - } - - return &storedReader{reader: reader, fsys: fsys}, nil -} - -// storedReader держит открытой файловую систему хранилища на всё время чтения: -// закрытая раньше времени, она обрывает поток на середине записи. -type storedReader struct { - reader io.ReadCloser - fsys io.Closer -} - -func (r *storedReader) Read(p []byte) (int, error) { return r.reader.Read(p) } - -func (r *storedReader) Close() error { - readerErr := r.reader.Close() - fsysErr := r.fsys.Close() - switch { - case readerErr != nil && fsysErr != nil: - return errors.New("failed to close stored file and its filesystem") - case readerErr != nil: - return errors.New("failed to close stored file") - default: - return fsysErr - } -} - -// writeTo переливает содержимое в файл потоком. В память запись целиком не -// читается: расчётный потолок — шесть часов. -func writeTo(path string, content io.Reader) error { - dst, err := os.Create(path) - if err != nil { - return fmt.Errorf("failed to open work file: %w", err) - } - - if _, err := io.Copy(dst, content); err != nil { - _ = dst.Close() - return fmt.Errorf("failed to write work file: %w", err) - } - - if err := dst.Close(); err != nil { - return fmt.Errorf("failed to close work file: %w", err) - } - - return nil -} - -func firstFileName(record *core.Record) string { - names := record.GetStringSlice("file") - if len(names) == 0 { - return "" - } - return names[0] -} - -func recordToFile(record *core.Record) *entity.File { - return &entity.File{ - Id: record.Id, - Location: record.GetString("location"), - FileName: firstFileName(record), - Size: int64(record.GetInt("size")), - Format: record.GetString("format"), - DurationMs: int64(record.GetInt("duration_ms")), - CreatedAt: record.GetDateTime("created").Time(), - } -} diff --git a/internal/adapter/repo/pocketbase/identity.go b/internal/adapter/repo/pocketbase/identity.go deleted file mode 100644 index 44857b6..0000000 --- a/internal/adapter/repo/pocketbase/identity.go +++ /dev/null @@ -1,264 +0,0 @@ -package pocketbase - -import ( - "errors" - "fmt" - "strings" - "unicode" - "unicode/utf8" - - validation "github.com/pocketbase/ozzo-validation/v4" - "github.com/pocketbase/ozzo-validation/v4/is" - "github.com/pocketbase/pocketbase/core" - - "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" -) - -// MaxProviderLoginLength — предел длины логина у провайдера. -// -// Значение приходит заголовком, то есть целиком задаётся тем, кто шлёт запрос, и -// без предела в колонку уехало бы столько, сколько влезет в заголовки. Число то -// же, что у имени в умолчании библиотеки: длиннее имени логин не бывает, а два -// разных предела на соседних колонках одной записи разошлись бы молча. -const MaxProviderLoginLength = 255 - -// MaxDisplayNameLength — предел длины имени, пригодного к показу. Число то же и -// по той же причине: столько держит колонка имени в умолчании библиотеки. -const MaxDisplayNameLength = 255 - -// ErrLoginNotAcceptable — логин негоден: пустой, из одних пробельных знаков, -// длиннее предела или с управляющими знаками. Это не отказ хранилища, а -// негодный ввод, и звать по нему учётную запись не надо. -var ErrLoginNotAcceptable = errors.New("provider login is not acceptable") - -// Identity — то, чем доверенный источник называет пришедшего. -// -// Логин — ключ, остальное берётся только при заведении записи. -type Identity struct { - Login string - Name string - Email string -} - -// EnsureUser находит учётную запись по логину у провайдера, а не найдя — -// заводит её. -// -// **Дом правила один, и он здесь, а не в транспорте.** Второй способ -// представиться — личные токены — придёт следующей задачей и возьмёт этот же -// метод; правило, уложенное куском в слой транспорта, пришлось бы тогда либо -// дублировать вторым куском, либо вытаскивать задним числом. -// -// Найденную запись метод **не переписывает**. Иначе всякий запрос был бы записью -// в базу, а правка имени у провайдера меняла бы карточку человека молча, посреди -// его работы. -// -// Сравнение точное, знак в знак: приведение регистра завело бы правило, которого -// у провайдера нет, — считает ли он `admin` и `Admin` одним человеком, сервису -// неизвестно, а угаданное правило склеило бы двух разных людей. -func EnsureUser(app core.App, identity Identity) (record *core.Record, created bool, err error) { - login, ok := AcceptProviderLogin(identity.Login) - if !ok { - return nil, false, ErrLoginNotAcceptable - } - - record, err = findUserByLogin(app, login) - if err != nil { - return nil, false, err - } - if record != nil { - return record, false, nil - } - - users, err := findCollection(app, migrations.UsersCollection) - if err != nil { - return nil, false, err - } - - record = core.NewRecord(users) - record.Set(migrations.ProviderLoginField, login) - // Имя и почта принимаются так же, как логин, а не кладутся как есть. - // Значения приходят заголовками, то есть задаются тем, кто шлёт запрос; - // имя длиннее предела колонки отвергается проверкой записи, и человек с - // таким именем у провайдера не завёлся бы **никогда** — каждый его запрос - // отвечал бы отказом сервиса. Негодное значение необязательного поля не - // вправе отменять заведение записи. - record.Set("name", acceptDisplayName(identity.Name)) - if email, ok := acceptEmail(identity.Email); ok { - record.SetEmail(email) - } - // Пароль записи обязателен при любом значении признака — это проверка самой - // библиотеки, а не колонки. Ставится случайный: употребить его нельзя, - // потому что вход по паролю у коллекции выключен шагом схемы. - record.SetRandomPassword() - - if err := app.Save(record); err != nil { - record, err = retryAfterConflict(app, login, record, err) - return record, record != nil, err - } - - return record, true, nil -} - -// retryAfterConflict разбирает отказ сохранения. Два отказа уникальности здесь -// разные, и исход у них разный. -// -// По **ключевой** колонке — это гонка двух первых обращений одним логином: -// запись успел завести соседний запрос, и надо просто взять его. Отказ, который -// после повторного поиска никуда не делся, — уже не гонка, и его отдают наверх. -// -// По **любой другой** — почта, пришедшая от провайдера, занята другой учётной -// записью: общий почтовый ящик, семья, группа. Запись заводится без почты; она -// необязательна, а ключом не служит. Без этого разреза второй человек с общим -// адресом не завёлся бы никогда — повторный поиск по логину снова ничего не -// нашёл бы, и исход выродился бы либо в цикл, либо в вечный отказ без внятной -// причины. -func retryAfterConflict(app core.App, login string, record *core.Record, saveErr error) (*core.Record, error) { - if isUniqueViolation(saveErr, migrations.ProviderLoginField) { - existing, err := findUserByLogin(app, login) - if err != nil { - return nil, err - } - if existing != nil { - return existing, nil - } - return nil, fmt.Errorf("failed to create user account: %w", saveErr) - } - - if !isUniqueViolation(saveErr, core.FieldNameEmail) { - return nil, fmt.Errorf("failed to create user account: %w", saveErr) - } - - record.SetEmail("") - if err := app.Save(record); err != nil { - return nil, fmt.Errorf("failed to create user account without email: %w", err) - } - - return record, nil -} - -// findUserByLogin ищет учётную запись по ключу. Значение уходит хранилищу -// **параметром** запроса, а не подстановкой в текст фильтра: строка приходит -// снаружи, и подставленная в текст она правила бы сам запрос, а не только его -// аргумент. -func findUserByLogin(app core.App, login string) (*core.Record, error) { - records, err := app.FindRecordsByFilter( - migrations.UsersCollection, - migrations.ProviderLoginField+" = {:login}", - "", 1, 0, - map[string]any{"login": login}, - ) - if err != nil { - return nil, fmt.Errorf("failed to look up user account: %w", err) - } - if len(records) == 0 { - return nil, nil - } - - return records[0], nil -} - -// uniqueViolationCode — каким кодом библиотека называет отказ уникальности. -// -// Разбор идёт по **коду**, а не по имени текста и не по тексту драйвера: текст -// у драйвера свой на каждую версию, а имя колонки не говорит о причине. -const uniqueViolationCode = "validation_not_unique" - -// isUniqueViolation говорит, отказала ли по названной колонке проверка -// **уникальности** — а не какая-нибудь другая. -// -// Разница не педантизм. Под ключом `email` библиотека складывает и отказ -// уникальности, и отказ формы адреса; проверка «есть ли ключ в карте» считала -// бы опечатку прокси занятым семейным ящиком и молча заводила бы запись без -// почты. На ключевой колонке та же неточность когда-нибудь выстрелит громче: -// любой отказ проверки логина читался бы как гонка двух первых обращений. -func isUniqueViolation(err error, field string) bool { - var errs validation.Errors - if !errors.As(err, &errs) { - return false - } - - fieldErr, ok := errs[field] - if !ok { - return false - } - - var object validation.ErrorObject - if !errors.As(fieldErr, &object) { - return false - } - - return object.Code() == uniqueViolationCode -} - -// acceptDisplayName приводит имя к годному для колонки значению. -// -// Обрезается по пределу колонки и чистится от управляющих знаков — тем же -// приёмом, каким приём записи чистит имя файла отправителя. Пустое значение -// законно: имени у человека может не быть вовсе. -func acceptDisplayName(value string) string { - name := strings.TrimSpace(stripControls(value)) - - runes := []rune(name) - if len(runes) > MaxDisplayNameLength { - return string(runes[:MaxDisplayNameLength]) - } - - return name -} - -// acceptEmail отдаёт адрес почты, если он вообще похож на адрес. -// -// Негодный отбрасывается **здесь**, а не отказом сохранения: иначе опечатка в -// заголовке кончалась бы либо отказом сервиса, либо — что хуже — ветвью -// «почта занята», и владелец искал бы общий ящик там, где сломан контур. -func acceptEmail(value string) (string, bool) { - email := strings.TrimSpace(value) - if email == "" { - return "", false - } - - if err := is.EmailFormat.Validate(email); err != nil { - return "", false - } - - return email, true -} - -// stripControls убирает управляющие знаки: они приезжают заголовком и в колонке -// им делать нечего. -func stripControls(value string) string { - return strings.Map(func(r rune) rune { - if unicode.IsControl(r) { - return -1 - } - return r - }, value) -} - -// AcceptProviderLogin приводит пришедшее значение к годному логину либо -// отвергает его. -// -// Отвергается пустое, состоящее из одних пробельных знаков, длиннее предела и -// несущее управляющие знаки. Пустое значение — не крайний случай: обратный -// прокси штатно шлёт заголовок пустым там, где никого не назвал, и без этой -// проверки все неназванные собрались бы в одну учётную запись с общим архивом. -// -// Обрамляющие пробелы срезаются: заголовок с ведущим пробелом и без него -// называет одного человека, а две записи о нём разошлись бы молча. -func AcceptProviderLogin(value string) (string, bool) { - login := strings.TrimSpace(value) - - // Предел считается в **знаках**, а не в байтах: колонка считает знаки, и - // два предела в разных единицах разошлись бы вдвое на любой кириллице. - if login == "" || utf8.RuneCountInString(login) > MaxProviderLoginLength { - return "", false - } - - for _, r := range login { - if unicode.IsControl(r) { - return "", false - } - } - - return login, true -} diff --git a/internal/adapter/repo/pocketbase/identity_test.go b/internal/adapter/repo/pocketbase/identity_test.go deleted file mode 100644 index 0cf483d..0000000 --- a/internal/adapter/repo/pocketbase/identity_test.go +++ /dev/null @@ -1,316 +0,0 @@ -package pocketbase - -import ( - "strings" - "sync" - "testing" - "unicode/utf8" - - "github.com/pocketbase/pocketbase/core" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" -) - -// Проверки узнавания: как учётная запись находится и как заводится. - -// Заведение идемпотентно: второе обращение попадает в ту же запись и не -// переписывает её. -// -// Не переписывает — половина требования, и она отдельная: перепись на каждом -// запросе означала бы запись в базу на каждый запрос, а правка имени у -// провайдера меняла бы карточку человека молча, посреди его работы. -func TestEnsureUserIsIdempotent(t *testing.T) { - app := newTestStorage(t) - - first, _, err := EnsureUser(app, Identity{Login: "alice", Name: "Алиса", Email: "alice@example.test"}) - require.NoError(t, err) - - second, _, err := EnsureUser(app, Identity{Login: "alice", Name: "Другое имя", Email: "other@example.test"}) - require.NoError(t, err) - - assert.Equal(t, first.Id, second.Id, "второе обращение завело вторую запись") - assert.Equal(t, "Алиса", second.GetString("name"), "имя переписано вторым обращением") - assert.Equal(t, "alice@example.test", second.Email(), "почта переписана вторым обращением") - - assert.Equal(t, 1, countUsers(t, app)) -} - -// Одновременные первые обращения одним логином дают одну учётную запись. -// -// Проверка стоит потому, что норма без неё держалась бы на одном уникальном -// индексе: забытый в шаге схемы, он дал бы зелёную приёмку и две учётные записи -// на одного человека — а архив разъехался бы между ними молча и склеить его было -// бы нечем. -func TestEnsureUserSurvivesConcurrentFirstRequests(t *testing.T) { - app := newTestStorage(t) - - const racers = 8 - - var wg sync.WaitGroup - ids := make([]string, racers) - errs := make([]error, racers) - - start := make(chan struct{}) - for i := range racers { - wg.Add(1) - go func() { - defer wg.Done() - <-start - - record, _, err := EnsureUser(app, Identity{Login: "racer", Name: "Гонщик"}) - errs[i] = err - if record != nil { - ids[i] = record.Id - } - }() - } - - close(start) - wg.Wait() - - for i, err := range errs { - require.NoError(t, err, "обращение %d отказало", i) - } - for i, id := range ids { - assert.Equal(t, ids[0], id, "обращение %d попало в другую учётную запись", i) - } - - assert.Equal(t, 1, countUsers(t, app), "гонка завела больше одной учётной записи") -} - -// Занятая почта не мешает завести запись: она достаётся первому, а второй -// заводится без неё. -// -// Общий почтовый ящик — обычное дело в семье, а Authelia вправе отдать один -// адрес группе. Без разреза двух отказов уникальности второй человек не завёлся -// бы никогда: повторный поиск по логину снова ничего не находит. -func TestEnsureUserWithTakenEmail(t *testing.T) { - app := newTestStorage(t) - - first, _, err := EnsureUser(app, Identity{Login: "one", Email: "family@example.test"}) - require.NoError(t, err) - - second, _, err := EnsureUser(app, Identity{Login: "two", Email: "family@example.test"}) - require.NoError(t, err) - - assert.NotEqual(t, first.Id, second.Id) - assert.Equal(t, "family@example.test", first.Email(), "почта досталась первому") - assert.Empty(t, second.Email(), "второму почта не досталась, но запись завелась") - assert.Equal(t, 2, countUsers(t, app)) -} - -// Вырожденный логин никого не узнаёт и ничего не заводит. -func TestEnsureUserRejectsDegenerateLogin(t *testing.T) { - app := newTestStorage(t) - - values := map[string]string{ - "пустой": "", - "одни пробелы": " \t ", - "управляющий знак": "ali\x00ce", - "длиннее предела": strings.Repeat("a", MaxProviderLoginLength+1), - } - - for name, value := range values { - t.Run(name, func(t *testing.T) { - record, _, err := EnsureUser(app, Identity{Login: value}) - - assert.Nil(t, record) - require.ErrorIs(t, err, ErrLoginNotAcceptable) - assert.Equal(t, 0, countUsers(t, app)) - }) - } -} - -// Обрамляющие пробелы срезаются: заголовок с ведущим пробелом и без него -// называет одного человека, и две записи о нём разошлись бы молча. -func TestEnsureUserTrimsSurroundingSpaces(t *testing.T) { - app := newTestStorage(t) - - first, _, err := EnsureUser(app, Identity{Login: "alice"}) - require.NoError(t, err) - - second, _, err := EnsureUser(app, Identity{Login: " alice "}) - require.NoError(t, err) - - assert.Equal(t, first.Id, second.Id) - assert.Equal(t, 1, countUsers(t, app)) -} - -// Сравнение точное: приведение регистра завело бы правило, которого у -// провайдера нет, — и склеило бы двух разных людей. -func TestEnsureUserComparesExactly(t *testing.T) { - app := newTestStorage(t) - - lower, _, err := EnsureUser(app, Identity{Login: "alice"}) - require.NoError(t, err) - - upper, _, err := EnsureUser(app, Identity{Login: "Alice"}) - require.NoError(t, err) - - assert.NotEqual(t, lower.Id, upper.Id) - assert.Equal(t, 2, countUsers(t, app)) -} - -// Значение, похожее на условие отбора, ищется как значение, а не как часть -// запроса: оно уходит хранилищу параметром. -func TestEnsureUserDoesNotLetLoginChangeTheQuery(t *testing.T) { - app := newTestStorage(t) - - victim, _, err := EnsureUser(app, Identity{Login: "victim"}) - require.NoError(t, err) - - attacker, _, err := EnsureUser(app, Identity{Login: `x" || provider_login = "victim`}) - require.NoError(t, err) - - assert.NotEqual(t, victim.Id, attacker.Id, - "значение изменило сам запрос и вернуло чужую учётную запись") - assert.Equal(t, 2, countUsers(t, app)) -} - -func countUsers(t *testing.T, app core.App) int { - t.Helper() - - records, err := app.FindAllRecords(migrations.UsersCollection) - require.NoError(t, err) - - return len(records) -} - -// Признак заведения отличает первое обращение от всех следующих. -// -// По нему слой узнавания пишет строку журнала, и без него владелец не отличит -// «никто не заходил» от «завелось двадцать»: убрать заведённую запись потом -// нечем. -func TestEnsureUserReportsWhetherItCreated(t *testing.T) { - app := newTestStorage(t) - - _, created, err := EnsureUser(app, Identity{Login: "alice"}) - require.NoError(t, err) - assert.True(t, created, "первое обращение не назвалось заведением") - - _, created, err = EnsureUser(app, Identity{Login: "alice"}) - require.NoError(t, err) - assert.False(t, created, "второе обращение назвалось заведением") -} - -// Негодное имя не отменяет заведения: оно обрезается по пределу колонки. -// -// Прежде имя уходило в колонку как есть, и человек с длинным именем у -// провайдера получал отказ сервиса на **каждом** запросе — учётная запись не -// заводилась никогда, а починить у себя он ничего не мог. -func TestEnsureUserAcceptsDegenerateName(t *testing.T) { - app := newTestStorage(t) - - long := strings.Repeat("я", MaxDisplayNameLength+50) - - record, created, err := EnsureUser(app, Identity{Login: "bob", Name: long}) - require.NoError(t, err, "негодное имя отменило заведение записи") - require.True(t, created) - - name := record.GetString("name") - assert.Equal(t, MaxDisplayNameLength, utf8.RuneCountInString(name), "имя не обрезано по пределу") - assert.NotEmpty(t, name) -} - -// Управляющие знаки из имени убираются: значение приезжает заголовком. -func TestEnsureUserStripsControlsFromName(t *testing.T) { - app := newTestStorage(t) - - record, _, err := EnsureUser(app, Identity{Login: "carol", Name: "Ка\x00ро\nл"}) - require.NoError(t, err) - - assert.Equal(t, "Карол", record.GetString("name")) -} - -// Негодная почта отбрасывается **явно**, а не через ветвь «почта занята». -// -// Иначе опечатка в контуре неотличима от общего семейного ящика, и владелец -// ищет второго человека там, где сломан прокси. -func TestEnsureUserDropsMalformedEmail(t *testing.T) { - app := newTestStorage(t) - - record, created, err := EnsureUser(app, Identity{Login: "dave", Email: "не-адрес"}) - require.NoError(t, err, "негодная почта отменила заведение записи") - require.True(t, created) - - assert.Empty(t, record.Email()) -} - -// Предел логина считается в знаках, а не в байтах: колонка считает знаки. -// -// Прежде кириллический логин длиннее половины предела отвергался навсегда, -// хотя колонка приняла бы его. -func TestEnsureUserCountsLoginInRunes(t *testing.T) { - app := newTestStorage(t) - - login := strings.Repeat("я", MaxProviderLoginLength) - - record, _, err := EnsureUser(app, Identity{Login: login}) - require.NoError(t, err, "логин ровно на пределе отвергнут: предел считается в байтах") - assert.Equal(t, login, record.GetString(migrations.ProviderLoginField)) - - _, _, err = EnsureUser(app, Identity{Login: strings.Repeat("я", MaxProviderLoginLength+1)}) - assert.ErrorIs(t, err, ErrLoginNotAcceptable, "логин сверх предела принят") -} - -// Ключ учётной записи не меняется после заведения — ни правкой в панели, ни -// прямым сохранением. -// -// Правила доступа коллекции закрывают только путь снаружи; панель работает -// суперпользователем. Переписанный ключ отдал бы весь архив следующему, кто -// придёт с этим именем, и вернуть его было бы нечем. -func TestProviderLoginIsImmutable(t *testing.T) { - app := newTestStorage(t) - BindPanelRules(app) - - record, _, err := EnsureUser(app, Identity{Login: "victim-owner"}) - require.NoError(t, err) - - record.Set(migrations.ProviderLoginField, "someone-else") - err = app.Save(record) - require.Error(t, err, "ключ учётной записи переписан прямым сохранением") - - stored, err := app.FindRecordById(migrations.UsersCollection, record.Id) - require.NoError(t, err) - assert.Equal(t, "victim-owner", stored.GetString(migrations.ProviderLoginField)) -} - -// Правка прочих полей учётной записи при этом проходит: хук сторожит один ключ, -// а не запирает коллекцию целиком. -func TestUserRecordStaysEditableExceptTheKey(t *testing.T) { - app := newTestStorage(t) - BindPanelRules(app) - - record, _, err := EnsureUser(app, Identity{Login: "editable", Name: "Прежнее"}) - require.NoError(t, err) - - record.Set("name", "Новое") - require.NoError(t, app.Save(record), "правка имени в панели отвергнута") - - stored, err := app.FindRecordById(migrations.UsersCollection, record.Id) - require.NoError(t, err) - assert.Equal(t, "Новое", stored.GetString("name")) -} - -// Две учётные записи без ключа уживаются: индекс частичный, как и соседний -// индекс почты. -// -// Сплошной индекс ронял бы накатку шага на всякой базе, где записей больше -// одной, — то есть у разработчика, ходившего прежним рецептом входа. -func TestEmptyProviderLoginDoesNotCollide(t *testing.T) { - app := newTestStorage(t) - - users, err := app.FindCollectionByNameOrId(migrations.UsersCollection) - require.NoError(t, err) - - for _, name := range []string{"Первый", "Второй"} { - record := core.NewRecord(users) - record.Set("name", name) - record.SetRandomPassword() - require.NoError(t, app.Save(record), "вторая запись без ключа отвергнута индексом") - } - - assert.Equal(t, 2, countUsers(t, app)) -} diff --git a/internal/adapter/repo/pocketbase/migrations/202608110001_init.go b/internal/adapter/repo/pocketbase/migrations/202608110001_init.go deleted file mode 100644 index aa07dfc..0000000 --- a/internal/adapter/repo/pocketbase/migrations/202608110001_init.go +++ /dev/null @@ -1,109 +0,0 @@ -package migrations - -import ( - "github.com/pocketbase/pocketbase/core" - - "git.vakhrushev.me/av/transcriber/internal/entity" -) - -func up202608110001(app core.App) error { - files := core.NewBaseCollection(FilesCollection) - files.Fields.Add( - // Сам файл. Защищённым поле не помечено намеренно: право прочитать - // запись даёт знание её идентификатора, и файл встаёт вровень с опросом - // готовности задачи, а не ниже. - // - // Потолок задан **числом**: нулём библиотека читает не «без предела», а - // своё умолчание в 5 МиБ, и на нём отваливалось бы всё длиннее пяти - // минут. Число выведено из расчётного потолка записи в шесть часов с - // запасом на видео; оно же стоит строкой в docs/database.md. - &core.FileField{Name: "file", MaxSelect: 1, MaxSize: entity.MaxRecordSize}, - // Где лежит копия. Поле названо `location`, а не `storage`: последним - // словом зовут само хранилище, и третий смысл развёл бы одно слово по - // разным вещам. - &core.SelectField{ - Name: "location", - Values: []string{entity.LocationLocal, entity.LocationS3}, - MaxSelect: 1, - Required: true, - }, - // Ключ объекта во внешнем хранилище; у местной копии пуст. - &core.TextField{Name: "object_key"}, - &core.NumberField{Name: "size", OnlyInt: true}, - &core.AutodateField{Name: "created", OnCreate: true}, - &core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true}, - ) - - if err := app.Save(files); err != nil { - return err - } - - jobs := core.NewBaseCollection(JobsCollection) - jobs.Fields.Add( - // Перечень состояний закрыт схемой: задача, заведённая в панели руками, - // не должна попасть в выборку с состоянием, которого конвейер не знает. - &core.SelectField{ - Name: "state", - Values: []string{ - entity.StateCreated, - entity.StateConverted, - entity.StateTranscribe, - entity.StateDone, - entity.StateFailed, - entity.StateDead, - }, - MaxSelect: 1, - Required: true, - }, - &core.SelectField{ - Name: "source", - Values: []string{entity.SourceUnknown, entity.SourceApi, entity.SourceTelegram}, - MaxSelect: 1, - Required: true, - }, - // Текущий файл задачи: шаг конвейера переставляет ссылку на свой - // результат. - // Обязательна: задача без записи не может пройти ни одного шага, и - // заведённая в панели руками она дошла бы до шага только затем, чтобы - // отказать. Компилятор этого не держит — держит схема. - &core.RelationField{ - Name: "file", - CollectionId: files.Id, - MaxSelect: 1, - Required: true, - }, - &core.TextField{Name: "error_text"}, - &core.TextField{Name: "acquisition_id"}, - &core.DateField{Name: "acquire_time"}, - &core.DateField{Name: "delay_time"}, - // Число попыток: растёт при каждом захвате, обнуляется на шаге, - // завершившемся без отказа. - &core.NumberField{Name: "attempts", OnlyInt: true, Min: ptr(0.0)}, - &core.TextField{Name: "recognition_op_id"}, - &core.EditorField{Name: "transcription_text"}, - &core.NumberField{Name: "tg_chat_id", OnlyInt: true}, - &core.NumberField{Name: "tg_reply_message_id", OnlyInt: true}, - &core.AutodateField{Name: "created", OnCreate: true}, - &core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true}, - ) - - // Выборка воркера идёт по состоянию, паузе и сроку захвата — индекс по - // состоянию снимает полный перебор, который был у прежней таблицы. - jobs.AddIndex("idx_transcribe_jobs_state", false, "state", "") - - return app.Save(jobs) -} - -func down202608110001(app core.App) error { - // Порядок обратный порядку заведения: задачи ссылаются на файлы. - for _, name := range []string{JobsCollection, FilesCollection} { - collection, err := app.FindCollectionByNameOrId(name) - if err != nil { - continue - } - if err := app.Delete(collection); err != nil { - return err - } - } - return nil -} diff --git a/internal/adapter/repo/pocketbase/migrations/202608120001_oidc_login.go b/internal/adapter/repo/pocketbase/migrations/202608120001_oidc_login.go deleted file mode 100644 index bbfd63e..0000000 --- a/internal/adapter/repo/pocketbase/migrations/202608120001_oidc_login.go +++ /dev/null @@ -1,124 +0,0 @@ -package migrations - -import ( - "errors" - "fmt" - - "github.com/pocketbase/pocketbase/core" -) - -// defaultAuthTokenDuration — умолчание библиотеки, к которому возвращает откат. -const defaultAuthTokenDuration = 1209600 - -// up202608120001 закрывает поверхность, которую хранилище приносит своим -// системным шагом, и защищает файл записи. -// -// Коллекция пользователей заводится библиотекой с открытым созданием записи и -// включённым входом по паролю. Без этого шага закрытие API обходится двумя -// запросами: завести себе учётную запись, войти паролем, предъявить полученное -// заголовком. Отдельная цена открытого создания — захват учётной записи: обмен -// кода ищет запись сперва по неизменяемому признаку провайдера, а не найдя — -// по адресу почты, и запись, заведённая посторонним на чужой адрес, достаётся -// первому же настоящему входу с этим адресом. -func up202608120001(app core.App) error { - users, err := app.FindCollectionByNameOrId("users") - if err != nil { - return fmt.Errorf("failed to find users collection: %w", err) - } - - // Завести учётную запись можно только входом у провайдера. - // - // Правило именно такое, а не `nil`: запись при первом входе заводит - // внутренний запрос самого обмена, и он идёт без прав суперпользователя — - // глухое `nil` отвергло бы его наравне с посторонним, и войти не смог бы - // никто. Контекст `oauth2` ставит обмен (`core.RequestInfoContextOAuth2`), - // а посторонний запрос приходит с контекстом по умолчанию. - // - // Открывать правило пустой строкой нельзя: публичный обмен принимает поля - // создаваемой записи от вызывающего, и всякий владелец учётной записи у - // провайдера задал бы их сам. - users.CreateRule = ptr(`@request.context = "oauth2"`) - users.PasswordAuth.Enabled = false - users.OTP.Enabled = false - - // Провайдер включается здесь с пустыми значениями: адреса, идентификатор - // клиента и секрет приходят из конфига при каждом подъёме. Положенный сюда - // секрет не пережил бы ротации — применённый шаг не переписывается. - users.OAuth2.Enabled = true - - if err := app.Save(users); err != nil { - return fmt.Errorf("failed to close users collection surface: %w", err) - } - - files, err := app.FindCollectionByNameOrId(FilesCollection) - if err != nil { - return fmt.Errorf("failed to find files collection: %w", err) - } - - // Ссылка на файл перестаёт быть правом пройти по ней: до этого шага знание - // ссылки и было доступом, а отзыва у неё нет. Конвейер этим не затронут — - // он читает файл из файловой системы хранилища, а не по ссылке. - // - // Комментарий прежнего шага утверждает обратное — «защищённым поле не - // помечено намеренно». Прежний шаг не переписывается, поэтому решение - // отменяется здесь: право прочитать запись больше не даёт знание её - // идентификатора. - field, ok := files.Fields.GetByName("file").(*core.FileField) - if !ok { - return errors.New("files collection has no file field") - } - field.Protected = true - - // Одной пометки мало: защищённый файл судится ещё и правилом просмотра - // коллекции, а незаданное правило означает «только владелец панели» — файл - // не получил бы и вошедший. Правило пускает всякого узнанного: владельца у - // записи ещё нет, и сужать выборку эта задача не должна. - files.ViewRule = ptr(`@request.auth.id != ""`) - - if err := app.Save(files); err != nil { - return fmt.Errorf("failed to protect record file: %w", err) - } - - return nil -} - -// down202608120001 возвращает умолчания библиотеки — те, что стояли до шага. -// -// Открытое создание записи сюда не возвращается намеренно: это ровно то, что -// шаг и закрывал, и откат, восстанавливающий анонимную регистрацию, оставил бы -// сервис хуже, чем он был до задачи. Срок жизни сессии возвращается -// умолчанием, а не нулём: нулевую длительность валидация коллекции отвергает, и -// прежний откат падал на ней, не дойдя до снятия защиты с файла. -func down202608120001(app core.App) error { - users, err := app.FindCollectionByNameOrId("users") - if err != nil { - return fmt.Errorf("failed to find users collection: %w", err) - } - - users.CreateRule = nil - users.PasswordAuth.Enabled = true - users.OTP.Enabled = true - users.OAuth2.Enabled = false - users.OAuth2.Providers = nil - users.AuthToken.Duration = defaultAuthTokenDuration - - if err := app.Save(users); err != nil { - return fmt.Errorf("failed to restore users collection: %w", err) - } - - files, err := app.FindCollectionByNameOrId(FilesCollection) - if err != nil { - return fmt.Errorf("failed to find files collection: %w", err) - } - - if field, ok := files.Fields.GetByName("file").(*core.FileField); ok { - field.Protected = false - } - files.ViewRule = nil - - if err := app.Save(files); err != nil { - return fmt.Errorf("failed to unprotect record file: %w", err) - } - - return nil -} diff --git a/internal/adapter/repo/pocketbase/migrations/202608140001_record_owner.go b/internal/adapter/repo/pocketbase/migrations/202608140001_record_owner.go deleted file mode 100644 index 474803a..0000000 --- a/internal/adapter/repo/pocketbase/migrations/202608140001_record_owner.go +++ /dev/null @@ -1,106 +0,0 @@ -package migrations - -import ( - "errors" - "fmt" - - "github.com/pocketbase/pocketbase/core" -) - -// up202608140001 заводит владельца записи. -// -// Колонка — связь с коллекцией пользователей: хранилище само следит, чтобы -// владельцем стояла существующая учётная запись, а не строка, похожая на её -// идентификатор. -// -// Пустое значение допустимо, и это решение с названной ценой. Записи, принятые -// ботом, владельца не имеют вовсе: связи чата Telegram с учётной записью сервис -// не ведёт, её заводит отдельная задача. Обязательность для приёма по HTTP -// держит поэтому сам приём, а не схема. -// -// Каскадное удаление выключено, но одного этого мало: при выключенном каскаде -// хранилище **вынимает** идентификатор из поля связи и сохраняет запись без -// проверок, то есть архив удалённого пользователя стал бы ничьим и не достался -// бы никому. Поэтому удаление учётной записи, у которой остались задачи, -// отвергается слоем приложения — `GuardOwnerDeletion`. -func up202608140001(app core.App) error { - users, err := app.FindCollectionByNameOrId(UsersCollection) - if err != nil { - return fmt.Errorf("failed to find users collection: %w", err) - } - - jobs, err := app.FindCollectionByNameOrId(JobsCollection) - if err != nil { - return fmt.Errorf("failed to find jobs collection: %w", err) - } - - jobs.Fields.Add(ownerField(users.Id)) - - if err := app.Save(jobs); err != nil { - return fmt.Errorf("failed to add owner to jobs: %w", err) - } - - files, err := app.FindCollectionByNameOrId(FilesCollection) - if err != nil { - return fmt.Errorf("failed to find files collection: %w", err) - } - - files.Fields.Add(ownerField(users.Id)) - - // Правило просмотра сужается владельцем. Прежнее пускало всякого узнанного: - // владельца у записи тогда не было, и сужать выборку было нечем. Без этой - // строки разграничение закрыло бы метаданные задачи и оставило открытым - // содержимое — то самое, что оно и заведено прятать: знание идентификатора - // файловой записи равнялось бы праву скачать чужое аудио. - files.ViewRule = ptr(`@request.auth.id != "" && owner = @request.auth.id`) - - if err := app.Save(files); err != nil { - return fmt.Errorf("failed to narrow files by owner: %w", err) - } - - return nil -} - -// ownerField собирает описание колонки владельца. Обе коллекции получают -// одинаковую: разойдясь, они дали бы разное поведение у задачи и у её файла. -func ownerField(usersCollectionID string) *core.RelationField { - return &core.RelationField{ - Name: "owner", - CollectionId: usersCollectionID, - MaxSelect: 1, - // Пустое значение допустимо — см. шапку шага. Умолчания у колонки нет: - // связь его не имеет по устройству, и запись не достаётся никому по - // недосмотру схемы. - Required: false, - // Удаление учётной записи не уносит её записи следом: сервис объявлен - // архивом. Что происходит вместо этого, держит `GuardOwnerDeletion`. - CascadeDelete: false, - } -} - -// down202608140001 снимает колонку с обеих коллекций и возвращает правило -// просмотра файлов к тому, что стояло до шага, — «всякий узнанный». -func down202608140001(app core.App) error { - for _, name := range []string{JobsCollection, FilesCollection} { - collection, err := app.FindCollectionByNameOrId(name) - if err != nil { - return fmt.Errorf("failed to find collection %s: %w", name, err) - } - - field := collection.Fields.GetByName("owner") - if field == nil { - return errors.New("collection " + name + " has no owner field") - } - collection.Fields.RemoveById(field.GetId()) - - if name == FilesCollection { - collection.ViewRule = ptr(`@request.auth.id != ""`) - } - - if err := app.Save(collection); err != nil { - return fmt.Errorf("failed to drop owner from %s: %w", name, err) - } - } - - return nil -} diff --git a/internal/adapter/repo/pocketbase/migrations/202608140002_record_centric_model.go b/internal/adapter/repo/pocketbase/migrations/202608140002_record_centric_model.go deleted file mode 100644 index 3e0b868..0000000 --- a/internal/adapter/repo/pocketbase/migrations/202608140002_record_centric_model.go +++ /dev/null @@ -1,347 +0,0 @@ -package migrations - -import ( - "fmt" - - "github.com/pocketbase/pocketbase/core" - - "git.vakhrushev.me/av/transcriber/internal/entity" -) - -// up202608140002 перестраивает модель вокруг аудиозаписи. -// -// Прежняя коллекция задач уходит целиком: сервис на сервере остановлен, а -// прежние данные удалены решением владельца 2026-08-14 — переноса эта работа не -// делает, и оставленная пустая коллекция висела бы в панели вторым домом для -// того же понятия. -// -// Порядок заведения задан связями, а не вкусом: приложения ссылаются на запись, -// а запись — на них, поэтому запись заводится первой без обратных ссылок, потом -// приложения, и только потом ссылки дописываются. -// -// Правила доступа у новых коллекций остаются **незаданными**, то есть «только -// владелец панели». Содержимое записи отдаёт собственный адрес сервиса, а не -// поверхность хранилища; непустое правило открыло бы перечисление коллекции -// впрок, а норма проекта велит держать эту поверхность закрытой. -func up202608140002(app core.App) error { - users, err := app.FindCollectionByNameOrId(UsersCollection) - if err != nil { - return fmt.Errorf("failed to find users collection: %w", err) - } - - files, err := app.FindCollectionByNameOrId(FilesCollection) - if err != nil { - return fmt.Errorf("failed to find files collection: %w", err) - } - - // Формат и длительность у копии: по ним видно, чем запись была, не открывая - // её. Расширение наружу выходит только приведённым к перечню известных. - files.Fields.Add( - &core.TextField{Name: "format"}, - &core.NumberField{Name: "duration_ms", OnlyInt: true}, - ) - if err := app.Save(files); err != nil { - return fmt.Errorf("failed to extend files: %w", err) - } - - topics, err := createTopics(app, users.Id) - if err != nil { - return err - } - - records, err := createAudioRecords(app, users.Id, files.Id, topics.Id) - if err != nil { - return err - } - - texts, err := createTexts(app, records.Id) - if err != nil { - return err - } - - structures, err := createStructures(app, records.Id) - if err != nil { - return err - } - - recognitions, err := createRecognitions(app, records.Id) - if err != nil { - return err - } - - if err := createRecordEvents(app, records.Id); err != nil { - return err - } - - // Обратные ссылки дописываются последними: раньше коллекций-целей ещё нет. - records.Fields.Add( - &core.RelationField{Name: "transcript_text", CollectionId: texts.Id, MaxSelect: 1}, - &core.RelationField{Name: "literary_text", CollectionId: texts.Id, MaxSelect: 1}, - &core.RelationField{Name: "structure", CollectionId: structures.Id, MaxSelect: 1}, - &core.RelationField{Name: "recognition", CollectionId: recognitions.Id, MaxSelect: 1}, - ) - if err := app.Save(records); err != nil { - return fmt.Errorf("failed to link audio records to their appendices: %w", err) - } - - jobs, err := app.FindCollectionByNameOrId(JobsCollection) - if err != nil { - return fmt.Errorf("failed to find jobs collection: %w", err) - } - if err := app.Delete(jobs); err != nil { - return fmt.Errorf("failed to drop the former jobs collection: %w", err) - } - - return nil -} - -// createAudioRecords заводит центральную сущность. -// -// Ссылки на файлы две и порознь: шаг конвейера больше не переставляет одну на -// свой результат, и исходник остаётся доступным после того, как запись прошла -// конвейер. -func createAudioRecords(app core.App, usersID, filesID, topicsID string) (*core.Collection, error) { - records := core.NewBaseCollection(RecordsCollection) - records.Fields.Add( - ownerField(usersID), - &core.SelectField{ - Name: "source", - Values: []string{entity.SourceUnknown, entity.SourceApi, entity.SourceTelegram}, - MaxSelect: 1, - Required: true, - }, - // Заголовок и краткое описание читаются вместе со списком, сотней штук - // разом, и потому лежат колонками записи, а не строками текстов. - &core.TextField{Name: "title"}, - &core.TextField{Name: "brief"}, - // Перечень рубежей закрыт схемой: запись, заведённая в панели руками, не - // должна попасть в выборку с рубежом, которого конвейер не знает. - &core.SelectField{ - Name: "state", - Values: entity.AllStates(), - MaxSelect: 1, - Required: true, - }, - // Время входа в рубеж — сторож застревания. Ставится только сменой рубежа - // и возвратом записи в работу; откладывание опроса его не двигает. - &core.DateField{Name: "state_entered_at"}, - // Остановка — признак, а не рубеж: `state` при ней не стирается, и снятие - // признака продолжает работу с места остановки. - &core.DateField{Name: "halted_at"}, - &core.SelectField{ - Name: "halt_reason", - Values: entity.AllHaltReasons(), - MaxSelect: 1, - }, - &core.TextField{Name: "error_text"}, - // Признак **этого** захвата: значение уникально для каждого захвата, и - // запись результата условна по нему, а не по занятости записи. - &core.TextField{Name: "acquisition_id"}, - // Срок протухания захвата приезжает с рубежом и пишется числом при самом - // захвате: воркер не привязан к шагу и вывести срок из себя не может. - &core.DateField{Name: "acquire_expires_at"}, - &core.DateField{Name: "delay_time"}, - // Число отказов ограничивает повторы внутри шага. Время в рубеже мерит - // отдельный сторож: одно число не справлялось ни с одной из обязанностей. - &core.NumberField{Name: "attempts", OnlyInt: true, Min: ptr(0.0)}, - &core.RelationField{Name: "original_file", CollectionId: filesID, MaxSelect: 1}, - &core.RelationField{Name: "normalized_file", CollectionId: filesID, MaxSelect: 1}, - &core.RelationField{ - Name: "topics", - CollectionId: topicsID, - MaxSelect: entity.MaxTopicsPerRecord, - }, - &core.NumberField{Name: "tg_chat_id", OnlyInt: true}, - &core.NumberField{Name: "tg_reply_message_id", OnlyInt: true}, - &core.AutodateField{Name: "created", OnCreate: true}, - &core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true}, - ) - - // Отбор захвата идёт по рубежу, признаку остановки, паузе и сроку протухания - // захвата — индекс снимает полный перебор. - records.AddIndex("idx_audio_records_state", false, "state, halted_at", "") - - if err := app.Save(records); err != nil { - return nil, fmt.Errorf("failed to create audio records: %w", err) - } - return records, nil -} - -// createTexts заводит тексты записи. Пара «запись и вид» уникальна: повтор -// прерванного шага иначе завёл бы второй комплект строк, и вопрос «какой текст -// отдавать человеку» стал бы вопросом порядка записи, а не состояния. -func createTexts(app core.App, recordsID string) (*core.Collection, error) { - texts := core.NewBaseCollection(TextsCollection) - texts.Fields.Add( - &core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true}, - &core.SelectField{ - Name: "kind", - Values: entity.AllTextKinds(), - MaxSelect: 1, - Required: true, - }, - // Поле зовётся `kind`, а не `format`: словом `format` в этой же схеме - // зовут формат файла, и третий смысл у одного слова развёл бы по разным - // вещам вид текста и формат копии. - &core.EditorField{Name: "contents"}, - &core.AutodateField{Name: "created", OnCreate: true}, - &core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true}, - ) - texts.AddIndex("idx_texts_record_kind", true, "record, kind", "") - - if err := app.Save(texts); err != nil { - return nil, fmt.Errorf("failed to create texts: %w", err) - } - return texts, nil -} - -// createStructures заводит структуру реплик. Номер версии нужен потому, что -// разбор сохранённого ответа изменится раньше, чем архив пересчитают. -func createStructures(app core.App, recordsID string) (*core.Collection, error) { - structures := core.NewBaseCollection(StructuresCollection) - structures.Fields.Add( - &core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true}, - &core.NumberField{Name: "version", OnlyInt: true, Required: true}, - &core.JSONField{Name: "contents", MaxSize: structureContentsMaxSize}, - &core.AutodateField{Name: "created", OnCreate: true}, - &core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true}, - ) - structures.AddIndex("idx_structures_record_version", true, "record, version", "") - - if err := app.Save(structures); err != nil { - return nil, fmt.Errorf("failed to create structures: %w", err) - } - return structures, nil -} - -// createRecognitions заводит попытку распознавания у внешнего провайдера. -// -// Сырой ответ лежит **вложением**, а не колонкой: шаг опроса читает эту строку -// раз в несколько секунд, а хранилище читает запись целиком — ответ на -// многочасовую запись ехал бы в память при каждом опросе. -// -// Поле вложения помечено защищённым: сырой ответ это полный текст речи, и -// умолчание библиотеки отдавало бы его по ссылке любому, кто её знает. -func createRecognitions(app core.App, recordsID string) (*core.Collection, error) { - recognitions := core.NewBaseCollection(RecognitionsCollection) - payload := &core.FileField{Name: "payload", MaxSelect: 1, MaxSize: recognitionPayloadMaxSize} - payload.Protected = true - - recognitions.Fields.Add( - &core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true}, - &core.TextField{Name: "provider", Required: true}, - &core.TextField{Name: "model"}, - // Идентификатор операции у провайдера — самое провайдерское, что есть в - // модели, и живёт он здесь, а не колонкой записи. - &core.TextField{Name: "external_id"}, - // Адрес, по которому провайдер читает аудио. Копия во внешнем хранилище - // файлом записи не считается: другой провайдер её не потребует. - &core.TextField{Name: "source_uri"}, - payload, - &core.DateField{Name: "started_at"}, - &core.DateField{Name: "finished_at"}, - &core.AutodateField{Name: "created", OnCreate: true}, - &core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true}, - ) - - if err := app.Save(recognitions); err != nil { - return nil, fmt.Errorf("failed to create recognitions: %w", err) - } - return recognitions, nil -} - -// createRecordEvents заводит журнал событий записи. -// -// Колонка текста отказа зовётся `outcome_text`, а не `error_text`: последнее имя -// названо поимённо инвариантом проекта о секрете, и две колонки с этим именем -// сделали бы инвариант двусмысленным. -func createRecordEvents(app core.App, recordsID string) error { - events := core.NewBaseCollection(RecordEventsCollection) - events.Fields.Add( - &core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true}, - &core.SelectField{ - Name: "origin", - Values: entity.AllEventOrigins(), - MaxSelect: 1, - Required: true, - }, - &core.TextField{Name: "step"}, - &core.SelectField{ - Name: "outcome", - Values: entity.AllEventOutcomes(), - MaxSelect: 1, - Required: true, - }, - &core.TextField{Name: "outcome_text"}, - &core.NumberField{Name: "duration_ms", OnlyInt: true}, - &core.AutodateField{Name: "created", OnCreate: true}, - ) - events.AddIndex("idx_record_events_record", false, "record", "") - - if err := app.Save(events); err != nil { - return fmt.Errorf("failed to create record events: %w", err) - } - return nil -} - -// createTopics заводит словарь тем. Тема уникальна в паре «владелец и название»: -// словарь свой у каждого человека, и общий показал бы одному темы другого. -func createTopics(app core.App, usersID string) (*core.Collection, error) { - topics := core.NewBaseCollection(TopicsCollection) - topics.Fields.Add( - &core.RelationField{ - Name: "owner", - CollectionId: usersID, - MaxSelect: 1, - Required: true, - }, - &core.TextField{Name: "name", Required: true}, - &core.AutodateField{Name: "created", OnCreate: true}, - &core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true}, - ) - topics.AddIndex("idx_topics_owner_name", true, "owner, name", "") - - if err := app.Save(topics); err != nil { - return nil, fmt.Errorf("failed to create topics: %w", err) - } - return topics, nil -} - -const ( - // structureContentsMaxSize — потолок разбитой на реплики расшифровки. Число - // с запасом: шестичасовой разговор даёт порядка мегабайта текста с временем. - structureContentsMaxSize = 16 << 20 - // recognitionPayloadMaxSize — потолок сохранённого ответа провайдера. Он - // многословнее самой расшифровки: несёт альтернативы, время каждого слова и - // разбор говорящих. - recognitionPayloadMaxSize = 256 << 20 -) - -// Поля объявляются россыпью, а не помощником, который принимал бы имя доводом: -// сверка перечня колонок со схемой читает литерал `Name:` в шагах, и имя, -// спрятанное за вызовом, она не видит — колонка выпала бы из-под правила молча. - -// down202608140002 снимает новые коллекции. Прежнюю коллекцию задач он не -// восстанавливает: данных под ней не было, а пустая копия прежней схемы была бы -// вторым домом для понятия, которого больше нет. -func down202608140002(app core.App) error { - // Порядок обратный порядку заведения: приложения ссылаются на запись. - order := []string{ - RecordEventsCollection, - RecognitionsCollection, - StructuresCollection, - TextsCollection, - RecordsCollection, - TopicsCollection, - } - for _, name := range order { - collection, err := app.FindCollectionByNameOrId(name) - if err != nil { - continue - } - if err := app.Delete(collection); err != nil { - return fmt.Errorf("failed to drop %s: %w", name, err) - } - } - return nil -} diff --git a/internal/adapter/repo/pocketbase/migrations/202608140003_owner_required.go b/internal/adapter/repo/pocketbase/migrations/202608140003_owner_required.go deleted file mode 100644 index fe46727..0000000 --- a/internal/adapter/repo/pocketbase/migrations/202608140003_owner_required.go +++ /dev/null @@ -1,77 +0,0 @@ -package migrations - -import ( - "errors" - "fmt" - - "github.com/pocketbase/pocketbase/core" -) - -// up202608140003 запрещает пустого владельца у аудиозаписи и у её файла. -// -// Прежде пустое значение допускалось, и цену за это платили записи, принятые -// ботом: связи чата Telegram с учётной записью сервис не вёл, и владельца у них -// не было вовсе. Вход Telegram убран, заводить ничью запись стало некому, и -// обязательность переезжает из приёма в схему — туда, где её держит хранилище, а -// не договорённость. Разница не косметическая: пока обязательность жила в -// приёме, ничью запись заводили руками в панели, она уходила в конвейер, стоила -// денег на распознавание и не доставалась потом никому. -// -// Существующих строк шаг **не смотрит**, и это проверено прогоном: хранилище -// держит обязательность связи проверкой записи при сохранении, а не ограничением -// таблицы, поэтому смена признака на базе с ничьей записью проходит зелёным и -// такую запись оставляет. Искать ничьи строки надо до выкладки и запросом — -// `SELECT count(*) FROM audio_records WHERE owner = ”` и то же по `files`; -// прогон самого шага на копии этого не показывает. -// -// Оставленная ничья запись становится незакрываемой: захват идёт сырым запросом -// мимо проверки и выдаёт её воркеру, а всякое сохранение — включая то, которым -// ставится признак остановки, — отказывает. Порядок выкладки поэтому начинается -// с проверки данных, а не с прогона шага. -func up202608140003(app core.App) error { - for _, name := range []string{RecordsCollection, FilesCollection} { - if err := setOwnerRequired(app, name, true); err != nil { - return err - } - } - return nil -} - -// down202608140003 возвращает колонке необязательность. Записей это не касается: -// пустых значений среди них нет, а появиться им теперь неоткуда. -func down202608140003(app core.App) error { - for _, name := range []string{RecordsCollection, FilesCollection} { - if err := setOwnerRequired(app, name, false); err != nil { - return err - } - } - return nil -} - -// setOwnerRequired правит признак обязательности у колонки владельца одной -// коллекции. Колонка ищется по имени и приводится к типу связи: шаг, молча -// пропустивший чужой тип, оставил бы схему в состоянии, о котором никто не -// узнает. -func setOwnerRequired(app core.App, collectionName string, required bool) error { - collection, err := app.FindCollectionByNameOrId(collectionName) - if err != nil { - return fmt.Errorf("failed to find collection %s: %w", collectionName, err) - } - - field := collection.Fields.GetByName("owner") - if field == nil { - return errors.New("collection " + collectionName + " has no owner field") - } - - relation, ok := field.(*core.RelationField) - if !ok { - return errors.New("owner field of collection " + collectionName + " is not a relation") - } - - relation.Required = required - - if err := app.Save(collection); err != nil { - return fmt.Errorf("failed to change owner requirement in %s: %w", collectionName, err) - } - return nil -} diff --git a/internal/adapter/repo/pocketbase/migrations/202608150001_record_contract_columns.go b/internal/adapter/repo/pocketbase/migrations/202608150001_record_contract_columns.go deleted file mode 100644 index 09b6b12..0000000 --- a/internal/adapter/repo/pocketbase/migrations/202608150001_record_contract_columns.go +++ /dev/null @@ -1,94 +0,0 @@ -package migrations - -import ( - "fmt" - - "github.com/pocketbase/pocketbase/core" - - "git.vakhrushev.me/av/transcriber/internal/entity" -) - -// up202608150001 заводит у аудиозаписи три колонки, которые показывает список -// приложения: имя файла, данное отправителем, длительность и размер принятого. -// -// Шаг один на все три намеренно. Применённый шаг не переписывается, и три шага -// вместо одного стоили бы трёх необратимых решений там, где хватает одного. -// -// **Имя файла ложится своей колонкой, а не в заголовок.** Заголовок несёт -// название, которое дал человек либо посчитала языковая модель; имя файла — то, -// по чему человек узнаёт свою запись, пока заголовка нет. Одной колонкой на оба -// смысла посчитанное название затирало бы имя, и вернуть затёртое было бы -// неоткуда. -// -// **Длительность и размер дублируют строку файла, и это решение владельца от -// 2026-08-15.** Равенство между ними не поддерживается никем: на записи лежит -// снимок принятого, взятый приёмом один раз, на файле — величины той копии, -// которой файл является сейчас. Расхождение — не поломка, а разные вопросы; -// норму держит capability `storage`. -// -// Единица стоит в имени колонки, а не в комментарии: расхождение «секунды против -// миллисекунд» между колонкой, ответом списка и объявленным пределом не увидит -// ни компилятор, ни гейт — оба конца числа. -func up202608150001(app core.App) error { - records, err := app.FindCollectionByNameOrId(RecordsCollection) - if err != nil { - return fmt.Errorf("failed to find collection %s: %w", RecordsCollection, err) - } - - records.Fields.Add( - // Имя приходит извне и содержимым своим приёму не подконтрольно, поэтому - // приём режет его по пределу и убирает управляющие знаки прежде, чем - // сохранить. Схема держит потолок вторым рубежом: значение сверх него - // отвергается хранилищем, а не доезжает до экрана. - &core.TextField{Name: "original_filename", Max: entity.MaxOriginalFilenameLen}, - // Длительность и размер принятого. «Неизвестно» колонки не выражают: - // числовая колонка хранилища пустого значения не держит, пустое кладётся - // нулём. Обе ставит приём и ставит всегда — запись с непрочитанными - // метаданными отвергается отказом и не заводится. Решение владельца - // 2026-08-15. - &core.NumberField{Name: "duration_ms", OnlyInt: true, Min: ptr(0.0)}, - &core.NumberField{Name: "size_bytes", OnlyInt: true, Min: ptr(0.0)}, - ) - - // Индекс под ленту приложения. Единственный прежний индекс — по рубежу и - // признаку остановки — заведён под захват воркера и выборке владельца не - // помогает ничем: страница сканирует таблицу целиком и досортировывает - // результат во временном дереве. - // - // Замер на этом же изменении: рост архива с 5 тысяч строк до 200 тысяч — - // сорокакратный — растит время одной страницы владельца в двадцать-тридцать - // раз, хотя записей у него всё те же сорок. Цена растёт с **чужими** - // записями, потому что сервис объявлен архивом и хранит их бессрочно. - // - // Порядок колонок повторяет порядок выборки: сужение по владельцу, затем - // сортировка «новыми сверху» полным ключом. - records.AddIndex("idx_audio_records_owner_feed", false, "owner, created DESC, id DESC", "") - // Отбор тремя состояниями сужает по владельцу вместе с рубежом и признаком - // остановки — своим индексом, потому что ведущей колонкой здесь владелец. - records.AddIndex("idx_audio_records_owner_state", false, "owner, state, halted_at", "") - - if err := app.Save(records); err != nil { - return fmt.Errorf("failed to add contract columns to %s: %w", RecordsCollection, err) - } - return nil -} - -// down202608150001 снимает три колонки. Данные в них при этом теряются, и -// восстановить их неоткуда: имя файла отправителя нигде больше не хранится. -func down202608150001(app core.App) error { - records, err := app.FindCollectionByNameOrId(RecordsCollection) - if err != nil { - return fmt.Errorf("failed to find collection %s: %w", RecordsCollection, err) - } - - for _, name := range []string{"original_filename", "duration_ms", "size_bytes"} { - records.Fields.RemoveByName(name) - } - records.RemoveIndex("idx_audio_records_owner_feed") - records.RemoveIndex("idx_audio_records_owner_state") - - if err := app.Save(records); err != nil { - return fmt.Errorf("failed to drop contract columns from %s: %w", RecordsCollection, err) - } - return nil -} diff --git a/internal/adapter/repo/pocketbase/migrations/202608220001_trusted_header_login.go b/internal/adapter/repo/pocketbase/migrations/202608220001_trusted_header_login.go deleted file mode 100644 index d51bf52..0000000 --- a/internal/adapter/repo/pocketbase/migrations/202608220001_trusted_header_login.go +++ /dev/null @@ -1,125 +0,0 @@ -package migrations - -import ( - "errors" - "fmt" - - "github.com/pocketbase/pocketbase/core" -) - -// ProviderLoginField — колонка, в которой лежит ключ учётной записи: логин -// человека у провайдера, тот самый, которым его называет обратный прокси. -// -// Имя говорит о происхождении значения, а не о заголовке, которым оно приехало: -// заголовок — способ доставки и может смениться, а логин у провайдера — то, чем -// значение является. Колонка уезжает шагом схемы и потому не переименовывается. -const ProviderLoginField = "provider_login" - -// providerLoginIndex — имя уникального индекса по ключу учётной записи. -const providerLoginIndex = "idx_users_provider_login" - -// up202608220001 переводит узнавание пришедшего с протокола OIDC на логин, -// который называет доверенный источник. -// -// Три части, и каждая закрывает своё. -// -// Первая — ключ учётной записи. Прежде идентичность человека лежала в системной -// таблице внешних учётных записей библиотеки: её вела механика обмена кода, и -// правил её только владелец панели. Механика уходит, и ключу нужен свой дом — -// колонка с уникальным индексом. Почта ключом не годится: провайдер не обязан -// её приносить, человек её меняет, а первое обращение с чужим адресом досталось -// бы чужой записи. -// -// Вторая — необязательная почта. Умолчание библиотеки требует непустого адреса -// у всякой учётной записи; заголовка с почтой может не быть вовсе, а -// уникальность почты держится **частичным** индексом (`WHERE email != ”`), -// поэтому записи без почты уживаются друг с другом. Пароль остаётся -// обязательным при любом значении признака — ему ставится случайный, употребить -// его нельзя: вход по паролю у коллекции выключен прежним шагом. -// -// Третья — поверхность коллекции пользователей. Умолчание библиотеки открывает -// владельцу записи чтение, правку и удаление собственной строки, и до сих пор -// это ничему не мешало ровно потому, что до поверхности хранилища браузер с -// кукой не дотягивался: слой предъявления жил под корнем приложения. С -// узнаванием по заголовку такая защита перестаёт быть защитой, а ключ учётной -// записи лежит теперь обычной колонкой — то есть правка своей записи и есть -// захват чужого имени: поставил себе ключом чужой логин, и первое обращение -// настоящего его владельца попало бы в твою запись вместе со всем архивом. -// Правила снимаются в пустое, что у хранилища означает «только владелец -// панели»; наш код читает и заводит запись мимо правил, панель работает -// суперпользователем, своих экранов профиля сервис не заводит. -func up202608220001(app core.App) error { - users, err := app.FindCollectionByNameOrId(UsersCollection) - if err != nil { - return fmt.Errorf("failed to find users collection: %w", err) - } - - users.Fields.Add(&core.TextField{ - Name: ProviderLoginField, - // Предел тот же, что у имени в умолчании библиотеки: логин длиннее - // имени не бывает, а колонка без предела принимала бы килобайты, - // пришедшие заголовком. - Max: 255, - }) - // Индекс **частичный** — ровно как соседний индекс почты у той же коллекции. - // Сплошной запретил бы вторую запись с пустым ключом, а такая заводится - // рукой в панели и остаётся у всякой базы, пережившей прежний вход: подъём - // на ней ронял бы накатку шага отказом уникальности, и сервис не стартовал - // бы вовсе. Пустым ключом при этом не узнаётся никто — это держит приём - // значения, а не индекс. - users.AddIndex(providerLoginIndex, true, ProviderLoginField, ProviderLoginField+" != ''") - - email, ok := users.Fields.GetByName(core.FieldNameEmail).(*core.EmailField) - if !ok { - return errors.New("users collection has no email field") - } - email.Required = false - - // Механика OIDC снимается целиком: настройки провайдера больше не приводятся - // к конфигу при подъёме, и обменивать код не на что. - users.OAuth2.Enabled = false - users.OAuth2.Providers = nil - - // Наглухо все пять: заведение записи идёт нашим кодом, мимо правил. - users.ListRule = nil - users.ViewRule = nil - users.CreateRule = nil - users.UpdateRule = nil - users.DeleteRule = nil - - if err := app.Save(users); err != nil { - return fmt.Errorf("failed to switch users collection to provider login: %w", err) - } - - return nil -} - -// down202608220001 убирает ключ учётной записи и возвращает обязательность -// почты. -// -// Правила доступа сюда не возвращаются намеренно — ни открытое создание записи, -// которое закрывал прежний шаг, ни открытая правка, которую закрывает этот. -// Откат, восстанавливающий их, оставил бы сервис хуже, чем он был: правка своей -// записи открыта только тому, кто узнан, а узнают теперь по колонке, которую эта -// правка и переписывает. -func down202608220001(app core.App) error { - users, err := app.FindCollectionByNameOrId(UsersCollection) - if err != nil { - return fmt.Errorf("failed to find users collection: %w", err) - } - - users.RemoveIndex(providerLoginIndex) - users.Fields.RemoveByName(ProviderLoginField) - - email, ok := users.Fields.GetByName(core.FieldNameEmail).(*core.EmailField) - if !ok { - return errors.New("users collection has no email field") - } - email.Required = true - - if err := app.Save(users); err != nil { - return fmt.Errorf("failed to restore users collection: %w", err) - } - - return nil -} diff --git a/internal/adapter/repo/pocketbase/migrations/migrations.go b/internal/adapter/repo/pocketbase/migrations/migrations.go deleted file mode 100644 index 187e795..0000000 --- a/internal/adapter/repo/pocketbase/migrations/migrations.go +++ /dev/null @@ -1,57 +0,0 @@ -// Package migrations — шаги схемы хранилища и имена коллекций, которые они -// заводят. -// -// Схема заводится версионированными шагами, и применённый шаг не переписывается -// — только новым шагом. Инвариант проекта перенесён дословно: хранилище считает -// применённое по **имени шага**, а не по пути файла, поэтому имена в -// `Register` ниже не переносятся и не переименовываются, даже если файл переехал. -// -// Шаги лежат своим каталогом, а не файлом внутри пакета репозитория, и причина -// внешняя: сверка документов ловит изменённый шаг схемы при нетронутом -// `docs/database.md` по префиксу пути (`.av-dev.toml`, ключ `migrations` секции -// `[docs]`), а префикс наводится только на каталог. Пока шаги лежали файлом, -// наводить его -// было не на что, и проверка молчала на всякой правке схемы. -package migrations - -import ( - pbmigrations "github.com/pocketbase/pocketbase/migrations" -) - -// Имена коллекций живут здесь, рядом с шагом, который их заводит. Они же — часть -// пути к файлу в раскладке хранилища и часть адреса ссылки на него, поэтому -// меняются только новым шагом схемы. -const ( - FilesCollection = "files" - // JobsCollection — прежняя коллекция задач. Шаг 202608140002 её удаляет; - // имя остаётся здесь, потому что на него ссылаются прежние шаги схемы, а - // применённый шаг не переписывается. - JobsCollection = "transcribe_jobs" - // RecordsCollection — аудиозапись, центральная сущность сервиса. Имя в - // snake_case, как у соседей по схеме: одно исключение разошлось бы молча по - // константе имён, запросу захвата, правилам панели и запрету удаления. - RecordsCollection = "audio_records" - TextsCollection = "texts" - StructuresCollection = "structures" - RecognitionsCollection = "recognitions" - RecordEventsCollection = "record_events" - TopicsCollection = "topics" - // UsersCollection заводит не наш шаг, а системный шаг библиотеки. Имя стоит - // здесь потому, что на него ссылаются и шаги схемы, и проверка предъявителя - // на приёме: строковый литерал в двух местах разошёлся бы молча. - UsersCollection = "users" -) - -// Шаг регистрируется в списке приложения при загрузке пакета, а накатывает его -// `apis.Serve` прежде, чем поднять сервер. -func init() { - pbmigrations.Register(up202608110001, down202608110001, "202608110001_init.go") - pbmigrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go") - pbmigrations.Register(up202608140001, down202608140001, "202608140001_record_owner.go") - pbmigrations.Register(up202608140002, down202608140002, "202608140002_record_centric_model.go") - pbmigrations.Register(up202608140003, down202608140003, "202608140003_owner_required.go") - pbmigrations.Register(up202608150001, down202608150001, "202608150001_record_contract_columns.go") - pbmigrations.Register(up202608220001, down202608220001, "202608220001_trusted_header_login.go") -} - -func ptr[T any](v T) *T { return &v } diff --git a/internal/adapter/repo/pocketbase/owner_guard.go b/internal/adapter/repo/pocketbase/owner_guard.go deleted file mode 100644 index 0244445..0000000 --- a/internal/adapter/repo/pocketbase/owner_guard.go +++ /dev/null @@ -1,93 +0,0 @@ -package pocketbase - -import ( - "fmt" - - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tools/router" - - "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" -) - -// GuardOwnerDeletion отвергает удаление учётной записи, у которой остались -// аудиозаписи, их файлы либо темы её словаря. -// -// Колонка владельца — связь с выключенным каскадным удалением, и одного этого -// мало: при выключенном каскаде хранилище не удаляет ссылающуюся запись, а -// **вынимает** идентификатор из поля связи и сохраняет её без проверок. Задачи -// остались бы на месте, но стали бы ничьими, а ничья задача не достаётся по API -// никому — архив человека исчез бы молча и восстановлению не подлежал: -// прежнего владельца не остаётся нигде. -// -// Цена запрета названа прямо: владелец панели упирается в отказ, а способа -// удалить записи в сервисе пока нет вовсе — его приносит отдельная задача. До -// неё удаление учётной записи с записями невозможно, и это осознанный тупик. -// -// Слой стоит на удалении записи, а не на запросе к панели: панель ходит правами -// суперпользователя, и правило коллекции её не судит. Удаление при этом не -// только панельное — умолчание библиотеки разрешает вошедшему удалить свою -// учётную запись запросом, так что страж закрывает и публичную поверхность. -// -// Считаются **все** коллекции с владельцем, и перечень их живёт одним списком -// ниже. Файл переживает свою запись: шаг конвейера заводит его до сохранения, и -// потерянный захват оставляет файл с владельцем и без ссылки. Учётная запись, у -// которой остались одни такие файлы, без этого счёта удалялась бы штатно, а -// аудио становилось бы ничьим. -func GuardOwnerDeletion(app core.App) { - app.OnRecordDelete(migrations.UsersCollection).BindFunc(func(e *core.RecordEvent) error { - count, err := countOwned(e.App, e.Record.Id) - if err != nil { - return err - } - - if count > 0 { - // Отказ отдаётся ошибкой роутера, а не обычной: библиотека пропускает - // наружу только `*router.ApiError`, а всякую другую подменяет своим - // сообщением — «убедитесь, что запись не участвует в обязательной - // связи». Подсказка эта не просто бесполезная, а **ведущая**: - // единственная обязательная связь у задачи — файл, и владелец панели, - // поверив ей, пойдёт удалять задачи и файлы руками. То есть сделает - // ровно то необратимое, ради предотвращения чего страж и заведён. - // - // Число в отказе — не содержимое записей, а их счёт: он говорит - // владельцу панели, почему удаление не прошло, и не выносит наружу - // ничего о самих записях. - return router.NewBadRequestError(fmt.Sprintf( - "у учётной записи остались записи (%d): сервис — архив, и удаление сделало бы их ничьими", - count, - ), nil) - } - - return e.Next() - }) -} - -// ownedCollections — коллекции с колонкой владельца. Перечень живёт здесь одним -// списком, и разойтись с шагом схемы ему нельзя: пропущенная коллекция -// пропускает удаление вперёд, а наружу приезжает не наш отказ с причиной, а -// подсказка библиотеки про обязательную связь — та самая, по которой владелец -// панели пойдёт удалять записи руками. -// -// Так уже случилось однажды: `topics` завелась третьей и в списке не появилась. -var ownedCollections = []string{ - migrations.RecordsCollection, - migrations.FilesCollection, - migrations.TopicsCollection, -} - -// countOwned считает всё, что принадлежит учётной записи, — по всем коллекциям -// с колонкой владельца. -func countOwned(app core.App, ownerID string) (int64, error) { - var total int64 - - for _, collection := range ownedCollections { - count, err := app.CountRecords(collection, dbx.HashExp{"owner": ownerID}) - if err != nil { - return 0, fmt.Errorf("failed to count owned records in %s: %w", collection, err) - } - total += count - } - - return total, nil -} diff --git a/internal/adapter/repo/pocketbase/owner_test.go b/internal/adapter/repo/pocketbase/owner_test.go deleted file mode 100644 index e2c6678..0000000 --- a/internal/adapter/repo/pocketbase/owner_test.go +++ /dev/null @@ -1,167 +0,0 @@ -package pocketbase - -import ( - "strings" - "testing" - - "github.com/google/uuid" - "github.com/pocketbase/pocketbase/core" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" - "git.vakhrushev.me/av/transcriber/internal/clock" - "git.vakhrushev.me/av/transcriber/internal/contract" - "git.vakhrushev.me/av/transcriber/internal/entity" -) - -// Страж удаления учётной записи — единственное, что стоит между владельцем -// панели и молчаливым обезличиванием чужого архива: при выключенном каскаде -// хранилище снимает ссылку и сохраняет запись без проверок. - -func newAccount(t *testing.T, app core.App) *core.Record { - t.Helper() - - users, err := app.FindCollectionByNameOrId(migrations.UsersCollection) - require.NoError(t, err) - - record := core.NewRecord(users) - record.Set("email", uuid.NewString()+"@example.test") - record.Set("verified", true) - record.Set("password", uuid.NewString()) - require.NoError(t, app.Save(record)) - - return record -} - -// newRecordOf заводит аудиозапись названного владельца. -func newRecordOf(t *testing.T, app core.App, ownerID string) *entity.AudioRecord { - t.Helper() - - record := &entity.AudioRecord{ - State: entity.StateUploaded, - StateEnteredAt: clock.Now(), - Source: entity.SourceApi, - OwnerID: ownerID, - } - require.NoError(t, NewAudioRecordRepository(app).Create(record)) - return record -} - -// Учётная запись с архивом не удаляется, и отказ называет причину — иначе -// наружу приезжает подсказка библиотеки про обязательную связь, по которой -// владелец панели пойдёт удалять записи руками. -func TestGuardOwnerDeletion(t *testing.T) { - app := newTestStorage(t) - - account := newAccount(t, app) - record := newRecordOf(t, app, account.Id) - - err := app.Delete(account) - require.Error(t, err, "учётная запись с архивом не удаляется") - assert.Contains(t, err.Error(), "остались записи", "отказ называет причину") - - after, err := NewAudioRecordRepository(app).Get(record.Id) - require.NoError(t, err, "запись на месте") - assert.Equal(t, account.Id, after.OwnerID, "и владелец у неё прежний") -} - -// Считаются все коллекции с владельцем, а не одни записи: файл переживает свою -// запись, а тема живёт в словаре человека. -func TestGuardOwnerDeletionCountsEveryOwnedCollection(t *testing.T) { - cases := map[string]func(t *testing.T, app core.App, ownerID string){ - "аудиозапись": func(t *testing.T, app core.App, ownerID string) { - newRecordOf(t, app, ownerID) - }, - "один файл без записи": func(t *testing.T, app core.App, ownerID string) { - repo := NewFileRepository(app) - work, err := repo.Stage(".mp3", strings.NewReader("запись")) - require.NoError(t, err) - defer func() { require.NoError(t, work.Close()) }() - - _, err = repo.Create("sample.mp3", work, contract.FileMeta{Format: "mp3"}, ownerID) - require.NoError(t, err) - }, - "одна тема словаря": func(t *testing.T, app core.App, ownerID string) { - topics, err := app.FindCollectionByNameOrId(migrations.TopicsCollection) - require.NoError(t, err) - - topic := core.NewRecord(topics) - topic.Set("owner", ownerID) - topic.Set("name", "личная тема") - require.NoError(t, app.Save(topic)) - }, - } - - for name, own := range cases { - t.Run(name, func(t *testing.T) { - app := newTestStorage(t) - account := newAccount(t, app) - own(t, app, account.Id) - - err := app.Delete(account) - require.Error(t, err, "учётная запись с этим добром не удаляется") - assert.Contains(t, err.Error(), "остались записи", - "отказ наш, а не подсказка библиотеки про обязательную связь") - }) - } -} - -// Учётная запись, за которой ничего не числится, удаляется штатно: страж -// заведён против потери архива, а не против удаления вообще. -func TestGuardOwnerDeletionLetsEmptyAccountGo(t *testing.T) { - app := newTestStorage(t) - - account := newAccount(t, app) - require.NoError(t, app.Delete(account), "пустая учётная запись удаляется") -} - -// Колонка владельца пустого значения не принимает и умолчания не имеет: -// ничьей записи в хранилище не бывает, и завести её нечем — ни приёмом, ни -// конвейером, ни рукой в панели. -func TestOwnerColumnRefusesEmptyValue(t *testing.T) { - app := newTestStorage(t) - - for _, name := range []string{migrations.RecordsCollection, migrations.FilesCollection} { - t.Run(name, func(t *testing.T) { - collection, err := app.FindCollectionByNameOrId(name) - require.NoError(t, err) - - field := collection.Fields.GetByName("owner") - require.NotNil(t, field, "колонка владельца заведена") - - relation, ok := field.(*core.RelationField) - require.True(t, ok, "владелец — связь с учётной записью, а не строка") - assert.True(t, relation.Required, "пустое значение колонка не принимает") - assert.False(t, relation.CascadeDelete, "удаление учётной записи не уносит архив следом") - }) - } -} - -// Та же норма со стороны сохранения: схема отвергает запись без владельца, а не -// только объявляет колонку обязательной. -func TestStorageRefusesRecordWithoutOwner(t *testing.T) { - app := newTestStorage(t) - - record := &entity.AudioRecord{ - State: entity.StateUploaded, - StateEnteredAt: clock.Now(), - Source: entity.SourceApi, - } - - require.Error(t, NewAudioRecordRepository(app).Create(record), - "ничья запись в хранилище не ложится") -} - -// И файл — наравне с записью: разное правило у них читалось бы как недосмотр. -func TestStorageRefusesFileWithoutOwner(t *testing.T) { - app := newTestStorage(t) - - repo := NewFileRepository(app) - work, err := repo.Stage(".mp3", strings.NewReader("запись")) - require.NoError(t, err) - defer func() { require.NoError(t, work.Close()) }() - - _, err = repo.Create("sample.mp3", work, contract.FileMeta{Format: "mp3"}, "") - require.Error(t, err, "ничей файл в хранилище не ложится") -} diff --git a/internal/adapter/repo/pocketbase/panel.go b/internal/adapter/repo/pocketbase/panel.go deleted file mode 100644 index cf47e65..0000000 --- a/internal/adapter/repo/pocketbase/panel.go +++ /dev/null @@ -1,131 +0,0 @@ -package pocketbase - -import ( - "errors" - - "github.com/pocketbase/pocketbase/core" - - "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" - "git.vakhrushev.me/av/transcriber/internal/entity" -) - -// BindPanelRules подчиняет правку записи в панели тем же правилам, что и правку -// из кода. -// -// Панель — вход в запись наравне с конвейером, а не окно просмотра: ради правки -// она и покупалась, остановленная запись возвращается в работу снятием признака. -// Но правка полем идёт мимо кода, который чистит служебные поля, и владелец, -// «вернувший запись в работу», получил бы запись с прежним признаком захвата -// (захвату она не выдастся до конца срока), с числом отказов на пределе -// (остановится от первого же отказа) и со старым временем входа в рубеж -// (остановится снова первым же захватом по пределу простоя). Узнать об этом ему -// неоткуда. -// -// Правило живёт **одним местом** — доменными `Resume` и `MoveToState`, — и хук -// зовёт именно их, а не повторяет перечень служебных полей колонками. Повтор -// перечня был бы вторым домом того же правила: новый сторож попал бы в домен и -// не попал в панель, и владелец «вернул бы запись в работу», а она снова выпала -// бы из выборки — молча. -// -// Хук стоит на правке **запросом**, а не на всяком сохранении записи. Модельное -// событие не различает, кто пишет, и срабатывало бы на каждом переходе -// конвейера: тогда пауза, поставленная шагом вместе со сменой рубежа, стиралась -// бы тем же сохранением, а число отказов остановленной записи — которое -// остановка хранит намеренно — приходило бы владельцу нулём. -func BindPanelRules(app core.App) { - bindProviderLoginIsImmutable(app) - - app.OnRecordUpdateRequest(migrations.RecordsCollection).BindFunc(func(e *core.RecordRequestEvent) error { - original := e.Record.Original() - if original == nil { - return e.Next() - } - - stateChanged := original.GetString("state") != e.Record.GetString("state") - // Снятие признака остановки — то самое движение, ради которого признак и - // заведён: запись возвращается в работу с сохранённого рубежа. - resumed := !original.GetDateTime("halted_at").IsZero() && - e.Record.GetDateTime("halted_at").IsZero() - - if !stateChanged && !resumed { - return e.Next() - } - - // Запись читается уже с правкой человека: рубеж здесь тот, который он - // выбрал, а признак остановки — тот, который он снял или оставил. - record := recordToAudioRecord(e.Record) - switch { - case resumed: - record.Resume() - default: - record.MoveToState(record.State) - } - applyOwnedByPipeline(e.Record, record) - - if err := e.Next(); err != nil { - return err - } - - if resumed { - // Перезапуск виден в журнале событий с указанием, что его сделал - // человек: иначе запись, вернувшаяся в работу, выглядела бы как - // запись, которая туда и не уходила. - // - // Строка пишется **после** сохранения: событие о правке, которая не - // прошла, соврало бы о состоянии записи. Отказ записи журнала саму - // правку не отменяет — журнал никем не читается ради решения. - event := &entity.RecordEvent{ - RecordID: e.Record.Id, - Origin: entity.EventOriginHuman, - Step: "resume", - Outcome: entity.EventOutcomeResumed, - } - if err := NewRecordEventRepository(e.App).Append(event); err != nil { - e.App.Logger().Error("Failed to log record resume", "error", err, "record_id", e.Record.Id) - } - } - - return nil - }) -} - -// bindProviderLoginIsImmutable запрещает менять ключ учётной записи после -// заведения. -// -// Ключ — логин человека у провайдера, и по нему сервис узнаёт пришедшего. -// Переписанный, он отдаёт весь архив прежнего владельца следующему, кто придёт -// с этим именем: владелец записи назначается один раз и не меняется, так что -// вернуть архив будет нечем. Молча — журнала событий у коллекции пользователей -// нет. -// -// Правила доступа коллекции закрывают этот путь **снаружи**, но не изнутри: -// панель работает суперпользователем и правила обходит по построению. Отсюда -// хук, и он вешается на **модельное** событие, а не на правку запросом — иначе -// панель осталась бы незакрытой, а закрывать её и есть весь смысл. -// -// Заведение проходит: событие правки на нём не срабатывает вовсе. -// -// Прежнее значение читается **из базы**, а не из снимка правящейся записи. -// Снимок у записи, только что заведённой в этом же процессе, пуст — он не -// обновляется сохранением, — и сторож, опирающийся на него, пропускал бы правку -// в зависимости от того, откуда вызывающий взял запись. Панель её загружает, и -// на ней сторож сработал бы; молчаливая же зависимость от способа получения — -// ровно тот класс, из-за которого правило и заводится. -func bindProviderLoginIsImmutable(app core.App) { - app.OnRecordUpdate(migrations.UsersCollection).BindFunc(func(e *core.RecordEvent) error { - stored, err := e.App.FindRecordById(migrations.UsersCollection, e.Record.Id) - if err != nil { - // Записи в базе нет — правки тоже нет: сохранение отвергнется само. - return e.Next() - } - - was := stored.GetString(migrations.ProviderLoginField) - now := e.Record.GetString(migrations.ProviderLoginField) - - if was != "" && was != now { - return errors.New("provider login is assigned once and never changes") - } - - return e.Next() - }) -} diff --git a/internal/adapter/repo/pocketbase/panel_test.go b/internal/adapter/repo/pocketbase/panel_test.go deleted file mode 100644 index 8e3f83a..0000000 --- a/internal/adapter/repo/pocketbase/panel_test.go +++ /dev/null @@ -1,240 +0,0 @@ -package pocketbase - -import ( - "testing" - "time" - - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tools/types" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" - "git.vakhrushev.me/av/transcriber/internal/clock" - "git.vakhrushev.me/av/transcriber/internal/entity" -) - -// Панель — единственный сегодня путь вернуть остановленную запись в работу, и -// хук правил стоит на правке **запросом**. Модельное сохранение его не трогает, -// поэтому проверки ниже идут через запрос — иначе они зеленели бы, не касаясь -// того пути, которым владелец и ходит. - -// newPanelStorage поднимает хранилище с повешенными правилами панели — так же, -// как это делает сборка сервиса. Без них проверки судили бы хранилище без -// правил, то есть не то, что работает в проде. -func newPanelStorage(t *testing.T) core.App { - t.Helper() - - app := newTestStorage(t) - BindPanelRules(app) - return app -} - -// updateByRequest правит запись так, как это делает панель: запросом, а не -// сохранением модели. -func updateByRequest(t *testing.T, app core.App, recordID string, body map[string]any) *core.Record { - t.Helper() - - record, err := app.FindRecordById(migrations.RecordsCollection, recordID) - require.NoError(t, err) - - // Запись, прочитанная из хранилища, помнит прежние значения сама — по ним - // хук и отличает смену рубежа от правки соседнего поля. - for key, value := range body { - record.Set(key, value) - } - - // Событие правки запросом несёт и запрос, и коллекцию: `RequestEvent` вложен - // указателем, а по коллекции хук и отбирается — без неё он не сработает вовсе, - // и проверка зеленела бы, не коснувшись правила. - collection, err := app.FindCollectionByNameOrId(migrations.RecordsCollection) - require.NoError(t, err) - - event := &core.RecordRequestEvent{RequestEvent: &core.RequestEvent{}} - event.App = app - event.Collection = collection - event.Record = record - - require.NoError(t, app.OnRecordUpdateRequest(migrations.RecordsCollection).Trigger(event, func(e *core.RecordRequestEvent) error { - return e.App.Save(e.Record) - })) - - after, err := app.FindRecordById(migrations.RecordsCollection, recordID) - require.NoError(t, err) - return after -} - -// haltedRecord заводит остановленную запись со всеми накопленными сторожами — -// такой её видит владелец, открывая панель. -func haltedRecord(t *testing.T, app core.App) *entity.AudioRecord { - t.Helper() - - record := newRecordOf(t, app, newAccount(t, app).Id) - record.MoveToState(entity.StateNormalized) - record.Attempts = 4 - record.AcquisitionID = ptrOf("прежний-захват") - record.AcquireExpiresAt = ptrOf(clock.Now().Add(8 * time.Hour)) - record.DelayTime = ptrOf(clock.Now().Add(time.Hour)) - record.Halt(entity.HaltReasonStepFailed, "сбой конвертации файла") - // Время входа в рубеж отодвигаем: запись простояла остановленной дольше - // предела простоя, и это ровно тот случай, ради которого сторож сбрасывается. - record.StateEnteredAt = clock.Now().Add(-24 * time.Hour) - - require.NoError(t, NewAudioRecordRepository(app).Save(record, "")) - return record -} - -func ptrOf[T any](v T) *T { return &v } //nolint:newexpr // значение вычисляется, new(x) его не примет - -// Снятие признака остановки возвращает запись в работу с сохранённого рубежа и -// сбрасывает **всех** сторожей. Без сброса времени входа в рубеж запись, -// простоявшая остановленной дольше предела, остановилась бы снова первым же -// захватом — и владелец не узнал бы об этом. -func TestPanelResumeClearsEveryGuard(t *testing.T) { - app := newPanelStorage(t) - record := haltedRecord(t, app) - - after := updateByRequest(t, app, record.Id, map[string]any{"halted_at": ""}) - - assert.Equal(t, entity.StateNormalized, after.GetString("state"), "рубеж сохранён") - assert.True(t, after.GetDateTime("halted_at").IsZero(), "признак остановки снят") - assert.Empty(t, after.GetString("halt_reason"), "причина снята вместе с ним") - assert.Empty(t, after.GetString("error_text"), "и текст отказа") - assert.Empty(t, after.GetString("acquisition_id"), "признак прежнего захвата очищен") - assert.True(t, after.GetDateTime("acquire_expires_at").IsZero(), "срок протухания тоже") - assert.True(t, after.GetDateTime("delay_time").IsZero(), "пауза снята") - assert.Equal(t, 0, after.GetInt("attempts"), "отказы сброшены") - - entered := after.GetDateTime("state_entered_at").Time() - assert.WithinDuration(t, clock.Now(), entered, time.Minute, - "время входа в рубеж поставлено заново: иначе сторож простоя остановит запись снова") - - // И ближайший захват её выдаёт — то есть перезапуск действительно работает. - acquired, err := NewAudioRecordRepository(app).FindAndAcquire(entity.WorkingStages()) - require.NoError(t, err, "запись вернулась в выборку") - assert.Equal(t, record.Id, acquired.ID) -} - -// Перезапуск виден в журнале событий с указанием, что его сделал человек: иначе -// запись, вернувшаяся в работу, выглядела бы как запись, которая туда и не -// уходила. -func TestPanelResumeIsLogged(t *testing.T) { - app := newPanelStorage(t) - record := haltedRecord(t, app) - - updateByRequest(t, app, record.Id, map[string]any{"halted_at": ""}) - - events, err := app.FindAllRecords(migrations.RecordEventsCollection) - require.NoError(t, err) - - var human int - for _, event := range events { - if event.GetString("record") == record.Id && event.GetString("origin") == entity.EventOriginHuman { - human++ - assert.Equal(t, entity.EventOutcomeResumed, event.GetString("outcome")) - } - } - assert.Equal(t, 1, human, "ровно одна строка о перезапуске человеком") -} - -// Правка рубежа руками чистит служебные поля прошлого захвата так же, как -// снятие остановки: иначе владелец, «вернувший запись в работу» сменой рубежа, -// получит запись, которая не выдаётся захвату до конца прежнего срока. -func TestPanelStateEditClearsGuards(t *testing.T) { - app := newPanelStorage(t) - - record := newRecordOf(t, app, newAccount(t, app).Id) - record.Attempts = 4 - record.AcquisitionID = ptrOf("прежний-захват") - record.AcquireExpiresAt = ptrOf(clock.Now().Add(8 * time.Hour)) - require.NoError(t, NewAudioRecordRepository(app).Save(record, "")) - - after := updateByRequest(t, app, record.Id, map[string]any{"state": entity.StateNormalized}) - - assert.Equal(t, entity.StateNormalized, after.GetString("state")) - assert.Empty(t, after.GetString("acquisition_id")) - assert.Equal(t, 0, after.GetInt("attempts")) -} - -// Правка соседнего поля служебных полей не трогает: хук судит смену рубежа и -// снятие остановки, а не всякое сохранение. Иначе владелец, поправивший -// заголовок, снял бы захват у работающего шага. -func TestPanelKeepsGuardsOnUnrelatedEdit(t *testing.T) { - app := newPanelStorage(t) - - record := newRecordOf(t, app, newAccount(t, app).Id) - record.Attempts = 3 - record.AcquisitionID = ptrOf("живой-захват") - require.NoError(t, NewAudioRecordRepository(app).Save(record, "")) - - after := updateByRequest(t, app, record.Id, map[string]any{"title": "Разговор с бабушкой"}) - - assert.Equal(t, "Разговор с бабушкой", after.GetString("title")) - assert.Equal(t, "живой-захват", after.GetString("acquisition_id"), "захват работающего шага не снят") - assert.Equal(t, 3, after.GetInt("attempts"), "отказы не сброшены") -} - -// Захват отдаёт идентификатор и признак **этого** захвата, а срок протухания -// приезжает с рубежом: воркер не привязан к шагу и вывести срок из себя не -// может. -func TestAcquireCarriesStageDeadline(t *testing.T) { - app := newTestStorage(t) - repo := NewAudioRecordRepository(app) - - record := newRecordOf(t, app, newAccount(t, app).Id) - - acquired, err := repo.FindAndAcquire(entity.WorkingStages()) - require.NoError(t, err) - require.Equal(t, record.Id, acquired.ID) - require.NotEmpty(t, acquired.Holder) - - stored, err := app.FindRecordById(migrations.RecordsCollection, record.Id) - require.NoError(t, err) - assert.Equal(t, acquired.Holder, stored.GetString("acquisition_id")) - - stage, ok := entity.StageByName(entity.StateUploaded) - require.True(t, ok) - expected := clock.Now().Add(stage.AcquireTimeout) - assert.WithinDuration(t, expected, stored.GetDateTime("acquire_expires_at").Time(), time.Minute, - "срок протухания приехал с рубежа записи") -} - -// Одна запись достаётся ровно одному захвату: на этом стоит инвариант «Принятая -// запись не теряется молча». -func TestAcquireHandsRecordToExactlyOne(t *testing.T) { - app := newTestStorage(t) - repo := NewAudioRecordRepository(app) - - newRecordOf(t, app, newAccount(t, app).Id) - - first, err := repo.FindAndAcquire(entity.WorkingStages()) - require.NoError(t, err, "первому запись досталась") - require.NotEmpty(t, first.Holder) - - for range 2 { - _, err = repo.FindAndAcquire(entity.WorkingStages()) - require.Error(t, err, "остальным — признак «работы нет»") - } -} - -// Протухший захват возвращает запись в работу, и признак нового захвата -// отличается от прежнего: условие записи результата сверяет именно значение. -func TestRottenAcquisitionIsHandedOutAgain(t *testing.T) { - app := newTestStorage(t) - repo := NewAudioRecordRepository(app) - - record := newRecordOf(t, app, newAccount(t, app).Id) - - first, err := repo.FindAndAcquire(entity.WorkingStages()) - require.NoError(t, err) - - stored, err := app.FindRecordById(migrations.RecordsCollection, record.Id) - require.NoError(t, err) - stored.Set("acquire_expires_at", types.NowDateTime().Add(-time.Hour)) - require.NoError(t, app.Save(stored)) - - second, err := repo.FindAndAcquire(entity.WorkingStages()) - require.NoError(t, err, "протухший захват не мешает выдать запись следующему") - assert.Equal(t, record.Id, second.ID) - assert.NotEqual(t, first.Holder, second.Holder, "признак нового захвата отличается от прежнего") -} diff --git a/internal/adapter/repo/pocketbase/recognition_repo.go b/internal/adapter/repo/pocketbase/recognition_repo.go deleted file mode 100644 index cf339e8..0000000 --- a/internal/adapter/repo/pocketbase/recognition_repo.go +++ /dev/null @@ -1,161 +0,0 @@ -package pocketbase - -import ( - "errors" - "fmt" - "io" - - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tools/filesystem" - - "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" - "git.vakhrushev.me/av/transcriber/internal/clock" - "git.vakhrushev.me/av/transcriber/internal/entity" -) - -type RecognitionRepository struct { - app core.App -} - -func NewRecognitionRepository(app core.App) *RecognitionRepository { - return &RecognitionRepository{app: app} -} - -// Create заводит строку попытки **до** обращения к провайдеру. -// -// Порядок здесь несущий: окно между ответом провайдера и записью идентификатора -// операции — то место, где теряется оплаченное. Заведённая заранее строка даёт -// повторному шагу, чем проверить сделанное прежде, чем платить второй раз. -func (repo *RecognitionRepository) Create(r *entity.Recognition) error { - collection, err := findCollection(repo.app, migrations.RecognitionsCollection) - if err != nil { - return err - } - - started := clock.Now() - record := core.NewRecord(collection) - record.Set("record", r.RecordID) - record.Set("provider", r.Provider) - record.Set("model", r.Model) - record.Set("external_id", r.ExternalID) - record.Set("source_uri", r.SourceURI) - record.Set("started_at", dateOrEmpty(&started)) - - if err := repo.app.Save(record); err != nil { - return fmt.Errorf("failed to create recognition attempt for record %s: %w", r.RecordID, err) - } - - r.Id = record.Id - r.StartedAt = &started - return nil -} - -// Submitted сохраняет адрес аудио и идентификатор заведённой операции. По -// последнему повторный шаг узнаёт, что за эту запись уже заплачено, и второй раз -// наружу не платит. -func (repo *RecognitionRepository) Submitted(id, sourceURI, externalID string) error { - record, err := repo.app.FindRecordById(migrations.RecognitionsCollection, id) - if err != nil { - return fmt.Errorf("failed to find recognition attempt %s: %w", id, err) - } - record.Set("source_uri", sourceURI) - record.Set("external_id", externalID) - if err := repo.app.Save(record); err != nil { - return fmt.Errorf("failed to store operation id of attempt %s: %w", id, err) - } - return nil -} - -// Finish кладёт сырой ответ провайдера вложением и отмечает завершение. -// -// Вложением, а не колонкой: шаг опроса читает эту строку раз в несколько секунд, -// а хранилище читает запись целиком — ответ на многочасовую запись ехал бы в -// память при каждом опросе. Хранится он потому, что результат операции у -// провайдера не переспрашивается. -func (repo *RecognitionRepository) Finish(id string, raw []byte) error { - record, err := repo.app.FindRecordById(migrations.RecognitionsCollection, id) - if err != nil { - return fmt.Errorf("failed to find recognition attempt %s: %w", id, err) - } - - if len(raw) > 0 { - // Имя вложения задаём мы: умолчание хранилища строит его из имени - // исходного файла, а имя, данное отправителем, в хранилище не попадает. - payload, err := filesystem.NewFileFromBytes(raw, id+".payload") - if err != nil { - return fmt.Errorf("failed to prepare provider payload of attempt %s", id) - } - record.Set("payload", payload) - } - - finished := clock.Now() - record.Set("finished_at", dateOrEmpty(&finished)) - - if err := repo.app.Save(record); err != nil { - // Отказ хранилища несёт имя файла вложения целиком, а оно — последняя - // часть ссылки: цепочка `%w` уехала бы в журнал вместе с ним. - return fmt.Errorf("failed to store provider payload of attempt %s", id) - } - return nil -} - -func (repo *RecognitionRepository) GetByID(id string) (*entity.Recognition, error) { - record, err := repo.app.FindRecordById(migrations.RecognitionsCollection, id) - if err != nil { - return nil, fmt.Errorf("failed to get recognition attempt %s: %w", id, err) - } - - return &entity.Recognition{ - Id: record.Id, - RecordID: record.GetString("record"), - Provider: record.GetString("provider"), - Model: record.GetString("model"), - ExternalID: record.GetString("external_id"), - SourceURI: record.GetString("source_uri"), - StartedAt: timeOrNil(record.GetDateTime("started_at")), - FinishedAt: timeOrNil(record.GetDateTime("finished_at")), - }, nil -} - -// ReadRaw отдаёт сохранённый ответ провайдера. Зовётся только тогда, когда ответ -// нужен: шаг опроса читает строку попытки без него. -func (repo *RecognitionRepository) ReadRaw(id string) ([]byte, error) { - record, err := repo.app.FindRecordById(migrations.RecognitionsCollection, id) - if err != nil { - return nil, fmt.Errorf("failed to find recognition attempt %s: %w", id, err) - } - - names := record.GetStringSlice("payload") - if len(names) == 0 { - return nil, fmt.Errorf("recognition attempt %s has no stored payload", id) - } - - fsys, err := repo.app.NewFilesystem() - if err != nil { - return nil, fmt.Errorf("failed to open storage filesystem: %w", err) - } - - reader, err := fsys.GetReader(record.BaseFilesPath() + "/" + names[0]) - if err != nil { - // Отказ хранилища несёт имя вложения целиком, а имя — последняя часть - // ссылки на скачивание: наружу идёт идентификатор попытки, и только он. - return nil, errors.Join( - fmt.Errorf("failed to read stored payload of attempt %s", id), - fsys.Close(), - ) - } - - raw, readErr := io.ReadAll(reader) - closeErr := errors.Join(reader.Close(), fsys.Close()) - if readErr != nil { - return nil, errors.Join( - fmt.Errorf("failed to read stored payload of attempt %s", id), - closeErr, - ) - } - if closeErr != nil { - return nil, closeErr - } - - return raw, nil -} diff --git a/internal/adapter/repo/pocketbase/record_event_repo.go b/internal/adapter/repo/pocketbase/record_event_repo.go deleted file mode 100644 index 69c9826..0000000 --- a/internal/adapter/repo/pocketbase/record_event_repo.go +++ /dev/null @@ -1,50 +0,0 @@ -package pocketbase - -import ( - "fmt" - - "github.com/pocketbase/pocketbase/core" - - "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" - "git.vakhrushev.me/av/transcriber/internal/entity" -) - -type RecordEventRepository struct { - app core.App -} - -func NewRecordEventRepository(app core.App) *RecordEventRepository { - return &RecordEventRepository{app: app} -} - -// Append пишет строку журнала событий записи. -// -// Журнал пишется на смену рубежа, на остановку и на снятие остановки, а не на -// каждое откладывание опроса: часовая запись дала бы сотни строк ни о чём. Ни -// один шаг конвейера его не читает, чтобы решить, что делать дальше: решение -// принимается по рубежу записи, и второй источник решения разошёлся бы с первым -// молча. -// -// Содержимое записи сюда не попадает — инвариант приватности действует здесь -// наравне с журналом сервиса. -func (repo *RecordEventRepository) Append(event *entity.RecordEvent) error { - collection, err := findCollection(repo.app, migrations.RecordEventsCollection) - if err != nil { - return err - } - - record := core.NewRecord(collection) - record.Set("record", event.RecordID) - record.Set("origin", event.Origin) - record.Set("step", event.Step) - record.Set("outcome", event.Outcome) - record.Set("outcome_text", event.OutcomeText) - record.Set("duration_ms", event.DurationMs) - - if err := repo.app.Save(record); err != nil { - return fmt.Errorf("failed to append event of record %s: %w", event.RecordID, err) - } - - event.Id = record.Id - return nil -} diff --git a/internal/adapter/repo/pocketbase/record_mapping.go b/internal/adapter/repo/pocketbase/record_mapping.go deleted file mode 100644 index 1848899..0000000 --- a/internal/adapter/repo/pocketbase/record_mapping.go +++ /dev/null @@ -1,155 +0,0 @@ -package pocketbase - -import ( - "time" - - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tools/types" - - "git.vakhrushev.me/av/transcriber/internal/entity" -) - -// Отображение аудиозаписи в запись коллекции и обратно живёт одним местом. -// -// Мест стало **два** вместо прежних четырёх: захват больше не перечисляет -// колонки поимённо, а возвращает идентификатор и признак своего захвата. -// Инвариант проекта о колонках очереди этим съёживается и перестаёт расти с -// моделью — иначе каждая новая колонка записи попадала бы под него. - -// applyOwnedByPipeline кладёт в запись только те поля, которыми распоряжается -// конвейер. Поля, которые он не меняет никогда — владелец, вход, заголовок, -// краткое описание, темы и адресат ответа, — не трогаются вовсе. -// -// Разрез нужен потому, что шаг держит запись снимком с момента захвата и до -// своего сохранения, а это часы. Всё, что владелец правил в панели за это время, -// безусловная запись снимка стёрла бы молча: ни строки в журнале, ни отказа в -// панели — владелец видел бы успешное сохранение и был бы уверен, что правка на -// месте. -func applyOwnedByPipeline(record *core.Record, r *entity.AudioRecord) { - record.Set("state", r.State) - record.Set("state_entered_at", dateOrEmpty(&r.StateEnteredAt)) - record.Set("halted_at", dateOrEmpty(r.HaltedAt)) - record.Set("halt_reason", derefString(r.HaltReason)) - record.Set("error_text", derefString(r.ErrorText)) - record.Set("acquisition_id", derefString(r.AcquisitionID)) - record.Set("acquire_expires_at", dateOrEmpty(r.AcquireExpiresAt)) - record.Set("delay_time", dateOrEmpty(r.DelayTime)) - record.Set("attempts", r.Attempts) - record.Set("original_file", derefString(r.OriginalFileID)) - record.Set("normalized_file", derefString(r.NormalizedFileID)) - record.Set("transcript_text", derefString(r.TranscriptTextID)) - record.Set("literary_text", derefString(r.LiteraryTextID)) - record.Set("structure", derefString(r.StructureID)) - record.Set("recognition", derefString(r.RecognitionID)) -} - -// applyToRecord кладёт запись целиком — это заведение, и спорить за поля здесь -// не с кем. -func applyToRecord(record *core.Record, r *entity.AudioRecord) { - applyOwnedByPipeline(record, r) - // Владелец кладётся только здесь, при заведении. В applyOwnedByPipeline его - // нет намеренно: конвейер владельца не назначает и не меняет, а снимок шага, - // записанный поверх, стёр бы его молча. - record.Set("owner", r.OwnerID) - record.Set("source", r.Source) - record.Set("title", derefString(r.Title)) - record.Set("brief", derefString(r.Brief)) - // Имя файла отправителя, длительность и размер кладёт приём и только он: это - // снимок принятого, и конвейер его не пересчитывает. В applyOwnedByPipeline их - // нет намеренно — снимок шага, записанный поверх, стёр бы их молча. - record.Set("original_filename", derefString(r.OriginalFilename)) - record.Set("duration_ms", numberOrZero(r.DurationMs)) - record.Set("size_bytes", numberOrZero(r.SizeBytes)) - // Темы кладутся при заведении пустыми и конвейером не трогаются: считает их - // языковая модель отдельной задачей. Пишутся здесь ради симметрии с чтением — - // колонка, которую читают и не пишут, ничем не отличима от забытой. - record.Set("topics", r.TopicIDs) -} - -func recordToAudioRecord(record *core.Record) *entity.AudioRecord { - return &entity.AudioRecord{ - Id: record.Id, - OwnerID: record.GetString("owner"), - Source: record.GetString("source"), - Title: nilIfEmpty(record.GetString("title")), - Brief: nilIfEmpty(record.GetString("brief")), - State: record.GetString("state"), - StateEnteredAt: record.GetDateTime("state_entered_at").Time(), - HaltedAt: timeOrNil(record.GetDateTime("halted_at")), - HaltReason: nilIfEmpty(record.GetString("halt_reason")), - ErrorText: nilIfEmpty(record.GetString("error_text")), - AcquisitionID: nilIfEmpty(record.GetString("acquisition_id")), - AcquireExpiresAt: timeOrNil(record.GetDateTime("acquire_expires_at")), - DelayTime: timeOrNil(record.GetDateTime("delay_time")), - Attempts: record.GetInt("attempts"), - OriginalFileID: nilIfEmpty(record.GetString("original_file")), - NormalizedFileID: nilIfEmpty(record.GetString("normalized_file")), - TranscriptTextID: nilIfEmpty(record.GetString("transcript_text")), - LiteraryTextID: nilIfEmpty(record.GetString("literary_text")), - StructureID: nilIfEmpty(record.GetString("structure")), - RecognitionID: nilIfEmpty(record.GetString("recognition")), - OriginalFilename: nilIfEmpty(record.GetString("original_filename")), - // Имя колонки стоит литералом рядом с `.Get…`, а не уезжает в аргумент - // помощника: сверка колонок в `internal/archrules` ищет именно эту форму, а - // инвариант о колонках компилятор не проверяет. - DurationMs: numberValue(record.GetInt("duration_ms")), - SizeBytes: numberValue(record.GetInt("size_bytes")), - TopicIDs: record.GetStringSlice("topics"), - CreatedAt: record.GetDateTime("created").Time(), - UpdatedAt: record.GetDateTime("updated").Time(), - } -} - -func derefString(v *string) string { - if v == nil { - return "" - } - return *v -} - -// dateOrEmpty отдаёт пустое значение вместо нулевой даты: пустая колонка даты в -// хранилище это пустая строка, и она же значит «времени нет». -func dateOrEmpty(v *time.Time) any { - if v == nil || v.IsZero() { - return "" - } - date, err := types.ParseDateTime(*v) - if err != nil { - return "" - } - return date -} - -// numberOrZero отдаёт ноль вместо отсутствующего числа. -// -// «Неизвестно» числовая колонка хранилища не выражает вовсе: пустое значение она -// не держит и кладёт нулём. Отличимость потребовала бы четвёртой колонки-признака -// либо текстового типа у чисел, и платить за это нечем — обе величины ставит -// приём и ставит всегда. Решение владельца 2026-08-15. -func numberOrZero(v *int64) int64 { - if v == nil { - return 0 - } - return *v -} - -// numberValue читает колонку числом. Ноль здесь означает ноль — см. numberOrZero. -func numberValue(value int) *int64 { - v := int64(value) - return &v -} - -func nilIfEmpty(v string) *string { - if v == "" { - return nil - } - return &v -} - -func timeOrNil(v types.DateTime) *time.Time { - if v.IsZero() { - return nil - } - t := v.Time() - return &t -} diff --git a/internal/adapter/repo/pocketbase/record_repo.go b/internal/adapter/repo/pocketbase/record_repo.go deleted file mode 100644 index 13dbd53..0000000 --- a/internal/adapter/repo/pocketbase/record_repo.go +++ /dev/null @@ -1,236 +0,0 @@ -package pocketbase - -import ( - "database/sql" - "errors" - "fmt" - "strings" - - "github.com/google/uuid" - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tools/types" - - "git.vakhrushev.me/av/transcriber/internal/contract" - "git.vakhrushev.me/av/transcriber/internal/entity" - - "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" - - "git.vakhrushev.me/av/transcriber/internal/clock" -) - -type AudioRecordRepository struct { - app core.App -} - -func NewAudioRecordRepository(app core.App) *AudioRecordRepository { - return &AudioRecordRepository{app: app} -} - -func (repo *AudioRecordRepository) Create(r *entity.AudioRecord) error { - collection, err := findCollection(repo.app, migrations.RecordsCollection) - if err != nil { - return err - } - - record := core.NewRecord(collection) - if r.Id != "" { - record.Id = r.Id - } - applyToRecord(record, r) - - if err := repo.app.Save(record); err != nil { - return fmt.Errorf("failed to insert audio record: %w", err) - } - - r.Id = record.Id - r.CreatedAt = record.GetDateTime("created").Time() - r.UpdatedAt = record.GetDateTime("updated").Time() - - return nil -} - -// Save сохраняет запись, захват которой держит holder. Проверка и запись идут -// одной транзакцией: шаг, потерявший запись за время работы, получает -// LostAcquisitionError и результата не пишет. -// -// Сверяется **значение** признака захвата, а не занятость записи. Захват, -// перевыданный другому — по протуханию срока или после того, как человек снял -// признак остановки в панели, — обязан обратить запись первого в отказ; условие -// по непустоте признака пропустило бы обоих, и два шага записали бы в одну -// запись по очереди, портя её результат. -func (repo *AudioRecordRepository) Save(r *entity.AudioRecord, holder string) error { - return repo.app.RunInTransaction(func(txApp core.App) error { - record, err := txApp.FindRecordById(migrations.RecordsCollection, r.Id) - if err != nil { - return fmt.Errorf("failed to find audio record: %w", err) - } - - if holder != "" && record.GetString("acquisition_id") != holder { - return &contract.LostAcquisitionError{JobID: r.Id} - } - - // Кладём только то, чем распоряжается конвейер: правку владельца в - // панели снимок шага стирать не должен. - applyOwnedByPipeline(record, r) - - if err := txApp.Save(record); err != nil { - return fmt.Errorf("failed to update audio record: %w", err) - } - - r.UpdatedAt = record.GetDateTime("updated").Time() - return nil - }) -} - -// GetByID отдаёт запись, только если её владелец — ownerID. -// -// Чужая запись, запись без владельца и несуществующая дают одну и ту же ошибку: -// по разнице ответов иначе перебирается список заведённых записей, а -// идентификатор записи и есть то, что разграничение прячет. -// -// Пустой ownerID отсекается **до** чтения и не совпадает ни с чем. Правило это -// не стало избыточным с обязательностью колонки: схема запрещает **заводить** -// ничью запись, а здесь запрещено **спрашивать** ничьим именем — иначе -// вызывающий без учётной записи получил бы выборку вместо отказа. -func (repo *AudioRecordRepository) GetByID(id, ownerID string) (*entity.AudioRecord, error) { - if ownerID == "" { - return nil, &contract.JobNotFoundError{Message: "record not found"} - } - - record, err := repo.find(id) - if err != nil { - return nil, err - } - - if record.GetString("owner") != ownerID { - return nil, &contract.JobNotFoundError{Message: "record not found"} - } - - return recordToAudioRecord(record), nil -} - -// Get отдаёт запись без сужения владельцем: им пользуется конвейер, чья выборка -// владельцем не сужается. -func (repo *AudioRecordRepository) Get(id string) (*entity.AudioRecord, error) { - record, err := repo.find(id) - if err != nil { - return nil, err - } - return recordToAudioRecord(record), nil -} - -func (repo *AudioRecordRepository) find(id string) (*core.Record, error) { - record, err := repo.app.FindRecordById(migrations.RecordsCollection, id) - if err != nil { - // «Такой записи нет» переводится в доменную ошибку **здесь**, у - // источника, как велит конвенция об ошибках. Иначе три исхода, которые - // разграничение обязано сделать неразличимыми, разъезжаются: чужая и - // ничья записи дают доменную ошибку, а несуществующая — отказ базы, - // неотличимый от настоящей аварии хранилища. - if errors.Is(err, sql.ErrNoRows) { - return nil, &contract.JobNotFoundError{Message: "record not found"} - } - return nil, fmt.Errorf("failed to get audio record: %w", err) - } - return record, nil -} - -// FindAndAcquire забирает пригодную к работе запись одним неделимым шагом: -// выбор подходящей и пометка её захваченной идут вместе. -// -// Возвращается **идентификатор и признак этого захвата**, а не перечень колонок. -// Колонки шаг читает обычным чтением: иначе всякая новая колонка записи попадала -// бы под инвариант проекта о колонках очереди, а забытая приезжала бы нулевой, и -// первое же сохранение писало бы этот ноль поверх сохранённого значения. -// -// Срок протухания захвата приезжает **с рубежом**, а не с воркером: воркер не -// привязан к шагу и не знает заранее, что вытянет. Перечень рубежей и их сроков -// приходит одним дескриптором — перечислять их порознь нельзя: рубеж, забытый в -// отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту -// проекта не пишется в журнал и не считается в метрику. -// -// Запрос идёт сырым, мимо записей коллекции: `app.DB()` направляет всё, кроме -// выборок, в пул с единственным соединением, и захваты выстраиваются в очередь. -// Хуки коллекции на нём не срабатывают, поэтому время изменения проставляет сам -// запрос. -// -// Все времена кладутся и сравниваются тем же видом, каким хранилище пишет свои -// `created`/`updated`: сравнение строк побайтово, и вид, разошедшийся хоть -// разделителем, обратил бы условие срока в постоянную истину или постоянную -// ложь — молча. -func (repo *AudioRecordRepository) FindAndAcquire(stages []entity.Stage) (*contract.AcquiredRecord, error) { - if len(stages) == 0 { - return nil, &contract.JobNotFoundError{Message: "no working stages declared"} - } - - // Метка времени берётся единой точкой, а не `types.NowDateTime()`: обёртка - // хранилища читает часы сама, и запрет линтера её не видит — новая метка в - // этом запросе обошла бы единую точку молча. - now, err := types.ParseDateTime(clock.Now()) - if err != nil { - return nil, fmt.Errorf("failed to parse current time: %w", err) - } - - holder := uuid.NewString() - params := dbx.Params{ - "holder": holder, - "now": now.String(), - } - - // Срок протухания у каждого рубежа свой, поэтому он выбирается по рубежу - // самой записи прямо в запросе: воркер, ещё не знающий, что вытянет, - // подставить его не может. - var expiry strings.Builder - expiry.WriteString("CASE state") - var states []string - for i, stage := range stages { - stateKey := fmt.Sprintf("state%d", i) - expiryKey := fmt.Sprintf("expiry%d", i) - - deadline, err := types.ParseDateTime(clock.Now().Add(stage.AcquireTimeout)) - if err != nil { - return nil, fmt.Errorf("failed to parse acquire deadline: %w", err) - } - - fmt.Fprintf(&expiry, " WHEN {:%s} THEN {:%s}", stateKey, expiryKey) - params[stateKey] = stage.Name - params[expiryKey] = deadline.String() - states = append(states, "{:"+stateKey+"}") - } - expiry.WriteString(" END") - - table := "{{" + migrations.RecordsCollection + "}}" - query := repo.app.DB().NewQuery(` - UPDATE ` + table + ` - SET acquisition_id = {:holder}, - acquire_expires_at = ` + expiry.String() + `, - attempts = attempts + 1, - updated = {:now} - WHERE id = ( - SELECT id FROM ` + table + ` - WHERE state IN (` + strings.Join(states, ", ") + `) - AND (halted_at = '' OR halted_at IS NULL) - AND (delay_time = '' OR delay_time IS NULL OR delay_time < {:now}) - AND (acquisition_id = '' OR acquisition_id IS NULL - OR acquire_expires_at = '' OR acquire_expires_at IS NULL - OR acquire_expires_at < {:now}) - ORDER BY created, id - LIMIT 1 - ) - RETURNING id`) - - query.Bind(params) - - var row struct { - Id string `db:"id"` - } - if err := query.One(&row); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, &contract.JobNotFoundError{Message: "no record is ready for work"} - } - return nil, fmt.Errorf("failed to acquire an audio record: %w", err) - } - - return &contract.AcquiredRecord{ID: row.Id, Holder: holder}, nil -} diff --git a/internal/adapter/repo/pocketbase/schema_test.go b/internal/adapter/repo/pocketbase/schema_test.go deleted file mode 100644 index 9129517..0000000 --- a/internal/adapter/repo/pocketbase/schema_test.go +++ /dev/null @@ -1,139 +0,0 @@ -package pocketbase - -import ( - "strings" - "testing" - - "github.com/pocketbase/pocketbase/core" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" -) - -// newTestStorage поднимает хранилище на пустом каталоге и накатывает схему — -// тем же путём, каким это делает сервис при старте. -func newTestStorage(t *testing.T) core.App { - t.Helper() - - app, err := New(t.TempDir()) - require.NoError(t, err) - t.Cleanup(func() { - if err := app.ResetBootstrapState(); err != nil { - t.Logf("не удалось закрыть хранилище: %v", err) - } - }) - - return app -} - -// Критерий приёмки 10. Содержимое записи закрыто во всех коллекциях, куда оно -// переехало. -// -// Прежде содержимое лежало одной колонкой задачи, и закрывала его одна норма про -// файл записи. Теперь оно живёт в шести коллекциях, и реализация, следующая -// только прежней норме, завела бы поле вложения с умолчанием библиотеки: ссылка -// на сырой ответ провайдера — а это полный текст речи — отдавала бы его любому, -// кто её знает, без сессии. -func TestRecordContentIsClosedEverywhere(t *testing.T) { - app := newTestStorage(t) - - // Правило просмотра остаётся незаданным, то есть «только владелец панели». - // Содержимое отдаёт собственный адрес сервиса, а не поверхность хранилища; - // непустое правило открыло бы перечисление коллекции впрок. - for _, name := range []string{ - migrations.RecordsCollection, - migrations.TextsCollection, - migrations.StructuresCollection, - migrations.RecognitionsCollection, - migrations.RecordEventsCollection, - migrations.TopicsCollection, - } { - collection, err := app.FindCollectionByNameOrId(name) - require.NoError(t, err, "коллекция %s заведена шагом схемы", name) - - assert.Nil(t, collection.ListRule, "перечисление %s закрыто", name) - assert.Nil(t, collection.ViewRule, "чтение %s закрыто", name) - assert.Nil(t, collection.CreateRule, "заведение записи в %s закрыто", name) - assert.Nil(t, collection.UpdateRule, "правка %s закрыта", name) - assert.Nil(t, collection.DeleteRule, "удаление из %s закрыто", name) - } - - // А поле вложения помечено защищённым: без пометки ссылка открывает - // содержимое любому, кто её знает, и знание ссылки становится правом. - recognitions, err := app.FindCollectionByNameOrId(migrations.RecognitionsCollection) - require.NoError(t, err) - - field := recognitions.Fields.GetByName("payload") - require.NotNil(t, field, "поле сохранённого ответа заведено") - - file, ok := field.(*core.FileField) - require.True(t, ok, "сохранённый ответ лежит вложением, а не колонкой") - assert.True(t, file.Protected, "поле вложения защищено") -} - -// Прежняя коллекция задач уходит вместе с моделью: данных под ней не было, а -// пустая копия висела бы в панели вторым домом для понятия, которого больше нет. -// Поверхность коллекции пользователей закрыта наглухо — все пять правил. -// -// Проверка стоит отдельно от соседней намеренно: та сторожит коллекции, которые -// заводит наш шаг схемы, а эту заводит системный шаг библиотеки, и её умолчания -// открывают владельцу записи чтение, правку и удаление собственной строки. Пока -// узнавание жило под корнем приложения, до этой поверхности браузер не -// дотягивался вовсе; с узнаванием по заголовку она достижима, а ключ учётной -// записи лежит здесь обычной колонкой — правка своей записи и есть захват чужого -// имени. -func TestUsersCollectionSurfaceIsClosed(t *testing.T) { - app := newTestStorage(t) - - users, err := app.FindCollectionByNameOrId(migrations.UsersCollection) - require.NoError(t, err) - - assert.Nil(t, users.ListRule, "перечисление учётных записей закрыто") - assert.Nil(t, users.ViewRule, "чтение учётной записи закрыто") - assert.Nil(t, users.CreateRule, "заведение учётной записи снаружи закрыто") - assert.Nil(t, users.UpdateRule, "правка учётной записи снаружи закрыта") - assert.Nil(t, users.DeleteRule, "удаление учётной записи снаружи закрыто") - - // Собственные способы войти выключены там же: без этого узнавание по - // заголовку обходится двумя запросами — завести себе запись и войти паролем. - assert.False(t, users.PasswordAuth.Enabled, "вход по паролю выключен") - assert.False(t, users.OTP.Enabled, "вход по одноразовому коду выключен") - assert.False(t, users.OAuth2.Enabled, "обмен кода у внешнего провайдера выключен") - assert.Empty(t, users.OAuth2.Providers, "настроенных провайдеров не осталось") -} - -func TestFormerJobsCollectionIsGone(t *testing.T) { - app := newTestStorage(t) - - _, err := app.FindCollectionByNameOrId(migrations.JobsCollection) - assert.Error(t, err, "прежней коллекции задач не осталось") -} - -// Пара «запись и вид» уникальна: повтор прерванного шага не заводит второго -// комплекта строк, и вопрос «какой текст отдавать человеку» не становится -// вопросом порядка записи. -func TestAppendicesAreUniquePerRecord(t *testing.T) { - app := newTestStorage(t) - - indexes := map[string][]string{ - migrations.TextsCollection: {"idx_texts_record_kind"}, - migrations.StructuresCollection: {"idx_structures_record_version"}, - migrations.TopicsCollection: {"idx_topics_owner_name"}, - } - - for name, expected := range indexes { - collection, err := app.FindCollectionByNameOrId(name) - require.NoError(t, err) - - for _, index := range expected { - var found bool - for _, declared := range collection.Indexes { - if strings.Contains(declared, index) && strings.Contains(declared, "UNIQUE") { - found = true - } - } - assert.Truef(t, found, "у %s есть уникальный индекс %s", name, index) - } - } -} diff --git a/internal/adapter/repo/pocketbase/text_repo.go b/internal/adapter/repo/pocketbase/text_repo.go deleted file mode 100644 index 0376669..0000000 --- a/internal/adapter/repo/pocketbase/text_repo.go +++ /dev/null @@ -1,170 +0,0 @@ -package pocketbase - -import ( - "database/sql" - "encoding/json" - "errors" - "fmt" - - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/core" - - "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" - "git.vakhrushev.me/av/transcriber/internal/entity" -) - -type TextRepository struct { - app core.App -} - -func NewTextRepository(app core.App) *TextRepository { - return &TextRepository{app: app} -} - -// Put кладёт текст записи, заменяя прежний того же вида. -// -// Замена, а не вставка: пара «запись и вид» уникальна, и повтор прерванного шага -// иначе завёл бы второй комплект строк — тогда вопрос «какой текст отдавать -// человеку» стал бы вопросом порядка записи, а не состояния. -// -// **Пустое не кладётся поверх непустого**, и это не осторожность, а защита -// архива. Повторный опрос той же операции — обычное дело: держатель захвата -// умер, сохранение рубежа отказало, человек снял остановку в панели. Провайдер -// при этом вправе ответить пустым потоком, отказом это не считается, и -// безусловная замена стирала бы сохранённую расшифровку живого человека без -// следа и без возврата. Та же защита стоит у сырого ответа провайдера -// (`RecognitionRepository.Finish`), и разное правило у двух хранителей одного -// результата читалось бы как недосмотр. -func (repo *TextRepository) Put(recordID, kind, contents string) (*entity.Text, error) { - collection, err := findCollection(repo.app, migrations.TextsCollection) - if err != nil { - return nil, err - } - - record, err := repo.app.FindFirstRecordByFilter( - migrations.TextsCollection, - "record = {:record} && kind = {:kind}", - dbx.Params{"record": recordID, "kind": kind}, - ) - switch { - case err == nil: - // Строка есть — заменяем содержимое. - case errors.Is(err, sql.ErrNoRows): - record = core.NewRecord(collection) - record.Set("record", recordID) - record.Set("kind", kind) - default: - // Отказ хранилища «строкой нет» не является, и подменять его вставкой - // нельзя: она упрётся в уникальный индекс, и наверх уедет жалоба на - // запись вместо правды о недоступной базе. - return nil, fmt.Errorf("failed to look up text of kind %s for record %s: %w", kind, recordID, err) - } - // Прежнее непустое содержимое пустым не заменяется: строка остаётся как - // есть, и вызывающий получает её обратно. - if contents == "" && record.GetString("contents") != "" { - return textFromRecord(record), nil - } - record.Set("contents", contents) - - if err := repo.app.Save(record); err != nil { - // Текст расшифровки наружу не выходит даже отказом: цепочка `%w` от - // хранилища несёт значение поля. - return nil, fmt.Errorf("failed to store text of kind %s for record %s", kind, recordID) - } - - return textFromRecord(record), nil -} - -func (repo *TextRepository) GetByID(id string) (*entity.Text, error) { - record, err := repo.app.FindRecordById(migrations.TextsCollection, id) - if err != nil { - return nil, fmt.Errorf("failed to get text %s: %w", id, err) - } - return textFromRecord(record), nil -} - -func textFromRecord(record *core.Record) *entity.Text { - return &entity.Text{ - Id: record.Id, - RecordID: record.GetString("record"), - Kind: record.GetString("kind"), - Contents: record.GetString("contents"), - } -} - -type StructureRepository struct { - app core.App -} - -func NewStructureRepository(app core.App) *StructureRepository { - return &StructureRepository{app: app} -} - -// Put кладёт структуру реплик, заменяя прежнюю той же версии разбора. Довод тот -// же, что и у текста: повтор шага не должен заводить второй строки. -func (repo *StructureRepository) Put(recordID string, version int, replicas []entity.Replica) (*entity.Structure, error) { - collection, err := findCollection(repo.app, migrations.StructuresCollection) - if err != nil { - return nil, err - } - - contents, err := json.Marshal(replicas) - if err != nil { - return nil, fmt.Errorf("failed to encode structure of record %s", recordID) - } - - record, err := repo.app.FindFirstRecordByFilter( - migrations.StructuresCollection, - "record = {:record} && version = {:version}", - dbx.Params{"record": recordID, "version": version}, - ) - switch { - case err == nil: - // Строка есть — заменяем содержимое. Пустой перечень реплик поверх - // непустого не кладётся по тому же доводу, что и у текста: повторный - // опрос с пустым ответом провайдера стирал бы разбор живой записи. - if len(replicas) == 0 && len(record.GetString("contents")) > len("[]") { - return repo.GetByID(record.Id) - } - case errors.Is(err, sql.ErrNoRows): - record = core.NewRecord(collection) - record.Set("record", recordID) - record.Set("version", version) - default: - return nil, fmt.Errorf("failed to look up structure of record %s: %w", recordID, err) - } - record.Set("contents", string(contents)) - - if err := repo.app.Save(record); err != nil { - return nil, fmt.Errorf("failed to store structure of record %s", recordID) - } - - return &entity.Structure{ - Id: record.Id, - RecordID: recordID, - Version: version, - Replicas: replicas, - }, nil -} - -func (repo *StructureRepository) GetByID(id string) (*entity.Structure, error) { - record, err := repo.app.FindRecordById(migrations.StructuresCollection, id) - if err != nil { - return nil, fmt.Errorf("failed to get structure %s: %w", id, err) - } - - var replicas []entity.Replica - raw := record.GetString("contents") - if raw != "" { - if err := json.Unmarshal([]byte(raw), &replicas); err != nil { - return nil, fmt.Errorf("failed to decode structure %s", id) - } - } - - return &entity.Structure{ - Id: record.Id, - RecordID: record.GetString("record"), - Version: record.GetInt("version"), - Replicas: replicas, - }, nil -} diff --git a/internal/adapter/repo/sqlite/db.go b/internal/adapter/repo/sqlite/db.go new file mode 100644 index 0000000..60f26d4 --- /dev/null +++ b/internal/adapter/repo/sqlite/db.go @@ -0,0 +1,172 @@ +// Package sqlite — хранилище сервиса: база на своей схеме и файлы записей своим +// каталогом. +// +// Пакет назван по драйверу, а не по роли: соседи в `internal/adapter` названы +// тем же способом — `converter`, `metaviewer`, `recognizer`, — и «repo/sqlite» +// читается как «репозитории поверх SQLite» без знания кода. +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "strconv" + + // Драйвер регистрируется загрузкой пакета. CGO ему не нужен — этим он и + // выбран: сборка бинарника остаётся без компилятора C. + _ "modernc.org/sqlite" +) + +// driverName — имя, под которым драйвер регистрируется в `database/sql`. +const driverName = "sqlite" + +// DatabaseFile — имя файла базы в каталоге данных. Рядом с ним драйвер кладёт +// журнал упреждающей записи и его указатель, поэтому каталог данных занят базой +// целиком, а не одним файлом. +const DatabaseFile = "transcriber.db" + +// Settings — числа, которыми настраивается база. Оба приходят настройкой, а не +// константой кода: крутят их при одном и том же отказе — «база занята» под +// несколькими воркерами, — и подбор ответа на такой отказ не должен требовать +// пересборки образа. +type Settings struct { + // BusyTimeoutMs — сколько ждать занятую базу, миллисекунды. + BusyTimeoutMs int + // ReadConnections — сколько соединений держит читающий пул. + ReadConnections int +} + +// Validate проверяет числа базы. Ноль и отрицательное — опечатка, а не режим: +// нулевое ожидание отдаёт «база занята» первому же воркеру, а нулевой пул +// чтения означает пул без предела, то есть настройку, которой не управляют. +func (s Settings) Validate() error { + if s.BusyTimeoutMs <= 0 { + return errors.New("storage: ожидание занятой базы задаётся положительным числом миллисекунд") + } + if s.ReadConnections <= 0 { + return errors.New("storage: число соединений читающего пула задаётся положительным числом") + } + return nil +} + +// Обращения к базе идут с **собственным** контекстом, а не с контекстом +// запроса, и это решение, а не недосмотр. Репозитории отменять нечего: операции +// местные и короткие, а единственное ожидание — занятая база — задано числом. За +// отмену при этом платили бы дважды: шаг, прерванный остановкой сервиса, +// перестал бы освобождать захват и писать причину остановки — то есть отмена +// ломала бы ровно ту уборку, ради которой она и делается. +// +// Отмена, которой сервис распоряжается по-настоящему, доходит туда, где она +// стоит денег и времени: до `ffmpeg` и до платного распознавания. + +// DB — база сервиса двумя пулами. +// +// Пишущий пул держит **одно** соединение: драйвер пишет единственным +// соединением, и несколько воркеров, пришедших писать разом мимо этого правила, +// получают отказ по занятости — на записи результата шага, то есть после +// оплаченной работы. Пул с одним соединением обращает их в очередь. +// +// Читающий пул отдельный: в журнале упреждающей записи читатели не мешают +// писателю, и список записей не ждёт, пока конвейер сохранит свой шаг. +type DB struct { + // writer — единственное пишущее соединение. Через него идёт всякая + // операция, которая читает состояние и следом его пишет: транзакцию, + // начатую на читающем соединении, SQLite до пишущей не повышает и отвечает + // отказом по занятости немедленно — заданное числом ожидание такой отказ не + // лечит, ждать там нечего. + writer *sql.DB + // reader — пул чтения. + reader *sql.DB +} + +// Writer отдаёт пишущее соединение. +func (db *DB) Writer() *sql.DB { return db.writer } + +// Reader отдаёт читающий пул. +func (db *DB) Reader() *sql.DB { return db.reader } + +// Open открывает базу в каталоге данных, заводя каталог, если его ещё нет. +// +// Настройки соединения задаются **строкой подключения обоих пулов**, а не +// запросом после открытия. Соблюдение внешних ключей в SQLite — настройка +// соединения, а не базы, и по умолчанию она выключена; пул раздаёт соединения и +// заводит новые по мере надобности, поэтому запрос, выполненный один раз, +// настроил бы одно соединение из многих, а остальные остались бы с умолчанием — +// молча. +func Open(dataDir string, settings Settings) (*DB, error) { + if err := settings.Validate(); err != nil { + return nil, err + } + + if err := os.MkdirAll(dataDir, 0o750); err != nil { + return nil, fmt.Errorf("failed to create data directory: %w", err) + } + + path := filepath.Join(dataDir, DatabaseFile) + + // Пишущее соединение начинает транзакцию сразу пишущей (`immediate`): + // операция, которая читает и следом пишет, иначе взяла бы читающую + // транзакцию и упёрлась бы в отказ при первой же записи. + writer, err := open(path, settings, "immediate") + if err != nil { + return nil, err + } + writer.SetMaxOpenConns(1) + writer.SetMaxIdleConns(1) + + reader, err := open(path, settings, "deferred") + if err != nil { + return nil, errors.Join(fmt.Errorf("failed to open read pool: %w", err), writer.Close()) + } + reader.SetMaxOpenConns(settings.ReadConnections) + reader.SetMaxIdleConns(settings.ReadConnections) + + db := &DB{writer: writer, reader: reader} + + // Пробное обращение делается сразу: `sql.Open` соединения не открывает, и + // негодная строка подключения вылезла бы не на старте, а на первом запросе — + // то есть отказом каждого запроса вместо одной строки о причине. + if err := writer.PingContext(context.Background()); err != nil { + return nil, errors.Join(fmt.Errorf("failed to open database: %w", err), db.Close()) + } + + return db, nil +} + +// open заводит один пул с общими настройками соединения. +func open(path string, settings Settings, txlock string) (*sql.DB, error) { + query := url.Values{} + query.Add("_pragma", "busy_timeout("+strconv.Itoa(settings.BusyTimeoutMs)+")") + query.Add("_pragma", "journal_mode(WAL)") + query.Add("_pragma", "foreign_keys(1)") + query.Set("_txlock", txlock) + + db, err := sql.Open(driverName, "file:"+path+"?"+query.Encode()) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + return db, nil +} + +// Close закрывает оба пула. Повторный вызов паники не даёт: закрытие уже +// закрытого пула отказом не считается. +func (db *DB) Close() error { + var errs []error + if db.reader != nil { + if err := db.reader.Close(); err != nil { + errs = append(errs, fmt.Errorf("failed to close read pool: %w", err)) + } + db.reader = nil + } + if db.writer != nil { + if err := db.writer.Close(); err != nil { + errs = append(errs, fmt.Errorf("failed to close write pool: %w", err)) + } + db.writer = nil + } + return errors.Join(errs...) +} diff --git a/internal/adapter/repo/sqlite/db_test.go b/internal/adapter/repo/sqlite/db_test.go new file mode 100644 index 0000000..a457189 --- /dev/null +++ b/internal/adapter/repo/sqlite/db_test.go @@ -0,0 +1,554 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "io" + "io/fs" + "log/slog" + "os" + "path/filepath" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.vakhrushev.me/av/transcriber/internal/clock" + "git.vakhrushev.me/av/transcriber/internal/contract" + "git.vakhrushev.me/av/transcriber/internal/entity" + "git.vakhrushev.me/av/transcriber/internal/ident" +) + +// testSettings — числа базы под проверками: те же по смыслу, что и умолчания +// конфига. +func testSettings() Settings { + return Settings{BusyTimeoutMs: 5000, ReadConnections: 4} +} + +// newTestDB поднимает базу на пустом каталоге и накатывает схему — ровно тем же +// путём, каким это делает сервис при старте. +func newTestDB(t *testing.T) (*DB, *Store, string) { + t.Helper() + + dir := t.TempDir() + db, err := Open(dir, testSettings()) + require.NoError(t, err) + t.Cleanup(func() { + if err := db.Close(); err != nil { + t.Logf("не удалось закрыть базу: %v", err) + } + }) + + require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler))) + + return db, NewStore(dir), dir +} + +// newOwner заводит учётную запись и отдаёт её идентификатор. +func newOwner(t *testing.T, db *DB) string { + t.Helper() + + account, _, err := NewUserRepository(db).EnsureUser(contract.Identity{Login: ident.New()}) + require.NoError(t, err) + + return account.ID +} + +// Настройки соединения задаются строкой подключения **обоих** пулов: соблюдение +// внешних ключей в SQLite принадлежит соединению, а не базе, и запрос, сделанный +// один раз после открытия, настроил бы одно соединение из многих. +func TestSettingsApplyToEveryConnection(t *testing.T) { + db, _, _ := newTestDB(t) + + var mode string + require.NoError(t, db.Writer().QueryRowContext(context.Background(), "PRAGMA journal_mode").Scan(&mode)) + assert.Equal(t, "wal", mode, "журнал упреждающей записи выключен") + + var busy int + require.NoError(t, db.Writer().QueryRowContext(context.Background(), "PRAGMA busy_timeout").Scan(&busy)) + assert.Equal(t, testSettings().BusyTimeoutMs, busy, "ожидание занятой базы осталось умолчанием драйвера") + + // Читающий пул раздаёт соединения по мере надобности, поэтому спрашиваем + // **несколько** разом: одно настроенное соединение из четырёх — ровно та + // поломка, ради которой настройка уехала в строку подключения. + var wg sync.WaitGroup + answers := make([]int, testSettings().ReadConnections) + start := make(chan struct{}) + for i := range answers { + wg.Add(1) + go func() { + defer wg.Done() + <-start + conn, err := db.Reader().Conn(context.Background()) + if !assert.NoError(t, err) { + return + } + defer func() { assert.NoError(t, conn.Close()) }() + + assert.NoError(t, + conn.QueryRowContext(context.Background(), "PRAGMA foreign_keys").Scan(&answers[i])) + // Соединение придерживается, пока спрашивают остальные: иначе пул + // раздал бы всем одно и то же и правило проверило бы одну настройку + // вместо четырёх. + time.Sleep(10 * time.Millisecond) + }() + } + close(start) + wg.Wait() + + for i, answer := range answers { + assert.Equal(t, 1, answer, "соединение %d читающего пула не соблюдает внешние ключи", i) + } + + // И держатся внешние ключи **на деле**, а не только настройкой: вставка с + // несуществующим владельцем отвергается обоими пулами. + now := clock.Now().Format(timeLayout) + insert := `INSERT INTO audio_records + (id, owner_id, duration_ms, size_bytes, state, state_entered_at, created_at, updated_at) + VALUES (?, ?, 0, 0, ?, ?, ?, ?)` + + _, err := db.Writer().ExecContext(context.Background(), insert, + ident.New(), ident.New(), entity.StateUploaded, now, now, now) + require.Error(t, err, "пишущее соединение приняло запись с несуществующим владельцем") + + _, err = db.Reader().ExecContext(context.Background(), insert, + ident.New(), ident.New(), entity.StateUploaded, now, now, now) + require.Error(t, err, "читающее соединение приняло запись с несуществующим владельцем") +} + +// Настройки проверяются на старте: ноль и отрицательное — опечатка, а не режим. +func TestSettingsAreValidated(t *testing.T) { + for name, settings := range map[string]Settings{ + "нулевое ожидание": {BusyTimeoutMs: 0, ReadConnections: 4}, + "нулевой пул чтения": {BusyTimeoutMs: 5000, ReadConnections: 0}, + "отрицательный пул": {BusyTimeoutMs: 5000, ReadConnections: -1}, + "отрицательный срок": {BusyTimeoutMs: -1, ReadConnections: 4}, + } { + t.Run(name, func(t *testing.T) { + _, err := Open(t.TempDir(), settings) + assert.Error(t, err, "старт на негодном числе прошёл молча") + }) + } +} + +// Повторный запуск на заведённом каталоге схему второй раз не заводит и прежних +// записей не теряет. +func TestMigrateIsIdempotent(t *testing.T) { + dir := t.TempDir() + db, err := Open(dir, testSettings()) + require.NoError(t, err) + defer func() { require.NoError(t, db.Close()) }() + + require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler))) + + owner := newOwner(t, db) + + require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler))) + + var login string + require.NoError(t, db.Reader(). + QueryRowContext(context.Background(), + "SELECT provider_login FROM users WHERE id = ?", owner).Scan(&login)) + assert.NotEmpty(t, login, "повторный накат потерял прежние строки") + + var applied int + require.NoError(t, db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM goose_db_version").Scan(&applied)) + assert.Equal(t, 2, applied, "шаг отмечен дважды: накат не идемпотентен") +} + +// Накат держится исключающей блокировкой каталога данных: второй накат ждёт +// освобождения, а не применяет шаги параллельно. +// +// Библиотека шагов под SQLite блокировки не поставляет вовсе — её запиратели +// объявлены только для PostgreSQL, — поэтому замок наш, и проверка сторожит +// именно его. +func TestMigrationLockSerializesRuns(t *testing.T) { + dir := t.TempDir() + + var ( + mu sync.Mutex + inside int + overlap bool + ) + + hold := func() error { + mu.Lock() + inside++ + if inside > 1 { + overlap = true + } + mu.Unlock() + + time.Sleep(50 * time.Millisecond) + + mu.Lock() + inside-- + mu.Unlock() + return nil + } + + var wg sync.WaitGroup + for range 3 { + wg.Add(1) + go func() { + defer wg.Done() + assert.NoError(t, withMigrationLock(dir, hold)) + }() + } + wg.Wait() + + assert.False(t, overlap, "два наката шли одновременно: замок не держит") +} + +// Отказ шага роняет накат и называет шаг: сервис, поднявшийся на неприведённой +// схеме, отвечал бы отказом на каждый запрос. +func TestMigrateFailsLoudly(t *testing.T) { + dir := t.TempDir() + db, err := Open(dir, testSettings()) + require.NoError(t, err) + defer func() { require.NoError(t, db.Close()) }() + + // Таблица уже занята чужой строкой: начальный шаг на такой базе не + // применяется. + _, err = db.Writer().ExecContext(context.Background(), "CREATE TABLE users (id TEXT)") + require.NoError(t, err) + + err = Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler)) + + require.Error(t, err, "отказ шага прошёл молча") + assert.Contains(t, err.Error(), "202608220002", "отказ не называет шаг") + + // **Шаг и отметка о нём идут одной транзакцией**, поэтому отказавший шаг не + // оставляет за собой ни отметки, ни половины схемы. Полуприменённое + // состояние — то самое, из-за которого следующий запуск применил бы шаг + // второй раз и упал бы на заведённой таблице. + var version int + err = db.Reader().QueryRowContext(context.Background(), + "SELECT COUNT(*) FROM goose_db_version WHERE version_id = 202608220002").Scan(&version) + if err == nil { + assert.Equal(t, 0, version, "отказавший шаг отмечен применённым") + } + + var tables int + require.NoError(t, db.Reader().QueryRowContext(context.Background(), + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'audio_records'").Scan(&tables)) + assert.Equal(t, 0, tables, "отказавший шаг оставил за собой половину схемы") +} + +// Все колонки времени объявлены одним типом и без умолчания: умолчание схемы +// писало бы свой вид времени, а вставка, забывшая проставить время, при нём +// прошла бы молча. +func TestSchemaHasOneTimeShapeWithoutDefaults(t *testing.T) { + db, _, _ := newTestDB(t) + + tables := []string{ + "users", "files", "topics", "audio_records", + "texts", "structures", "recognitions", "record_events", + } + + seen := 0 + for _, table := range tables { + rows, err := db.Reader().QueryContext(context.Background(), + "SELECT name, type, dflt_value FROM pragma_table_info(?)", table) + require.NoError(t, err) + + for rows.Next() { + var ( + name string + columnType string + dflt any + ) + require.NoError(t, rows.Scan(&name, &columnType, &dflt)) + + if !isTimeColumn(name) { + continue + } + seen++ + assert.Equal(t, "TEXT", columnType, "колонка %s.%s несёт время не текстом", table, name) + assert.Nil(t, dflt, "у колонки %s.%s есть умолчание времени", table, name) + } + require.NoError(t, rows.Err()) + closeRows(t, rows) + } + + require.Positive(t, seen, "колонок времени не найдено: правило потеряло предмет") +} + +// closeRows закрывает выборку. Отдельной функцией, потому что закрывается она в +// цикле по таблицам: отложенное закрытие копилось бы до конца проверки. +func closeRows(t *testing.T, rows *sql.Rows) { + t.Helper() + require.NoError(t, rows.Close()) +} + +func isTimeColumn(name string) bool { + return strings.HasSuffix(name, "_at") || name == "delay_time" +} + +// Строка, заведённая приёмом, и строка, заведённая запросом к базе, попадают в +// отбор захвата одинаково: вид времени в схеме один. +func TestHandwrittenRecordIsAcquiredToo(t *testing.T) { + db, _, _ := newTestDB(t) + + owner := newOwner(t, db) + records := NewAudioRecordRepository(db) + + byService := &entity.AudioRecord{ + Id: ident.New(), + OwnerID: owner, + State: entity.StateUploaded, + StateEnteredAt: clock.Now(), + } + require.NoError(t, records.Create(byService)) + + byHand := ident.New() + now := clock.Now().Format(timeLayout) + _, err := db.Writer().ExecContext(context.Background(), + `INSERT INTO audio_records + (id, owner_id, duration_ms, size_bytes, state, state_entered_at, created_at, updated_at) + VALUES (?, ?, 0, 0, ?, ?, ?, ?)`, + byHand, owner, entity.StateUploaded, now, now, now, + ) + require.NoError(t, err) + + acquired := map[string]bool{} + for range 2 { + got, err := records.FindAndAcquire(entity.WorkingStages()) + require.NoError(t, err) + acquired[got.ID] = true + } + + assert.True(t, acquired[byService.Id], "запись приёма захвату не досталась") + assert.True(t, acquired[byHand], "запись, заведённая запросом к базе, захвату не досталась") +} + +// Горячие выборки опираются на индекс: полного сканирования таблицы аудиозаписей +// не показывает ни отбор захвата, ни список, сужаемый владельцем и страницей. +func TestHotQueriesUseIndexes(t *testing.T) { + db, _, _ := newTestDB(t) + + acquire := explain(t, db, ` + SELECT id FROM audio_records + WHERE state IN (?, ?) + AND halted_at IS NULL + AND (delay_time IS NULL OR delay_time < ?) + AND (acquisition_id IS NULL OR acquire_expires_at IS NULL OR acquire_expires_at < ?) + ORDER BY created_at, id + LIMIT 1`, + entity.StateUploaded, entity.StateNormalized, "now", "now") + + list := explain(t, db, ` + SELECT id FROM audio_records + WHERE owner_id = ? + AND (created_at < ? OR (created_at = ? AND id < ?)) + ORDER BY created_at DESC, id DESC + LIMIT 31`, + "owner", "now", "now", "id") + + for name, plan := range map[string]string{"отбор захвата": acquire, "список": list} { + assert.NotContains(t, plan, "SCAN audio_records", + "%s идёт полным сканированием таблицы аудиозаписей: %s", name, plan) + assert.Contains(t, plan, "USING", "%s не опирается на индекс: %s", name, plan) + assert.Contains(t, plan, "INDEX", "%s не опирается на индекс: %s", name, plan) + } +} + +func explain(t *testing.T, db *DB, query string, args ...any) string { + t.Helper() + + rows, err := db.Reader().QueryContext(context.Background(), "EXPLAIN QUERY PLAN "+query, args...) + require.NoError(t, err) + defer func() { require.NoError(t, rows.Close()) }() + + var plan strings.Builder + for rows.Next() { + var id, parent, notUsed int + var detail string + require.NoError(t, rows.Scan(&id, &parent, ¬Used, &detail)) + plan.WriteString(detail) + plan.WriteString("; ") + } + require.NoError(t, rows.Err()) + + return plan.String() +} + +// Мягкая остановка закрывает то же, что открыл подъём, и повторная остановка не +// даёт паники. +func TestCloseIsIdempotent(t *testing.T) { + db, err := Open(t.TempDir(), testSettings()) + require.NoError(t, err) + + require.NoError(t, db.Close()) + assert.NoError(t, db.Close(), "повторное закрытие отказало") +} + +// Укладка атомарна: источник, отдавший отказ на середине потока, не оставляет ни +// файла под рабочим именем, ни временного имени в подкаталоге записи. +func TestStorePutIsAtomic(t *testing.T) { + _, store, dir := newTestDB(t) + + recordID := ident.New() + + _, err := store.Put(recordID, "voice.mp3", &brokenReader{}) + require.Error(t, err, "отказ источника прошёл молча") + + _, err = os.Stat(filepath.Join(dir, recordsDir, recordID, "voice.mp3")) + assert.True(t, os.IsNotExist(err), "рабочее имя появилось при оборванном потоке") + + temporary, err := store.HasTemporary(recordID) + require.NoError(t, err) + assert.False(t, temporary, "временное имя осталось в подкаталоге записи") +} + +// brokenReader отдаёт часть потока и обрывается — так выглядит отправитель, +// закрывший соединение на середине. +type brokenReader struct { + sent bool +} + +func (r *brokenReader) Read(p []byte) (int, error) { + if !r.sent { + r.sent = true + copy(p, strings.Repeat("a", min(len(p), 64))) + return min(len(p), 64), nil + } + return 0, errors.New("источник оборвался") +} + +// Отказ укладки несёт причину и не несёт пути. +// +// Обе половины — одно требование, и порознь они друг друга отменяют. Причина +// нужна владельцу: исчерпание места, отсутствие прав и негодная раскладка +// каталога требуют трёх разных действий, а отказ укладки — единственная +// поверхность, на которой он их видит. Путь не нужен: он ведёт внутрь каталога +// данных, а отказ кончается в журнале, откуда строку потом не убрать. +func TestStoreFailureCarriesCauseWithoutPath(t *testing.T) { + _, store, dir := newTestDB(t) + + // noPath судит вторую половину: ни каталога данных, ни временной приставки + // в цепочке отказа быть не должно. + noPath := func(t *testing.T, err error) { + t.Helper() + require.Error(t, err) + assert.NotContains(t, err.Error(), dir, "путь внутри каталога данных уехал в отказ") + assert.NotContains(t, err.Error(), tempPrefix, "временное имя укладки уехало в отказ") + } + + t.Run("места на диске нет", func(t *testing.T) { + recordID := ident.New() + _, err := store.Put(recordID, "voice.mp3", &diskFullReader{ + path: filepath.Join(dir, recordsDir, recordID, tempPrefix+"whatever"), + }) + noPath(t, err) + assert.ErrorIs(t, err, syscall.ENOSPC, "причина отказа отброшена: место на диске неотличимо от прочего") + }) + + t.Run("прав на подкаталог записи нет", func(t *testing.T) { + recordID := ident.New() + recordDir := filepath.Join(dir, recordsDir, recordID) + require.NoError(t, os.MkdirAll(recordDir, 0o750)) + require.NoError(t, os.Chmod(recordDir, 0o500)) + t.Cleanup(func() { + if err := os.Chmod(recordDir, 0o750); err != nil { + t.Logf("не удалось вернуть права подкаталогу записи: %v", err) + } + }) + + _, err := store.Put(recordID, "voice.mp3", strings.NewReader("данные")) + noPath(t, err) + assert.ErrorIs(t, err, fs.ErrPermission, "причина отказа отброшена: отсутствие прав неотличимо от прочего") + }) + + t.Run("подкаталогом записи занято не то", func(t *testing.T) { + recordID := ident.New() + require.NoError(t, os.MkdirAll(filepath.Join(dir, recordsDir), 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dir, recordsDir, recordID), []byte("не каталог"), 0o600)) + + _, err := store.Put(recordID, "voice.mp3", strings.NewReader("данные")) + noPath(t, err) + assert.ErrorIs(t, err, syscall.ENOTDIR, "причина отказа отброшена: негодная раскладка неотличима от прочего") + }) + + t.Run("копии нет", func(t *testing.T) { + _, err := store.Open(ident.New(), "voice.mp3") + noPath(t, err) + assert.ErrorIs(t, err, fs.ErrNotExist, "причина отказа отброшена: «файла нет» неотличимо от прочего") + }) +} + +// diskFullReader отказывает так, как отказывает диск: причина приходит обёрткой +// пакета `os`, и путь лежит в ней. Настоящим источником укладки служит `*os.File` +// рабочей копии, и его отказ приходит ровно этой формой. +type diskFullReader struct { + path string +} + +func (r *diskFullReader) Read([]byte) (int, error) { + return 0, &os.PathError{Op: "write", Path: r.path, Err: syscall.ENOSPC} +} + +// Копии одной записи лежат вместе — под её идентификатором, — и второго места, +// где лежит что-то из них, нет. +func TestCopiesOfRecordLiveTogether(t *testing.T) { + db, store, dir := newTestDB(t) + + owner := newOwner(t, db) + files := NewFileRepository(db, store) + recordID := ident.New() + + for _, name := range []string{"original.mp3", "normalized.ogg"} { + work, err := files.Stage(filepath.Ext(name), strings.NewReader("содержимое "+name)) + require.NoError(t, err) + + _, err = files.Create(recordID, name, work, contract.FileMeta{Format: "mp3"}, owner) + require.NoError(t, err) + require.NoError(t, work.Close()) + } + + entries, err := os.ReadDir(filepath.Join(dir, recordsDir, recordID)) + require.NoError(t, err) + + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + assert.ElementsMatch(t, []string{"original.mp3", "normalized.ogg"}, names) + + records, err := os.ReadDir(filepath.Join(dir, recordsDir)) + require.NoError(t, err) + assert.Len(t, records, 1, "второго места для копий записи не появляется") +} + +// Содержимое читается потоком с перемоткой: отдача по диапазону берёт кусок, а +// не файл целиком. +func TestOpenGivesSeekableStream(t *testing.T) { + db, store, _ := newTestDB(t) + + owner := newOwner(t, db) + files := NewFileRepository(db, store) + recordID := ident.New() + + work, err := files.Stage(".mp3", strings.NewReader("0123456789")) + require.NoError(t, err) + file, err := files.Create(recordID, "voice.mp3", work, contract.FileMeta{Format: "mp3"}, owner) + require.NoError(t, err) + require.NoError(t, work.Close()) + + reader, err := files.Open(file.Id) + require.NoError(t, err) + defer func() { require.NoError(t, reader.Close()) }() + + _, err = reader.Seek(4, io.SeekStart) + require.NoError(t, err) + + slice := make([]byte, 3) + _, err = io.ReadFull(reader, slice) + require.NoError(t, err) + assert.Equal(t, "456", string(slice)) +} diff --git a/internal/adapter/repo/sqlite/file_repo.go b/internal/adapter/repo/sqlite/file_repo.go new file mode 100644 index 0000000..3b32a8a --- /dev/null +++ b/internal/adapter/repo/sqlite/file_repo.go @@ -0,0 +1,237 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "git.vakhrushev.me/av/transcriber/internal/clock" + "git.vakhrushev.me/av/transcriber/internal/contract" + "git.vakhrushev.me/av/transcriber/internal/entity" + "git.vakhrushev.me/av/transcriber/internal/ident" +) + +// workFile — рабочая копия файла на диске. Живёт во временном каталоге системы, +// а не в каталоге данных: последний смонтирован на сервере, и временному там не +// место. +type workFile struct { + path string +} + +func (w *workFile) Path() string { return w.path } + +func (w *workFile) Size() (int64, error) { + info, err := os.Stat(w.path) + if err != nil { + return 0, fmt.Errorf("failed to stat work file: %w", err) + } + return info.Size(), nil +} + +// Close убирает копию. Отсутствие файла отказом не считается: шаг мог не дойти +// до его создания, и повторный Close тоже законен. +func (w *workFile) Close() error { + if err := os.Remove(w.path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove work file: %w", err) + } + return nil +} + +// FileRepository — копии записей: строка в базе и содержимое в каталоге данных. +type FileRepository struct { + db *DB + store *Store +} + +func NewFileRepository(db *DB, store *Store) *FileRepository { + return &FileRepository{db: db, store: store} +} + +// newWorkFile заводит пустую копию во временном каталоге. Расширение сохраняется +// в имени: `ffprobe` и `ffmpeg` по нему выбирают разбор. +func newWorkFile(ext string) (*workFile, error) { + f, err := os.CreateTemp("", "transcriber-*"+ext) + if err != nil { + return nil, fmt.Errorf("failed to create work file: %w", err) + } + path := f.Name() + if err := f.Close(); err != nil { + _ = os.Remove(path) + return nil, fmt.Errorf("failed to close work file: %w", err) + } + return &workFile{path: path}, nil +} + +func (repo *FileRepository) StageEmpty(ext string) (contract.WorkFile, error) { + return newWorkFile(ext) +} + +func (repo *FileRepository) Stage(ext string, content io.Reader) (contract.WorkFile, error) { + work, err := newWorkFile(ext) + if err != nil { + return nil, err + } + + if err := writeTo(work.path, content); err != nil { + // Отказ уборки не подменяет отказ записи, но и не теряется. + return nil, errors.Join(err, work.Close()) + } + + return work, nil +} + +func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) { + file, err := repo.GetByID(fileID) + if err != nil { + return nil, err + } + + work, err := newWorkFile(filepath.Ext(file.FileName)) + if err != nil { + return nil, err + } + + src, err := repo.store.Open(file.RecordID, file.FileName) + if err != nil { + return nil, errors.Join(err, work.Close()) + } + defer func() { _ = src.Close() }() + + if err := writeTo(work.path, src); err != nil { + return nil, errors.Join(err, work.Close()) + } + + return work, nil +} + +// Create кладёт рабочую копию в каталог данных и заводит строку о файле. +// +// Порядок один: строка заводится **после** того, как содержимое лежит целиком +// под рабочим именем. Обратный порядок оставлял бы в базе строку, указывающую на +// файл, которого ещё нет или который короче принятого. +// +// Отсюда и уборка: содержимое легло, а строка не сохранилась — уложенный файл +// убирается, и следа от него не остаётся. Файл, переживший свою строку, — +// штатное состояние только у приведённой копии, которую заводит шаг конвейера; у +// принятой это мусор, на который не ссылается ничто и о котором узнать неоткуда. +// +// Владелец обязателен и лежит своей колонкой: пустой отвергает схема — колонка +// объявлена связью с учётной записью, и пустое значение ей не отвечает. +func (repo *FileRepository) Create( + recordID, name string, + work contract.WorkFile, + meta contract.FileMeta, + ownerID string, +) (*entity.File, error) { + source, err := os.Open(work.Path()) + if err != nil { + // Причина сохраняется, путь снимается: он ведёт к рабочей копии чужого + // аудио, а отказ кончается в журнале. + return nil, fmt.Errorf("failed to read work file: %w", causeOf(err)) + } + + size, putErr := repo.store.Put(recordID, name, source) + closeErr := source.Close() + if err := errors.Join(putErr, closeErr); err != nil { + return nil, err + } + + file := &entity.File{ + Id: ident.New(), + RecordID: recordID, + FileName: name, + Size: size, + Format: meta.Format, + DurationMs: meta.DurationMs, + CreatedAt: clock.Now(), + } + + query, args := insertSQL("files", map[string]any{ + "id": file.Id, + "owner_id": ownerID, + "record_id": file.RecordID, + "file_name": file.FileName, + "size_bytes": file.Size, + "format": file.Format, + "duration_ms": file.DurationMs, + "created_at": formatTime(file.CreatedAt), + }) + + if _, err := repo.db.Writer().ExecContext(context.Background(), query, args...); err != nil { + // Уложенное содержимое убирается: строки о нём не будет, и ссылаться на + // него нечему. Имя файла в отказ не идёт — оно часть пути к чужому аудио. + return nil, errors.Join( + fmt.Errorf("failed to store the file row of record %s: %w", recordID, err), + repo.store.Remove(recordID, name), + ) + } + + return file, nil +} + +func (repo *FileRepository) GetByID(id string) (*entity.File, error) { + file := &entity.File{} + var ( + createdAt string + recordID string + fileName string + size int64 + format string + durationMs int64 + ) + + err := repo.db.Reader().QueryRowContext(context.Background(), + `SELECT record_id, file_name, size_bytes, format, duration_ms, created_at + FROM files WHERE id = ?`, id, + ).Scan(&recordID, &fileName, &size, &format, &durationMs, &createdAt) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("file %s is not found", id) + } + return nil, fmt.Errorf("failed to get file %s: %w", id, err) + } + + file.Id = id + file.RecordID = recordID + file.FileName = fileName + file.Size = size + file.Format = format + file.DurationMs = durationMs + file.CreatedAt = requiredTimeOf(createdAt) + + return file, nil +} + +// Open отдаёт содержимое хранимой копии потоком с перемоткой: отдача по +// диапазону читает кусок, а не файл целиком. +func (repo *FileRepository) Open(fileID string) (io.ReadSeekCloser, error) { + file, err := repo.GetByID(fileID) + if err != nil { + return nil, err + } + return repo.store.Open(file.RecordID, file.FileName) +} + +// writeTo переливает содержимое в файл потоком. В память запись целиком не +// читается: расчётный потолок — шесть часов. +func writeTo(path string, content io.Reader) error { + dst, err := os.Create(path) + if err != nil { + return fmt.Errorf("failed to open work file: %w", err) + } + + if _, err := io.Copy(dst, content); err != nil { + _ = dst.Close() + return fmt.Errorf("failed to write work file: %w", err) + } + + if err := dst.Close(); err != nil { + return fmt.Errorf("failed to close work file: %w", err) + } + + return nil +} diff --git a/internal/adapter/repo/sqlite/identity.go b/internal/adapter/repo/sqlite/identity.go new file mode 100644 index 0000000..c95dc0f --- /dev/null +++ b/internal/adapter/repo/sqlite/identity.go @@ -0,0 +1,159 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "git.vakhrushev.me/av/transcriber/internal/clock" + "git.vakhrushev.me/av/transcriber/internal/contract" + "git.vakhrushev.me/av/transcriber/internal/entity" + "git.vakhrushev.me/av/transcriber/internal/ident" +) + +// UserRepository — учётные записи сервиса. +type UserRepository struct { + db *DB +} + +func NewUserRepository(db *DB) *UserRepository { + return &UserRepository{db: db} +} + +// EnsureUser находит учётную запись по логину у провайдера, а не найдя — заводит +// её. +// +// **Дом правила один, и он здесь, а не в транспорте.** Второй способ +// представиться — личные токены — возьмёт этот же метод; правило, уложенное +// куском в слой транспорта, пришлось бы тогда либо дублировать вторым куском, +// либо вытаскивать задним числом. +// +// Найденную запись метод **не переписывает**. Иначе всякий запрос был бы записью +// в базу, а правка имени у провайдера меняла бы карточку человека молча, посреди +// его работы. +// +// **Поиск идёт читающим пулом, и пишущая транзакция открывается только тогда, +// когда запись не нашлась.** Узнавание одето на весь корень приложения, поэтому +// пишущая транзакция, взятая до поиска, доставалась бы всему узнанному потоку — +// опросу карточки и каждому запросу диапазона при проигрывании, — и вставала бы +// в очередь к единственному пишущему соединению. Ждать там нечего: заводится +// учётная запись один раз за жизнь человека. +// +// **Окно между двумя соединениями закрыто повторным поиском внутри +// транзакции.** Между поиском читающим пулом и открытием пишущей транзакции +// запись успевает завести сосед; ветвь ниже находит её и берёт заведённую, а +// уникальность ключа держит схема — не порядок обращений. +func (repo *UserRepository) EnsureUser(identity contract.Identity) (*contract.UserAccount, bool, error) { + login, ok := entity.AcceptProviderLogin(identity.Login) + if !ok { + return nil, false, contract.ErrLoginNotAcceptable + } + + account, err := findUserByLogin(repo.db.Reader(), login) + if err != nil { + return nil, false, err + } + if account != nil { + return account, false, nil + } + + tx, err := repo.db.Writer().BeginTx(context.Background(), nil) + if err != nil { + return nil, false, fmt.Errorf("failed to open a transaction for the user account: %w", err) + } + defer func() { _ = tx.Rollback() }() + + // Повторный поиск закрывает окно между читающим пулом и пишущей + // транзакцией: пока её ждали, запись мог завести сосед. + account, err = findUserByLogin(tx, login) + if err != nil { + return nil, false, err + } + if account != nil { + return account, false, commitAccount(tx, account) + } + + name := entity.AcceptDisplayName(identity.Name) + email, _ := entity.AcceptEmail(identity.Email) + + account, err = insertUser(tx, login, name, email) + switch { + case err == nil: + return account, true, commitAccount(tx, account) + case !isUniqueViolation(err): + return nil, false, fmt.Errorf("failed to create user account: %w", err) + } + + // **Два отказа уникальности различаются, и исход у них разный**, а какая + // колонка не сошлась, код отказа не называет. Различает их повторный поиск + // по ключу: нашёлся — это гонка двух первых обращений одним логином, и надо + // просто взять заведённую соседом запись. + account, err = findUserByLogin(tx, login) + if err != nil { + return nil, false, err + } + if account != nil { + return account, false, commitAccount(tx, account) + } + + // Не нашёлся — значит не сошлась другая колонка: адрес почты, пришедший от + // провайдера, занят другой учётной записью (общий ящик, семья, группа). + // Запись заводится **без почты**: она необязательна и ключом не служит. Без + // этого разреза второй человек с общим адресом не завёлся бы никогда — + // повторный поиск по логину снова ничего не находит. + account, err = insertUser(tx, login, name, "") + if err != nil { + return nil, false, fmt.Errorf("failed to create user account without email: %w", err) + } + + return account, true, commitAccount(tx, account) +} + +func commitAccount(tx *sql.Tx, account *contract.UserAccount) error { + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit the user account %s: %w", account.ID, err) + } + return nil +} + +func insertUser(tx *sql.Tx, login, name, email string) (*contract.UserAccount, error) { + id := ident.New() + now := formatTime(clock.Now()) + + _, err := tx.ExecContext(context.Background(), + `INSERT INTO users (id, provider_login, name, email, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)`, + id, login, name, email, now, now, + ) + if err != nil { + return nil, err + } + + return &contract.UserAccount{ID: id, Name: name}, nil +} + +// rowQuerier — то общее, чем поиск учётной записи пользуется у читающего пула и +// у пишущей транзакции. Оба поиска — до транзакции и внутри неё — идут одним +// запросом: второй его копией они разошлись бы молча. +type rowQuerier interface { + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row +} + +// findUserByLogin ищет учётную запись по ключу. Значение уходит базе +// **параметром** запроса, а не подстановкой в текст: строка приходит снаружи, и +// подставленная в текст она правила бы сам запрос, а не только его аргумент. +func findUserByLogin(q rowQuerier, login string) (*contract.UserAccount, error) { + account := &contract.UserAccount{} + err := q.QueryRowContext(context.Background(), + "SELECT id, name FROM users WHERE provider_login = ?", login, + ).Scan(&account.ID, &account.Name) + switch { + case err == nil: + return account, nil + case errors.Is(err, sql.ErrNoRows): + return nil, nil + default: + return nil, fmt.Errorf("failed to look up user account: %w", err) + } +} diff --git a/internal/adapter/repo/sqlite/migrate.go b/internal/adapter/repo/sqlite/migrate.go new file mode 100644 index 0000000..241b2f7 --- /dev/null +++ b/internal/adapter/repo/sqlite/migrate.go @@ -0,0 +1,110 @@ +package sqlite + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "syscall" + + "github.com/pressly/goose/v3" + + "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite/migrations" +) + +// migrationLockFile — файл, на котором берётся замок наката. Лежит в каталоге +// данных рядом с базой: замок принадлежит каталогу, а не машине. +const migrationLockFile = "migrate.lock" + +// Migrate приводит схему к последнему шагу. +// +// # Порядок +// +// Накат идёт **до подъёма входов и до старта воркеров**, а его отказ роняет +// старт. Сервис, поднявшийся на неприведённой схеме, отвечает отказом на каждый +// запрос и на каждый прогон воркера — вместо одной строки о причине их +// становятся сотни, и первопричина в них теряется. +// +// # Чем держится неделимость +// +// Шаг и отметка о нём идут одной транзакцией: библиотека открывает её на том же +// соединении и внутри выполняет и сам шаг, и вставку версии в таблицу учёта. +// Отменяет это только пометка `NO TRANSACTION` у самого шага, и мы её не ставим. +// +// Порядок шагов детерминирован и выводится из версии шага, а не из порядка +// чтения каталога: собранные шаги сортируются по версии, а две одинаковых версии +// дают отказ сбора, а не молчаливый выбор одного. +// +// # Почему замок наш +// +// Исключающей блокировки наката библиотека под SQLite не даёт вовсе: её +// запиратели объявлены только для PostgreSQL, а провайдер без запирателя +// накатывает без всякой блокировки. Замок поэтому берём сами — на файле в +// каталоге данных. С умершим процессом его снимает ядро, поэтому просроченного +// замка, который надо чистить руками, не остаётся. +// +// Накат идёт по **пишущему** соединению: он читает таблицу учёта и следом в неё +// пишет, а транзакцию, начатую на читающем соединении, SQLite до пишущей не +// повышает. +func Migrate(ctx context.Context, db *DB, dataDir string, logger *slog.Logger) error { + if logger == nil { + logger = slog.Default() + } + + provider, err := goose.NewProvider( + goose.DialectSQLite3, + db.Writer(), + nil, + goose.WithGoMigrations(migrations.All()...), + // Глобальный список библиотеки не читается: перечень шагов приходит + // доводом, и два провайдера в одном процессе за общее состояние не + // спорят. + goose.WithDisableGlobalRegistry(true), + ) + if err != nil { + return fmt.Errorf("failed to prepare schema migrations: %w", err) + } + + return withMigrationLock(dataDir, func() error { + results, err := provider.Up(ctx) + if err != nil { + // Отказ называет шаг: библиотека кладёт версию в текст отказа, и + // владелец сервиса по ней находит файл шага. + return fmt.Errorf("failed to apply schema migration: %w", err) + } + + for _, result := range results { + logger.Info("Schema migration applied", + "migration_version", result.Source.Version, + "duration_ms", result.Duration.Milliseconds()) + } + + return nil + }) +} + +// withMigrationLock берёт исключающий замок каталога данных на всё время наката. +// +// Замок блокирующий: второй процесс, поднятый на том же каталоге, ждёт его +// освобождения, а не применяет шаги параллельно. Два наката, разошедшихся на +// одном шаге, оставили бы схему в состоянии, которого не описывает ни один шаг. +func withMigrationLock(dataDir string, run func() error) error { + path := filepath.Join(dataDir, migrationLockFile) + + file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o640) + if err != nil { + return fmt.Errorf("failed to open migration lock: %w", err) + } + // Замок снимается **закрытием дескриптора**, и отдельного снятия не нужно: + // он принадлежит открытому файлу, а не процессу. С умершим процессом его + // снимает ядро тем же движением — просроченного замка, который надо чистить + // руками, не остаётся. + defer func() { _ = file.Close() }() + + if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil { + return fmt.Errorf("failed to lock the data directory for migration: %w", err) + } + + return run() +} diff --git a/internal/adapter/repo/sqlite/migrations/202608220002_init.go b/internal/adapter/repo/sqlite/migrations/202608220002_init.go new file mode 100644 index 0000000..6775b5e --- /dev/null +++ b/internal/adapter/repo/sqlite/migrations/202608220002_init.go @@ -0,0 +1,254 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + "strconv" + + "git.vakhrushev.me/av/transcriber/internal/entity" +) + +// up202608220002 заводит схему сервиса целиком. +// +// Шаг один, и он начальный: прежние шаги встроенного хранилища удалены вместе с +// ним — разовое снятие инварианта «применённая миграция не переписывается» +// решением владельца от 2026-08-22. Причина названа прямо: стадия проекта — +// стройка, на сервере данных нет, сервис остановлен, а новая база ведёт учёт +// применённого своей таблицей, которой отметки прежнего каталога не годятся +// вовсе. Снятие кончается этим шагом: уехав на сервер, он подпадает под +// инвариант как всякий прежний. +// +// Порядок заведения задан связями: сперва учётные записи, потом всё, что на них +// ссылается, и только потом обратные ссылки записи на её приложения. +// +// **Времени умолчанием схема не ставит.** Вид времени один на все колонки — +// `TEXT` в RFC 3339, UTC, секундная точность, — и ставит его приложение единой +// точкой. `CURRENT_TIMESTAMP` писал бы свой вид, отличный от объявленного, а +// вставка, забывшая проставить время, при умолчании прошла бы молча. +// +// **Перечни значений держит код, а не схема.** Прежде рубеж, причина остановки +// и вид текста были закрыты схемой, потому что панель владельца правила запись +// руками и вправе была завести значение, которого сервис не знает. Панели нет, +// правка идёт только нашим кодом, и `CHECK` остался бы ценой — новое значение +// стоило бы нового шага схемы — без покупателя. +func up202608220002(ctx context.Context, tx *sql.Tx) error { + for _, statement := range initStatements() { + if _, err := tx.ExecContext(ctx, statement); err != nil { + return fmt.Errorf("failed to apply initial schema: %w", err) + } + } + return nil +} + +// down202608220002 сносит схему целиком. Порядок обратный порядку заведения: +// приложения ссылаются на запись, запись — на учётную запись. +func down202608220002(ctx context.Context, tx *sql.Tx) error { + tables := []string{ + "record_events", + "recognitions", + "structures", + "texts", + "record_topics", + "audio_records", + "topics", + "files", + "users", + } + for _, table := range tables { + if _, err := tx.ExecContext(ctx, "DROP TABLE IF EXISTS "+table); err != nil { + return fmt.Errorf("failed to drop %s: %w", table, err) + } + } + return nil +} + +// initStatements — шаг по одному оператору на элемент. +// +// Россыпью, а не одной строкой с разделителями: тело триггера само несёт точку с +// запятой, и разбиение общей строки резало бы его пополам. +func initStatements() []string { + return []string{ + // Учётная запись. Ключ — логин у провайдера: его приносит заголовок + // доверенного источника, и по нему запись находится при каждом + // обращении. Адрес почты необязателен и ключом не служит — он меняется, + // и первое обращение с чужим адресом досталось бы чужой записи. + `CREATE TABLE users ( + id TEXT NOT NULL PRIMARY KEY, + provider_login TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + `CREATE UNIQUE INDEX idx_users_provider_login ON users (provider_login)`, + // Уникальность почты частичная: пустая почта законна и не спорит с + // другой пустой. Индекс нужен затем, чтобы занятый адрес отвергался + // схемой — по этому отказу заведение переходит на ветвь «запись без + // почты», а не отдаёт чужую учётную запись. + `CREATE UNIQUE INDEX idx_users_email ON users (email) WHERE email <> ''`, + + // Копия записи на диске. Владелец лежит своей колонкой, а не выводится + // через запись: файл переживает свою запись — шаг заводит его до + // сохранения, — и заведённый до неё остаётся с владельцем и без ссылки. + // + // Ссылки на аудиозапись внешним ключом нет намеренно, и `record_id` + // здесь — имя подкаталога, где копия лежит. Приём заводит файл **до** + // самой записи, и обязательная связь отвергала бы первую же принятую + // запись. + `CREATE TABLE files ( + id TEXT NOT NULL PRIMARY KEY, + owner_id TEXT NOT NULL REFERENCES users (id), + record_id TEXT NOT NULL, + file_name TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + format TEXT NOT NULL DEFAULT '', + duration_ms INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + )`, + `CREATE INDEX idx_files_owner ON files (owner_id)`, + + // Словарь тем. Своя таблица, а не набор строк в записи: перечень тем + // человека нужен целиком перед каждым обращением к модели, а собрать его + // из наборов строк можно только перебором всех его записей. + `CREATE TABLE topics ( + id TEXT NOT NULL PRIMARY KEY, + owner_id TEXT NOT NULL REFERENCES users (id), + name TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + `CREATE UNIQUE INDEX idx_topics_owner_name ON topics (owner_id, name)`, + + // Аудиозапись — центральная сущность. Поля очереди соседствуют с + // доменом, но не с содержимым: расшифровка лежит строкой `texts`, и + // чтение очереди её не тянет. + // + // Колонка владельца обязательна и объявлена внешним ключом: ничьей + // записи не бывает, и держит это схема, а не проверка вызывающего. + // Пустое значение внешнему ключу не отвечает — идентификаторы у учётных + // записей непустые, — поэтому ничью запись отвергает та же связь. + // + // `duration_ms` и `size_bytes` обязательны и различать «неизвестно» и + // «ноль» не обязаны: обе величины ставит приём и ставит всегда — запись, + // метаданные которой прочитать не удалось, отвергается отказом и не + // заводится вовсе. Решение владельца 2026-08-15. + `CREATE TABLE audio_records ( + id TEXT NOT NULL PRIMARY KEY, + owner_id TEXT NOT NULL REFERENCES users (id), + title TEXT, + brief TEXT, + original_filename TEXT, + duration_ms INTEGER NOT NULL, + size_bytes INTEGER NOT NULL, + state TEXT NOT NULL, + state_entered_at TEXT NOT NULL, + halted_at TEXT, + halt_reason TEXT, + error_text TEXT, + acquisition_id TEXT, + acquire_expires_at TEXT, + delay_time TEXT, + attempts INTEGER NOT NULL DEFAULT 0, + original_file_id TEXT REFERENCES files (id), + normalized_file_id TEXT REFERENCES files (id), + transcript_text_id TEXT, + literary_text_id TEXT, + structure_id TEXT, + recognition_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + // Отбор захвата идёт по рубежу, признаку остановки и порядку ленты. + // Индекс заводится здесь, а не потом: применённый шаг схемы не + // переписывается, и добавление индекса стоило бы отдельного шага. + `CREATE INDEX idx_audio_records_acquire + ON audio_records (state, halted_at, created_at, id)`, + // Страница списка сужается владельцем и режется полным ключом + // сортировки — парой «время заведения и ключ записи». + `CREATE INDEX idx_audio_records_owner_page + ON audio_records (owner_id, created_at, id)`, + + // Темы записи. Отдельной таблицей связи, а не колонкой-перечнем: у + // набора строк в колонке нет ни связи, ни потолка. + `CREATE TABLE record_topics ( + record_id TEXT NOT NULL REFERENCES audio_records (id), + topic_id TEXT NOT NULL REFERENCES topics (id), + PRIMARY KEY (record_id, topic_id) + )`, + `CREATE INDEX idx_record_topics_topic ON record_topics (topic_id)`, + // Потолок числа тем держит схема: без него часовой разговор даёт два + // десятка тем, и словарь распухает за неделю. Число берётся у домена — + // то же самое, которое сервис объявляет приложению. + `CREATE TRIGGER trg_record_topics_limit + BEFORE INSERT ON record_topics + BEGIN + SELECT RAISE(ABORT, 'record has too many topics') + WHERE ( + SELECT COUNT(*) FROM record_topics WHERE record_id = NEW.record_id + ) >= ` + strconv.Itoa(entity.MaxTopicsPerRecord) + `; + END`, + + // Тексты записи. Пара «запись и вид» уникальна: повтор прерванного шага + // иначе завёл бы второй комплект строк, и вопрос «какой текст отдавать + // человеку» стал бы вопросом порядка записи, а не состояния. + `CREATE TABLE texts ( + id TEXT NOT NULL PRIMARY KEY, + record_id TEXT NOT NULL REFERENCES audio_records (id), + kind TEXT NOT NULL, + contents TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + `CREATE UNIQUE INDEX idx_texts_record_kind ON texts (record_id, kind)`, + + // Структура реплик. Номер версии нужен потому, что разбор сохранённого + // ответа изменится раньше, чем архив пересчитают. + `CREATE TABLE structures ( + id TEXT NOT NULL PRIMARY KEY, + record_id TEXT NOT NULL REFERENCES audio_records (id), + version INTEGER NOT NULL, + contents TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + `CREATE UNIQUE INDEX idx_structures_record_version ON structures (record_id, version)`, + + // Попытка распознавания у внешнего провайдера. + // + // Сохранённый ответ лежит **третьим файлом в подкаталоге записи**, а + // здесь стоит только его имя: шаг опроса читает эту строку раз в + // несколько секунд, и ответ на многочасовую запись, положенный колонкой, + // ехал бы в память при каждом опросе. + `CREATE TABLE recognitions ( + id TEXT NOT NULL PRIMARY KEY, + record_id TEXT NOT NULL REFERENCES audio_records (id), + provider TEXT NOT NULL, + model TEXT NOT NULL DEFAULT '', + external_id TEXT NOT NULL DEFAULT '', + source_uri TEXT NOT NULL DEFAULT '', + payload_file TEXT NOT NULL DEFAULT '', + started_at TEXT, + finished_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + `CREATE INDEX idx_recognitions_record ON recognitions (record_id)`, + + // Журнал событий записи. Колонка текста отказа зовётся `outcome_text`, а + // не `error_text`: последнее имя названо поимённо инвариантом проекта о + // секрете, и две колонки с этим именем сделали бы инвариант + // двусмысленным. + `CREATE TABLE record_events ( + id TEXT NOT NULL PRIMARY KEY, + record_id TEXT NOT NULL REFERENCES audio_records (id), + origin TEXT NOT NULL, + step TEXT NOT NULL DEFAULT '', + outcome TEXT NOT NULL, + outcome_text TEXT NOT NULL DEFAULT '', + duration_ms INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + )`, + `CREATE INDEX idx_record_events_record ON record_events (record_id)`, + } +} diff --git a/internal/adapter/repo/sqlite/migrations/migrations.go b/internal/adapter/repo/sqlite/migrations/migrations.go new file mode 100644 index 0000000..cb4ae99 --- /dev/null +++ b/internal/adapter/repo/sqlite/migrations/migrations.go @@ -0,0 +1,36 @@ +// Package migrations — шаги схемы базы. +// +// Шаг лежит своим файлом, имя файла начинается версией, и **применённый шаг не +// переписывается** — только новым файлом. Инвариант проекта держится так же, как +// держался прежде: изменение схемы это новый шаг, а не правка уехавшего. +// +// Шаги лежат отдельным каталогом, а не файлом внутри пакета хранилища, по +// внешней причине: сверка документов ловит изменённый шаг схемы при нетронутом +// `docs/database.md` по префиксу пути (`.av-dev.toml`, ключ `migrations` секции +// `[docs]`), а префикс наводится только на каталог. +// +// Регистрация идёт **перечнем**, а не глобальным списком библиотеки: провайдер +// заводится в точке входа и получает этот перечень доводом, поэтому два +// провайдера в одном процессе — например, сервис и проверка — не спорят за общее +// состояние. +package migrations + +import ( + "github.com/pressly/goose/v3" +) + +// All — шаги схемы в порядке версий. +// +// Порядок исхода от порядка этого перечня не зависит: библиотека сортирует шаги +// по версии сама. Перечень собран ради того, чтобы шаг, добавленный файлом и +// забытый здесь, не оказался незамеченным: незарегистрированный шаг не +// накатывается вовсе. +func All() []*goose.Migration { + return []*goose.Migration{ + goose.NewGoMigration( + 202608220002, + &goose.GoFunc{RunTx: up202608220002}, + &goose.GoFunc{RunTx: down202608220002}, + ), + } +} diff --git a/internal/adapter/repo/sqlite/recognition_repo.go b/internal/adapter/repo/sqlite/recognition_repo.go new file mode 100644 index 0000000..cdfb32e --- /dev/null +++ b/internal/adapter/repo/sqlite/recognition_repo.go @@ -0,0 +1,163 @@ +package sqlite + +import ( + "context" + "errors" + "fmt" + "io" + + "git.vakhrushev.me/av/transcriber/internal/clock" + "git.vakhrushev.me/av/transcriber/internal/entity" + "git.vakhrushev.me/av/transcriber/internal/ident" +) + +// payloadSuffix — окончание имени файла, под которым лежит сохранённый ответ +// провайдера. Имя задаёт сервис, как и у копий аудио. +const payloadSuffix = ".payload" + +type RecognitionRepository struct { + db *DB + store *Store +} + +func NewRecognitionRepository(db *DB, store *Store) *RecognitionRepository { + return &RecognitionRepository{db: db, store: store} +} + +// Create заводит строку попытки **до** обращения к провайдеру. +// +// Порядок здесь несущий: окно между ответом провайдера и записью идентификатора +// операции — то место, где теряется оплаченное. Заведённая заранее строка даёт +// повторному шагу, чем проверить сделанное прежде, чем платить второй раз. +func (repo *RecognitionRepository) Create(r *entity.Recognition) error { + started := clock.Now() + if r.Id == "" { + r.Id = ident.New() + } + + now := formatTime(started) + _, err := repo.db.Writer().ExecContext(context.Background(), + `INSERT INTO recognitions + (id, record_id, provider, model, external_id, source_uri, started_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + r.Id, r.RecordID, r.Provider, r.Model, r.ExternalID, r.SourceURI, now, now, now, + ) + if err != nil { + return fmt.Errorf("failed to create recognition attempt for record %s: %w", r.RecordID, err) + } + + r.StartedAt = &started + return nil +} + +// Submitted сохраняет адрес аудио и идентификатор заведённой операции. По +// последнему повторный шаг узнаёт, что за эту запись уже заплачено, и второй раз +// наружу не платит. +func (repo *RecognitionRepository) Submitted(id, sourceURI, externalID string) error { + _, err := repo.db.Writer().ExecContext(context.Background(), + "UPDATE recognitions SET source_uri = ?, external_id = ?, updated_at = ? WHERE id = ?", + sourceURI, externalID, formatTime(clock.Now()), id, + ) + if err != nil { + return fmt.Errorf("failed to store operation id of attempt %s: %w", id, err) + } + return nil +} + +// Finish кладёт сохранённый ответ провайдера **третьим файлом в подкаталоге +// записи** и отмечает завершение попытки. +// +// Файлом, а не колонкой: шаг опроса читает эту строку раз в несколько секунд, и +// ответ на многочасовую запись, положенный колонкой, ехал бы в память при каждом +// опросе. Хранится он потому, что результат операции у провайдера не +// переспрашивается. +// +// Пустой ответ поверх сохранённого не кладётся — тем же доводом, что и у текста: +// повторный опрос вправе вернуть пустое, и безусловная замена стёрла бы +// сохранённое без возврата. +func (repo *RecognitionRepository) Finish(id string, raw []byte) error { + attempt, err := repo.GetByID(id) + if err != nil { + return err + } + + name := id + payloadSuffix + if len(raw) > 0 { + if _, err := repo.store.Put(attempt.RecordID, name, bytesReader(raw)); err != nil { + // Путь к сохранённому ответу наружу не идёт: отказ называет попытку + // её идентификатором. + return errors.Join(fmt.Errorf("failed to store provider payload of attempt %s", id), err) + } + } + + finished := formatTime(clock.Now()) + if len(raw) > 0 { + _, err = repo.db.Writer().ExecContext(context.Background(), + "UPDATE recognitions SET payload_file = ?, finished_at = ?, updated_at = ? WHERE id = ?", + name, finished, finished, id, + ) + } else { + _, err = repo.db.Writer().ExecContext(context.Background(), + "UPDATE recognitions SET finished_at = ?, updated_at = ? WHERE id = ?", + finished, finished, id, + ) + } + if err != nil { + return fmt.Errorf("failed to store provider payload of attempt %s", id) + } + + return nil +} + +func (repo *RecognitionRepository) GetByID(id string) (*entity.Recognition, error) { + attempt := &entity.Recognition{Id: id} + var startedAt, finishedAt, payloadFile nullString + err := repo.db.Reader().QueryRowContext(context.Background(), + `SELECT record_id, provider, model, external_id, source_uri, payload_file, started_at, finished_at + FROM recognitions WHERE id = ?`, id, + ).Scan( + &attempt.RecordID, &attempt.Provider, &attempt.Model, + &attempt.ExternalID, &attempt.SourceURI, &payloadFile, + &startedAt, &finishedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to get recognition attempt %s: %w", id, err) + } + + attempt.StartedAt = timeOf(startedAt.NullString) + attempt.FinishedAt = timeOf(finishedAt.NullString) + + return attempt, nil +} + +// ReadRaw отдаёт сохранённый ответ провайдера. Зовётся только тогда, когда ответ +// нужен: шаг опроса читает строку попытки без него. +func (repo *RecognitionRepository) ReadRaw(id string) ([]byte, error) { + attempt, err := repo.GetByID(id) + if err != nil { + return nil, err + } + + var payloadFile string + if err := repo.db.Reader().QueryRowContext(context.Background(), + "SELECT payload_file FROM recognitions WHERE id = ?", id, + ).Scan(&payloadFile); err != nil { + return nil, fmt.Errorf("failed to get recognition attempt %s: %w", id, err) + } + if payloadFile == "" { + return nil, fmt.Errorf("recognition attempt %s has no stored payload", id) + } + + file, err := repo.store.Open(attempt.RecordID, payloadFile) + if err != nil { + return nil, err + } + defer func() { _ = file.Close() }() + + raw, err := io.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("failed to read stored payload of attempt %s", id) + } + + return raw, nil +} diff --git a/internal/adapter/repo/sqlite/record_event_repo.go b/internal/adapter/repo/sqlite/record_event_repo.go new file mode 100644 index 0000000..80e6452 --- /dev/null +++ b/internal/adapter/repo/sqlite/record_event_repo.go @@ -0,0 +1,62 @@ +package sqlite + +import ( + "bytes" + "context" + "database/sql" + "fmt" + "io" + + "git.vakhrushev.me/av/transcriber/internal/clock" + "git.vakhrushev.me/av/transcriber/internal/entity" + "git.vakhrushev.me/av/transcriber/internal/ident" +) + +// nullString — обёртка ради читаемости выборок: колонка, допускающая пустое +// значение, читается в неё, а домену отдаётся указателем. +type nullString struct { + sql.NullString +} + +// bytesReader отдаёт содержимое в памяти потоком: сохранённый ответ провайдера +// приходит целиком байтами, а укладка принимает поток. +func bytesReader(raw []byte) io.Reader { + return bytes.NewReader(raw) +} + +type RecordEventRepository struct { + db *DB +} + +func NewRecordEventRepository(db *DB) *RecordEventRepository { + return &RecordEventRepository{db: db} +} + +// Append пишет строку журнала событий записи. +// +// Журнал пишется на смену рубежа, на остановку и на возврат в работу, а не на +// каждое откладывание опроса: часовая запись дала бы сотни строк ни о чём. Ни +// один шаг конвейера его не читает, чтобы решить, что делать дальше: решение +// принимается по рубежу записи, и второй источник решения разошёлся бы с первым +// молча. +// +// Содержимое записи сюда не попадает — инвариант приватности действует здесь +// наравне с журналом сервиса. +func (repo *RecordEventRepository) Append(event *entity.RecordEvent) error { + if event.Id == "" { + event.Id = ident.New() + } + + _, err := repo.db.Writer().ExecContext(context.Background(), + `INSERT INTO record_events + (id, record_id, origin, step, outcome, outcome_text, duration_ms, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + event.Id, event.RecordID, event.Origin, event.Step, + event.Outcome, event.OutcomeText, event.DurationMs, formatTime(clock.Now()), + ) + if err != nil { + return fmt.Errorf("failed to append event of record %s: %w", event.RecordID, err) + } + + return nil +} diff --git a/internal/adapter/repo/pocketbase/record_list.go b/internal/adapter/repo/sqlite/record_list.go similarity index 53% rename from internal/adapter/repo/pocketbase/record_list.go rename to internal/adapter/repo/sqlite/record_list.go index 6856e4b..798c584 100644 --- a/internal/adapter/repo/pocketbase/record_list.go +++ b/internal/adapter/repo/sqlite/record_list.go @@ -1,12 +1,10 @@ -package pocketbase +package sqlite import ( + "context" "fmt" + "strings" - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/core" - - "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" ) @@ -47,69 +45,86 @@ func (repo *AudioRecordRepository) List(q contract.RecordQuery) (*contract.Recor q.Limit = defaultListLimit } - collection, err := findCollection(repo.app, migrations.RecordsCollection) + conditions := []string{"owner_id = ?"} + args := []any{q.OwnerID} + + state, stateArgs, err := stateCondition(q.Filter) if err != nil { return nil, err } - - filter := dbx.HashExp{"owner": q.OwnerID} - conditions := []dbx.Expression{filter} - - state, err := stateCondition(q.Filter) - if err != nil { - return nil, err - } - if state != nil { + if state != "" { conditions = append(conditions, state) + args = append(args, stateArgs...) } - total, err := repo.countRecords(conditions) + total, err := repo.countRecords(conditions, args) if err != nil { return nil, err } + pageConditions := conditions + pageArgs := args // Курсор режет ленту по паре: строго раньше по времени, а при равном времени - // — строго меньше по идентификатору. Идентификаторы хранилища монотонны в - // пределах одной миллисекунды не всегда, но сравнение по ним устойчиво, и - // этого довольно: задача ключа — не пропустить и не повторить. + // — строго меньше по идентификатору. if q.Cursor != nil { - conditions = append(conditions, dbx.Or( - dbx.NewExp("created < {:created}", dbx.Params{"created": q.Cursor.CreatedAt}), - dbx.And( - dbx.NewExp("created = {:created}", dbx.Params{"created": q.Cursor.CreatedAt}), - dbx.NewExp("id < {:id}", dbx.Params{"id": q.Cursor.ID}), - ), - )) + pageConditions = append(append([]string{}, conditions...), + "(created_at < ? OR (created_at = ? AND id < ?))") + cursorTime := formatTime(q.Cursor.CreatedAt) + pageArgs = append(append([]any{}, args...), + cursorTime, cursorTime, q.Cursor.ID) } + row := &recordRow{} + columns, targets := selectList(readRecordColumns(row), "") + // Просим на одну больше предела: лишняя запись отвечает на вопрос «есть ли // следующая страница» без второго запроса и без вычислений по общему числу, // которое к этому моменту могло измениться. - records := []*core.Record{} - err = repo.app.RecordQuery(collection). - AndWhere(dbx.And(conditions...)). - OrderBy("created DESC", "id DESC"). - Limit(int64(q.Limit) + 1). - All(&records) + query := "SELECT " + columns + " FROM " + recordsTable + + " WHERE " + strings.Join(pageConditions, " AND ") + + " ORDER BY created_at DESC, id DESC LIMIT ?" + pageArgs = append(pageArgs, q.Limit+1) + + rows, err := repo.db.Reader().QueryContext(context.Background(), query, pageArgs...) if err != nil { return nil, fmt.Errorf("failed to list audio records: %w", err) } + defer func() { _ = rows.Close() }() + + items := []*entity.AudioRecord{} + for rows.Next() { + if err := rows.Scan(targets...); err != nil { + return nil, fmt.Errorf("failed to read an audio record of the page: %w", err) + } + items = append(items, rowToAudioRecord(row)) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("failed to read the page of audio records: %w", err) + } page := &contract.RecordPage{TotalItems: total} - if len(records) > q.Limit { - last := records[q.Limit-1] + if len(items) > q.Limit { + last := items[q.Limit-1] page.NextCursor = &contract.RecordCursor{ - CreatedAt: last.GetDateTime("created").String(), + CreatedAt: last.CreatedAt, ID: last.Id, } - records = records[:q.Limit] + items = items[:q.Limit] } - page.Items = make([]*entity.AudioRecord, 0, len(records)) - for _, record := range records { - page.Items = append(page.Items, recordToAudioRecord(record)) + ids := make([]string, 0, len(items)) + for _, item := range items { + ids = append(ids, item.Id) + } + topics, err := repo.topicsOf(ids) + if err != nil { + return nil, err + } + for _, item := range items { + item.TopicIDs = topics[item.Id] } + page.Items = items return page, nil } @@ -118,84 +133,128 @@ func (repo *AudioRecordRepository) List(q contract.RecordQuery) (*contract.Recor // Перечень рубежей сюда не переписывается: он приходит из дескриптора. Отбор // списка — очередной его потребитель, и рубеж, добавленный конвейером, иначе // молча поменял бы состав всех трёх состояний. -func stateCondition(filter *entity.ListFilter) (dbx.Expression, error) { +func stateCondition(filter *entity.ListFilter) (string, []any, error) { if filter == nil { - return nil, nil + return "", nil, nil } - notHalted := dbx.NewExp("halted_at = ''") - halted := dbx.NewExp("halted_at != ''") - switch *filter { case entity.ListFilterHalted: - return halted, nil + return "halted_at IS NOT NULL", nil, nil case entity.ListFilterWorking: - return dbx.And(notHalted, dbx.In("state", stageNameValues(entity.WorkingStages())...)), nil + condition, args := stateIn(entity.WorkingStages()) + return "halted_at IS NULL AND " + condition, args, nil case entity.ListFilterDone: - return dbx.And(notHalted, dbx.In("state", stageNameValues(entity.TerminalStages())...)), nil + condition, args := stateIn(entity.TerminalStages()) + return "halted_at IS NULL AND " + condition, args, nil } // Ветвь отказа, а не молчаливое «без сужения»: значение, добавленное в // перечень состояний и забытое здесь, иначе вернуло бы человеку весь архив // под именем отбора — и заметить это было бы нечем. - return nil, fmt.Errorf("%w: unknown list filter %q", contract.ErrBadRequest, *filter) + return "", nil, fmt.Errorf("%w: unknown list filter %q", contract.ErrBadRequest, *filter) } -func stageNameValues(stages []entity.Stage) []any { +func stateIn(stages []entity.Stage) (string, []any) { names := entity.StageNames(stages) - out := make([]any, 0, len(names)) - for _, n := range names { - out = append(out, n) + args := make([]any, 0, len(names)) + placeholders := make([]string, 0, len(names)) + for _, name := range names { + args = append(args, name) + placeholders = append(placeholders, "?") } - return out + return "state IN (" + strings.Join(placeholders, ", ") + ")", args } -func (repo *AudioRecordRepository) countRecords(conditions []dbx.Expression) (int, error) { - var counter struct { - Total int `db:"total"` - } - err := repo.app.RecordQuery(migrations.RecordsCollection). - Select("count(*) as total"). - AndWhere(dbx.And(conditions...)). - One(&counter) - if err != nil { +func (repo *AudioRecordRepository) countRecords(conditions []string, args []any) (int, error) { + var total int + query := "SELECT COUNT(*) FROM " + recordsTable + " WHERE " + strings.Join(conditions, " AND ") + if err := repo.db.Reader().QueryRowContext(context.Background(), query, args...).Scan(&total); err != nil { return 0, fmt.Errorf("failed to count audio records: %w", err) } - return counter.Total, nil + return total, nil +} + +// topicsOf читает темы страницы **одним запросом**: страница в сотню записей +// иначе стоила бы сотни обращений к базе. +func (repo *AudioRecordRepository) topicsOf(recordIDs []string) (map[string][]string, error) { + out := map[string][]string{} + if len(recordIDs) == 0 { + return out, nil + } + + placeholders := make([]string, 0, len(recordIDs)) + args := make([]any, 0, len(recordIDs)) + for _, id := range recordIDs { + placeholders = append(placeholders, "?") + args = append(args, id) + } + + query := "SELECT record_id, topic_id FROM record_topics WHERE record_id IN (" + + strings.Join(placeholders, ", ") + ") ORDER BY topic_id" + + rows, err := repo.db.Reader().QueryContext(context.Background(), query, args...) + if err != nil { + return nil, fmt.Errorf("failed to read record topics: %w", err) + } + defer func() { _ = rows.Close() }() + + for rows.Next() { + var recordID, topicID string + if err := rows.Scan(&recordID, &topicID); err != nil { + return nil, fmt.Errorf("failed to read a record topic: %w", err) + } + out[recordID] = append(out[recordID], topicID) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("failed to read record topics: %w", err) + } + + return out, nil } // ResolveTopicNames разрешает темы названиями **одним запросом на страницу**, а -// не по запросу на запись: страница в сотню записей иначе стоила бы сотни -// обращений к хранилищу. +// не по запросу на запись. // // Названия, а не идентификаторы, потому что экран показывает названия: отдай мы // ссылки, форму ответа переделывала бы задача языковой модели — ровно то, ради // чего контракт согласуется один раз. +// +// Сужение владельцем стоит и здесь: словарь тем свой у каждого человека — пара +// «владелец и название» уникальна, — и разрешение без сужения отдало бы название +// чужой темы, как только темы начнёт писать языковая модель. func (repo *AudioRecordRepository) ResolveTopicNames(ownerID string, ids []string) (map[string]string, error) { out := map[string]string{} if len(ids) == 0 || ownerID == "" { return out, nil } - values := make([]any, 0, len(ids)) + placeholders := make([]string, 0, len(ids)) + args := []any{ownerID} for _, id := range ids { - values = append(values, id) + placeholders = append(placeholders, "?") + args = append(args, id) } - // Сужение владельцем стоит и здесь: словарь тем свой у каждого человека — - // пара «владелец и название» уникальна, — и разрешение без сужения отдало бы - // название чужой темы, как только темы начнёт писать языковая модель. - records := []*core.Record{} - err := repo.app.RecordQuery(migrations.TopicsCollection). - AndWhere(dbx.HashExp{"owner": ownerID}). - AndWhere(dbx.In("id", values...)). - All(&records) + query := "SELECT id, name FROM topics WHERE owner_id = ? AND id IN (" + + strings.Join(placeholders, ", ") + ")" + + rows, err := repo.db.Reader().QueryContext(context.Background(), query, args...) if err != nil { return nil, fmt.Errorf("failed to resolve topics: %w", err) } + defer func() { _ = rows.Close() }() - for _, record := range records { - out[record.Id] = record.GetString("name") + for rows.Next() { + var id, name string + if err := rows.Scan(&id, &name); err != nil { + return nil, fmt.Errorf("failed to read a topic: %w", err) + } + out[id] = name } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("failed to resolve topics: %w", err) + } + return out, nil } diff --git a/internal/adapter/repo/sqlite/record_mapping.go b/internal/adapter/repo/sqlite/record_mapping.go new file mode 100644 index 0000000..b92c910 --- /dev/null +++ b/internal/adapter/repo/sqlite/record_mapping.go @@ -0,0 +1,173 @@ +package sqlite + +import ( + "database/sql" + + "git.vakhrushev.me/av/transcriber/internal/entity" +) + +// Отображение аудиозаписи в строку базы и обратно живёт одним местом. +// +// Работает оно **по имени колонки**: именованные параметры запроса и место +// назначения, найденное по имени. Причина в самой сущности — у аудиозаписи поля +// одного типа идут длинным непрерывным рядом, и ссылки на файл, на структуру +// реплик, на два вида текста и на попытку распознавания стоят в нём подряд. +// Позиционный список дал бы сдвиг на одно поле, который компилируется молча и +// кладёт идентификатор файла в колонку текста. По имени такого сдвига не +// существует вовсе: лишнее имя или недостающее — отказ запроса, а не тихая +// подмена значения. +// +// Инвариант проекта о колонках записи эта форма не снимает: колонку по-прежнему +// можно забыть в отображении или в шаге схемы, и сверку держат правила +// `internal/archrules`. + +// writeOwnedByPipeline — колонки, которыми распоряжается конвейер. +// +// Разрез нужен потому, что шаг держит запись снимком с момента захвата и до +// своего сохранения, а это часы. Всё, что владелец правил за это время, +// безусловная запись снимка стёрла бы молча: ни строки в журнале, ни отказа +// тому, кто правил. Владелец, заголовок, краткое описание, имя файла +// отправителя, длительность, размер и темы не трогаются вовсе. +func writeOwnedByPipeline(r *entity.AudioRecord) map[string]any { + return map[string]any{ + "state": r.State, + "state_entered_at": formatTime(r.StateEnteredAt), + "halted_at": timeValue(r.HaltedAt), + "halt_reason": stringValue(r.HaltReason), + "error_text": stringValue(r.ErrorText), + "acquisition_id": stringValue(r.AcquisitionID), + "acquire_expires_at": timeValue(r.AcquireExpiresAt), + "delay_time": timeValue(r.DelayTime), + "attempts": r.Attempts, + "original_file_id": stringValue(r.OriginalFileID), + "normalized_file_id": stringValue(r.NormalizedFileID), + "transcript_text_id": stringValue(r.TranscriptTextID), + "literary_text_id": stringValue(r.LiteraryTextID), + "structure_id": stringValue(r.StructureID), + "recognition_id": stringValue(r.RecognitionID), + "updated_at": formatTime(r.UpdatedAt), + } +} + +// writeRecord — запись целиком: это заведение, и спорить за поля здесь не с кем. +// +// Колонки заведения перечислены той же формой, что и колонки конвейера, — картой +// «имя колонки в значение»: сверка колонок в `internal/archrules` читает именно +// её, и колонка, положенная присваиванием мимо карты, выпала бы из-под правила +// молча. +func writeRecord(r *entity.AudioRecord) map[string]any { + values := writeOwnedByPipeline(r) + + own := map[string]any{ + "id": r.Id, + // Владелец кладётся только здесь, при заведении. В перечне конвейера его + // нет намеренно: конвейер владельца не назначает и не меняет, а снимок + // шага, записанный поверх, стёр бы его молча. + "owner_id": r.OwnerID, + "title": stringValue(r.Title), + "brief": stringValue(r.Brief), + // Имя файла отправителя, длительность и размер кладёт приём и только он: + // это снимок принятого, и конвейер его не пересчитывает. + "original_filename": stringValue(r.OriginalFilename), + "duration_ms": numberValue(r.DurationMs), + "size_bytes": numberValue(r.SizeBytes), + "created_at": formatTime(r.CreatedAt), + } + for name, value := range own { + values[name] = value + } + + return values +} + +// recordRow — сырые значения одной строки аудиозаписи. +type recordRow struct { + id string + ownerID string + title sql.NullString + brief sql.NullString + originalFilename sql.NullString + durationMs int64 + sizeBytes int64 + state string + stateEnteredAt string + haltedAt sql.NullString + haltReason sql.NullString + errorText sql.NullString + acquisitionID sql.NullString + acquireExpiresAt sql.NullString + delayTime sql.NullString + attempts int + originalFileID sql.NullString + normalizedFileID sql.NullString + transcriptTextID sql.NullString + literaryTextID sql.NullString + structureID sql.NullString + recognitionID sql.NullString + createdAt string + updatedAt string +} + +// readRecordColumns — куда кладётся каждая колонка при чтении. +// +// Перечень колонок выборки собирается из этой же карты, поэтому расхождению +// между тем, что спрошено, и тем, куда оно ляжет, взяться неоткуда. +func readRecordColumns(row *recordRow) map[string]any { + return map[string]any{ + "id": &row.id, + "owner_id": &row.ownerID, + "title": &row.title, + "brief": &row.brief, + "original_filename": &row.originalFilename, + "duration_ms": &row.durationMs, + "size_bytes": &row.sizeBytes, + "state": &row.state, + "state_entered_at": &row.stateEnteredAt, + "halted_at": &row.haltedAt, + "halt_reason": &row.haltReason, + "error_text": &row.errorText, + "acquisition_id": &row.acquisitionID, + "acquire_expires_at": &row.acquireExpiresAt, + "delay_time": &row.delayTime, + "attempts": &row.attempts, + "original_file_id": &row.originalFileID, + "normalized_file_id": &row.normalizedFileID, + "transcript_text_id": &row.transcriptTextID, + "literary_text_id": &row.literaryTextID, + "structure_id": &row.structureID, + "recognition_id": &row.recognitionID, + "created_at": &row.createdAt, + "updated_at": &row.updatedAt, + } +} + +// rowToAudioRecord собирает доменную запись из прочитанной строки. +func rowToAudioRecord(row *recordRow) *entity.AudioRecord { + return &entity.AudioRecord{ + Id: row.id, + OwnerID: row.ownerID, + Title: stringOf(row.title), + Brief: stringOf(row.brief), + OriginalFilename: stringOf(row.originalFilename), + DurationMs: numberOf(row.durationMs), + SizeBytes: numberOf(row.sizeBytes), + State: row.state, + StateEnteredAt: requiredTimeOf(row.stateEnteredAt), + HaltedAt: timeOf(row.haltedAt), + HaltReason: stringOf(row.haltReason), + ErrorText: stringOf(row.errorText), + AcquisitionID: stringOf(row.acquisitionID), + AcquireExpiresAt: timeOf(row.acquireExpiresAt), + DelayTime: timeOf(row.delayTime), + Attempts: row.attempts, + OriginalFileID: stringOf(row.originalFileID), + NormalizedFileID: stringOf(row.normalizedFileID), + TranscriptTextID: stringOf(row.transcriptTextID), + LiteraryTextID: stringOf(row.literaryTextID), + StructureID: stringOf(row.structureID), + RecognitionID: stringOf(row.recognitionID), + TopicIDs: []string{}, + CreatedAt: requiredTimeOf(row.createdAt), + UpdatedAt: requiredTimeOf(row.updatedAt), + } +} diff --git a/internal/adapter/repo/sqlite/record_repo.go b/internal/adapter/repo/sqlite/record_repo.go new file mode 100644 index 0000000..4137ca1 --- /dev/null +++ b/internal/adapter/repo/sqlite/record_repo.go @@ -0,0 +1,237 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + + "git.vakhrushev.me/av/transcriber/internal/clock" + "git.vakhrushev.me/av/transcriber/internal/contract" + "git.vakhrushev.me/av/transcriber/internal/entity" + "git.vakhrushev.me/av/transcriber/internal/ident" +) + +// recordsTable — таблица аудиозаписей. +const recordsTable = "audio_records" + +type AudioRecordRepository struct { + db *DB +} + +func NewAudioRecordRepository(db *DB) *AudioRecordRepository { + return &AudioRecordRepository{db: db} +} + +// Create заводит аудиозапись. +// +// Идентификатор приходит готовым, когда его назначил вызывающий: приём знает его +// раньше, чем кладёт файл, — копии записи лежат подкаталогом под этим самым +// идентификатором. Пустой заполняется единой точкой выдачи. +func (repo *AudioRecordRepository) Create(r *entity.AudioRecord) error { + if r.Id == "" { + r.Id = ident.New() + } + now := clock.Now() + if r.CreatedAt.IsZero() { + r.CreatedAt = now + } + r.UpdatedAt = now + if r.StateEnteredAt.IsZero() { + r.StateEnteredAt = now + } + + query, args := insertSQL(recordsTable, writeRecord(r)) + if _, err := repo.db.Writer().ExecContext(context.Background(), query, args...); err != nil { + return fmt.Errorf("failed to insert audio record: %w", err) + } + + return nil +} + +// Save сохраняет запись, захват которой держит holder. +// +// Сверка захвата и запись идут **одним запросом**: значение признака стоит +// условием правки, поэтому между проверкой и записью не остаётся окна. Сверяется +// именно значение, а не занятость записи — захват, перевыданный другому по +// протуханию срока или после того, как человек вернул запись в работу, обязан +// обратить запись первого в отказ; условие по непустоте признака пропустило бы +// обоих, и два шага записали бы в одну запись по очереди, портя её результат. +// +// Пустой holder снимает условность и в конвейере не употребляется: все его шаги +// получают признак захвата от FindAndAcquire. +func (repo *AudioRecordRepository) Save(r *entity.AudioRecord, holder string) error { + r.UpdatedAt = clock.Now() + + where := "id = :id" + whereArgs := []any{sql.Named("id", r.Id)} + if holder != "" { + where += " AND acquisition_id = :holder" + whereArgs = append(whereArgs, sql.Named("holder", holder)) + } + + query, args := updateSQL(recordsTable, writeOwnedByPipeline(r), where, whereArgs) + + result, err := repo.db.Writer().ExecContext(context.Background(), query, args...) + if err != nil { + return fmt.Errorf("failed to update audio record: %w", err) + } + + affected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to read the outcome of an audio record update: %w", err) + } + if affected > 0 { + return nil + } + + // Строк не тронуто по одной из двух причин, и различить их можно только + // чтением: записи нет вовсе либо захват достался другому. Разница несущая — + // первая означает поломку, вторая штатный исход шага, потерявшего запись. + if _, err := repo.Get(r.Id); err != nil { + return err + } + + return &contract.LostAcquisitionError{JobID: r.Id} +} + +// GetByID отдаёт запись, только если её владелец — ownerID. +// +// Чужая запись и несуществующая дают одну и ту же ошибку: по разнице ответов +// иначе перебирается список заведённых записей, а идентификатор записи и есть +// то, что разграничение прячет. +// +// Пустой ownerID отсекается **до** чтения и не совпадает ни с чем. Правило не +// стало избыточным с обязательностью колонки: схема запрещает **заводить** ничью +// запись, а здесь запрещено **спрашивать** ничьим именем — иначе вызывающий без +// учётной записи получил бы выборку вместо отказа. +func (repo *AudioRecordRepository) GetByID(id, ownerID string) (*entity.AudioRecord, error) { + if ownerID == "" { + return nil, &contract.JobNotFoundError{Message: "record not found"} + } + + record, err := repo.read("id = ? AND owner_id = ?", id, ownerID) + if err != nil { + return nil, err + } + return record, nil +} + +// Get отдаёт запись без сужения владельцем: им пользуется конвейер, чья выборка +// владельцем не сужается. +func (repo *AudioRecordRepository) Get(id string) (*entity.AudioRecord, error) { + return repo.read("id = ?", id) +} + +// read читает одну запись по условию. +func (repo *AudioRecordRepository) read(where string, args ...any) (*entity.AudioRecord, error) { + row := &recordRow{} + columns, targets := selectList(readRecordColumns(row), "") + + query := "SELECT " + columns + " FROM " + recordsTable + " WHERE " + where + " LIMIT 1" + if err := repo.db.Reader().QueryRowContext(context.Background(), query, args...).Scan(targets...); err != nil { + // «Такой записи нет» переводится в доменную ошибку здесь, у источника, + // как велит конвенция об ошибках. Иначе исходы, которые разграничение + // обязано сделать неразличимыми, разъезжаются: чужая запись даёт + // доменную ошибку, а несуществующая — отказ базы, неотличимый от + // настоящей аварии хранилища. + if errors.Is(err, sql.ErrNoRows) { + return nil, &contract.JobNotFoundError{Message: "record not found"} + } + return nil, fmt.Errorf("failed to get audio record: %w", err) + } + + record := rowToAudioRecord(row) + + topics, err := repo.topicsOf([]string{record.Id}) + if err != nil { + return nil, err + } + record.TopicIDs = topics[record.Id] + + return record, nil +} + +// FindAndAcquire забирает пригодную к работе запись одним неделимым шагом: +// выбор подходящей и пометка её захваченной идут вместе, одним оператором с +// возвратом. +// +// Возвращается **идентификатор и признак этого захвата**, а не перечень колонок. +// Колонки шаг читает обычным чтением: иначе всякая новая колонка записи попадала +// бы под инвариант проекта о колонках очереди, а забытая приезжала бы нулевой, и +// первое же сохранение писало бы этот ноль поверх сохранённого значения. +// +// Срок протухания захвата приезжает **с рубежом**, а не с воркером: воркер не +// привязан к шагу и не знает заранее, что вытянет. Перечень рубежей и их сроков +// приходит одним дескриптором — перечислять их порознь нельзя: рубеж, забытый в +// отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту +// проекта не пишется в журнал и не считается в метрику. +// +// Запрос идёт по **пишущему** соединению: он читает состояние, которое сам же +// меняет, а транзакцию, начатую на читающем соединении, SQLite до пишущей не +// повышает. +func (repo *AudioRecordRepository) FindAndAcquire(stages []entity.Stage) (*contract.AcquiredRecord, error) { + if len(stages) == 0 { + return nil, &contract.JobNotFoundError{Message: "no working stages declared"} + } + + now := clock.Now() + holder := ident.New() + + args := []any{ + sql.Named("holder", holder), + sql.Named("now", formatTime(now)), + } + + // Срок протухания у каждого рубежа свой, поэтому он выбирается по рубежу + // самой записи прямо в запросе: воркер, ещё не знающий, что вытянет, + // подставить его не может. + var expiry strings.Builder + expiry.WriteString("CASE state") + states := make([]string, 0, len(stages)) + for i, stage := range stages { + stateKey := fmt.Sprintf("state%d", i) + expiryKey := fmt.Sprintf("expiry%d", i) + + fmt.Fprintf(&expiry, " WHEN :%s THEN :%s", stateKey, expiryKey) + args = append(args, + sql.Named(stateKey, stage.Name), + sql.Named(expiryKey, formatTime(now.Add(stage.AcquireTimeout))), + ) + states = append(states, ":"+stateKey) + } + expiry.WriteString(" END") + + // Порядок выборки определён однозначно: время заведения плюс ключ записи. + // Сравнения по неуникальному значению для этого мало — порядок обработки + // стал бы невоспроизводимым. + query := ` + UPDATE ` + recordsTable + ` + SET acquisition_id = :holder, + acquire_expires_at = ` + expiry.String() + `, + attempts = attempts + 1, + updated_at = :now + WHERE id = ( + SELECT id FROM ` + recordsTable + ` + WHERE state IN (` + strings.Join(states, ", ") + `) + AND halted_at IS NULL + AND (delay_time IS NULL OR delay_time < :now) + AND (acquisition_id IS NULL + OR acquire_expires_at IS NULL + OR acquire_expires_at < :now) + ORDER BY created_at, id + LIMIT 1 + ) + RETURNING id` + + var id string + if err := repo.db.Writer().QueryRowContext(context.Background(), query, args...).Scan(&id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, &contract.JobNotFoundError{Message: "no record is ready for work"} + } + return nil, fmt.Errorf("failed to acquire an audio record: %w", err) + } + + return &contract.AcquiredRecord{ID: id, Holder: holder}, nil +} diff --git a/internal/adapter/repo/sqlite/repo_test.go b/internal/adapter/repo/sqlite/repo_test.go new file mode 100644 index 0000000..eba9c85 --- /dev/null +++ b/internal/adapter/repo/sqlite/repo_test.go @@ -0,0 +1,626 @@ +package sqlite + +import ( + "context" + "log/slog" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.vakhrushev.me/av/transcriber/internal/clock" + "git.vakhrushev.me/av/transcriber/internal/contract" + "git.vakhrushev.me/av/transcriber/internal/entity" + "git.vakhrushev.me/av/transcriber/internal/ident" +) + +// newWorkingRecord заводит запись, пригодную к захвату. +func newWorkingRecord(t *testing.T, db *DB, owner string) *entity.AudioRecord { + t.Helper() + + record := &entity.AudioRecord{ + Id: ident.New(), + OwnerID: owner, + State: entity.StateUploaded, + StateEnteredAt: clock.Now(), + } + require.NoError(t, NewAudioRecordRepository(db).Create(record)) + + return record +} + +// Ничьей записи не бывает, и держит это схема: колонка владельца объявлена +// связью с учётной записью и пустого значения не принимает. +func TestRecordWithoutOwnerIsRejected(t *testing.T) { + db, _, _ := newTestDB(t) + records := NewAudioRecordRepository(db) + + t.Run("пустой владелец", func(t *testing.T) { + err := records.Create(&entity.AudioRecord{ + Id: ident.New(), State: entity.StateUploaded, StateEnteredAt: clock.Now(), + }) + assert.Error(t, err, "запись с пустым владельцем сохранилась") + }) + + t.Run("владельца нет среди учётных записей", func(t *testing.T) { + err := records.Create(&entity.AudioRecord{ + Id: ident.New(), OwnerID: ident.New(), + State: entity.StateUploaded, StateEnteredAt: clock.Now(), + }) + assert.Error(t, err, "запись с выдуманным владельцем сохранилась") + }) + + t.Run("запросом к базе тоже", func(t *testing.T) { + now := clock.Now().Format(timeLayout) + _, err := db.Writer().ExecContext(context.Background(), + `INSERT INTO audio_records + (id, owner_id, duration_ms, size_bytes, state, state_entered_at, created_at, updated_at) + VALUES (?, '', 0, 0, ?, ?, ?, ?)`, + ident.New(), entity.StateUploaded, now, now, now, + ) + assert.Error(t, err, "ничья запись завелась запросом к базе") + }) +} + +// Файл без владельца не сохраняется — тем же правилом схемы. +func TestFileWithoutOwnerIsRejected(t *testing.T) { + db, store, _ := newTestDB(t) + files := NewFileRepository(db, store) + + work, err := files.Stage(".mp3", strings.NewReader("запись")) + require.NoError(t, err) + defer func() { require.NoError(t, work.Close()) }() + + _, err = files.Create(ident.New(), "voice.mp3", work, contract.FileMeta{Format: "mp3"}, "") + assert.Error(t, err, "файл с пустым владельцем сохранился") +} + +// Учётная запись, у которой остались аудиозаписи, файлы либо темы, не удаляется: +// запрет держит схема обязательной связью, а не проверка вызывающего. +func TestAccountWithBelongingsIsNotDeletable(t *testing.T) { + db, store, _ := newTestDB(t) + + t.Run("с аудиозаписями", func(t *testing.T) { + owner := newOwner(t, db) + newWorkingRecord(t, db, owner) + + _, err := db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner) + assert.Error(t, err, "учётная запись с аудиозаписями удалилась") + }) + + t.Run("с одними файлами", func(t *testing.T) { + owner := newOwner(t, db) + files := NewFileRepository(db, store) + work, err := files.Stage(".mp3", strings.NewReader("запись")) + require.NoError(t, err) + defer func() { require.NoError(t, work.Close()) }() + _, err = files.Create(ident.New(), "voice.mp3", work, contract.FileMeta{Format: "mp3"}, owner) + require.NoError(t, err) + + _, err = db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner) + assert.Error(t, err, "учётная запись с файлами удалилась") + }) + + t.Run("с одними темами", func(t *testing.T) { + owner := newOwner(t, db) + now := clock.Now().Format(timeLayout) + _, err := db.Writer().ExecContext(context.Background(), + "INSERT INTO topics (id, owner_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + ident.New(), owner, "тема", now, now, + ) + require.NoError(t, err) + + _, err = db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner) + assert.Error(t, err, "учётная запись с темами удалилась") + }) + + t.Run("пустая удаляется", func(t *testing.T) { + owner := newOwner(t, db) + + _, err := db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner) + assert.NoError(t, err, "учётная запись без принадлежностей не удалилась") + }) +} + +// У записи не больше пяти тем, и держит это схема. +func TestRecordTopicsAreCapped(t *testing.T) { + db, _, _ := newTestDB(t) + + owner := newOwner(t, db) + record := newWorkingRecord(t, db, owner) + + now := clock.Now().Format(timeLayout) + for i := range entity.MaxTopicsPerRecord + 1 { + topicID := ident.New() + _, err := db.Writer().ExecContext(context.Background(), + "INSERT INTO topics (id, owner_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + topicID, owner, "тема-"+topicID, now, now, + ) + require.NoError(t, err) + + _, err = db.Writer().ExecContext(context.Background(), + "INSERT INTO record_topics (record_id, topic_id) VALUES (?, ?)", record.Id, topicID, + ) + if i < entity.MaxTopicsPerRecord { + require.NoError(t, err, "тема %d не назначилась", i) + continue + } + assert.Error(t, err, "шестая тема назначилась") + } +} + +// TestAcquireGivesRecordToExactlyOne — **критерий приёмки**: захват неделим. +// +// Одна пригодная запись, несколько захватов разом: запись достаётся ровно +// одному, остальные получают признак «работы сейчас нет». +func TestAcquireGivesRecordToExactlyOne(t *testing.T) { + db, _, _ := newTestDB(t) + + owner := newOwner(t, db) + record := newWorkingRecord(t, db, owner) + + records := NewAudioRecordRepository(db) + + const racers = 8 + + var ( + mu sync.Mutex + acquired []*contract.AcquiredRecord + empty int + ) + + start := make(chan struct{}) + var wg sync.WaitGroup + for range racers { + wg.Add(1) + go func() { + defer wg.Done() + <-start + + got, err := records.FindAndAcquire(entity.WorkingStages()) + + mu.Lock() + defer mu.Unlock() + + var missing *contract.JobNotFoundError + switch { + case err == nil: + acquired = append(acquired, got) + case assert.ErrorAs(t, err, &missing): + empty++ + } + }() + } + close(start) + wg.Wait() + + require.Len(t, acquired, 1, "запись досталась не одному захвату") + assert.Equal(t, racers-1, empty, "остальные получили не признак «работы нет»") + assert.Equal(t, record.Id, acquired[0].ID) + assert.NotEmpty(t, acquired[0].Holder, "захват не отдал своего признака") +} + +// Результат пишет только держатель захвата, и держатель узнаётся **значением** +// признака, а не занятостью записи. +func TestSaveIsConditionalOnHolderValue(t *testing.T) { + db, _, _ := newTestDB(t) + + owner := newOwner(t, db) + record := newWorkingRecord(t, db, owner) + records := NewAudioRecordRepository(db) + + first, err := records.FindAndAcquire(entity.WorkingStages()) + require.NoError(t, err) + + // Захват уходит другому: срок протухает, и запись достаётся следующему. + _, err = db.Writer().ExecContext(context.Background(), + "UPDATE audio_records SET acquire_expires_at = ? WHERE id = ?", + clock.Now().Add(-time.Hour).Format(timeLayout), record.Id, + ) + require.NoError(t, err) + + second, err := records.FindAndAcquire(entity.WorkingStages()) + require.NoError(t, err) + assert.NotEqual(t, first.Holder, second.Holder, "признак перевыданного захвата совпал с прежним") + + // Прежний держатель пишет свой результат — и не пишет. + stale, err := records.Get(record.Id) + require.NoError(t, err) + stale.MoveToState(entity.StateNormalized) + + err = records.Save(stale, first.Holder) + + var lost *contract.LostAcquisitionError + require.ErrorAs(t, err, &lost, "шаг, потерявший захват, записал результат") + + after, err := records.Get(record.Id) + require.NoError(t, err) + assert.Equal(t, entity.StateUploaded, after.State, "чужая запись изменила состояние") +} + +// Правка владельца переживает сохранение шага: конвейер пишет только те поля, +// которыми распоряжается сам. +func TestPipelineSaveKeepsOwnerFields(t *testing.T) { + db, _, _ := newTestDB(t) + + owner := newOwner(t, db) + record := newWorkingRecord(t, db, owner) + records := NewAudioRecordRepository(db) + + acquired, err := records.FindAndAcquire(entity.WorkingStages()) + require.NoError(t, err) + + held, err := records.Get(record.Id) + require.NoError(t, err) + + // Владелец за это время правит поле, которого шаг не касается. + _, err = db.Writer().ExecContext(context.Background(), "UPDATE audio_records SET title = ? WHERE id = ?", "название", record.Id) + require.NoError(t, err) + + held.MoveToState(entity.StateNormalized) + require.NoError(t, records.Save(held, acquired.Holder)) + + after, err := records.Get(record.Id) + require.NoError(t, err) + assert.Equal(t, entity.StateNormalized, after.State, "результат шага не записан") + require.NotNil(t, after.Title) + assert.Equal(t, "название", *after.Title, "правка владельца стёрта снимком шага") +} + +// Составная операция, которая читает и следом пишет, идёт по пишущему +// соединению и по занятости базы не отказывает — сколько бы потоков её ни вело. +func TestComposedOperationsDoNotFailOnBusyDatabase(t *testing.T) { + db, _, _ := newTestDB(t) + + owner := newOwner(t, db) + record := newWorkingRecord(t, db, owner) + texts := NewTextRepository(db) + + var wg sync.WaitGroup + start := make(chan struct{}) + for i := range 8 { + wg.Add(1) + go func() { + defer wg.Done() + <-start + _, err := texts.Put(record.Id, entity.TextKindTranscript, "разбор") + assert.NoError(t, err, "поток %d отказал по занятости базы", i) + }() + } + close(start) + wg.Wait() + + var count int + require.NoError(t, db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM texts").Scan(&count)) + assert.Equal(t, 1, count, "восемь потоков завели больше одной строки текста") +} + +// Пустая замена не стирает ни сохранённый текст, ни сохранённый ответ +// провайдера: повторный опрос вправе вернуть пустое, и безусловная замена +// стёрла бы расшифровку живого человека без следа. +func TestEmptyReplacementKeepsStoredResult(t *testing.T) { + db, store, _ := newTestDB(t) + + owner := newOwner(t, db) + record := newWorkingRecord(t, db, owner) + + texts := NewTextRepository(db) + stored, err := texts.Put(record.Id, entity.TextKindTranscript, "живая расшифровка") + require.NoError(t, err) + + again, err := texts.Put(record.Id, entity.TextKindTranscript, "") + require.NoError(t, err) + assert.Equal(t, stored.Id, again.Id, "строка та же") + + read, err := texts.GetByID(stored.Id) + require.NoError(t, err) + assert.Equal(t, "живая расшифровка", read.Contents, "пустое стёрло сохранённую расшифровку") + + structures := NewStructureRepository(db) + first, err := structures.Put(record.Id, entity.StructureVersion, + []entity.Replica{{StartMs: 0, EndMs: 10, Text: "реплика"}}) + require.NoError(t, err) + + _, err = structures.Put(record.Id, entity.StructureVersion, nil) + require.NoError(t, err) + + structure, err := structures.GetByID(first.Id) + require.NoError(t, err) + assert.Len(t, structure.Replicas, 1, "пустой разбор стёр сохранённые реплики") + + recognitions := NewRecognitionRepository(db, store) + attempt := &entity.Recognition{RecordID: record.Id, Provider: "проверка"} + require.NoError(t, recognitions.Create(attempt)) + require.NoError(t, recognitions.Finish(attempt.Id, []byte("ответ провайдера"))) + require.NoError(t, recognitions.Finish(attempt.Id, nil)) + + raw, err := recognitions.ReadRaw(attempt.Id) + require.NoError(t, err) + assert.Equal(t, "ответ провайдера", string(raw), "пустое стёрло сохранённый ответ провайдера") +} + +// Сохранённый ответ провайдера лежит третьим файлом в подкаталоге записи, и шаг +// опроса читает строку попытки без него. +func TestProviderPayloadLivesInRecordDirectory(t *testing.T) { + db, store, _ := newTestDB(t) + + owner := newOwner(t, db) + record := newWorkingRecord(t, db, owner) + + recognitions := NewRecognitionRepository(db, store) + attempt := &entity.Recognition{RecordID: record.Id, Provider: "проверка"} + require.NoError(t, recognitions.Create(attempt)) + require.NoError(t, recognitions.Finish(attempt.Id, []byte("полный ответ провайдера"))) + + // Строка попытки читается без ответа: он не колонка. + read, err := recognitions.GetByID(attempt.Id) + require.NoError(t, err) + require.NotNil(t, read.FinishedAt) + + file, err := store.Open(record.Id, attempt.Id+payloadSuffix) + require.NoError(t, err) + require.NoError(t, file.Close()) +} + +// Два одновременных первых обращения одним значением дают ровно одну учётную +// запись: уникальность держит схема, а не порядок обращений. +func TestConcurrentFirstRequestsGiveOneAccount(t *testing.T) { + db, _, _ := newTestDB(t) + + users := NewUserRepository(db) + login := ident.New() + + var ( + mu sync.Mutex + ids = map[string]bool{} + ) + + start := make(chan struct{}) + var wg sync.WaitGroup + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + <-start + + account, _, err := users.EnsureUser(contract.Identity{Login: login}) + if assert.NoError(t, err) { + mu.Lock() + ids[account.ID] = true + mu.Unlock() + } + }() + } + close(start) + wg.Wait() + + assert.Len(t, ids, 1, "одновременные первые обращения дали разные учётные записи") + + var rows int + require.NoError(t, db.Reader(). + QueryRowContext(context.Background(), + "SELECT COUNT(*) FROM users WHERE provider_login = ?", login).Scan(&rows)) + assert.Equal(t, 1, rows, "в таблице пользователей больше одной строки") +} + +// Занятая почта не мешает завести запись: она необязательна и ключом не служит. +func TestBusyEmailDoesNotBlockAccount(t *testing.T) { + db, _, _ := newTestDB(t) + + users := NewUserRepository(db) + + first, _, err := users.EnsureUser(contract.Identity{Login: "one", Email: "shared@example.com"}) + require.NoError(t, err) + + second, created, err := users.EnsureUser(contract.Identity{Login: "two", Email: "shared@example.com"}) + require.NoError(t, err) + require.True(t, created) + assert.NotEqual(t, first.ID, second.ID) + + var email string + require.NoError(t, db.Reader(). + QueryRowContext(context.Background(), + "SELECT email FROM users WHERE id = ?", second.ID).Scan(&email)) + assert.Empty(t, email, "вторая запись завелась с чужой почтой") +} + +// Найденную запись повторное обращение не переписывает: иначе правка имени у +// провайдера меняла бы карточку человека молча, посреди его работы. +func TestSecondRequestDoesNotRewriteAccount(t *testing.T) { + db, _, _ := newTestDB(t) + + users := NewUserRepository(db) + + first, created, err := users.EnsureUser(contract.Identity{Login: "person", Name: "Первое имя"}) + require.NoError(t, err) + require.True(t, created) + + again, created, err := users.EnsureUser(contract.Identity{Login: "person", Name: "Второе имя"}) + require.NoError(t, err) + assert.False(t, created) + assert.Equal(t, first.ID, again.ID) + assert.Equal(t, "Первое имя", again.Name, "имя переписано вторым обращением") +} + +// Узнавание известного не берёт пишущего соединения: поиск идёт читающим пулом, +// и занятый писатель его не держит. +// +// Проверка нужна потому, что цена ошибки здесь не видна ни отказом, ни строкой в +// журнале. Слой узнавания одет на весь корень приложения, поэтому пишущая +// транзакция, взятая до поиска, досталась бы всему узнанному потоку — опросу +// карточки раз в четыре секунды и каждому запросу диапазона при проигрывании, — +// и встала бы в очередь к единственному пишущему соединению. Замером триажа +// такое ожидание доходило до сотен миллисекунд и **отказом не кончалось**: +// очередь к соединению ожиданием занятой базы не ограничена. +func TestKnownAccountDoesNotTakeWriter(t *testing.T) { + db, _, _ := newTestDB(t) + + users := NewUserRepository(db) + login := ident.New() + + first, created, err := users.EnsureUser(contract.Identity{Login: login}) + require.NoError(t, err) + require.True(t, created) + + // Писателя занимает открытая транзакция: соединение у пишущего пула одно, + // поэтому пока она держится, второй транзакции не начаться. + tx, err := db.Writer().BeginTx(context.Background(), nil) + require.NoError(t, err) + defer func() { _ = tx.Rollback() }() + + type answer struct { + account *contract.UserAccount + created bool + err error + took time.Duration + } + done := make(chan answer, 1) + go func() { + started := time.Now() + account, created, err := users.EnsureUser(contract.Identity{Login: login}) + done <- answer{account: account, created: created, err: err, took: time.Since(started)} + }() + + // Предел взят с запасом: запрос читающим пулом идёт по месту, а ждущее + // узнавание не дождётся вовсе — транзакцию отпускают уже после проверки. + const limit = time.Second + + select { + case got := <-done: + require.NoError(t, got.err) + assert.False(t, got.created, "узнавание известного завело вторую учётную запись") + assert.Equal(t, first.ID, got.account.ID) + t.Logf("узнавание известного заняло %s при занятом писателе", got.took) + case <-time.After(limit): + t.Errorf("узнавание известного ждёт писателя дольше %s", limit) + } +} + +// Негодный логин учётной записи не заводит: пустой заголовок прокси шлёт штатно +// там, где никого не назвал. +func TestUnacceptableLoginCreatesNothing(t *testing.T) { + db, _, _ := newTestDB(t) + + users := NewUserRepository(db) + + for name, login := range map[string]string{ + "пустой": "", + "одни пробелы": " ", + "управляющий знак": "ali\x00ce", + "длиннее предела": strings.Repeat("a", entity.MaxProviderLoginLength+1), + } { + t.Run(name, func(t *testing.T) { + _, _, err := users.EnsureUser(contract.Identity{Login: login}) + assert.ErrorIs(t, err, contract.ErrLoginNotAcceptable) + }) + } + + var rows int + require.NoError(t, db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM users").Scan(&rows)) + assert.Equal(t, 0, rows, "негодный логин завёл учётную запись") +} + +// TestResumeReleasesEveryGuard — то, что делает подкоманда возврата в работу. +// +// Возврат идёт **через домен**: тот, кто его делает, называет запись, а поля +// сбрасывает домен одним действием. Перечень назван целиком, потому что забытое +// поле не даёт ни отказа, ни строки в журнале: оставленный признак захвата +// держит запись занятой до протухания срока, оставленное время входа в рубеж +// останавливает её снова первым же захватом, а оставленные отказы и пауза +// откладывают первый прогон на накопленный срок. +func TestResumeReleasesEveryGuard(t *testing.T) { + db, _, _ := newTestDB(t) + + owner := newOwner(t, db) + record := newWorkingRecord(t, db, owner) + records := NewAudioRecordRepository(db) + events := NewRecordEventRepository(db) + + // Так выглядит запись, остановленная после долгих отказов: захват на ней + // стоит, срок его далеко впереди, отказы накоплены, пауза назначена, а в + // рубеже она простояла дольше предела. + _, err := db.Writer().ExecContext(context.Background(), ` + UPDATE audio_records + SET halted_at = ?, halt_reason = ?, error_text = ?, + acquisition_id = ?, acquire_expires_at = ?, + delay_time = ?, attempts = 7, state_entered_at = ? + WHERE id = ?`, + clock.Now().Format(timeLayout), entity.HaltReasonAttempts, "исчерпаны отказы", + ident.New(), clock.Now().Add(8*time.Hour).Format(timeLayout), + clock.Now().Add(time.Hour).Format(timeLayout), + clock.Now().Add(-48*time.Hour).Format(timeLayout), + record.Id, + ) + require.NoError(t, err) + + // Остановленная запись захвату не выдаётся. + _, err = records.FindAndAcquire(entity.WorkingStages()) + var missing *contract.JobNotFoundError + require.ErrorAs(t, err, &missing, "остановленная запись досталась захвату") + + halted, err := records.Get(record.Id) + require.NoError(t, err) + + halted.Resume() + require.NoError(t, records.Save(halted, "")) + require.NoError(t, events.Append(&entity.RecordEvent{ + RecordID: record.Id, + Origin: entity.EventOriginHuman, + Step: "resume", + Outcome: entity.EventOutcomeResumed, + })) + + after, err := records.Get(record.Id) + require.NoError(t, err) + + assert.False(t, after.IsHalted(), "признак остановки остался") + assert.Nil(t, after.HaltReason, "причина остановки осталась") + assert.Nil(t, after.ErrorText, "машинный текст отказа остался") + assert.Nil(t, after.AcquisitionID, "признак захвата остался") + assert.Nil(t, after.AcquireExpiresAt, "срок протухания захвата остался") + assert.Nil(t, after.DelayTime, "пауза перед повтором осталась") + assert.Equal(t, 0, after.Attempts, "число отказов осталось") + assert.WithinDuration(t, clock.Now(), after.StateEnteredAt, time.Minute, + "время входа в рубеж не сброшено: запись остановится снова первым же захватом") + assert.Equal(t, entity.StateUploaded, after.State, "рубеж не пережил возврата в работу") + + // Ближайший захват выдаёт запись, не дожидаясь протухания прежнего срока. + acquired, err := records.FindAndAcquire(entity.WorkingStages()) + require.NoError(t, err, "возвращённая в работу запись захвату не досталась") + assert.Equal(t, record.Id, acquired.ID) + + // И возврат виден в журнале событий записи — происхождением «человек». + var origin, outcome string + require.NoError(t, db.Reader().QueryRowContext(context.Background(), + "SELECT origin, outcome FROM record_events WHERE record_id = ?", record.Id, + ).Scan(&origin, &outcome)) + assert.Equal(t, entity.EventOriginHuman, origin) + assert.Equal(t, entity.EventOutcomeResumed, outcome) +} + +// Подъём на чистом каталоге данных не оставляет в журнале ни одного отказа: до +// строки о готовности схема приведена целиком. +func TestCleanStartLeavesNoFailureInJournal(t *testing.T) { + dir := t.TempDir() + + journal := &strings.Builder{} + logger := slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug})) + + db, err := Open(dir, testSettings()) + require.NoError(t, err) + defer func() { require.NoError(t, db.Close()) }() + + require.NoError(t, Migrate(context.Background(), db, dir, logger)) + + assert.NotContains(t, journal.String(), "level=ERROR", "подъём оставил отказ в журнале") + assert.NotContains(t, journal.String(), "level=WARN", "подъём оставил предупреждение в журнале") + assert.Contains(t, journal.String(), "Schema migration applied", "накат не отчитался") + + // И хранилище готово принимать записи сразу: ручного шага между подъёмом и + // первым приёмом нет. + owner := newOwner(t, db) + newWorkingRecord(t, db, owner) +} diff --git a/internal/adapter/repo/sqlite/store.go b/internal/adapter/repo/sqlite/store.go new file mode 100644 index 0000000..9099be8 --- /dev/null +++ b/internal/adapter/repo/sqlite/store.go @@ -0,0 +1,170 @@ +package sqlite + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "git.vakhrushev.me/av/transcriber/internal/ident" +) + +// recordsDir — раздел каталога данных, в котором лежат файлы записей. +const recordsDir = "records" + +// tempPrefix — приставка временного имени укладки. Точка в начале уводит такие +// имена из обычного перечисления каталога, а сама приставка отличает +// незавершённую укладку от рабочего имени. +const tempPrefix = ".partial-" + +// Store — файлы записей в каталоге данных. +// +// Раскладка: подкаталог на запись, названный её идентификатором, и в нём копии +// под именами, которые задаёт сервис. Так копии одной записи лежат вместе, а +// запись убирается целиком одним движением — плоский каталог, где копии +// различаются приставкой в имени, обращал бы уборку в перебор по маске. +// +// Имя, данное отправителем, в раскладку не попадает ни одной частью: ни именем +// файла, ни именем каталога. +type Store struct { + root string +} + +// NewStore заводит раздел записей в каталоге данных. +func NewStore(dataDir string) *Store { + return &Store{root: filepath.Join(dataDir, recordsDir)} +} + +// dir — подкаталог одной записи. +func (s *Store) dir(recordID string) string { + return filepath.Join(s.root, recordID) +} + +// path — путь копии. Наружу не отдаётся: путь на диске не идёт ни в журнал, ни +// в ответ, ни в метку метрики. +func (s *Store) path(recordID, name string) string { + return filepath.Join(s.dir(recordID), name) +} + +// Put кладёт содержимое под рабочим именем **атомарно**. +// +// Содержимое пишется во временное имя в том же подкаталоге записи и +// переименовывается в рабочее только после того, как поток дочитан до конца без +// отказа. Временное имя берётся в том же каталоге потому, что переименование в +// его пределах не копирует содержимое и не может оборваться на середине. +// +// Средство обнаружить усечение у сервиса одно, и оно снято намеренно: величины +// записи со строкой файла не сверяются. Усечённая запись поэтому уехала бы в +// конвейер, оплатила распознавание и отдала расшифровку половины как готовый +// результат — атомарная укладка единственное, что этого не допускает. +// +// Отказ источника и отмена посреди потока кончаются одним исходом: временного +// имени не остаётся, рабочего имени не появляется. +func (s *Store) Put(recordID, name string, src io.Reader) (int64, error) { + if err := os.MkdirAll(s.dir(recordID), 0o750); err != nil { + return 0, fmt.Errorf("failed to create the directory of record %s: %w", recordID, causeOf(err)) + } + + temp := s.path(recordID, tempPrefix+ident.New()) + file, err := os.OpenFile(temp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640) + if err != nil { + return 0, fmt.Errorf("failed to open the incoming copy of record %s: %w", recordID, causeOf(err)) + } + + size, copyErr := io.Copy(file, src) + syncErr := file.Sync() + closeErr := file.Close() + if err := errors.Join(copyErr, syncErr, closeErr); err != nil { + _ = os.Remove(temp) + // Путь и имя файла в цепочку не идут, а причина идёт: отказ кончается в + // журнале, журнал уезжает в собранные логи, откуда строку не убрать, — + // но по причине владелец различает исчерпание места, отсутствие прав и + // файловую систему только для чтения. + return 0, fmt.Errorf("failed to store a copy of record %s: %w", recordID, causeOf(err)) + } + + if err := os.Rename(temp, s.path(recordID, name)); err != nil { + _ = os.Remove(temp) + return 0, fmt.Errorf("failed to publish a copy of record %s: %w", recordID, causeOf(err)) + } + + return size, nil +} + +// Open отдаёт содержимое копии потоком с возможностью перемотки: отдача файла +// по диапазону читает кусок, а не файл целиком. +func (s *Store) Open(recordID, name string) (*os.File, error) { + file, err := os.Open(s.path(recordID, name)) + if err != nil { + // Отказ называет запись её идентификатором и не несёт имени файла: + // имя — часть пути к чужому аудио. Причина при этом остаётся: «файла + // нет» и «прав нет» ведут владельца к разным действиям. + return nil, fmt.Errorf("failed to read a copy of record %s: %w", recordID, causeOf(err)) + } + return file, nil +} + +// Remove убирает копию. Отсутствие файла отказом не считается: уборка зовётся и +// там, где укладка до него не дошла. +func (s *Store) Remove(recordID, name string) error { + if err := os.Remove(s.path(recordID, name)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove a copy of record %s: %w", recordID, causeOf(err)) + } + return nil +} + +// HasTemporary говорит, осталось ли в подкаталоге записи незавершённое имя. +// Нужен проверкам: обещание атомарной укладки иначе судилось бы только по +// отсутствию рабочего имени. +func (s *Store) HasTemporary(recordID string) (bool, error) { + entries, err := os.ReadDir(s.dir(recordID)) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("failed to read the directory of record %s: %w", recordID, causeOf(err)) + } + + for _, entry := range entries { + if len(entry.Name()) > len(tempPrefix) && entry.Name()[:len(tempPrefix)] == tempPrefix { + return true, nil + } + } + + return false, nil +} + +// causeOf снимает с отказа файловой операции путь, оставляя причину. +// +// Обе половины обязательны, и порознь они друг друга отменяют. Причина нужна: +// по ней владелец различает исчерпание места, отсутствие прав и файловую систему +// только для чтения — три поломки, требующие трёх разных действий, а отказ +// укладки — единственная поверхность, на которой он их видит. Путь не нужен и +// вреден: он ведёт внутрь каталога данных, а отказ кончается в журнале, откуда +// строку потом не убрать. +// +// Пакет `os` отдаёт причину обёрнутой в `*os.PathError` либо `*os.LinkError` — +// именно там и лежит путь. Заворачивается поэтому `.Err`, а не обёртка целиком: +// `errors.Is` до `fs.ErrPermission` и `syscall.ENOSPC` сравнивает значение под +// обёрткой и от её снятия не страдает. +// +// Соединённый отказ разбирается по частям: укладка складывает отказы записи, +// сброса и закрытия, и путь лежит в каждой из них. +func causeOf(err error) error { + switch typed := err.(type) { //nolint:errorlint // разбирается сам отказ, а не цепочка: обёртку и надо снять + case *os.PathError: + return typed.Err + case *os.LinkError: + return typed.Err + case interface{ Unwrap() []error }: + parts := typed.Unwrap() + causes := make([]error, 0, len(parts)) + for _, part := range parts { + causes = append(causes, causeOf(part)) + } + return errors.Join(causes...) + } + + return err +} diff --git a/internal/adapter/repo/sqlite/text_repo.go b/internal/adapter/repo/sqlite/text_repo.go new file mode 100644 index 0000000..dbac2dc --- /dev/null +++ b/internal/adapter/repo/sqlite/text_repo.go @@ -0,0 +1,205 @@ +package sqlite + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + + "git.vakhrushev.me/av/transcriber/internal/clock" + "git.vakhrushev.me/av/transcriber/internal/entity" + "git.vakhrushev.me/av/transcriber/internal/ident" +) + +type TextRepository struct { + db *DB +} + +func NewTextRepository(db *DB) *TextRepository { + return &TextRepository{db: db} +} + +// Put кладёт текст записи, заменяя прежний того же вида. +// +// Замена, а не вставка: пара «запись и вид» уникальна, и повтор прерванного шага +// иначе завёл бы второй комплект строк — тогда вопрос «какой текст отдавать +// человеку» стал бы вопросом порядка записи, а не состояния. +// +// **Пустое не кладётся поверх непустого**, и это не осторожность, а защита +// архива. Повторный опрос той же операции — обычное дело: держатель захвата +// умер, сохранение рубежа отказало, человек вернул запись в работу. Провайдер +// при этом вправе ответить пустым потоком, отказом это не считается, и +// безусловная замена стирала бы сохранённую расшифровку живого человека без +// следа и без возврата. Та же защита стоит у сохранённого ответа провайдера, и +// разное правило у двух хранителей одного результата читалось бы как недосмотр. +// +// **Граница транзакции — весь метод.** Он читает состояние, которое сам же +// пишет, и идёт целиком по пишущему соединению: разорванный надвое, он завёл бы +// вторую строку на гонке двух шагов. +func (repo *TextRepository) Put(recordID, kind, contents string) (*entity.Text, error) { + tx, err := repo.db.Writer().BeginTx(context.Background(), nil) + if err != nil { + return nil, fmt.Errorf("failed to open a transaction for the text of record %s: %w", recordID, err) + } + defer func() { _ = tx.Rollback() }() + + var ( + id string + existing string + ) + err = tx.QueryRowContext(context.Background(), + "SELECT id, contents FROM texts WHERE record_id = ? AND kind = ?", recordID, kind, + ).Scan(&id, &existing) + + now := formatTime(clock.Now()) + + switch { + case err == nil: + // Прежнее непустое содержимое пустым не заменяется: строка остаётся как + // есть, и вызывающий получает её обратно. + if contents == "" && existing != "" { + return &entity.Text{Id: id, RecordID: recordID, Kind: kind, Contents: existing}, nil + } + if _, err := tx.ExecContext(context.Background(), + "UPDATE texts SET contents = ?, updated_at = ? WHERE id = ?", contents, now, id, + ); err != nil { + // Текст расшифровки наружу не выходит даже отказом: цепочка `%w` от + // драйвера несёт значение поля. + return nil, fmt.Errorf("failed to store text of kind %s for record %s", kind, recordID) + } + case errors.Is(err, sql.ErrNoRows): + id = ident.New() + if _, err := tx.ExecContext(context.Background(), + `INSERT INTO texts (id, record_id, kind, contents, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)`, + id, recordID, kind, contents, now, now, + ); err != nil { + return nil, fmt.Errorf("failed to store text of kind %s for record %s", kind, recordID) + } + default: + // Отказ базы «строкой нет» не является, и подменять его вставкой нельзя: + // она упрётся в уникальный индекс, и наверх уедет жалоба на запись + // вместо правды о недоступной базе. + return nil, fmt.Errorf("failed to look up text of kind %s for record %s: %w", kind, recordID, err) + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("failed to commit the text of record %s: %w", recordID, err) + } + + return &entity.Text{Id: id, RecordID: recordID, Kind: kind, Contents: contents}, nil +} + +func (repo *TextRepository) GetByID(id string) (*entity.Text, error) { + text := &entity.Text{Id: id} + err := repo.db.Reader().QueryRowContext(context.Background(), + "SELECT record_id, kind, contents FROM texts WHERE id = ?", id, + ).Scan(&text.RecordID, &text.Kind, &text.Contents) + if err != nil { + return nil, fmt.Errorf("failed to get text %s: %w", id, err) + } + return text, nil +} + +type StructureRepository struct { + db *DB +} + +func NewStructureRepository(db *DB) *StructureRepository { + return &StructureRepository{db: db} +} + +// Put кладёт структуру реплик, заменяя прежнюю той же версии разбора. Довод тот +// же, что и у текста: повтор шага не должен заводить второй строки, а пустой +// перечень реплик поверх непустого не кладётся. +func (repo *StructureRepository) Put(recordID string, version int, replicas []entity.Replica) (*entity.Structure, error) { + if replicas == nil { + replicas = []entity.Replica{} + } + contents, err := json.Marshal(replicas) + if err != nil { + return nil, fmt.Errorf("failed to encode structure of record %s", recordID) + } + + tx, err := repo.db.Writer().BeginTx(context.Background(), nil) + if err != nil { + return nil, fmt.Errorf("failed to open a transaction for the structure of record %s: %w", recordID, err) + } + defer func() { _ = tx.Rollback() }() + + var ( + id string + existing string + ) + err = tx.QueryRowContext(context.Background(), + "SELECT id, contents FROM structures WHERE record_id = ? AND version = ?", recordID, version, + ).Scan(&id, &existing) + + now := formatTime(clock.Now()) + + switch { + case err == nil: + if len(replicas) == 0 && len(existing) > len("[]") { + stored, decodeErr := decodeReplicas(id, existing) + if decodeErr != nil { + return nil, decodeErr + } + return &entity.Structure{Id: id, RecordID: recordID, Version: version, Replicas: stored}, nil + } + if _, err := tx.ExecContext(context.Background(), + "UPDATE structures SET contents = ?, updated_at = ? WHERE id = ?", string(contents), now, id, + ); err != nil { + return nil, fmt.Errorf("failed to store structure of record %s", recordID) + } + case errors.Is(err, sql.ErrNoRows): + id = ident.New() + if _, err := tx.ExecContext(context.Background(), + `INSERT INTO structures (id, record_id, version, contents, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)`, + id, recordID, version, string(contents), now, now, + ); err != nil { + return nil, fmt.Errorf("failed to store structure of record %s", recordID) + } + default: + return nil, fmt.Errorf("failed to look up structure of record %s: %w", recordID, err) + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("failed to commit the structure of record %s: %w", recordID, err) + } + + return &entity.Structure{Id: id, RecordID: recordID, Version: version, Replicas: replicas}, nil +} + +func (repo *StructureRepository) GetByID(id string) (*entity.Structure, error) { + structure := &entity.Structure{Id: id} + var contents string + err := repo.db.Reader().QueryRowContext(context.Background(), + "SELECT record_id, version, contents FROM structures WHERE id = ?", id, + ).Scan(&structure.RecordID, &structure.Version, &contents) + if err != nil { + return nil, fmt.Errorf("failed to get structure %s: %w", id, err) + } + + replicas, err := decodeReplicas(id, contents) + if err != nil { + return nil, err + } + structure.Replicas = replicas + + return structure, nil +} + +// decodeReplicas разбирает сохранённые реплики. Текст расшифровки наружу +// отказом не выходит: сообщение несёт идентификатор строки, и только его. +func decodeReplicas(id, contents string) ([]entity.Replica, error) { + if contents == "" { + return nil, nil + } + var replicas []entity.Replica + if err := json.Unmarshal([]byte(contents), &replicas); err != nil { + return nil, fmt.Errorf("failed to decode structure %s", id) + } + return replicas, nil +} diff --git a/internal/adapter/repo/sqlite/values.go b/internal/adapter/repo/sqlite/values.go new file mode 100644 index 0000000..81e6dd9 --- /dev/null +++ b/internal/adapter/repo/sqlite/values.go @@ -0,0 +1,190 @@ +package sqlite + +import ( + "database/sql" + "errors" + "fmt" + "sort" + "strings" + "time" + + sqlitedriver "modernc.org/sqlite" + sqlitelib "modernc.org/sqlite/lib" +) + +// timeLayout — единственный вид времени в схеме: RFC 3339, UTC, суффикс `Z`, +// секундная точность. +// +// Ширина такой записи постоянная, поэтому лексикографический порядок `TEXT` +// совпадает с хронологией, и отбор по колонке времени работает без разбора +// значения. Своего типа времени у SQLite нет: колонка хранит то, что в неё +// положили, — колонка, заполненная то одним видом, то другим, обратила бы +// условие срока протухания захвата в постоянную истину или ложь молча, и запись +// не выдавалась бы ни одному воркеру никогда. +const timeLayout = "2006-01-02T15:04:05Z" + +// formatTime приводит метку времени к виду колонки. +func formatTime(v time.Time) string { + return v.UTC().Format(timeLayout) +} + +// timeValue кладёт время в колонку, допускающую пустое значение. Нулевое время +// и отсутствующее — одно и то же: «времени нет». +func timeValue(v *time.Time) any { + if v == nil || v.IsZero() { + return nil + } + return formatTime(*v) +} + +// timeOf читает колонку времени. Нечитаемое значение отдаётся нулевым: колонка +// пишется только нами, и разбор здесь — сторож, а не ветвь поведения. +func timeOf(v sql.NullString) *time.Time { + if !v.Valid || v.String == "" { + return nil + } + parsed, err := time.Parse(timeLayout, v.String) + if err != nil { + return nil + } + parsed = parsed.UTC() + return &parsed +} + +// requiredTimeOf читает обязательную колонку времени. +func requiredTimeOf(v string) time.Time { + parsed, err := time.Parse(timeLayout, v) + if err != nil { + return time.Time{} + } + return parsed.UTC() +} + +// stringValue кладёт необязательную строку: пустая и отсутствующая — одно и то +// же. +func stringValue(v *string) any { + if v == nil || *v == "" { + return nil + } + return *v +} + +// stringOf читает необязательную строку. +func stringOf(v sql.NullString) *string { + if !v.Valid || v.String == "" { + return nil + } + out := v.String + return &out +} + +// numberOf читает необязательное число. +// +// Указатель здесь не выражает «неизвестно»: обе величины записи ставит приём и +// ставит всегда, а колонки объявлены обязательными. Форма осталась указателем +// потому, что её несёт домен, а ответ приложению обязан различать поле и его +// отсутствие. +func numberOf(v int64) *int64 { + out := v + return &out +} + +// numberValue кладёт необязательное число нулём: колонка обязательна. +func numberValue(v *int64) int64 { + if v == nil { + return 0 + } + return *v +} + +// insertSQL собирает вставку из карты «колонка → значение». +// +// Именованными параметрами, а не позиционным списком: у аудиозаписи поля одного +// типа идут длинным непрерывным рядом, и позиционный сдвиг на одно поле +// скомпилировался бы молча, положив идентификатор файла в колонку текста. По +// имени такого сдвига не существует вовсе. +// +// Порядок колонок берётся сортировкой, а не порядком обхода карты: обход карты +// в Go случаен, и текст запроса менялся бы от прогона к прогону — отладка по +// журналу читала бы каждый раз новый запрос. +func insertSQL(table string, values map[string]any) (string, []any) { + names := sortedNames(values) + + placeholders := make([]string, 0, len(names)) + args := make([]any, 0, len(names)) + for _, name := range names { + placeholders = append(placeholders, ":"+name) + args = append(args, sql.Named(name, values[name])) + } + + query := fmt.Sprintf( + "INSERT INTO %s (%s) VALUES (%s)", + table, + strings.Join(names, ", "), + strings.Join(placeholders, ", "), + ) + + return query, args +} + +// updateSQL собирает правку из карты «колонка → значение» и условия. +func updateSQL(table string, values map[string]any, where string, whereArgs []any) (string, []any) { + names := sortedNames(values) + + assignments := make([]string, 0, len(names)) + args := make([]any, 0, len(names)+len(whereArgs)) + for _, name := range names { + assignments = append(assignments, name+" = :"+name) + args = append(args, sql.Named(name, values[name])) + } + args = append(args, whereArgs...) + + query := fmt.Sprintf( + "UPDATE %s SET %s WHERE %s", + table, + strings.Join(assignments, ", "), + where, + ) + + return query, args +} + +func sortedNames(values map[string]any) []string { + names := make([]string, 0, len(values)) + for name := range values { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// selectList собирает перечень колонок для выборки из той же карты, по которой +// потом идёт чтение. Один источник у обеих половин: колонка, забытая в перечне, +// не имеет места назначения, и наоборот — расхождению взяться неоткуда. +func selectList(targets map[string]any, prefix string) (string, []any) { + names := sortedNames(targets) + + columns := make([]string, 0, len(names)) + scan := make([]any, 0, len(names)) + for _, name := range names { + columns = append(columns, prefix+name) + scan = append(scan, targets[name]) + } + + return strings.Join(columns, ", "), scan +} + +// isUniqueViolation говорит, отказала ли запись по уникальному индексу. +// +// Судится **код** отказа, а не его текст: текст у драйвера свой на каждую +// версию, а узнавание ошибки по тексту запрещено правилом проекта. Какая именно +// колонка не сошлась, код не называет — и это не мешает: заведение учётной +// записи различает два отказа повторным поиском по ключу, а не разбором текста. +func isUniqueViolation(err error) bool { + var sqliteErr *sqlitedriver.Error + if !errors.As(err, &sqliteErr) { + return false + } + return sqliteErr.Code() == sqlitelib.SQLITE_CONSTRAINT_UNIQUE || + sqliteErr.Code() == sqlitelib.SQLITE_CONSTRAINT_PRIMARYKEY +} diff --git a/internal/archrules/arch_test.go b/internal/archrules/arch_test.go index e1059f5..db1b731 100644 --- a/internal/archrules/arch_test.go +++ b/internal/archrules/arch_test.go @@ -113,6 +113,31 @@ func TestТранспортыНеЗнаютДругОДруге(t *testing.T) { } } +// Транспорт не знает адаптеров. Изъятие, разрешавшее ему знать адаптер +// хранилища, снято вместе с предметом: HTTP-поверхность была роутером +// встроенного хранилища, а стала своей, и правило на это направление заводится +// впервые. +// +// Что оно ловит: возврат `EnsureUser`, `NewFileRepository` и прочих имён +// адаптера в обработчики. Знание о внешнем мире приходит транспорту интерфейсом +// `internal/contract`, а реализацию подставляет точка входа. +func TestТранспортыНеЗнаютАдаптеров(t *testing.T) { + for pkg, imports := range internalImports(t) { + if !transports[pkg] { + continue + } + for _, imp := range imports { + if strings.HasPrefix(imp, adapterPrefix) { + t.Errorf( + "транспорт %s импортирует адаптер %s: реализацию подставляет "+ + "cmd/transcriber, а транспорт знает только internal/contract", + pkg, imp, + ) + } + } + } +} + func TestАдаптерыНеЗнаютНиЯдра_НиТранспортов(t *testing.T) { for pkg, imports := range internalImports(t) { if !strings.HasPrefix(pkg, adapterPrefix) { @@ -173,16 +198,19 @@ func TestОшибкаНеУзнаётсяПоТексту(t *testing.T) { } } -// Перечень колонок аудиозаписи компилятор не видит: их пишет `applyOwnedByPipeline`, -// читает `recordToAudioRecord`, и заводит шаг схемы. Колонка, забытая в паре -// «пишем — читаем», теряется молча: запись, прочитанная не тем путём, приезжает -// с нулевым полем, и первое же сохранение пишет этот ноль поверх значения. +// Перечень колонок аудиозаписи компилятор не видит: их пишет `writeOwnedByPipeline` +// вместе с `writeRecord`, читает `readRecordColumns`, доводит до сущности +// `rowToAudioRecord`, и заводит шаг схемы. Колонка, забытая в любом звене этой +// цепочки, теряется молча: запись, прочитанная не тем путём, приезжает с нулевым +// полем, и первое же сохранение пишет этот ноль поверх значения. // -// Мест стало **два** вместо прежних четырёх: захват больше не перечисляет -// колонки поимённо, а возвращает идентификатор и признак своего захвата. Правила -// ниже держат оставшуюся пару плюс шаг схемы. +// Отображение работает **по имени колонки** — именованные параметры запроса и +// место назначения, найденное по имени, — поэтому правила ниже сверяют имена, а +// не порядок полей. Ту поломку, где колонка не забыта, а перепутана местом, эта +// форма снимает сама: позиционного списка, который сдвинулся бы на одно поле, у +// отображения нет вовсе. const ( - repoPkg = "internal/adapter/repo/pocketbase" + repoPkg = "internal/adapter/repo/sqlite" mappingFile = repoPkg + "/record_mapping.go" migrationsPath = repoPkg + "/migrations" stageFile = "internal/entity/stage.go" @@ -190,46 +218,64 @@ const ( serviceFile = "internal/service/transcribe.go" ) -// Колонки, которые заводит и заполняет само хранилище: нашего кода они не -// касаются. -var storageOwned = map[string]bool{"id": true, "created": true, "updated": true} - func TestКолонкиЗаписиПишутсяИЧитаются(t *testing.T) { written := writtenColumns(t) read := readColumns(t) for col := range written { - if storageOwned[col] { - continue - } if !read[col] { t.Errorf( - "колонку %q пишет отображение записи, но recordToAudioRecord её не "+ - "читает: запись приедет из хранилища без этого поля", + "колонку %q пишет отображение записи, но readRecordColumns её не "+ + "читает: запись приедет из базы без этого поля", col, ) } } for col := range read { - if storageOwned[col] { - continue - } if !written[col] { t.Errorf( - "колонку %q читает recordToAudioRecord, но её не пишет ни "+ - "applyOwnedByPipeline, ни applyToRecord: поле не сохранится", + "колонку %q читает readRecordColumns, но её не пишет ни "+ + "writeOwnedByPipeline, ни writeRecord: поле не сохранится", col, ) } } } +// Колонка, прочитанная в поле сырой строки, обязана доехать до сущности: +// `readRecordColumns` называет, куда ляжет значение, а `rowToAudioRecord` +// решает, возьмут ли его оттуда. Поле, забытое во втором, теряется молча — +// компилятор его не видит, спрошенная колонка приезжает и остаётся лежать в +// сырой строке, сущность получает нулевое значение, а ближайшее сохранение +// пишет этот ноль поверх сохранённого. +func TestПрочитанныеКолонкиДоезжаютДоСущности(t *testing.T) { + targets := readTargets(t) + used := rowFieldsTakenByEntity(t) + + for field, column := range targets { + if !used[field] { + t.Errorf( + "колонка %q читается в поле row.%s, но rowToAudioRecord его не берёт: "+ + "значение не доедет до сущности, а ближайшее сохранение запишет "+ + "нулевое поверх сохранённого", + column, field, + ) + } + } + for field := range used { + if _, ok := targets[field]; !ok { + t.Errorf( + "rowToAudioRecord берёт поле row.%s, но readRecordColumns ни одной "+ + "колонки в него не кладёт: сущность получит нулевое значение всегда", + field, + ) + } + } +} + func TestКолонкиЗаписиЗаведеныШагомСхемы(t *testing.T) { declared := schemaFieldNames(t) for col := range writtenColumns(t) { - if storageOwned[col] { - continue - } if !declared[col] { t.Errorf( "колонка %q пишется отображением записи, но ни один шаг схемы её не "+ @@ -312,13 +358,16 @@ func TestШагиОбъявленыРубежамиДескриптора(t *tes } } -// writtenColumns — колонки, которые пишет отображение записи в хранилище. +// columnKey — имя колонки в карте отображения: строковый ключ в начале строки. +var columnKey = regexp.MustCompile(`(?m)^\s*"([a-z_0-9]+)":`) + +// writtenColumns — колонки, которые пишет отображение записи в базу. func writtenColumns(t *testing.T) map[string]bool { t.Helper() - body := funcBody(t, mappingFile, "func applyOwnedByPipeline(") + - funcBody(t, mappingFile, "func applyToRecord(") + body := funcBody(t, mappingFile, "func writeOwnedByPipeline(") + + funcBody(t, mappingFile, "func writeRecord(") out := map[string]bool{} - for _, m := range regexp.MustCompile(`record\.Set\("([^"]+)"`).FindAllStringSubmatch(body, -1) { + for _, m := range columnKey.FindAllStringSubmatch(body, -1) { out[m[1]] = true } if len(out) == 0 { @@ -327,16 +376,51 @@ func writtenColumns(t *testing.T) map[string]bool { return out } -// readColumns — колонки, которые читает обратное отображение. +// readColumns — колонки, которые читает обратное отображение. Перечень выборки +// собирается из той же карты, поэтому сверяется именно она. func readColumns(t *testing.T) map[string]bool { t.Helper() - body := funcBody(t, mappingFile, "func recordToAudioRecord(") + body := funcBody(t, mappingFile, "func readRecordColumns(") out := map[string]bool{} - for _, m := range regexp.MustCompile(`\.Get\w+\("([^"]+)"\)`).FindAllStringSubmatch(body, -1) { + for _, m := range columnKey.FindAllStringSubmatch(body, -1) { out[m[1]] = true } if len(out) == 0 { - t.Fatalf("recordToAudioRecord не читает ни одной колонки: правило потеряло предмет") + t.Fatalf("readRecordColumns не читает ни одной колонки: правило потеряло предмет") + } + return out +} + +// readTarget — колонка чтения и поле сырой строки, куда она ложится. +var readTarget = regexp.MustCompile(`(?m)^\s*"([a-z_0-9]+)":\s*&row\.(\w+),`) + +// rowFieldUse — обращение к полю сырой строки при сборке сущности. +var rowFieldUse = regexp.MustCompile(`\brow\.(\w+)\b`) + +// readTargets — поле сырой строки в имя колонки, которая в него читается. +func readTargets(t *testing.T) map[string]string { + t.Helper() + body := funcBody(t, mappingFile, "func readRecordColumns(") + out := map[string]string{} + for _, m := range readTarget.FindAllStringSubmatch(body, -1) { + out[m[2]] = m[1] + } + if len(out) == 0 { + t.Fatalf("readRecordColumns не кладёт ни одной колонки в поле строки: правило потеряло предмет") + } + return out +} + +// rowFieldsTakenByEntity — поля сырой строки, которые берёт сборка сущности. +func rowFieldsTakenByEntity(t *testing.T) map[string]bool { + t.Helper() + body := funcBody(t, mappingFile, "func rowToAudioRecord(") + out := map[string]bool{} + for _, m := range rowFieldUse.FindAllStringSubmatch(body, -1) { + out[m[1]] = true + } + if len(out) == 0 { + t.Fatalf("rowToAudioRecord не берёт ни одного поля строки: правило потеряло предмет") } return out } @@ -447,9 +531,11 @@ func funcBody(t *testing.T, file, header string) string { return body[start : start+end] } -// schemaFieldNames собирает имена полей, заведённых шагами схемы: `Name: "…"` в -// любом файле каталога шагов. Перечень объединённый — колонку заводит тот шаг, -// который её добавил, а переписывать применённый шаг нельзя. +// schemaFieldNames собирает имена колонок аудиозаписи, заведённых шагами схемы. +// +// Читается объявление таблицы в любом файле каталога шагов: колонку заводит тот +// шаг, который её добавил, а переписывать применённый шаг нельзя. Перечень +// поэтому объединённый — по всем шагам сразу. func schemaFieldNames(t *testing.T) map[string]bool { t.Helper() dir := filepath.Join(repoRoot, migrationsPath) @@ -457,7 +543,8 @@ func schemaFieldNames(t *testing.T) map[string]bool { if err != nil { t.Fatalf("читаю каталог шагов схемы: %v", err) } - re := regexp.MustCompile(`Name:\s*"([^"]+)"`) + + column := regexp.MustCompile(`(?m)^\s*([a-z_0-9]+)\s+(TEXT|INTEGER)`) out := map[string]bool{} for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") { @@ -467,16 +554,41 @@ func schemaFieldNames(t *testing.T) map[string]bool { if err != nil { t.Fatalf("читаю %s: %v", e.Name(), err) } - for _, m := range re.FindAllStringSubmatch(string(body), -1) { - out[m[1]] = true + for _, block := range tableBlocks(string(body), recordsTable) { + for _, m := range column.FindAllStringSubmatch(block, -1) { + out[m[1]] = true + } } } if len(out) == 0 { - t.Fatalf("шаги схемы не объявили ни одного поля: правило потеряло предмет") + t.Fatalf("шаги схемы не объявили ни одной колонки аудиозаписи: правило потеряло предмет") } return out } +// recordsTable — имя таблицы аудиозаписей в шагах схемы. +const recordsTable = "audio_records" + +// tableBlocks вырезает объявления названной таблицы: от `CREATE TABLE имя (` до +// закрывающей скобки в начале строки. +func tableBlocks(body, table string) []string { + var out []string + marker := "CREATE TABLE " + table + " (" + for { + start := strings.Index(body, marker) + if start < 0 { + return out + } + body = body[start+len(marker):] + end := strings.Index(body, "\n\t\t)") + if end < 0 { + return out + } + out = append(out, body[:end]) + body = body[end:] + } +} + func readFile(t *testing.T, rel string) string { t.Helper() body, err := os.ReadFile(filepath.Join(repoRoot, rel)) diff --git a/internal/config/config.go b/internal/config/config.go index bdefaec..9ba0e49 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -64,10 +64,38 @@ type ServerConfig struct { ForceShutdownTimeout int `toml:"force_shutdown_timeout"` } -// StorageConfig — единственный каталог данных: под ним лежат и база, и файлы -// записей. Двух путей, как было раньше, у хранилища не бывает. +// StorageConfig — хранилище сервиса: каталог данных и числа его базы. +// +// Каталог единственный: под ним лежат и база, и файлы записей. Двух путей, как +// было раньше, у хранилища не бывает. type StorageConfig struct { DataDir string `toml:"data_dir"` + // BusyTimeoutMs — сколько ждать занятую базу, миллисекунды. + // + // Ключом, а не константой кода: крутят его при отказе «база занята» под + // несколькими воркерами, и подбор ответа на такой отказ не должен требовать + // пересборки образа. + BusyTimeoutMs int `toml:"busy_timeout_ms"` + // ReadConnections — сколько соединений держит читающий пул. Пишущее + // соединение при этом всегда одно, и настройкой оно не делается: драйвер + // пишет единственным соединением, и второе означало бы отказы по занятости. + ReadConnections int `toml:"read_connections"` +} + +// Validate проверяет настройки хранилища. Ноль и отрицательное — опечатка, а не +// режим: нулевое ожидание отдаёт «база занята» первому же воркеру, а нулевой пул +// чтения означает пул без предела, то есть настройку, которой не управляют. +func (c StorageConfig) Validate() error { + if strings.TrimSpace(c.DataDir) == "" { + return errors.New("storage: не заполнен ключ data_dir: сервису негде держать базу и файлы записей") + } + if c.BusyTimeoutMs <= 0 { + return errors.New("storage: busy_timeout_ms задаётся положительным числом миллисекунд") + } + if c.ReadConnections <= 0 { + return errors.New("storage: read_connections задаётся положительным числом соединений") + } + return nil } type YandexConfig struct { @@ -156,6 +184,13 @@ func defaultConfig() *Config { }, Storage: StorageConfig{ DataDir: "data", + // Пять секунд ожидания и четыре читающих соединения: числа выведены + // из числа воркеров по умолчанию, а не из замера. Смысл ожидания — + // пережить чужую запись, а не чужую работу: пишет сервис короткими + // операциями, и очередь из трёх воркеров укладывается в него с + // запасом. + BusyTimeoutMs: 5000, + ReadConnections: 4, }, Pipeline: PipelineConfig{ Workers: 3, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8e782b1..53696d2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -216,3 +216,42 @@ func TestPipelineValidateSeparatesModeFromTypo(t *testing.T) { } } } + +// Настройки хранилища проверяются на старте, и каждая ветвь проверки закрывает +// свою поломку. Ноль и отрицательное — опечатка, а не режим: нулевое ожидание +// отдаёт «база занята» первому же воркеру, нулевой пул чтения означает пул без +// предела, а пустой каталог данных оставляет сервис без места под базу и файлы. +// Без проверки такая опечатка проявилась бы отказом под нагрузкой, а не на +// подъёме. +func TestStorageConfigValidate(t *testing.T) { + valid := StorageConfig{DataDir: "data", BusyTimeoutMs: 5000, ReadConnections: 4} + + if err := valid.Validate(); err != nil { + t.Fatalf("заполненные настройки отвергнуты: %v", err) + } + + cases := []struct { + name string + config StorageConfig + mention string + }{ + {"каталог данных не заполнен", StorageConfig{DataDir: "", BusyTimeoutMs: 5000, ReadConnections: 4}, "data_dir"}, + {"каталог данных из одних пробелов", StorageConfig{DataDir: " ", BusyTimeoutMs: 5000, ReadConnections: 4}, "data_dir"}, + {"ожидание нулевое", StorageConfig{DataDir: "data", BusyTimeoutMs: 0, ReadConnections: 4}, "busy_timeout_ms"}, + {"ожидание отрицательное", StorageConfig{DataDir: "data", BusyTimeoutMs: -1, ReadConnections: 4}, "busy_timeout_ms"}, + {"пул чтения нулевой", StorageConfig{DataDir: "data", BusyTimeoutMs: 5000, ReadConnections: 0}, "read_connections"}, + {"пул чтения отрицательный", StorageConfig{DataDir: "data", BusyTimeoutMs: 5000, ReadConnections: -3}, "read_connections"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.config.Validate() + if err == nil { + t.Fatal("негодная настройка принята: поломка дошла бы до боя") + } + if !strings.Contains(err.Error(), tc.mention) { + t.Fatalf("имя ключа %q не названо: %v", tc.mention, err) + } + }) + } +} diff --git a/internal/contract/error.go b/internal/contract/error.go index 3ca86bf..682f383 100644 --- a/internal/contract/error.go +++ b/internal/contract/error.go @@ -8,6 +8,11 @@ import ( // ErrOwnerRequired — приём по HTTP дошёл до заведения задачи, а владельца ему не // назвали. Значение сентинельное: нести отказу нечего, а имя учётной записи в // него не кладётся никогда. +// +// Достижимого случая у него нет: узнавание заводит учётную запись само, и +// предъявителя без неё под корнем приложения не бывает. Отдельной ветви ответа +// он поэтому не получает — ветвь по умолчанию читает его как аварию сервиса, +// каковой он и был бы. var ErrOwnerRequired = errors.New("owner is required to accept a record") // ErrRecordUnreadable — присланную запись не удалось прочитать: источник @@ -31,6 +36,18 @@ var ErrRecordTooLarge = errors.New("uploaded record exceeds size limit") // человек увидел бы «не найдено» на своей записи, загруженной минуту назад. var ErrTextNotReady = errors.New("requested text view is not ready yet") +// ErrCopyNotReady — копии записи запрошенного вида у неё ещё нет. Состояние, а +// не отсутствие, и код у него тот же, что у ненаписанного текста: пустой ответ +// читался бы как пустой файл, а «не найдено» слилось бы с ответом на чужую и +// неизвестную запись — человек увидел бы его на своей записи, загруженной +// минуту назад. +var ErrCopyNotReady = errors.New("requested file copy is not ready yet") + +// ErrTooManyRequests — бюджет ограничителя частоты выбран. Признак заводится +// затем, чтобы отказ ограничителя уходил той же формой тела, что и отказ +// обработчика: он рождается слоем и до обработчика не доходит вовсе. +var ErrTooManyRequests = errors.New("request rate budget is exhausted") + // ErrBadRequest — во входе запроса негодное значение: неизвестный вид текста, // нечитаемый ключ страницы, отрицательный размер. Отличается от ErrRecordUnreadable // тем, что негодна **просьба**, а не присланная запись. @@ -47,6 +64,11 @@ var ErrUnauthorized = errors.New("session is required") // отсутствующий адрес отсутствующей записью. var ErrNotFound = errors.New("address not found") +// ErrLoginNotAcceptable — логин негоден: пустой, из одних пробельных знаков, +// длиннее предела или с управляющими знаками. Это не отказ хранилища, а негодный +// ввод, и звать по нему учётную запись не надо. +var ErrLoginNotAcceptable = errors.New("provider login is not acceptable") + type JobNotFoundError struct { State string Message string diff --git a/internal/contract/repository.go b/internal/contract/repository.go index 6cf18e4..ae77c70 100644 --- a/internal/contract/repository.go +++ b/internal/contract/repository.go @@ -2,6 +2,7 @@ package contract import ( "io" + "time" "git.vakhrushev.me/av/transcriber/internal/entity" ) @@ -40,19 +41,24 @@ type FileRepository interface { StageEmpty(ext string) (WorkFile, error) // Localize выдаёт рабочую копию хранимого файла. Localize(fileID string) (WorkFile, error) - // Create кладёт рабочую копию в хранилище под именем name и заводит запись о - // файле. Имя задаёт сервис: умолчание хранилища, строящее его из имени - // отправителя, не применяется. + // Create кладёт рабочую копию в каталог данных под именем name и заводит + // строку о файле. Имя задаёт сервис, и имя, данное отправителем, в него не + // попадает: от него взято только расширение. + // + // recordID — запись, которой копия принадлежит: копии одной записи лежат её + // подкаталогом, и имя этого подкаталога и есть идентификатор записи. Приём + // знает его раньше, чем кладёт файл, потому что назначает сам. // // ownerID — владелец записи, которой файл принадлежит, и он обязателен: // колонка владельца пустого значения не принимает, пустой отвергается // схемой. Владелец лежит своей колонкой, а не выводится через запись: файл // переживает свою запись — шаг заводит его до сохранения, и потерянный // захват оставляет файл с владельцем и без ссылки. - Create(name string, work WorkFile, meta FileMeta, ownerID string) (*entity.File, error) + Create(recordID, name string, work WorkFile, meta FileMeta, ownerID string) (*entity.File, error) GetByID(id string) (*entity.File, error) - // Open отдаёт содержимое хранимого файла потоком. - Open(fileID string) (io.ReadCloser, error) + // Open отдаёт содержимое хранимого файла потоком с перемоткой: отдача по + // диапазону читает запрошенный кусок, а не файл целиком. + Open(fileID string) (io.ReadSeekCloser, error) } // AcquiredRecord — то, что отдаёт захват: идентификатор записи и признак @@ -74,8 +80,12 @@ type AcquiredRecord struct { // RecordCursor — положение в ленте записей, заданное **полным** ключом // сортировки. Одного времени мало: у записей, принятых одним запросом, оно // совпадает, и порядок между ними иначе не определён. +// +// Время лежит здесь значением времени, а не строкой: вид, каким оно уходит в +// запрос, принадлежит хранилищу — сравнение там побайтово, и вид, собранный +// транспортом, разошёлся бы с колонкой молча, обратив условие в постоянную ложь. type RecordCursor struct { - CreatedAt string + CreatedAt time.Time ID string } @@ -154,7 +164,8 @@ type RecognitionRepository interface { // Submitted сохраняет адрес аудио и идентификатор заведённой операции. По // последнему повторный шаг узнаёт, что за эту запись уже заплачено. Submitted(id, sourceURI, externalID string) error - // Finish отмечает завершение операции и кладёт сырой ответ вложением. + // Finish отмечает завершение операции и кладёт сохранённый ответ отдельным + // файлом в подкаталоге записи. Finish(id string, raw []byte) error GetByID(id string) (*entity.Recognition, error) // ReadRaw отдаёт сохранённый ответ провайдера. Зовётся только тогда, когда @@ -166,3 +177,31 @@ type RecognitionRepository interface { type RecordEventRepository interface { Append(event *entity.RecordEvent) error } + +// Identity — то, чем доверенный источник называет пришедшего. +// +// Логин — ключ учётной записи, остальное берётся только при её заведении. +type Identity struct { + Login string + Name string + Email string +} + +// UserAccount — учётная запись сервиса, какой её видит транспорт: ключ и имя, +// пригодное к показу. Логина у провайдера и адреса почты здесь нет: оба +// принадлежат человеку, а не сервису, и наружу не выходят. +type UserAccount struct { + ID string + Name string +} + +// UserRepository — учётные записи. +// +// Дом правила «найти по логину, а не найдя — завести» один, и он в хранилище, а +// не в транспорте: второй способ представиться возьмёт этот же метод. +type UserRepository interface { + // EnsureUser находит учётную запись по логину у провайдера, а не найдя — + // заводит её. Второе значение истинно только у заведённой: заведение — + // событие, и владелец обязан видеть его строкой журнала. + EnsureUser(identity Identity) (account *UserAccount, created bool, err error) +} diff --git a/internal/controller/http/app.go b/internal/controller/http/app.go index 313360f..4d765b3 100644 --- a/internal/controller/http/app.go +++ b/internal/controller/http/app.go @@ -11,25 +11,18 @@ import ( "strings" "time" - "github.com/pocketbase/pocketbase/apis" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tools/router" - "github.com/pocketbase/pocketbase/tools/types" - - "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" + "git.vakhrushev.me/av/transcriber/internal/ident" "git.vakhrushev.me/av/transcriber/internal/metrics" "git.vakhrushev.me/av/transcriber/internal/service" ) // AppRoot — корень адресов приложения. // -// Приложение живёт своим пространством, а не в общем `/api/`: последнее -// принадлежит хранилищу, оно вешает туда собственные наборы адресов, и поменять -// этот префикс нельзя — он литерал библиотеки, а не настройка. Свободных имён -// сегодня хватает, но обновление библиотеки вправе занять новое имя рядом с -// нашим, и разойдутся они молча. +// Корень остался **один**: пространства `/api/`, принадлежавшего встроенному +// хранилищу, и адреса панели `/_/` больше не существует — сервис их не занимает. +// Соседство, ради которого корень был выбран, кончилось вместе с соседом. const AppRoot = "/app" // Пределы страницы. Умолчание — столько, сколько помещается на экран телефона @@ -55,17 +48,23 @@ const pollBudgetShare = 8 // задаётся своей константой: иначе приложение, честно опрашивающее карточку с // объявленной частотой, упирается в ограничитель сервиса — и получает отказ, // которого сервис сам же ему обещал избежать. -// -// Прежде вывод давал **весь** бюджет целиком, и запаса не оставалось ни на один -// соседний запрос: любой второй в ту же секунду отвергался. Теперь объявленная -// частота — доля бюджета, и неравенство «объявленное меньше применяемого» -// выполняется с запасом. const PollIntervalMs = int64(appRateWindowSec * 1000 * pollBudgetShare / appRateMaxRequests) +// Значения параметра `copy` у адреса файла записи. Перечень закрыт, и каждое +// значение называет ровно одну хранимую вещь: имя параметра нормативно наравне +// со значениями — разбирает его каждый экран, и выбранное кодом оно стало бы +// публичным контрактом молча. +const ( + CopyParam = "copy" + CopyOriginal = "original" + CopyNormalized = "normalized" +) + type AppHandler struct { recordRepo contract.AudioRecordRepository textRepo contract.TextRepository structureRepo contract.StructureRepository + fileRepo contract.FileRepository trsService *service.TranscribeService logger *slog.Logger } @@ -74,6 +73,7 @@ func NewAppHandler( recordRepo contract.AudioRecordRepository, textRepo contract.TextRepository, structureRepo contract.StructureRepository, + fileRepo contract.FileRepository, trsService *service.TranscribeService, logger *slog.Logger, ) *AppHandler { @@ -84,6 +84,7 @@ func NewAppHandler( recordRepo: recordRepo, textRepo: textRepo, structureRepo: structureRepo, + fileRepo: fileRepo, trsService: trsService, logger: logger, } @@ -95,8 +96,11 @@ func NewAppHandler( // // Машинного текста отказа здесь нет: он принадлежит журналу владельца сервиса. // Причина остановки — значение из закрытого перечня, и она не он: без причины -// признак остановки не говорит человеку, чего ждать. Русскую фразу из значения -// делает приложение — второй словарь фраз на сервере разошёлся бы с экраном. +// признак остановки не говорит человеку, чего ждать. +// +// Перечня доступных копий файла здесь нет намеренно: копий две, и каждая +// выводится из рубежа записи, который карточка несёт и так. Второе поле +// повторяло бы рубеж и разошлось бы с ним молча. type RecordView struct { ID string `json:"id"` Title *string `json:"title"` @@ -120,11 +124,6 @@ type RecordView struct { } // IntakeItem — элемент ответа приёма: карточка плюс признак повторного файла. -// -// Место под признак заведено вперёд и заполняется другой задачей. Форма -// согласована один раз: приём, отдающий одну запись, пришлось бы переписывать -// вместе с приёмом нескольких файлов, а экран загрузки — переделывать под вторую -// форму. type IntakeItem struct { RecordView Duplicate bool `json:"duplicate"` @@ -161,68 +160,134 @@ type ReplicaView struct { Text string `json:"text"` } -// Register вешает адреса приложения на роутер хранилища. Порт у сервиса и у -// панели один, поэтому и роутер один. -func (h *AppHandler) Register(r *router.Router[*core.RequestEvent]) { - app := r.Group(AppRoot) +// Routes — адреса приложения одним обработчиком. +// +// Слоёв здесь нет: ограничитель частоты, узнавание и требование учётной записи +// вешаются на **всю** цепочку корня приложения, а корень берётся из перечня +// адресного пространства. Так область их действия выводится из объявленного +// пространства, а не перечисляется вторым списком. +// +// Метод разбирается обработчиком, а не образцом маршрута: отказ маршрутизатора +// на неверный метод ушёл бы его формой тела, а форма отказа под корнем +// приложения одна. +func (h *AppHandler) Routes() http.Handler { + mux := http.NewServeMux() - // Слой формы отказа стоит первым и снаружи всех: отказы, рождённые ниже — - // предел тела, ограничитель частоты, неизвестный путь под нашим корнем, — - // иначе ушли бы телом библиотеки, мимо единой формы. - // - // Слоя узнавания здесь нет: он вешается корневым, потому что накрывает ещё и - // адрес выдачи файлового токена из пространства хранилища. Область его - // действия при этом выводится из **этого же** перечня адресного - // пространства — см. `underIdentifiedArea`. - app.Bind(OneErrorForm()) - app.Bind(RequireUser(migrations.UsersCollection)) + for _, pattern := range AppRoutePatterns { + mux.HandleFunc(pattern, h.handlerOf(pattern)) + } - app.GET("/me", h.Me) - app.GET("/config", h.Config) + // Перехват «под нашим корнем такого адреса нет». Голый корень попадает сюда + // же: он принадлежит корню приложения, адресом приложения не является и + // потому отвечает как неизвестный путь под ним. + // + // Оба образца обязательны: без точного `/app` маршрутизатор увёл бы его + // перенаправлением на `/app/`, а перенаправления норма не заказывала. В + // закрытый перечень образцов они не входят: под них подходит **всё**, что + // накрыто корнем, а значит путь под ними выбирает спрашивающий. + mux.HandleFunc(AppRoot+"/", h.notFound) + mux.HandleFunc(AppRoot, h.notFound) - // Приём стоит тем же адресом, что и список, и отличается только методом: он - // заводит аудиозапись, а не кладёт файл. - // - // Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись - // раньше обработчика, без строки в журнале приёма. Предел тела равен потолку - // самой записи, а отказ по нему уходит нашей формой. - app.POST("/audiorecords", h.CreateRecord).Bind(apis.BodyLimit(entity.MaxRecordSize)) - app.GET("/audiorecords", h.ListRecords) - app.GET("/audiorecords/{id}", h.GetRecord) - app.GET("/audiorecords/{id}/text", h.GetRecordText) - - // Перехват «под нашим корнем такого адреса нет». - // - // Слой единой формы его не покрывает, и это не оплошность приоритета: отказ - // «ничего не совпало» рождается маршрутом **корневой** группы, к которому - // слои группы `/app` не привязаны вовсе. Без своего перехвата неизвестный - // путь и неверный метод отвечали бы телом библиотеки — то есть форм отказа - // под корнем приложения было бы две. - // - // Маршрут стоит за слоем предъявления, поэтому неизвестный путь без сессии - // отвечает `401`, а не `404`, — ровно так же, как отвечают все прочие адреса - // приложения, и по той же причине: сперва «кто спрашивает», потом «что». - app.Any("/{path...}", func(e *core.RequestEvent) error { - return fail(e, errWithMessage(contract.ErrNotFound, "Адрес не найден")) - }) + return mux } -func (h *AppHandler) Me(e *core.RequestEvent) error { +// Образцы адресов приложения. Перечень закрытый и **единственный**: из него +// вешаются обработчики, и из него же берётся значение `http.route` для журнала. +// Второй список образцов разошёлся бы с первым молча, и разошёлся бы в сторону +// журнала — путь, не попавший в перечень, уехал бы в строку дословно. +const ( + AppRouteMe = AppRoot + "/me" + AppRouteConfig = AppRoot + "/config" + AppRouteRecords = AppRoot + "/audiorecords" + AppRouteRecord = AppRoot + "/audiorecords/{id}" + AppRouteRecordText = AppRoot + "/audiorecords/{id}/text" + AppRouteRecordFile = AppRoot + "/audiorecords/{id}/file" +) + +// AppRoutePatterns — тот самый перечень. Порядок значения не имеет: +// маршрутизатор выбирает образец по точности, а не по месту в списке. +var AppRoutePatterns = []string{ + AppRouteMe, + AppRouteConfig, + AppRouteRecords, + AppRouteRecord, + AppRouteRecordText, + AppRouteRecordFile, +} + +// handlerOf выдаёт обработчик образца. +// +// Ветка на каждый образец, а не карта рядом с перечнем: недостающий образец +// здесь — отказ на подъёме, а не тихо не заведённый адрес. +func (h *AppHandler) handlerOf(pattern string) http.HandlerFunc { + switch pattern { + case AppRouteMe: + return only(h.Me, http.MethodGet) + case AppRouteConfig: + return only(h.Config, http.MethodGet) + // Приём стоит тем же адресом, что и список, и отличается только методом: он + // заводит аудиозапись, а не кладёт файл. + case AppRouteRecords: + return h.records + case AppRouteRecord: + return only(h.GetRecord, http.MethodGet) + case AppRouteRecordText: + return only(h.GetRecordText, http.MethodGet) + case AppRouteRecordFile: + return only(h.GetRecordFile, http.MethodGet, http.MethodHead) + } + + panic("адрес приложения " + pattern + " объявлен перечнем, но обработчика у него нет") +} + +// only ограничивает адрес перечнем методов. +// +// Неверный метод отвечает «адреса нет»: код отказа принадлежит закрытому +// перечню, и своего значения у «метод не тот» в нём не заведено — адрес, +// которого нет для этого метода, и есть ненайденный адрес. +func only(handler http.HandlerFunc, methods ...string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + for _, method := range methods { + if r.Method == method { + handler(w, r) + return + } + } + fail(w, errWithMessage(contract.ErrNotFound, "Адрес не найден")) + } +} + +func (h *AppHandler) notFound(w http.ResponseWriter, _ *http.Request) { + fail(w, errWithMessage(contract.ErrNotFound, "Адрес не найден")) +} + +// records — приём и список одним адресом: разница только в методе. +func (h *AppHandler) records(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + h.ListRecords(w, r) + case http.MethodPost: + h.CreateRecord(w, r) + default: + fail(w, errWithMessage(contract.ErrNotFound, "Адрес не найден")) + } +} + +func (h *AppHandler) Me(w http.ResponseWriter, r *http.Request) { + account, _ := AccountOf(r) + // Адрес почты в ответ не идёт: он приходит от провайдера и принадлежит // человеку, а не сервису. Логин у провайдера — тоже: это его имя у // провайдера, и правило о непечатаемых значениях запрещает ему выходить // наружу наравне с журналом. - return e.JSON(http.StatusOK, MeView{ - ID: e.Auth.Id, - Name: e.Auth.GetString("name"), - }) + writeJSON(w, http.StatusOK, MeView{ID: account.ID, Name: account.Name}) } -func (h *AppHandler) Config(e *core.RequestEvent) error { +func (h *AppHandler) Config(w http.ResponseWriter, _ *http.Request) { // Каждый предел — то же значение, которое сервис применяет, а не его копия. // Приложение, знающее предел своей константой, расходится с сервером молча — // до первого отказа на записи, которую человек уже успел отправить. - return e.JSON(http.StatusOK, ConfigView{ + writeJSON(w, http.StatusOK, ConfigView{ MaxRecordSizeBytes: entity.MaxRecordSize, MaxPageSize: MaxPageLimit, PollIntervalMs: PollIntervalMs, @@ -231,17 +296,35 @@ func (h *AppHandler) Config(e *core.RequestEvent) error { }) } -func (h *AppHandler) CreateRecord(e *core.RequestEvent) error { - file, header, err := e.Request.FormFile("audio") +func (h *AppHandler) CreateRecord(w http.ResponseWriter, r *http.Request) { + account, _ := AccountOf(r) + + // Предел тела назван числом: умолчания здесь не «без предела», а величины на + // два-три порядка меньше нужного, и оставленные как есть они отвергли бы + // штатную запись сервиса. Отказ по нему уходит нашей формой тела. + // + // Ловится он **дважды**, и это не избыточность. Объявленная длина судится + // заранее: запись, за которую сервис платить не станет, не должна попасть + // даже в память. Необъявленная и солгавшая ловятся на чтении — объявленной + // длины у запроса с кусочной передачей нет вовсе. + if r.ContentLength > entity.MaxRecordSize { + fail(w, contract.ErrRecordTooLarge) + return + } + r.Body = http.MaxBytesReader(w, r.Body, entity.MaxRecordSize) + + file, header, err := r.FormFile("audio") if err != nil { - // Предел тела ловит объявленную длину заранее, слоем; необъявленную — - // на чтении, уже здесь. Не различив эти два отказа, приём сказал бы - // человеку «вы не приложили файл» о записи, которую он приложил и - // которая просто больше потолка. - if errors.Is(err, apis.ErrRequestEntityTooLarge) { - return fail(e, contract.ErrRecordTooLarge) + // Предел тела ловит запись на чтении. Не различив этот отказ и + // отсутствующее поле, приём сказал бы человеку «вы не приложили файл» о + // записи, которую он приложил и которая просто больше потолка. + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + fail(w, contract.ErrRecordTooLarge) + return } - return fail(e, errWithMessage(contract.ErrBadRequest, "Запись не приложена к запросу")) + fail(w, errWithMessage(contract.ErrBadRequest, "Запись не приложена к запросу")) + return } defer func() { if err := file.Close(); err != nil { @@ -252,71 +335,78 @@ func (h *AppHandler) CreateRecord(e *core.RequestEvent) error { // Запись доехала целиком, поэтому она заводится независимо от того, дождётся // ли отправитель ответа: на контексте запроса приём терял бы полностью // загруженную запись от одного обрыва соединения, а забрать результат он - // может и позже — карточкой записи. Значения контекста (журнал запроса, - // сессия) при этом сохраняются, теряется только отмена. - ctx := context.WithoutCancel(e.Request.Context()) + // может и позже — карточкой записи. + ctx := context.WithoutCancel(r.Context()) - // Владелец берётся из предъявленной сессии и ниоткуда больше: владелец, - // пришедший полем запроса, дал бы всякому вошедшему право завести запись на + // Владелец берётся из узнанного предъявителя и ниоткуда больше: владелец, + // пришедший полем запроса, дал бы всякому узнанному право завести запись на // чужое имя. - record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, e.Auth.Id) + record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, account.ID) if err != nil { // Второй раз отказ не логируем: приём назван конвенцией логирующей // границей и уже написал о нём. Транспорт переводит ошибку в ответ, и // делает это одним местом — по причине отказа, а не по месту. - return fail(e, err) + fail(w, err) + return } // Ответ списком, даже когда файл в запросе один: форма согласована вперёд, // чтобы приём нескольких файлов и распознавание повтора её не переписывали. - return e.JSON(http.StatusCreated, []IntakeItem{{ + writeJSON(w, http.StatusCreated, []IntakeItem{{ // Свежая запись текстов не имеет, но поле обязано быть на проводе: // отсутствие поля и пустой перечень приложение не различит. RecordView: h.viewOf(record, nil, &[]string{}), }}) } -func (h *AppHandler) ListRecords(e *core.RequestEvent) error { - q := contract.RecordQuery{OwnerID: e.Auth.Id, Limit: DefaultPageLimit} +func (h *AppHandler) ListRecords(w http.ResponseWriter, r *http.Request) { + account, _ := AccountOf(r) - if raw := e.Request.URL.Query().Get("limit"); raw != "" { + q := contract.RecordQuery{OwnerID: account.ID, Limit: DefaultPageLimit} + + if raw := r.URL.Query().Get("limit"); raw != "" { limit, err := strconv.Atoi(raw) if err != nil || limit <= 0 { - return fail(e, errWithMessage(contract.ErrBadRequest, "Размер страницы должен быть положительным числом")) + fail(w, errWithMessage(contract.ErrBadRequest, "Размер страницы должен быть положительным числом")) + return } // Сверх потолка — усечение, а не отказ: человек попросил больше, чем // сервис отдаёт, но просьба сама по себе не негодна. q.Limit = min(limit, MaxPageLimit) } - if raw := e.Request.URL.Query().Get("filter"); raw != "" { + if raw := r.URL.Query().Get("filter"); raw != "" { filter, ok := entity.ParseListFilter(raw) if !ok { - return fail(e, errWithMessage(contract.ErrBadRequest, "Неизвестное состояние отбора")) + fail(w, errWithMessage(contract.ErrBadRequest, "Неизвестное состояние отбора")) + return } q.Filter = &filter } - if raw := e.Request.URL.Query().Get("cursor"); raw != "" { + if raw := r.URL.Query().Get("cursor"); raw != "" { cursor, err := decodeCursor(raw) if err != nil { // Молчаливая отдача первой страницы вместо отказа дала бы человеку // архив, листающийся по кругу, и ни строки в журнале. - return fail(e, errWithMessage(contract.ErrBadRequest, "Ключ страницы не читается")) + fail(w, errWithMessage(contract.ErrBadRequest, "Ключ страницы не читается")) + return } q.Cursor = cursor } page, err := h.recordRepo.List(q) if err != nil { - h.logger.Error("Failed to list audio records", "error", err, "owner_id", e.Auth.Id) - return fail(e, err) + h.logger.Error("Failed to list audio records", "error", err, "owner_id", account.ID) + fail(w, err) + return } - names, err := h.topicNames(e.Auth.Id, page.Items) + names, err := h.topicNames(account.ID, page.Items) if err != nil { - h.logger.Error("Failed to resolve topics", "error", err, "owner_id", e.Auth.Id) - return fail(e, err) + h.logger.Error("Failed to resolve topics", "error", err, "owner_id", account.ID) + fail(w, err) + return } view := PageView{Items: make([]RecordView, 0, len(page.Items)), TotalItems: page.TotalItems} @@ -331,55 +421,71 @@ func (h *AppHandler) ListRecords(e *core.RequestEvent) error { view.NextCursor = &encoded } - return e.JSON(http.StatusOK, view) + writeJSON(w, http.StatusOK, view) } -func (h *AppHandler) GetRecord(e *core.RequestEvent) error { - record, err := h.readOwn(e) +func (h *AppHandler) GetRecord(w http.ResponseWriter, r *http.Request) { + account, _ := AccountOf(r) + + record, err := h.readOwn(r, account.ID) if err != nil { - return fail(e, err) + fail(w, err) + return } - names, err := h.topicNames(e.Auth.Id, []*entity.AudioRecord{record}) + names, err := h.topicNames(account.ID, []*entity.AudioRecord{record}) if err != nil { h.logger.Error("Failed to resolve topics", "error", err, "record_id", record.Id) - return fail(e, err) + fail(w, err) + return } views := h.availableViews(record) - return e.JSON(http.StatusOK, h.viewOf(record, names, &views)) + writeJSON(w, http.StatusOK, h.viewOf(record, names, &views)) } -func (h *AppHandler) GetRecordText(e *core.RequestEvent) error { - view := e.Request.URL.Query().Get("view") +func (h *AppHandler) GetRecordText(w http.ResponseWriter, r *http.Request) { + account, _ := AccountOf(r) + + view := r.URL.Query().Get("view") if !entity.IsKnownTextView(view) { - return fail(e, errWithMessage(contract.ErrBadRequest, "Неизвестный вид текста")) + fail(w, errWithMessage(contract.ErrBadRequest, "Неизвестный вид текста")) + return } - record, err := h.readOwn(e) + record, err := h.readOwn(r, account.ID) if err != nil { - return fail(e, err) + fail(w, err) + return } if view == entity.TextViewReplicas { - return h.replicasOf(e, record) + h.replicasOf(w, record) + return } - return h.plainTextOf(e, record, view) + h.plainTextOf(w, record, view) } -// readOwn читает запись спрашивающего. Чужая, ничья и несуществующая отвечают -// одним и тем же: по разнице ответов иначе перебирается список заведённых -// записей. -func (h *AppHandler) readOwn(e *core.RequestEvent) (*entity.AudioRecord, error) { - recordID := e.Request.PathValue("id") +// readOwn читает запись спрашивающего. Чужая, ничья, несуществующая и +// нечитаемая по виду идентификатора отвечают одним и тем же: по разнице ответов +// иначе перебирается список заведённых записей. +func (h *AppHandler) readOwn(r *http.Request, ownerID string) (*entity.AudioRecord, error) { + // Идентификатор разбирается на границе: он приходит от спрашивающего, а + // сравнение в базе побайтово — запись в верхнем регистре не совпала бы ни с + // одной строкой. Негодный по виду считается несуществующим и до базы не + // доходит вовсе. + recordID, ok := ident.Parse(r.PathValue("id")) + if !ok { + return nil, &contract.JobNotFoundError{Message: "record not found"} + } - record, err := h.recordRepo.GetByID(recordID, e.Auth.Id) + record, err := h.recordRepo.GetByID(recordID, ownerID) if err != nil { // Наружу ответ один на все исходы, а в журнал они идут по-разному. // «Записи нет» и «запись чужая» — штатная работа разграничения, о ней - // писать нечего; всё прочее — отказ хранилища, и без этой строки он - // приходит отправителю как «вашей записи нет», а владелец сервиса об - // аварии не узнаёт ниоткуда. + // писать нечего; всё прочее — отказ базы, и без этой строки он приходит + // отправителю как «вашей записи нет», а владелец сервиса об аварии не + // узнаёт ниоткуда. var notFound *contract.JobNotFoundError if !errors.As(err, ¬Found) { h.logger.Error("Failed to read audio record", "error", err, "record_id", recordID) @@ -389,52 +495,62 @@ func (h *AppHandler) readOwn(e *core.RequestEvent) (*entity.AudioRecord, error) return record, nil } -func (h *AppHandler) plainTextOf(e *core.RequestEvent, record *entity.AudioRecord, view string) error { +func (h *AppHandler) plainTextOf(w http.ResponseWriter, record *entity.AudioRecord, view string) { textID := record.TranscriptTextID if view == entity.TextViewLiterary { textID = record.LiteraryTextID } if textID == nil { - return fail(e, contract.ErrTextNotReady) + fail(w, contract.ErrTextNotReady) + return } text, err := h.textRepo.GetByID(*textID) if err != nil { h.logger.Error("Failed to read text", "error", err, "record_id", record.Id) - return fail(e, err) + fail(w, err) + return } if text.Contents == "" { - return fail(e, contract.ErrTextNotReady) + fail(w, contract.ErrTextNotReady) + return } - return e.JSON(http.StatusOK, TextView{View: view, Contents: text.Contents}) + writeJSON(w, http.StatusOK, TextView{View: view, Contents: text.Contents}) } -func (h *AppHandler) replicasOf(e *core.RequestEvent, record *entity.AudioRecord) error { +func (h *AppHandler) replicasOf(w http.ResponseWriter, record *entity.AudioRecord) { if record.StructureID == nil { - return fail(e, contract.ErrTextNotReady) + fail(w, contract.ErrTextNotReady) + return } structure, err := h.structureRepo.GetByID(*record.StructureID) if err != nil { h.logger.Error("Failed to read structure", "error", err, "record_id", record.Id) - return fail(e, err) + fail(w, err) + return } if len(structure.Replicas) == 0 { - return fail(e, contract.ErrTextNotReady) + fail(w, contract.ErrTextNotReady) + return } replicas := make([]ReplicaView, 0, len(structure.Replicas)) - for _, r := range structure.Replicas { - replicas = append(replicas, ReplicaView{StartMs: r.StartMs, EndMs: r.EndMs, Text: r.Text}) + for _, replica := range structure.Replicas { + replicas = append(replicas, ReplicaView{ + StartMs: replica.StartMs, + EndMs: replica.EndMs, + Text: replica.Text, + }) } - return e.JSON(http.StatusOK, TextView{View: entity.TextViewReplicas, Replicas: replicas}) + writeJSON(w, http.StatusOK, TextView{View: entity.TextViewReplicas, Replicas: replicas}) } // topicNames разрешает темы всех записей страницы **одним** запросом: страница в -// сотню записей иначе стоила бы сотни обращений к хранилищу. +// сотню записей иначе стоила бы сотни обращений к базе. func (h *AppHandler) topicNames(ownerID string, records []*entity.AudioRecord) (map[string]string, error) { seen := map[string]bool{} ids := []string{} @@ -481,9 +597,7 @@ func (h *AppHandler) viewOf(record *entity.AudioRecord, names map[string]string, // Вид считается доступным по **содержимому**, а не по наличию ссылки. Ссылка // без содержимого — состояние штатное: пустой ответ распознавания проект признаёт // нормой и записывает его в журнал. Строй мы перечень по ссылкам, карточка -// объявляла бы вид доступным, а адрес текста отвечал бы «ещё не готов» вечно: -// приложение опрашивало бы его без конца, а человек видел бы завершённую запись, -// из которой текст «вот-вот появится». +// объявляла бы вид доступным, а адрес текста отвечал бы «ещё не готов» вечно. func (h *AppHandler) availableViews(record *entity.AudioRecord) []string { views := []string{} @@ -501,8 +615,7 @@ func (h *AppHandler) availableViews(record *entity.AudioRecord) []string { // hasText — есть ли у записи непустой текст этого вида. Отказ чтения читается // как «вида нет»: перечень доступных видов — подсказка приложению, и уронить -// из-за неё карточку хуже, чем недосказать. Сам отказ виден владельцу сервиса -// журналом, который пишет чтение текста. +// из-за неё карточку хуже, чем недосказать. func (h *AppHandler) hasText(textID *string) bool { if textID == nil { return false @@ -531,11 +644,12 @@ func (h *AppHandler) hasReplicas(structureID *string) bool { // непрозрачной строкой: спрашивающему её содержимое не принадлежит, а // составлять ключ руками значило бы завязаться на порядок сортировки. // -// Кодировка нужна и по существу: время заведения несёт пробел, и голая пара -// разорвала бы строку запроса. Кодирование без набивки и в адресном алфавите — -// ключ уезжает параметром, а не телом. +// Кодирование без набивки и в адресном алфавите — ключ уезжает параметром, а не +// телом. func encodeCursor(c *contract.RecordCursor) string { - return base64.RawURLEncoding.EncodeToString([]byte(c.CreatedAt + "|" + c.ID)) + return base64.RawURLEncoding.EncodeToString( + []byte(c.CreatedAt.UTC().Format(time.RFC3339) + "|" + c.ID), + ) } func decodeCursor(raw string) (*contract.RecordCursor, error) { @@ -545,32 +659,23 @@ func decodeCursor(raw string) (*contract.RecordCursor, error) { } createdAt, id, ok := strings.Cut(string(decoded), "|") - if !ok || createdAt == "" || id == "" { + if !ok { return nil, errors.New("malformed cursor") } - // Время разбирается, а не берётся строкой: в запрос оно уходит побайтовым - // сравнением, и вид, разошедшийся с тем, каким пишет хранилище, молча - // обращает условие в постоянную истину или ложь — человек получает либо - // пустой архив при непустом счётчике, либо ленту с начала. - parsed, err := types.ParseDateTime(createdAt) - if err != nil || parsed.IsZero() { + // Обе половины ключа разбираются, а не берутся строкой: время уходит в + // запрос сравнением, а идентификатор — точным совпадением, и негодная + // половина дала бы человеку либо пустой архив при непустом счётчике, либо + // ленту с начала. + parsed, err := time.Parse(time.RFC3339, createdAt) + if err != nil { return nil, errors.New("cursor carries no readable time") } - return &contract.RecordCursor{CreatedAt: parsed.String(), ID: id}, nil -} + recordID, valid := ident.Parse(id) + if !valid { + return nil, errors.New("cursor carries no readable record key") + } -// errWithMessage приклеивает к признаку негодного ввода свой текст: причина у -// всех одна, а сказать человеку надо разное. -func errWithMessage(base error, message string) error { - return &messagedError{base: base, message: message} + return &contract.RecordCursor{CreatedAt: parsed.UTC(), ID: recordID}, nil } - -type messagedError struct { - base error - message string -} - -func (e *messagedError) Error() string { return e.message } -func (e *messagedError) Unwrap() error { return e.base } diff --git a/internal/controller/http/auth_test.go b/internal/controller/http/auth_test.go index 8d6f4c1..17846d0 100644 --- a/internal/controller/http/auth_test.go +++ b/internal/controller/http/auth_test.go @@ -7,16 +7,13 @@ import ( "strings" "testing" - "github.com/pocketbase/pocketbase/apis" - "github.com/pocketbase/pocketbase/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" - "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" + "git.vakhrushev.me/av/transcriber/internal/entity" ) -// Проверки этого файла судят допуск: кого пускают к приёму и опросу, чем +// Проверки этого файла судят допуск: кого пускают к адресам приложения, чем // называется пришедший, кому верят и какие адреса остаются открытыми. // TestApiRequiresIdentity — первый критерий приёмки. Запрос неузнанного получает @@ -33,31 +30,22 @@ func TestApiRequiresIdentity(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, w.Code) assert.NotContains(t, w.Body.String(), "job_id") - // Ни файла, ни задачи: отказ наступает раньше, чем запись попадает в - // хранилище. - files, err := env.app.FindAllRecords(migrations.FilesCollection) - require.NoError(t, err) - assert.Empty(t, files) - - jobs, err := env.app.FindAllRecords(migrations.RecordsCollection) - require.NoError(t, err) - assert.Empty(t, jobs) + // Ни файла, ни записи: отказ наступает раньше, чем запись попадает в + // каталог данных. + assert.Equal(t, 0, countFiles(t, env)) + assert.Equal(t, 0, countJobs(t, env)) }) t.Run("карточка записи неузнанному", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/app/audiorecords/anything", nil) - w := httptest.NewRecorder() - - env.mux.ServeHTTP(w, req) + w := env.get("/app/audiorecords/" + strings.Repeat("0", 26)) assert.Equal(t, http.StatusUnauthorized, w.Code) - assert.NotContains(t, w.Body.String(), "transcription_text") assert.NotContains(t, w.Body.String(), "created_at") }) } // TestUnknownJobIsIndistinguishableWithoutIdentity: по кодам ответа неузнанному -// не перебирается список заведённых задач. +// не перебирается список заведённых записей. func TestUnknownJobIsIndistinguishableWithoutIdentity(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) @@ -65,24 +53,16 @@ func TestUnknownJobIsIndistinguishableWithoutIdentity(t *testing.T) { env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio"))) require.Equal(t, http.StatusCreated, created.Code) - jobs, err := env.app.FindAllRecords(migrations.RecordsCollection) - require.NoError(t, err) - require.Len(t, jobs, 1) - - existing := httptest.NewRecorder() - env.mux.ServeHTTP(existing, httptest.NewRequest(http.MethodGet, "/app/audiorecords/"+jobs[0].Id, nil)) - - missing := httptest.NewRecorder() - env.mux.ServeHTTP(missing, httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)) + existing := env.get("/app/audiorecords/" + intakeItemOf(t, created).ID) + missing := env.get("/app/audiorecords/" + strings.Repeat("0", 26)) assert.Equal(t, http.StatusUnauthorized, existing.Code) assert.Equal(t, missing.Code, existing.Code) + assert.Equal(t, missing.Body.String(), existing.Body.String()) } -// TestFirstRequestCreatesAccountAndSecondReuses — **первый критерий приёмки**. -// -// Два запроса подряд с одним значением заголовка: учётная запись заводится -// первым и находится вторым, а в хранилище её строка одна. +// TestFirstRequestCreatesAccountAndSecondReuses — учётная запись заводится +// первым обращением и находится вторым, а строка её в базе одна. func TestFirstRequestCreatesAccountAndSecondReuses(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) @@ -90,12 +70,10 @@ func TestFirstRequestCreatesAccountAndSecondReuses(t *testing.T) { before := countAccounts(t, env) - first := httptest.NewRecorder() - env.mux.ServeHTTP(first, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login)) + first := env.getAs(login, "/app/me") require.Equal(t, http.StatusOK, first.Code, "первое обращение узнано") - second := httptest.NewRecorder() - env.mux.ServeHTTP(second, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login)) + second := env.getAs(login, "/app/me") require.Equal(t, http.StatusOK, second.Code) assert.Equal(t, before+1, countAccounts(t, env), @@ -104,8 +82,8 @@ func TestFirstRequestCreatesAccountAndSecondReuses(t *testing.T) { "второе обращение попало в другую учётную запись") } -// TestUntrustedPeerIsNotIdentified — **второй критерий приёмки**. Тот же -// заголовок с недоверенного адреса даёт отказ, а не вход под названным именем. +// TestUntrustedPeerIsNotIdentified — тот же заголовок с недоверенного адреса +// даёт отказ, а не вход под названным именем. func TestUntrustedPeerIsNotIdentified(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) @@ -123,118 +101,75 @@ func TestUntrustedPeerIsNotIdentified(t *testing.T) { "заголовок с недоверенного адреса завёл учётную запись") } -// TestStorageOwnLoginAddressesGiveNothing — **третий критерий приёмки**. +// TestStorageAddressSpaceIsGone — **критерий приёмки**: пространства хранилища +// не существует. // -// Перечень собственных адресов входа хранилища: каждый отвечает отказом и -// учётной записи не меняет. Перечень закрыт и назван поимённо — пока хоть один -// из них работает, узнавание по заголовку обходится двумя запросами. -func TestStorageOwnLoginAddressesGiveNothing(t *testing.T) { +// Прежде под корнем `/api/` жила собственная поверхность встроенного хранилища: +// собственные входы, перечисление коллекции пользователей, правка своей строки — +// то есть путь захвата чужого имени. Хранилище ушло целиком, и адресов этих нет: +// они отвечают тем же, чем отвечает всякий путь вне корней сервиса. +// +// Проверка судит **и то, что ответ прежний, и то, что ничего не произошло**: +// число учётных записей после обхода то же самое. +func TestStorageAddressSpaceIsGone(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) - const usersRoot = "/api/collections/users" + before := countAccounts(t, env) - cases := []struct { - name string - path string - body string - }{ - { - name: "завести учётную запись самому", - path: usersRoot + "/records", - body: `{"email":"intruder@example.com","password":"12345678901","passwordConfirm":"12345678901"}`, - }, - { - name: "вход по паролю", - path: usersRoot + "/auth-with-password", - body: `{"identity":"person@example.com","password":"whatever"}`, - }, - { - name: "обмен кода у внешнего провайдера", - path: usersRoot + "/auth-with-oauth2", - body: `{"provider":"oidc","code":"whatever","codeVerifier":"whatever","redirectURL":"https://example.com/"}`, - }, - { - name: "вход по одноразовому коду", - path: usersRoot + "/auth-with-otp", - body: `{"otpId":"whatever","password":"whatever"}`, - }, - { - name: "запрос одноразового кода", - path: usersRoot + "/request-otp", - body: `{"email":"person@example.com"}`, - }, - { - name: "восстановление пароля", - path: usersRoot + "/request-password-reset", - body: `{"email":"person@example.com"}`, - }, - { - name: "продление сессии", - path: usersRoot + "/auth-refresh", - body: `{}`, - }, + // Эталон: путь вне корней сервиса, за которым не стояло ничего никогда. + reference := env.get("/nothing-was-ever-here") + require.Equal(t, http.StatusOK, reference.Code) + + cases := map[string]string{ + "перечисление учётных записей": "/api/collections/users/records", + "вход по паролю": "/api/collections/users/auth-with-password", + "обмен кода у провайдера": "/api/collections/users/auth-with-oauth2", + "выдача токена файла": "/api/files/token", + "адрес панели": "/_/", + "адрес панели знаком кода": "/%5f/", } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - before := countAccounts(t, env) + for name, path := range cases { + t.Run(name, func(t *testing.T) { + w := env.getOwn(path) - req := httptest.NewRequest(http.MethodPost, c.path, strings.NewReader(c.body)) - req.Header.Set("Content-Type", "application/json") - - w := httptest.NewRecorder() - env.mux.ServeHTTP(w, req) - - assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest, - "адрес %s ответил успехом: собственный вход хранилища открыт", c.path) + assert.Equal(t, reference.Code, w.Code, + "адрес %s отвечает не как всякий неизвестный путь", path) + assert.Equal(t, reference.Body.String(), w.Body.String(), + "адрес %s отвечает не тем же телом, что всякий неизвестный путь", path) assert.NotContains(t, w.Body.String(), `"token"`, - "адрес %s выдал значение доступа", c.path) - assert.Equal(t, before, countAccounts(t, env), - "адрес %s изменил число учётных записей", c.path) + "адрес %s выдал значение доступа", path) }) } + + assert.Equal(t, before, countAccounts(t, env), + "обход прежнего пространства хранилища изменил число учётных записей") } -// TestUserRecordCannotBeEditedFromOutside — путь захвата чужого имени закрыт. +// TestAccountKeyHasNoEditAddress: ключ учётной записи не правится ничем, кроме +// заведения самим сервисом. // -// Ключ учётной записи лежит обычной колонкой, а умолчание библиотеки открывает -// владельцу записи правку собственной строки. Пока узнавание жило под корнем -// приложения, до этой поверхности браузер не дотягивался вовсе; теперь она -// достижима, и правка своей записи была бы захватом чужого имени: поставил себе -// чужой логин — и первое обращение настоящего его владельца попало бы в твою -// запись вместе со всем архивом. -func TestUserRecordCannotBeEditedFromOutside(t *testing.T) { +// Переписанный ключ отдаёт архив следующему, кто придёт с этим именем, а вернуть +// его будет нечем. Держится это тем, что адреса правки учётной записи у сервиса +// нет вовсе — своих экранов профиля он не заводит, а поверхности хранилища, +// правившей запись библиотечным правилом, не осталось. +func TestAccountKeyHasNoEditAddress(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) - body := strings.NewReader(`{"` + migrations.ProviderLoginField + `":"victim"}`) - req := httptest.NewRequest(http.MethodPatch, "/api/collections/users/records/"+env.account.Id, body) + body := strings.NewReader(`{"provider_login":"victim"}`) + req := httptest.NewRequest(http.MethodPatch, "/api/collections/users/records/"+env.account.ID, body) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() env.mux.ServeHTTP(w, asUser(req, env.login)) - assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest, "правка своей учётной записи прошла") - - after, err := env.app.FindRecordById(migrations.UsersCollection, env.account.Id) - require.NoError(t, err) - assert.Equal(t, env.login, after.GetString(migrations.ProviderLoginField), + // Метод правки до раздачи приложения не доходит: она открывает страницу + // только на `GET` и `HEAD`. + assert.Equal(t, http.StatusMethodNotAllowed, w.Code) + assert.Equal(t, env.login, accountLogin(t, env, env.account.ID), "ключ учётной записи переписан снаружи") } -// TestUserRecordsCannotBeListed: перечисление коллекции пользователей закрыто. -// Открытое, оно отдавало бы узнанному логины всех остальных — то есть ровно те -// значения, которыми довольно назваться. -func TestUserRecordsCannotBeListed(t *testing.T) { - env := setupTestEnv(t, readableMetaViewer()) - - w := httptest.NewRecorder() - env.mux.ServeHTTP(w, asUser( - httptest.NewRequest(http.MethodGet, "/api/collections/users/records", nil), env.login)) - - assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest) - assert.NotContains(t, w.Body.String(), env.login) -} - // TestDegenerateHeaderIdentifiesNobody: вырожденное значение никого не узнаёт и // ничего не заводит. // @@ -248,7 +183,7 @@ func TestDegenerateHeaderIdentifiesNobody(t *testing.T) { "пустое значение": "", "одни пробелы": " ", "управляющий знак": "ali\x00ce", - "длиннее предела": strings.Repeat("a", pbrepo.MaxProviderLoginLength+1), + "длиннее предела": strings.Repeat("a", entity.MaxProviderLoginLength+1), } for name, value := range cases { @@ -292,104 +227,66 @@ func TestTwoLoginHeadersIdentifyNobody(t *testing.T) { assert.Equal(t, before, countAccounts(t, env)) } -// TestStorageTokenBeatsHeader: годный собственный токен хранилища побеждает -// заголовок, а протухший узнаванию не мешает. +// TestPresentedValueIsNotAccepted: предъявленного значения сервис не признаёт. // -// Первая половина защищает владельца панели: подмена его учётной записью -// пользователя отобрала бы у него панель посреди работы. Вторая — обычного -// человека: негодный токен, оставшийся в браузере, не должен запирать его -// снаружи. -func TestStorageTokenBeatsHeader(t *testing.T) { +// Собственных токенов у него не существует — ни выдаваемых, ни принимаемых, — и +// пришедшим считается названный заголовком. Прежде такое значение било заголовок: +// им пользовался владелец панели, а панели больше нет. +func TestPresentedValueIsNotAccepted(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) - stranger, strangerLogin := newSecondAccount(t, env.app) - token, err := stranger.NewAuthToken() - require.NoError(t, err) + stranger := newSecondAccount(t, env) - t.Run("годный токен побеждает", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/app/me", nil) - req.Header.Set("Authorization", token) + req := httptest.NewRequest(http.MethodGet, "/app/me?token=whatever", nil) + req.Header.Set("Authorization", "Bearer whatever") - w := httptest.NewRecorder() - env.mux.ServeHTTP(w, asUser(req, env.login)) + w := httptest.NewRecorder() + env.mux.ServeHTTP(w, asUser(req, env.login)) - require.Equal(t, http.StatusOK, w.Code) - assert.Contains(t, w.Body.String(), stranger.Id, - "заголовок победил предъявленный токен") - _ = strangerLogin - }) - - t.Run("протухший токен узнаванию не мешает", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/app/me", nil) - req.Header.Set("Authorization", "not-a-token") - - w := httptest.NewRecorder() - env.mux.ServeHTTP(w, asUser(req, env.login)) - - require.Equal(t, http.StatusOK, w.Code) - assert.Contains(t, w.Body.String(), env.account.Id) - }) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), env.account.ID, + "предъявленное значение победило заголовок") + assert.NotContains(t, w.Body.String(), stranger.ID) } -// TestOpenAddressesDoNotIdentify: проба здоровья и метрики открыты -// неузнанному, а заголовок на них учётной записи не заводит. +// TestOpenAddressesDoNotIdentify: проба здоровья и метрики открыты неузнанному, +// а заголовок на них учётной записи не заводит. // // Вторая половина важнее первой: узнавание сужено до области приложения именно // затем, чтобы запрос за каждой картинкой не стоил обращения к базе, а первый // такой запрос с новым именем — записи в неё. func TestOpenAddressesDoNotIdentify(t *testing.T) { - app := newTestStorage(t) + env := setupTestEnv(t, readableMetaViewer()) - r, err := apis.NewRouter(app) - require.NoError(t, err) + before := countAccounts(t, env) - appHandler := NewAppHandler(nil, nil, nil, nil, nil) - mounts := ServiceMounts(appHandler, http.NotFoundHandler()) - r.Bind(TrustedHeaderIdentity(app, mounts, testTrustedNetworks(t), nil)) - - r.GET(HealthPath, func(e *core.RequestEvent) error { - return e.JSON(http.StatusOK, map[string]string{"status": "ok"}) - }) - r.GET(MetricsPath, func(e *core.RequestEvent) error { - return e.String(http.StatusOK, "# metrics") - }) - - mux, err := r.BuildMux() - require.NoError(t, err) - - for _, path := range []string{HealthPath, MetricsPath} { - anonymous := httptest.NewRecorder() - mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, path, nil)) + for _, path := range []string{HealthPath, "/", "/assets/index-abc123.js"} { + anonymous := env.get(path) assert.Equal(t, http.StatusOK, anonymous.Code, "адрес %s обязан отвечать неузнанному", path) - withHeader := httptest.NewRecorder() - mux.ServeHTTP(withHeader, asUser(httptest.NewRequest(http.MethodGet, path, nil), "passerby")) + withHeader := env.getAs("passerby", path) assert.Equal(t, http.StatusOK, withHeader.Code, "чужой заголовок изменил ответ адреса %s: наблюдение гасится строкой в запросе", path) } - records, err := app.FindAllRecords(migrations.UsersCollection) - require.NoError(t, err) - assert.Empty(t, records, "обращение к открытому адресу завело учётную запись") + assert.Equal(t, before, countAccounts(t, env), + "обращение к открытому адресу завело учётную запись") } // TestServiceIssuesNothingThatOutlivesRequest: сервис не ставит браузеру куки. // // Проверка судит именно **отсутствие**: пока сервис выдавал значение на семь -// суток, отозванный у провайдера человек работал до его истечения. Вернувшаяся -// кука вернула бы и это. +// суток, отозванный у провайдера человек работал до его истечения. func TestServiceIssuesNothingThatOutlivesRequest(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) - w := httptest.NewRecorder() - env.serve(w, httptest.NewRequest(http.MethodGet, "/app/me", nil)) + w := env.getOwn("/app/me") require.Equal(t, http.StatusOK, w.Code) assert.Empty(t, w.Result().Cookies(), "ответ поставил куку: значение переживёт запрос") } -// TestIdentityValuesAreNotLogged — **четвёртый критерий приёмки**: не -// печатается ничто, что даёт доступ. +// TestIdentityValuesAreNotLogged — не печатается ничто, что даёт доступ. // // Проверка ищет в журнале **значения**, а не имена полей: значение, уехавшее под // другим ключом, поиск по ключу не разбудил бы. Логин здесь наравне с почтой: им @@ -406,16 +303,12 @@ func TestIdentityValuesAreNotLogged(t *testing.T) { assert.NotContains(t, journal, env.login, "логин в журнале: строкой довольно назваться, чтобы стать этим человеком") - assert.NotContains(t, journal, env.account.Email(), + assert.NotContains(t, journal, "person@example.com", "адрес почты в журнале: он приходит от провайдера и принадлежит человеку") } // TestUntrustedPeerIsLogged: недоверенный источник виден владельцу журналом, и // виден **адресом пира**, а не значением заголовка. -// -// Без этой строки владелец, у которого никто не может войти, не отличит своей -// поломки (перечень доверенных адресов) от поломки контура (прокси заголовка не -// ставит) — а это разные поломки в разных местах. func TestUntrustedPeerIsLogged(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) @@ -434,110 +327,46 @@ func TestUntrustedPeerIsLogged(t *testing.T) { assert.NotContains(t, journal, intruder, "значение заголовка уехало в журнал") } -// TestRecordFileIsProtected: ссылка на файл перестала быть правом пройти по ней. -func TestRecordFileIsProtected(t *testing.T) { - app := newTestStorage(t) - - files, err := app.FindCollectionByNameOrId(migrations.FilesCollection) - require.NoError(t, err) - - field, ok := files.Fields.GetByName("file").(*core.FileField) - require.True(t, ok) - assert.True(t, field.Protected, - "поле файла не защищено: знание ссылки снова стало бы доступом, а отзыва у неё нет") -} - -// TestRecordFileNeedsToken: ссылка на файл записи без токена отказывает, а -// конвейер тот же файл по-прежнему читает — он ходит в файловую систему, а не по -// ссылке. -func TestRecordFileNeedsToken(t *testing.T) { - env := setupTestEnv(t, readableMetaViewer()) - - created := httptest.NewRecorder() - env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio content"))) - require.Equal(t, http.StatusCreated, created.Code) - - files, err := env.app.FindAllRecords(migrations.FilesCollection) - require.NoError(t, err) - require.Len(t, files, 1) - - names := files[0].GetStringSlice("file") - require.Len(t, names, 1) - - link := "/api/files/" + migrations.FilesCollection + "/" + files[0].Id + "/" + names[0] - - anonymous := httptest.NewRecorder() - env.mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, link, nil)) - // Отказ приходит кодом «не найдено»: защищённый файл не раскрывает даже - // своего существования. До пометки поля защищённым эта же ссылка отдавала - // содержимое кому угодно — знание ссылки и было доступом. - assert.Equal(t, http.StatusNotFound, anonymous.Code, - "ссылка отдала файл без токена: знание ссылки снова стало доступом") - assert.NotContains(t, anonymous.Body.String(), "audio content") - - // А узнанный по заголовку берёт токен и проходит: путь «узнавание → токен - // файла → ссылка» обязан работать целиком, иначе файл записи недостижим для - // браузера вовсе. - withToken := httptest.NewRecorder() - env.mux.ServeHTTP(withToken, - httptest.NewRequest(http.MethodGet, link+"?token="+fileToken(t, env, env.login), nil)) - require.Equal(t, http.StatusOK, withToken.Code) - assert.Equal(t, "audio content", withToken.Body.String()) - - // Конвейер читает тот же файл своим путём — из файловой системы хранилища. - fileRepo := pbrepo.NewFileRepository(env.app) - reader, err := fileRepo.Open(files[0].Id) - require.NoError(t, err) - defer func() { - assert.NoError(t, reader.Close()) - }() - - content := make([]byte, len("audio content")) - _, err = reader.Read(content) - require.NoError(t, err) - assert.Equal(t, "audio content", string(content)) -} - -// countAccounts — сколько учётных записей лежит в хранилище. Проверки судят -// заведение по числу строк: «запись одна» и «записи две» — разные исходы, а по -// ответу обработчика они неразличимы. +// countAccounts — сколько учётных записей лежит в базе. Проверки судят заведение +// по числу строк: «запись одна» и «записи две» — разные исходы, а по ответу +// обработчика они неразличимы. func countAccounts(t *testing.T, env *testEnv) int { t.Helper() - records, err := env.app.FindAllRecords(migrations.UsersCollection) - require.NoError(t, err) + return countRows(t, env, "users") +} - return len(records) +// accountLogin читает ключ учётной записи прямо из базы. +func accountLogin(t *testing.T, env *testEnv, accountID string) string { + t.Helper() + + var login string + require.NoError(t, env.db.Reader(). + QueryRow("SELECT provider_login FROM users WHERE id = ?", accountID).Scan(&login)) + return login } // TestRejectedByRateLimitCreatesNoAccount — отвергнутый ограничителем частоты // запрос не заводит учётной записи. // // Слой узнавания читает базу, а на новом имени ещё и пишет в неё. Стоя раньше -// ограничителя, он работал на запросах, которые тот уже отверг: сто двадцать -// запросов выбирали бюджет, следующие пятьдесят получали отказ — и заводили -// пятьдесят учётных записей. Убрать их потом нечем: учётная запись с записями -// не удаляется, а мусорная растёт в той же единственной базе. +// ограничителя, он работал бы на запросах, которые тот уже отверг: бюджет +// выбирается, следующие запросы получают отказ — и заводят учётные записи. +// Убрать их потом нечем: учётная запись с записями не удаляется. func TestRejectedByRateLimitCreatesNoAccount(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) - require.NoError(t, ApplyAppRateLimit(env.app)) - before := countAccounts(t, env) // Бюджет выбирается запросами одного имени, чтобы счётчик успел упереться в // потолок раньше, чем начнутся новые имена. for range appRateMaxRequests + 5 { - w := httptest.NewRecorder() - env.serve(w, httptest.NewRequest(http.MethodGet, "/app/me", nil)) + env.getOwn("/app/me") } rejected := 0 for i := range 20 { - w := httptest.NewRecorder() - env.mux.ServeHTTP(w, asUser( - httptest.NewRequest(http.MethodGet, "/app/me", nil), - fmt.Sprintf("newcomer-%d", i))) + w := env.getAs(fmt.Sprintf("newcomer-%d", i), "/app/me") if w.Code == http.StatusTooManyRequests { rejected++ } @@ -549,18 +378,12 @@ func TestRejectedByRateLimitCreatesNoAccount(t *testing.T) { } // TestAccountCreationIsLogged — заведение учётной записи видно владельцу. -// -// Без строки журнала «никто не заходил» неотличимо от «завелось двадцать», а -// прокси, пропустивший чужой заголовок, не оставляет следа вовсе. Значение -// заголовка при этом в строку не идёт: им довольно назваться, чтобы стать этим -// человеком. func TestAccountCreationIsLogged(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) const login = "brand-new-person" - w := httptest.NewRecorder() - env.mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login)) + w := env.getAs(login, "/app/me") require.Equal(t, http.StatusOK, w.Code) journal := env.journal.String() @@ -570,8 +393,7 @@ func TestAccountCreationIsLogged(t *testing.T) { // Второе обращение новой строки не прибавляет: заводится запись однажды. before := strings.Count(journal, "Account created from login header") - again := httptest.NewRecorder() - env.mux.ServeHTTP(again, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login)) + again := env.getAs(login, "/app/me") require.Equal(t, http.StatusOK, again.Code) assert.Equal(t, before, strings.Count(env.journal.String(), "Account created from login header"), @@ -579,10 +401,6 @@ func TestAccountCreationIsLogged(t *testing.T) { } // TestDuplicateLoginHeaderIsVisibleToOwner — поломка контура видна в бою. -// -// Два значения заголовка означают прокси, который его добавляет вместо замены, -// — модель угроз называет это главным барьером. Отладочным уровнем такая -// поломка в бою не видна вовсе: боевой уровень журнала информационный. func TestDuplicateLoginHeaderIsVisibleToOwner(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) @@ -601,27 +419,23 @@ func TestDuplicateLoginHeaderIsVisibleToOwner(t *testing.T) { assert.NotContains(t, journal, "intruder", "значение заголовка уехало в журнал") } -// TestStorageFailureOnIdentityIsServiceFailure — отказ хранилища на пути -// узнавания кончается отказом сервиса, а не молчаливым проходом неузнанным. +// TestStorageFailureOnIdentityIsServiceFailure — отказ базы на пути узнавания +// кончается отказом сервиса, а не молчаливым проходом неузнанным. // -// Иначе человек увидел бы отказ входа там, где легла база, и чинил бы у себя -// то, что сломано не у него. +// Иначе человек увидел бы отказ входа там, где легла база, и чинил бы у себя то, +// что сломано не у него. func TestStorageFailureOnIdentityIsServiceFailure(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) - // Колонка ключа убирается из схемы: выборка по ней перестаёт работать — так - // же, как она перестанет работать при отказе хранилища. - users, err := env.app.FindCollectionByNameOrId(migrations.UsersCollection) + // Колонка ключа переименовывается: выборка по ней перестаёт работать — так + // же, как она перестанет работать при отказе базы. + _, err := env.db.Writer().Exec("ALTER TABLE users RENAME COLUMN provider_login TO provider_login_gone") require.NoError(t, err) - users.RemoveIndex("idx_users_provider_login") - users.Fields.RemoveByName(migrations.ProviderLoginField) - require.NoError(t, env.app.Save(users)) - w := httptest.NewRecorder() - env.mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), "somebody")) + w := env.getAs("somebody", "/app/me") assert.GreaterOrEqual(t, w.Code, http.StatusInternalServerError, - "отказ хранилища выдан за «вас не узнали»") + "отказ базы выдан за «вас не узнали»") assert.Contains(t, env.journal.String(), "Failed to resolve account by login header") } @@ -632,7 +446,7 @@ func TestStorageFailureOnIdentityIsServiceFailure(t *testing.T) { // начал бы отвечать отказом контракта, и старая закладка молча сменила бы // поведение. func TestFormerAuthRootServesMarkup(t *testing.T) { - env := setupWebappEnv(t, builtDist(), true) + env := setupTestEnv(t, readableMetaViewer()) res := env.get("/auth/login") diff --git a/internal/controller/http/contract_test.go b/internal/controller/http/contract_test.go index eda8019..4b67d1d 100644 --- a/internal/controller/http/contract_test.go +++ b/internal/controller/http/contract_test.go @@ -1,16 +1,24 @@ package http import ( + "bufio" + "context" "encoding/json" + "fmt" + "io" + "log/slog" + "net" "net/http" "net/http/httptest" + "strconv" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" + sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite" "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/metrics" @@ -57,7 +65,7 @@ func TestMe_CarriesAccountWithoutEmail(t *testing.T) { var me MeView require.NoError(t, json.Unmarshal(w.Body.Bytes(), &me)) - assert.Equal(t, env.account.Id, me.ID) + assert.Equal(t, env.account.ID, me.ID) assert.NotContains(t, w.Body.String(), "person@example.com", "адрес почты принадлежит человеку, а не сервису") } @@ -73,7 +81,7 @@ func TestUnauthorized_ExistingRecordLooksLikeUnknown(t *testing.T) { env.mux.ServeHTTP(existing, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id, http.NoBody)) unknown := httptest.NewRecorder() - env.mux.ServeHTTP(unknown, httptest.NewRequest("GET", "/app/audiorecords/nosuchrecordid", http.NoBody)) + env.mux.ServeHTTP(unknown, httptest.NewRequest("GET", "/app/audiorecords/"+unknownRecordID, http.NoBody)) require.Equal(t, http.StatusUnauthorized, existing.Code) require.Equal(t, http.StatusUnauthorized, unknown.Code) @@ -97,7 +105,7 @@ func TestIntake_SenderFilenameLandsInOwnColumn(t *testing.T) { item := intakeItemOf(t, w) - record, err := env.handler.recordRepo.GetByID(item.ID, env.account.Id) + record, err := env.handler.recordRepo.GetByID(item.ID, env.account.ID) require.NoError(t, err) require.NotNil(t, record.OriginalFilename) @@ -128,7 +136,7 @@ func TestIntake_LongFilenameIsTrimmed(t *testing.T) { item := intakeItemOf(t, w) - record, err := env.handler.recordRepo.GetByID(item.ID, env.account.Id) + record, err := env.handler.recordRepo.GetByID(item.ID, env.account.ID) require.NoError(t, err) require.NotNil(t, record.OriginalFilename) @@ -170,7 +178,9 @@ func TestErrorBody_OneShapeAcrossBranches(t *testing.T) { }{ { name: "записи нет", - req: func() *http.Request { return httptest.NewRequest("GET", "/app/audiorecords/nosuch", http.NoBody) }, + req: func() *http.Request { + return httptest.NewRequest("GET", "/app/audiorecords/"+unknownRecordID, http.NoBody) + }, code: CodeNotFound, }, { @@ -209,37 +219,279 @@ func TestErrorBody_OneShapeAcrossBranches(t *testing.T) { } } -// Своё правило ограничителя частоты заведено под корнем приложения: правило -// хранилища настроено на его собственный корень и наших адресов не покрывает. -func TestRateLimitRuleCoversAppRoot(t *testing.T) { +// Ограничитель частоты покрывает адреса приложения и **не трогает** адресов +// наблюдения: правило своё, и настроено оно на корень приложения. +func TestRateLimitCoversAppRootOnly(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) - require.NoError(t, ApplyAppRateLimit(env.app)) - - var found int - for _, rule := range env.app.Settings().RateLimits.Rules { - if strings.HasPrefix(rule.Label, AppRoot+"/") { - found++ - assert.Positive(t, rule.MaxRequests) - assert.Positive(t, rule.Duration) + var refused *httptest.ResponseRecorder + for range appRateMaxRequests + 1 { + refused = env.getOwn("/app/me") + if refused.Code == http.StatusTooManyRequests { + break } } - assert.Equal(t, 1, found, "правило под корнем приложения заведено, и оно одно") - assert.True(t, env.app.Settings().RateLimits.Enabled, "и ограничитель включён") + require.Equal(t, http.StatusTooManyRequests, refused.Code, + "бюджет под корнем приложения не исчерпался — проверке не на чем сработать") - // Правило приводится к настройке **при каждом подъёме**, то есть на каждом - // рестарте сервиса. Без этой проверки ветвь замены не исполнялась бы ни разу, - // и правила молча копились бы с каждой выкладкой. - require.NoError(t, ApplyAppRateLimit(env.app)) - require.NoError(t, ApplyAppRateLimit(env.app)) + // Проба здоровья тем же бюджетом не ограничена: она лежит вне корня + // приложения, а слои одеты на корень. + assert.Equal(t, http.StatusOK, env.get(HealthPath).Code, + "ограничитель приложения закрыл наблюдение за сервисом") +} - again := 0 - for _, rule := range env.app.Settings().RateLimits.Rules { - if strings.HasPrefix(rule.Label, AppRoot+"/") { - again++ +// Два клиентских адреса через один доверенный прокси расходуют **разные** +// бюджеты, а заголовок пересылки с недоверенного адреса на ключ бюджета не +// влияет. +// +// Обе половины закрывают свою поломку: бюджет, посчитанный по пиру, становится +// общим на весь сервис, а вера заголовку без сверки пира отдаёт обход +// ограничителя ровно тому, кого он ограничивает. +func TestRateLimitKeyNamesTheClient(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + spend := func(peer, forwarded string) int { + refused := 0 + for range appRateMaxRequests + 1 { + req := httptest.NewRequest("GET", "/app/me", http.NoBody) + req.Header.Set(LoginHeader, env.login) + req.RemoteAddr = peer + if forwarded != "" { + req.Header.Set(ForwardedForHeader, forwarded) + } + w := httptest.NewRecorder() + env.mux.ServeHTTP(w, req) + if w.Code == http.StatusTooManyRequests { + refused++ + } + } + return refused + } + + // Первый клиент выбирает свой бюджет целиком. + require.Positive(t, spend(trustedPeer, "198.51.100.7"), + "бюджет первого клиента не исчерпался — проверке не на чем сработать") + + // Второй клиент за тем же прокси начинает со своего. + firstRefusalOfSecond := 0 + for range appRateMaxRequests { + req := httptest.NewRequest("GET", "/app/me", http.NoBody) + req.Header.Set(LoginHeader, env.login) + req.RemoteAddr = trustedPeer + req.Header.Set(ForwardedForHeader, "198.51.100.8") + w := httptest.NewRecorder() + env.mux.ServeHTTP(w, req) + if w.Code == http.StatusTooManyRequests { + firstRefusalOfSecond++ } } - assert.Equal(t, 1, again, "повторный подъём правило заменяет, а не добавляет второе") + assert.Zero(t, firstRefusalOfSecond, + "исчерпание бюджета одним клиентом отказало другому: бюджет считается по пиру") +} + +// Заголовок пересылки, пришедший с недоверенного адреса, на ключ бюджета не +// влияет: иначе спрашивающий назначал бы себе ключ счётчика сам и обходил +// ограничитель, меняя значение. +func TestRateLimitIgnoresForwardedHeaderFromUntrustedPeer(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + refused := 0 + for i := range appRateMaxRequests + 10 { + req := httptest.NewRequest("GET", "/app/me", http.NoBody) + req.RemoteAddr = untrustedPeer + req.Header.Set(ForwardedForHeader, fmt.Sprintf("198.51.100.%d", i%200)) + w := httptest.NewRecorder() + env.mux.ServeHTTP(w, req) + if w.Code == http.StatusTooManyRequests { + refused++ + } + } + + assert.Positive(t, refused, + "меняя заголовок пересылки, спрашивающий обошёл ограничитель") +} + +// spendBudget шлёт запросы под корнем приложения и считает отказы ограничителя. +// Заголовки пересылки ставит вызывающий: ключ бюджета выводится из них, и +// проверке нужен каждый их вид — одна строка, несколько строк, цепочка. +func spendBudget(env *testEnv, peer string, count int, forwarded func(i int) []string) int { + refused := 0 + for i := range count { + req := httptest.NewRequest("GET", "/app/me", http.NoBody) + req.Header.Set(LoginHeader, env.login) + req.RemoteAddr = peer + for _, value := range forwarded(i) { + req.Header.Add(ForwardedForHeader, value) + } + w := httptest.NewRecorder() + env.mux.ServeHTTP(w, req) + if w.Code == http.StatusTooManyRequests { + refused++ + } + } + return refused +} + +// Значение, которое приписал сам спрашивающий, ключа бюджета не задаёт. +// +// Прокси заголовок **дописывает**, а не заменяет: слева в цепочке стоит то, что +// прислал аноним, а справа — адрес, который приписал прокси. Ключ, взятый слева, +// менялся бы на каждом запросе, и бюджет обходился бы с первого. +func TestRateLimitIgnoresValuePresentedByTheClient(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + refused := spendBudget(env, trustedPeer, appRateMaxRequests+10, func(i int) []string { + return []string{fmt.Sprintf("198.51.100.%d, 203.0.113.7", i%200)} + }) + + assert.Positive(t, refused, + "подставляя своё значение слева, спрашивающий обошёл ограничитель") +} + +// Цепочка законно приходит несколькими строками заголовка, и читаются они все: +// разбор одной строки увидел бы кусок, которым распоряжается аноним. +func TestRateLimitReadsEveryForwardedHeaderLine(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + refused := spendBudget(env, trustedPeer, appRateMaxRequests+10, func(i int) []string { + return []string{fmt.Sprintf("198.51.100.%d", i%200), "203.0.113.8"} + }) + + assert.Positive(t, refused, + "вторая строка заголовка не прочитана: ключ достался присланному значению") +} + +// Доверенные шаги цепочки отбрасываются, и ключом становится первый недоверенный +// справа. Два клиента за одним прокси при этом расходуют разные бюджеты. +func TestRateLimitSkipsTrustedHopsFromTheRight(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + require.Positive(t, + spendBudget(env, trustedPeer, appRateMaxRequests+1, func(int) []string { + return []string{"203.0.113.11, 10.9.9.9"} + }), + "бюджет первого клиента не исчерпался — проверке не на чем сработать") + + assert.Zero(t, + spendBudget(env, trustedPeer, appRateMaxRequests, func(int) []string { + return []string{"203.0.113.12, 10.9.9.9"} + }), + "исчерпание бюджета одним клиентом отказало другому: доверенный шаг стал ключом") +} + +// Тип содержимого ответа выбирает сервис, а не отправитель. +// +// Расширение приходит из имени, данное отправителем: `запись.html`, отданный +// типом `text/html` с показом на месте, стал бы страницей в браузере. Тип +// выводится поэтому из **закрытого** перечня известных форматов — той же единой +// точки, что и метка метрики, — а всё прочее отдаётся `application/octet-stream` +// на сохранение. +func TestFileDownload_ContentTypeComesFromKnownFormats(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + fetch := func(t *testing.T, name string) *httptest.ResponseRecorder { + t.Helper() + + created := httptest.NewRecorder() + env.serve(created, createMultipartRequest(t, name, []byte("данные"))) + require.Equal(t, http.StatusCreated, created.Code, name) + + return env.getOwn("/app/audiorecords/" + + intakeItemOf(t, created).ID + "/file?" + CopyParam + "=" + CopyOriginal) + } + + t.Run("расширение вне перечня исполняемым типом не отдаётся", func(t *testing.T) { + for _, name := range []string{"запись.html", "запись.svg", "запись.xhtml"} { + w := fetch(t, name) + require.Equal(t, http.StatusOK, w.Code, name) + header := w.Result().Header + assert.Equal(t, unknownContentType, header.Get("Content-Type"), name) + assert.Contains(t, header.Get("Content-Disposition"), dispositionAttachment, name) + } + }) + + t.Run("известный формат отдаётся своим типом", func(t *testing.T) { + for name, want := range map[string]string{ + "запись.mkv": "video/x-matroska", + "запись.mov": "video/quicktime", + "запись.avi": "video/x-msvideo", + "запись.mp3": "audio/mpeg", + } { + w := fetch(t, name) + require.Equal(t, http.StatusOK, w.Code, name) + header := w.Result().Header + assert.Equal(t, want, header.Get("Content-Type"), name) + assert.Contains(t, header.Get("Content-Disposition"), dispositionInline, name) + } + }) +} + +// Перечень типов содержимого сверяется с перечнем известных форматов +// механически: формат, объявленный диалогу выбора файла и оставшийся без типа, +// уехал бы ответом `application/octet-stream` — то есть сервис предлагал бы +// загрузить то, что потом не умеет показать. +func TestEveryKnownFormatHasContentType(t *testing.T) { + formats := metrics.PublicFormats() + require.NotEmpty(t, formats, "перечень форматов пуст: правилу не на чем сработать") + + for _, format := range formats { + contentType, disposition := presentationOf(format) + assert.NotEqual(t, unknownContentType, contentType, + "формат %q сервис объявляет диалогу выбора файла, но типа содержимого у него нет", format) + assert.Equal(t, dispositionInline, disposition, format) + } + + for format := range contentTypes { + assert.Equal(t, format, metrics.FormatLabel(format), + "тип содержимого заведён формату %q, которого нет среди известных: ключ никогда не совпадёт", format) + } +} + +// Паника обработчика отдаёт `500` нашей формой тела, а процесс живёт дальше. +// +// Слой восстановления — верхняя граница поверхности, и проверяется он через +// **всю** цепочку: паника ловится снаружи журнала и маршрутизатора, поэтому +// собранная иначе поверхность судила бы не то. Без него один паникующий запрос +// уронил бы процесс вместе с конвейером и всеми, кто в это время что-то грузил. +func TestPanickingHandlerAnswersOurFailureFormAndProcessLives(t *testing.T) { + db, _, _ := newTestStorage(t) + users := sqliterepo.NewUserRepository(db) + + journal := &journalBuffer{} + logger := slog.New(slog.NewTextHandler(journal, nil)) + + panicking := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + panic("шаг обработчика упал") + }) + mounts := ServiceMounts( + AppChain(panicking, users, testTrustedNetworks(t), logger), + http.NotFoundHandler(), + ) + mux := BuildHandler(mounts, NewWebappHandler(builtDist(), true, logger), logger) + + account, _, err := users.EnsureUser(contract.Identity{Login: "person"}) + require.NoError(t, err) + require.NotEmpty(t, account.ID) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, "/app/me", http.NoBody), "person")) + + require.Equal(t, http.StatusInternalServerError, w.Code) + + var body ErrorBody + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "тело отказа — не наша форма") + assert.Equal(t, CodeInternal, body.Code) + assert.NotEmpty(t, body.Message) + assert.NotContains(t, w.Body.String(), "шаг обработчика упал", + "значение паники ушло спрашивающему") + + assert.Contains(t, journal.String(), "Handler panicked", + "владелец сервиса о панике не узнал") + + // Процесс жив: следующий запрос отвечает как ни в чём не бывало. + alive := httptest.NewRecorder() + mux.ServeHTTP(alive, httptest.NewRequest(http.MethodGet, HealthPath, http.NoBody)) + assert.Equal(t, http.StatusOK, alive.Code, "после паники поверхность перестала отвечать") } // Перечень доступных видов растёт вместе с готовыми текстами, и вычитанный текст @@ -250,13 +502,13 @@ func TestAvailableViewsCoverEveryKind(t *testing.T) { record := jobWithFile(t, env) - texts := pbrepo.NewTextRepository(env.app) + texts := env.handler.textRepo literary, err := texts.Put(record.Id, entity.TextKindLiterary, "вычитанный текст") require.NoError(t, err) transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка") require.NoError(t, err) - structures := pbrepo.NewStructureRepository(env.app) + structures := env.handler.structureRepo structure, err := structures.Put(record.Id, 1, []entity.Replica{{StartMs: 0, EndMs: 10, Text: "реплика"}}) require.NoError(t, err) @@ -318,7 +570,6 @@ func TestTooLargeOnTheRealPath(t *testing.T) { // частом отказе после превышения размера. func TestRateLimitRefusalGoesThroughOneErrorForm(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) - require.NoError(t, ApplyAppRateLimit(env.app)) var last *httptest.ResponseRecorder for range appRateMaxRequests + 1 { @@ -403,7 +654,7 @@ func TestEmptyTextIsNotAnAvailableView(t *testing.T) { record := jobWithFile(t, env) - texts := pbrepo.NewTextRepository(env.app) + texts := env.handler.textRepo empty, err := texts.Put(record.Id, entity.TextKindTranscript, "") require.NoError(t, err) @@ -425,3 +676,108 @@ func TestEmptyTextIsNotAnAvailableView(t *testing.T) { // И адрес текста отвечает тем же: состоянием, а не обещанием. assert.Equal(t, http.StatusConflict, textOf(t, env, record.Id, entity.TextViewTranscript).Code) } + +// TestFailuresBornOutsideHandlerShareOneForm — **критерий приёмки**: отказы, +// рождающиеся не в обработчике, приходят той же формой, что и отказы +// обработчика. +// +// Проверка идёт **настоящими** HTTP-запросами через поднятую цепочку слоёв: +// вызовом отображателя ошибки это не проверяется — ни один из трёх отказов до +// него не доходит. Предел тела ловит запрос слоем чтения, ограничитель частоты — +// слоем перед узнаванием, неизвестный путь — маршрутизатором. +func TestFailuresBornOutsideHandlerShareOneForm(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + server := env.liveServer(t) + + // Перечень кодов закрыт: значение вне его приложению разбирать нечем. + known := map[string]bool{ + CodeUnauthorized: true, CodeNotFound: true, CodeBadRequest: true, + CodeTooLarge: true, CodeNotReady: true, CodeTooManyRequests: true, + CodeInternal: true, + } + + assertOurForm := func(t *testing.T, status int, body []byte) { + t.Helper() + + var raw map[string]any + require.NoError(t, json.Unmarshal(body, &raw), "тело не наше: %s", body) + require.Contains(t, raw, "error_code") + require.Contains(t, raw, "message") + + code, ok := raw["error_code"].(string) + require.True(t, ok) + assert.True(t, known[code], "код отказа %q вне закрытого перечня", code) + assert.NotEmpty(t, raw["message"]) + assert.NotEqual(t, http.StatusOK, status) + } + + t.Run("предел тела", func(t *testing.T) { + // Запрос идёт **сырым соединением**: клиент стандартной библиотеки + // отказывается слать объявленную длину, которой не соответствует тело, а + // прислать восемь гигабайт на самом деле проверка не может. Сервер при + // этом видит обычный запрос: заголовки разобраны, длина объявлена, тела + // он не читает вовсе — отказ наступает раньше. + status, body := rawRequest(t, server, ""+ + "POST /app/audiorecords HTTP/1.1\r\n"+ + "Host: transcriber.test\r\n"+ + LoginHeader+": "+env.login+"\r\n"+ + "Content-Type: multipart/form-data; boundary=x\r\n"+ + "Content-Length: "+strconv.FormatInt(entity.MaxRecordSize+1, 10)+"\r\n"+ + "Connection: close\r\n\r\n") + + require.Equal(t, http.StatusRequestEntityTooLarge, status) + assertOurForm(t, status, body) + assert.Contains(t, string(body), `"limit"`, "предел уходит человеку числом") + assert.Equal(t, 0, countJobs(t, env), "записи не заводится") + }) + + t.Run("неизвестный путь под корнем приложения", func(t *testing.T) { + res := env.liveRequest(t, server, "/app/nosuchendpoint", nil) + + require.Equal(t, http.StatusNotFound, res.StatusCode) + assertOurForm(t, res.StatusCode, res.Body) + }) + + t.Run("ограничитель частоты", func(t *testing.T) { + var last liveResponse + for range appRateMaxRequests + 5 { + last = env.liveRequest(t, server, "/app/me", nil) + if last.StatusCode == http.StatusTooManyRequests { + break + } + } + + require.Equal(t, http.StatusTooManyRequests, last.StatusCode, + "ограничитель не сработал — проверке не на чем сработать") + assertOurForm(t, last.StatusCode, last.Body) + }) +} + +// rawRequest шлёт запрос сырым соединением и отдаёт код с телом ответа. +// +// Нужен там, где клиент стандартной библиотеки запрос не отправит: он судит +// соответствие объявленной длины телу, а проверке нужна ровно объявленная. +func rawRequest(t *testing.T, server *httptest.Server, request string) (int, []byte) { + t.Helper() + + address := strings.TrimPrefix(server.URL, "http://") + + dialer := &net.Dialer{Timeout: 5 * time.Second} + conn, err := dialer.DialContext(context.Background(), "tcp", address) + require.NoError(t, err) + defer func() { require.NoError(t, conn.Close()) }() + + require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second))) + + _, err = conn.Write([]byte(request)) + require.NoError(t, err) + + res, err := http.ReadResponse(bufio.NewReader(conn), nil) + require.NoError(t, err) + defer func() { require.NoError(t, res.Body.Close()) }() + + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + + return res.StatusCode, body +} diff --git a/internal/controller/http/errors.go b/internal/controller/http/errors.go index 5bb28dd..42ae71f 100644 --- a/internal/controller/http/errors.go +++ b/internal/controller/http/errors.go @@ -1,14 +1,11 @@ package http import ( + "encoding/json" "errors" + "log/slog" "net/http" - "github.com/pocketbase/pocketbase/apis" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tools/hook" - "github.com/pocketbase/pocketbase/tools/router" - "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" ) @@ -17,9 +14,13 @@ import ( // HTTP не различает «файл негоден», «поля записи нет» и «неизвестный вид» — все // три `400`, — а приложению надо решать, предлагать ли повтор и что показать // человеку. Разбор русской фразы был бы единственным оставшимся путём. +// +// Кода `forbidden` в перечне больше нет: его единственным случаем был владелец +// панели, предъявивший собственный токен хранилища. Ни панели, ни токенов у +// сервиса не осталось, а узнавание по заголовку учётную запись заводит само — +// предъявителя без неё не бывает. const ( CodeUnauthorized = "unauthorized" - CodeForbidden = "forbidden" CodeNotFound = "not_found" CodeBadRequest = "bad_request" CodeTooLarge = "too_large" @@ -32,7 +33,7 @@ const ( // // Два поля, а не одно: код разбирает программа, сообщение читает человек. Сырой // текст ошибки сюда не попадает — ни `err.Error()`, ни детали устройства: имена -// внешних сервисов, пути на диске, ключи файлов. Полная ошибка остаётся в +// внешних сервисов, пути на диске, имена файлов. Полная ошибка остаётся в // журнале владельца сервиса. // // Limit заполняется только у отказа по размеру: экран обязан показать предел @@ -46,8 +47,7 @@ type ErrorBody struct { // mapDomainError — **единственная** точка, где доменная ошибка становится кодом // ответа и сообщением. Прежде такой точки не было вовсе, и каждый обработчик // решал сам: опрос отвечал «записи нет» на упавшую базу, а приём — «внутренняя -// ошибка» на негодный файл. Человек читал первое как «моя запись пропала», а -// второе не говорило ему ничего. +// ошибка» на негодный файл. // // Ветвь по умолчанию определена намеренно: новая штатная ветвь отказа заводится // добавлением сюда, а не строкой в обработчике. Иначе обычный конфликт уезжает в @@ -87,6 +87,18 @@ func mapDomainError(err error) (int, ErrorBody) { Message: "Текст этого вида для записи ещё не готов", } + case errors.Is(err, contract.ErrCopyNotReady): + return http.StatusConflict, ErrorBody{ + Code: CodeNotReady, + Message: "Этой копии записи ещё нет", + } + + case errors.Is(err, contract.ErrTooManyRequests): + return http.StatusTooManyRequests, ErrorBody{ + Code: CodeTooManyRequests, + Message: "Слишком много запросов подряд, попробуйте позже", + } + case errors.Is(err, contract.ErrNotFound): message := "Адрес не найден" var owned *messagedError @@ -104,12 +116,6 @@ func mapDomainError(err error) (int, ErrorBody) { Code: CodeUnauthorized, Message: "Сервис вас не узнал", } - - case errors.Is(err, contract.ErrOwnerRequired): - return http.StatusForbidden, ErrorBody{ - Code: CodeForbidden, - Message: "У предъявителя нет учётной записи пользователя", - } } // Чужая запись, ничья и несуществующая отвечают одним и тем же: по разнице @@ -130,114 +136,61 @@ func mapDomainError(err error) (int, ErrorBody) { // fail отвечает отказом по доменной ошибке — единственный способ, которым отказ // уходит наружу с адресов приложения. -func fail(e *core.RequestEvent, err error) error { +// +// Отказы, рождающиеся **не в обработчике** — предел тела, ограничитель частоты, +// неизвестный путь под корнем приложения, — приходят сюда же: слои сервиса +// написаны нами и отвечают своей доменной ошибкой, а не телом библиотеки. Второй +// формы тела на адресах приложения не существует. +func fail(w http.ResponseWriter, err error) { status, body := mapDomainError(err) - return e.JSON(status, body) + writeJSON(w, status, body) } -// OneErrorForm переводит отказ библиотеки в нашу форму тела. -// -// Своей единой точки мало: часть отказов на адресах приложения рождается **не в -// обработчике** и до `mapDomainError` не доходит вовсе. Их три, и все три частые: -// предел тела (`413`), ограничитель частоты (`429`) и неизвестный путь под нашим -// корнем (`404`). Каждый уходил бы телом `router.ApiError` — без машиночитаемого -// кода, — и форм отказа на адресах приложения оказалось бы две вместо одной. -// -// Дороже всего первый: «запись больше потолка» — самый частый отказ у человека -// на мобильной сети, и приложение, разобрав чужое тело, показало бы ветвь -// «внутренняя ошибка» вместо предела числом. -// -// Слой стоит **самым внешним**: он обязан видеть отказ, рождённый слоями ниже -// него, включая предел тела и ограничитель частоты. -func OneErrorForm() *hook.Handler[*core.RequestEvent] { - return &hook.Handler[*core.RequestEvent]{ - Id: "transcriberOneErrorForm", - Priority: apis.DefaultRateLimitMiddlewarePriority - 100, - Func: func(e *core.RequestEvent) error { - err := e.Next() - if err == nil { - return nil - } - - // Обработчик, ответивший через fail, ошибки не возвращает — его - // форма уже ушла в ответ, и сюда доходит только чужая. - var apiErr *router.ApiError - if !errors.As(err, &apiErr) { - return err - } - - status, translated := translateAPIError(apiErr) - return e.JSON(status, translated) - }, - } +// writeJSON отдаёт тело ответа. Отказ записи в журнал не идёт: соединение к +// этому моменту оборвано, и сказать о нём некому — строка о каждом закрытом +// браузере наполняла бы журнал ничем. +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) } -// translateAPIError переводит отказ библиотеки в перечень наших кодов. Ветви -// названы поимённо: значение вне перечня приложению разбирать нечем. -func translateAPIError(apiErr *router.ApiError) (int, ErrorBody) { - switch apiErr.Status { - case http.StatusRequestEntityTooLarge: - limit := entity.MaxRecordSize - return http.StatusRequestEntityTooLarge, ErrorBody{ - Code: CodeTooLarge, - Message: "Запись больше допустимого размера", - Limit: &limit, - } - case http.StatusTooManyRequests: - return http.StatusTooManyRequests, ErrorBody{ - Code: CodeTooManyRequests, - Message: "Слишком много запросов подряд, попробуйте позже", - } - case http.StatusNotFound: - return http.StatusNotFound, ErrorBody{ - Code: CodeNotFound, - Message: "Адрес не найден", - } - case http.StatusUnauthorized: - return mapDomainError(contract.ErrUnauthorized) - } - - return apiErr.Status, ErrorBody{ - Code: CodeInternal, - Message: "Внутренняя ошибка сервиса", - } +// errWithMessage приклеивает к признаку негодного ввода свой текст: причина у +// всех одна, а сказать человеку надо разное. +func errWithMessage(base error, message string) error { + return &messagedError{base: base, message: message} } -// RequireUser — слой предъявления адресов приложения. -// -// Своя проверка, а не `apis.RequireAuth`, по одной причине: отказ библиотеки -// уходит **её** формой тела, и на адресах приложения оказалось бы две формы -// отказа вместо одной. Проверка при этом та же самая, и коллекция названа -// поимённо: без имени пускается всякая учётная запись хранилища, включая -// владельца панели, — а записи в коллекции пользователей у него нет, и владельцем -// записи он стать не может. -// -// Отказ наступает **до чтения тела**: запись, за которую не заплатит узнанный -// отправитель, не должна попасть даже в память, а позже пришлось бы убирать уже -// уложенный файл — чего сервис не умеет вовсе. -func RequireUser(usersCollection string) *hook.Handler[*core.RequestEvent] { - return &hook.Handler[*core.RequestEvent]{ - Id: "transcriberRequireUser", - // Сразу после слоя узнавания: раньше него `e.Auth` ещё пуст, и всякий - // запрос получал бы отказ. Слой узнавания, в свою очередь, стоит за - // ограничителем частоты — см. `TrustedHeaderIdentity`. - // - // Предел тела библиотеки идёт следом (−990), и это обязательно: отказ - // неузнанному наступает **до** чтения тела. - Priority: apis.DefaultRateLimitMiddlewarePriority + 2, - Func: func(e *core.RequestEvent) error { - if e.Auth == nil { - return fail(e, contract.ErrUnauthorized) - } +type messagedError struct { + base error + message string +} - // Узнан он всё же узнан, а учётной записи пользователя у него нет: - // код здесь другой не по оплошности. `401` значит «предъяви себя», а - // предъявитель себя предъявил. - if e.Auth.Collection().Name != usersCollection { - return fail(e, contract.ErrOwnerRequired) - } +func (e *messagedError) Error() string { return e.message } +func (e *messagedError) Unwrap() error { return e.base } - return e.Next() - }, +// Recover — верхняя граница обработчика: паникующий запрос отдаёт `500` нашей +// формой тела, а процесс живёт. +// +// Слой свой, потому что своим стал и роутер: прежде его вешала чужая библиотека. +// У воркеров такой границы по-прежнему нет — паника в шаге конвейера роняет +// процесс целиком, и это осознанно. +func Recover(logger *slog.Logger) func(http.Handler) http.Handler { + if logger == nil { + logger = slog.Default() + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if recovered := recover(); recovered != nil { + logger.Error("Handler panicked", + "error", recovered, "transport", "http") + fail(w, errors.New("handler panicked")) + } + }() + + next.ServeHTTP(w, r) + }) } } diff --git a/internal/controller/http/file.go b/internal/controller/http/file.go new file mode 100644 index 0000000..ef2e796 --- /dev/null +++ b/internal/controller/http/file.go @@ -0,0 +1,274 @@ +package http + +import ( + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "git.vakhrushev.me/av/transcriber/internal/contract" + "git.vakhrushev.me/av/transcriber/internal/entity" + "git.vakhrushev.me/av/transcriber/internal/metrics" +) + +// contentTypes — тип содержимого по **известному** формату. +// +// Ключи здесь — значения той же единой точки, что и метка метрики: формат копии +// сперва приводится `metrics.FormatLabel`, и только приведённое ищется в этой +// карте. Справочник системы не спрашивается вовсе — он читается из файла хоста, +// которого в рабочем слое образа нет, и тип содержимого стал бы функцией того, +// где сервис собран. +// +// Перечень закрыт, и это главное его свойство. Расширение приходит из имени, +// данного отправителем, и потому может быть чем угодно: `запись.html` без +// приведения ушёл бы ответом `text/html`, который браузер показывает как +// страницу. Всё, чего в перечне нет, отдаётся `application/octet-stream` с +// расположением `attachment` — тип, который браузер не исполняет. +// +// Полноту перечня держит проверка: всякий формат, который сервис объявляет +// диалогу выбора файла, обязан иметь здесь тип содержимого. +var contentTypes = map[string]string{ + "aac": "audio/aac", + "avi": "video/x-msvideo", + "flac": "audio/flac", + "m4a": "audio/mp4", + "mkv": "video/x-matroska", + "mov": "video/quicktime", + "mp3": "audio/mpeg", + "mp4": "video/mp4", + "oga": "audio/ogg", + "ogg": "audio/ogg", + "opus": "audio/ogg", + "wav": "audio/wav", + "webm": "video/webm", + "wma": "audio/x-ms-wma", +} + +// Расположение ответа. Известному формату — показ на месте: проигрыватель в +// браузере иначе не откроет запись. Всему прочему — сохранение: содержимым и +// расширением распоряжается отправитель, а `attachment` браузер не исполняет. +const ( + dispositionInline = "inline" + dispositionAttachment = "attachment" +) + +// unknownContentType — тип содержимого всего, чего нет в закрытом перечне. +const unknownContentType = "application/octet-stream" + +// GetRecordFile отдаёт копию записи её владельцу. +// +// **Порядок проверок один: владение записью судится до разбора значения копии.** +// Неизвестное либо незаданное значение копии у чужой и у несуществующей записи +// даёт тот же ответ, что и неизвестный идентификатор. Неотличимость чужой записи +// от несуществующей главнее формы ответа на негодный ввод: разбор параметра, +// выполненный раньше, отвечал бы одинаково на чужую и на неизвестную только +// случайно, а стоило бы ответам разойтись — по этой разнице перебирался бы +// список заведённых записей одним негодным параметром. +func (h *AppHandler) GetRecordFile(w http.ResponseWriter, r *http.Request) { + account, _ := AccountOf(r) + + record, err := h.readOwn(r, account.ID) + if err != nil { + fail(w, err) + return + } + + fileID, known := copyOf(record, r.URL.Query().Get(CopyParam)) + if !known { + fail(w, errWithMessage(contract.ErrBadRequest, "Неизвестная копия записи")) + return + } + // Копии, которой у записи ещё нет, отвечает отказ состояния: пустой ответ + // читался бы как пустой файл, а «не найдено» слилось бы с ответом на чужую и + // неизвестную запись — человек увидел бы его на своей записи, загруженной + // минуту назад. + if fileID == nil { + fail(w, contract.ErrCopyNotReady) + return + } + + file, err := h.fileRepo.GetByID(*fileID) + if err != nil { + h.logger.Error("Failed to read the file row of a record", "error", err, "record_id", record.Id) + fail(w, err) + return + } + + content, err := h.fileRepo.Open(*fileID) + if err != nil { + h.logger.Error("Failed to open the file of a record", "error", err, "record_id", record.Id) + fail(w, err) + return + } + defer func() { _ = content.Close() }() + + h.serveCopy(w, r, record, file, content) +} + +// copyOf выбирает копию по значению параметра. Второе значение ложно у копии, +// которой сервис не знает, и у незаданной: умолчание сделало бы ответ функцией +// того, что успел записать конвейер, а не состояния записи. +func copyOf(record *entity.AudioRecord, name string) (*string, bool) { + switch name { + case CopyOriginal: + return record.OriginalFileID, true + case CopyNormalized: + return record.NormalizedFileID, true + } + return nil, false +} + +// serveCopy отдаёт содержимое целиком либо запрошенным куском. +// +// Выдача по частям обязательна: запись расчётного потолка — шесть часов, и +// проигрыватель в браузере перематывает её запросом диапазона, а не повторной +// загрузкой целиком. +// +// **Негодный диапазон приводится к обычному отказу сервиса** — телом той же +// формы и кодом из закрытого перечня, — а не отвечает `416` телом библиотеки. +// Негодных диапазонов два вида, и оба ведут себя одинаково: неудовлетворимый +// (начало за концом файла) и множественный (в запросе назван больше чем один +// диапазон). Второй сервис не отдаёт намеренно: ответ из нескольких частей — это +// отдельный тип содержимого со своими границами, а просит его один только +// самодельный запрос. +func (h *AppHandler) serveCopy( + w http.ResponseWriter, + r *http.Request, + record *entity.AudioRecord, + file *entity.File, + content io.ReadSeeker, +) { + contentType, disposition := presentationOf(file.Format) + + header := w.Header() + header.Set("Content-Type", contentType) + header.Set("Accept-Ranges", "bytes") + // Имя файла на диске в ответ не идёт: имя, предлагаемое браузеру при + // сохранении, строится из имени, данного отправителем, и лежит оно колонкой + // записи. + header.Set("Content-Disposition", dispositionOf(record, disposition)) + + raw := r.Header.Get("Range") + if raw == "" { + header.Set("Content-Length", strconv.FormatInt(file.Size, 10)) + w.WriteHeader(http.StatusOK) + h.copyBody(w, r, content, file.Size) + return + } + + start, length, ok := parseSingleRange(raw, file.Size) + if !ok { + fail(w, errWithMessage(contract.ErrBadRequest, "Запрошенный диапазон записи не читается")) + return + } + + if _, err := content.Seek(start, io.SeekStart); err != nil { + h.logger.Error("Failed to seek the file of a record", "error", err, "record_id", record.Id) + fail(w, err) + return + } + + header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, start+length-1, file.Size)) + header.Set("Content-Length", strconv.FormatInt(length, 10)) + w.WriteHeader(http.StatusPartialContent) + h.copyBody(w, r, content, length) +} + +// copyBody переливает содержимое в ответ. Запрос `HEAD` тела не получает: у него +// те же заголовки и пустое тело. +// +// Отказ переливания идёт **отладочной** строкой: он значит оборванное +// соединение — человек закрыл вкладку или перемотал запись, — и владельцу +// сервиса разбирать здесь нечего. Проглотить его молча всё же нельзя: тогда +// оборванная отдача не отличалась бы от полной ничем. +func (h *AppHandler) copyBody(w http.ResponseWriter, r *http.Request, content io.Reader, length int64) { + if r.Method == http.MethodHead { + return + } + + if _, err := io.CopyN(w, content, length); err != nil { + h.logger.Debug("Failed to send the file of a record", "error", err, "transport", "http") + } +} + +// presentationOf называет тип содержимого копии и её расположение. +// +// Оба значения выводятся из одного приведения, и порознь их выводить нельзя: +// известный формат, показанный на месте, и незнакомый, отданный на сохранение, — +// это одно решение, а два независимых дали бы `text/html` с `inline` у первого +// же расширения, которого сервис не знает. +func presentationOf(format string) (contentType, disposition string) { + if known, ok := contentTypes[metrics.FormatLabel(format)]; ok { + return known, dispositionInline + } + return unknownContentType, dispositionAttachment +} + +// dispositionOf строит расположение ответа вместе с именем, предлагаемым +// браузеру при сохранении. +// +// Имя берётся у записи — то, что дал отправитель, — и кодируется по правилам +// заголовка: оно приходит извне и содержимым своим сервису не подконтрольно. +// Записи без имени получают одно расположение, без имени файла. +func dispositionOf(record *entity.AudioRecord, disposition string) string { + if record.OriginalFilename == nil || *record.OriginalFilename == "" { + return disposition + } + return disposition + "; filename*=UTF-8''" + url.PathEscape(*record.OriginalFilename) +} + +// parseSingleRange разбирает заголовок диапазона. +// +// Второе значение ложно у всего, что сервис не отдаёт: у нечитаемого заголовка, +// у неудовлетворимого диапазона и у запроса, называющего больше одного +// диапазона. +func parseSingleRange(raw string, size int64) (start, length int64, ok bool) { + const prefix = "bytes=" + + spec, found := strings.CutPrefix(strings.TrimSpace(raw), prefix) + if !found || strings.Contains(spec, ",") { + return 0, 0, false + } + + first, last, found := strings.Cut(strings.TrimSpace(spec), "-") + if !found { + return 0, 0, false + } + first, last = strings.TrimSpace(first), strings.TrimSpace(last) + + switch { + case first == "": + // Хвост записи: `bytes=-N` просит последние N байтов. + suffix, err := strconv.ParseInt(last, 10, 64) + if err != nil || suffix <= 0 || size == 0 { + return 0, 0, false + } + if suffix > size { + suffix = size + } + return size - suffix, suffix, true + + case last == "": + start, err := strconv.ParseInt(first, 10, 64) + if err != nil || start < 0 || start >= size { + return 0, 0, false + } + return start, size - start, true + + default: + start, err := strconv.ParseInt(first, 10, 64) + if err != nil || start < 0 || start >= size { + return 0, 0, false + } + end, err := strconv.ParseInt(last, 10, 64) + if err != nil || end < start { + return 0, 0, false + } + if end >= size { + end = size - 1 + } + return start, end - start + 1, true + } +} diff --git a/internal/controller/http/identity.go b/internal/controller/http/identity.go index f3ca5bd..582f7c8 100644 --- a/internal/controller/http/identity.go +++ b/internal/controller/http/identity.go @@ -1,121 +1,93 @@ package http import ( + "context" "errors" "log/slog" "net" + "net/http" "net/netip" - "github.com/pocketbase/pocketbase/apis" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tools/hook" - - pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" + "git.vakhrushev.me/av/transcriber/internal/contract" ) // Заголовки, которыми обратный прокси называет пришедшего. // -// Имена нормативны — ровно как было нормативно имя куки сессии, и по той же -// причине: смена имени молча перестаёт узнавать всех, а проверка, которая сама -// ставит и сама читает своё имя, этого не замечает. Контур уже пишет эти имена -// соседним сервисам, и настройкой они не делаются: второе место, где их можно -// написать неверно, выгоды не даёт. +// Имена нормативны: смена имени молча перестаёт узнавать всех, а проверка, +// которая сама ставит и сама читает своё имя, этого не замечает. Контур уже +// пишет эти имена соседним сервисам, и настройкой они не делаются: второе место, +// где их можно написать неверно, выгоды не даёт. const ( LoginHeader = "Remote-User" NameHeader = "Remote-Name" EmailHeader = "Remote-Email" ) -// FileTokenPath — адрес, которым хранилище выдаёт короткий токен файла. -// -// Он лежит в пространстве хранилища, а не приложения, и потому назван здесь -// поимённо: без узнавания на нём файл записи недостижим для браузера вовсе — -// порядок «узнавание → токен файла → ссылка» обрывается на первом шаге. -const FileTokenPath = StorageRoot + "/files/token" +// ForwardedForHeader — заголовок, которым прокси называет адрес спрашивающего. +// Читает его только ограничитель частоты: барьером узнавания он не служит и +// служить не может — кто пришёл, решает адрес самого соединения. +const ForwardedForHeader = "X-Forwarded-For" + +// accountKey — ключ, под которым узнанная учётная запись живёт в контексте +// запроса. Свой тип, а не строка: чужой ключ с тем же текстом иначе перезаписал +// бы значение. +type accountKey struct{} + +// AccountOf отдаёт учётную запись, от имени которой идёт запрос. Второе значение +// ложно у неузнанного. +func AccountOf(r *http.Request) (*contract.UserAccount, bool) { + account, ok := r.Context().Value(accountKey{}).(*contract.UserAccount) + return account, ok +} + +// withAccount кладёт узнанную учётную запись в контекст запроса. +func withAccount(r *http.Request, account *contract.UserAccount) *http.Request { + return r.WithContext(context.WithValue(r.Context(), accountKey{}, account)) +} // TrustedHeaderIdentity узнаёт пришедшего по заголовку доверенного источника. // // # Область // -// Слой вешается корневым — иначе к адресу выдачи файлового токена его не -// привязать: тот принадлежит роутеру хранилища, и группой его не накрыть. Но -// узнаёт он **только на объявленной области**: корень приложения плюс этот -// адрес. Область выводится из перечня адресного пространства, а не пишется -// вторым списком. +// Слой вешается на цепочку корня приложения и только на неё. Область поэтому +// выводится из объявленного адресного пространства сервиса, а не перечисляется +// вторым списком: корень, переехавший в перечне, уносит слой с собой. // -// Сужение здесь не бережливость, а барьер. Ключ учётной записи лежит обычной -// колонкой коллекции пользователей, и узнавание на всей поверхности хранилища -// дало бы узнанному переписать себе ключ на чужое имя — а первое обращение -// настоящего владельца этого имени попало бы в чужую запись вместе со всем -// архивом. Схема закрывает этот путь и со своей стороны, правилами коллекции; -// два барьера здесь именно потому, что прежний был один и держался на -// случайности — на том, что браузер сам не шлёт заголовка авторизации. -// -// Второе следствие: узнавание не срабатывает на пробе здоровья, на метриках и -// на ресурсах приложения. Иначе запрос за каждой картинкой стоил бы обращения к -// базе, а первый такой запрос с новым именем — записи в неё. -// -// # Кто побеждает -// -// Учётная запись ставится, только когда её ещё нет, — то есть когда слой чтения -// токена никого не нашёл. Владелец панели предъявляет свой токен, и подмена его -// учётной записью пользователя отобрала бы у него панель посреди работы. -// Протухший и негодный токен предъявленными не считаются: библиотека их не -// прочитала, `e.Auth` пуст, и запрос узнаётся заголовком. +// Сужение закрывает вещь, которая от смены хранилища не зависит: узнавание не +// срабатывает на пробе здоровья, на метриках и на ресурсах приложения. Иначе +// запрос за каждой картинкой стоил бы обращения к базе, а первый такой запрос с +// новым именем — записи в неё. // // # Чего слой не делает // -// Отказа он не выдаёт. Проба здоровья, метрики и разметка приложения открыты -// неузнанному, и отказ в слое закрыл бы наблюдение за сервисом всякому, кто -// пришлёт заголовок. Отказ приходит там, где приходил всегда, — требованием -// учётной записи на адресах приложения. -// -// Исключение одно: отказ **хранилища**. Он кончается отказом сервиса, а не -// молчаливым проходом неузнанным, — иначе человек увидел бы отказ входа там, где -// легла база. +// Отказа он не выдаёт: отказ приходит там, где приходил всегда, — требованием +// учётной записи. Исключение одно — отказ базы: он кончается отказом сервиса, а +// не молчаливым проходом неузнанным, иначе человек увидел бы отказ входа там, +// где легла база. func TrustedHeaderIdentity( - app core.App, - mounts []Mount, + users contract.UserRepository, trusted []netip.Prefix, logger *slog.Logger, -) *hook.Handler[*core.RequestEvent] { +) func(http.Handler) http.Handler { if logger == nil { logger = slog.Default() } - return &hook.Handler[*core.RequestEvent]{ - Id: "transcriberTrustedHeaderIdentity", - // **За ограничителем частоты, а не перед ним.** Узнавание читает базу, а - // на новом имени ещё и пишет в неё; поставленное раньше ограничителя, оно - // работало на запросах, которые тот уже отверг. Замер: сто двадцать - // запросов выбирают бюджет, следующие пятьдесят с новыми именами - // получают отказ — и заводят пятьдесят учётных записей, которые потом не - // убираются ничем. - // - // Порядок целиком: чтение токена (−1020) → ограничитель (−1000) → - // узнавание (−999) → требование учётной записи (−998) → предел тела - // (−990). Требование стоит перед пределом тела намеренно: отказ - // неузнанному обязан наступать до чтения тела. - Priority: apis.DefaultRateLimitMiddlewarePriority + 1, - Func: func(e *core.RequestEvent) error { - if e.Auth != nil || !underIdentifiedArea(mounts, e.Request.URL.Path) { - return e.Next() - } - + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Более одного значения — не выбор, а отказ. Прокси, настроенный // добавлять заголовок вместо замены, оставляет рядом со своим // значением присланное анонимом, и умолчание «берём первое» отдало // бы вход анониму. - values := e.Request.Header.Values(LoginHeader) + values := r.Header.Values(LoginHeader) if len(values) != 1 { if len(values) > 1 { // Уровень предупреждающий: два значения означают прокси, // который заголовок **добавляет** вместо замены, — то есть // ровно ту поломку контура, которую модель угроз называет - // главной. Отладочным уровнем она в бою не видна вовсе: - // боевой уровень журнала информационный. + // главной. Отладочным уровнем она в бою не видна вовсе. logger.Warn("Request carries more than one login header", - "http.peer_addr", e.Request.RemoteAddr, + "http.peer_addr", r.RemoteAddr, "capability", "access", "transport", "http") } else { // Заголовка нет вовсе. В этом контуре это значит, что прокси @@ -123,13 +95,14 @@ func TrustedHeaderIdentity( // штатный — так выглядит и человек, которому провайдер // отказал. logger.Debug("Request carries no login header", - "http.peer_addr", e.Request.RemoteAddr, + "http.peer_addr", r.RemoteAddr, "capability", "access", "transport", "http") } - return e.Next() + next.ServeHTTP(w, r) + return } - peer, ok := peerAddress(e.Request.RemoteAddr) + peer, ok := peerAddress(r.RemoteAddr) if !ok || !isTrusted(trusted, peer) { // Уровень предупреждающий, а не отладочный, и это решение о // цене. Заголовок с недоверенного адреса в этом контуре — не @@ -143,31 +116,34 @@ func TrustedHeaderIdentity( // видно, чья это поломка: своя (перечень) или контура (прокси // заголовка не ставит). logger.Warn("Login header came from an untrusted peer", - "http.peer_addr", e.Request.RemoteAddr, + "http.peer_addr", r.RemoteAddr, "capability", "access", "transport", "http") - return e.Next() + next.ServeHTTP(w, r) + return } - record, created, err := pbrepo.EnsureUser(app, pbrepo.Identity{ + account, created, err := users.EnsureUser(contract.Identity{ Login: values[0], - Name: e.Request.Header.Get(NameHeader), - Email: e.Request.Header.Get(EmailHeader), + Name: r.Header.Get(NameHeader), + Email: r.Header.Get(EmailHeader), }) if err != nil { - if errors.Is(err, pbrepo.ErrLoginNotAcceptable) { + if errors.Is(err, contract.ErrLoginNotAcceptable) { // Негодный логин — это негодный ввод, а не поломка сервиса: // пустой заголовок прокси шлёт штатно там, где никого не // назвал. Уровень поэтому отладочный, и запрос идёт дальше // неузнанным. logger.Debug("Login header value is not acceptable", - "http.peer_addr", e.Request.RemoteAddr, + "http.peer_addr", r.RemoteAddr, "capability", "access", "transport", "http") - return e.Next() + next.ServeHTTP(w, r) + return } logger.Error("Failed to resolve account by login header", "error", err, "capability", "access", "transport", "http") - return fail(e, err) + fail(w, err) + return } if created { @@ -181,36 +157,35 @@ func TrustedHeaderIdentity( // чтобы стать этим человеком. Идут адрес пира и идентификатор // записи — оба выданы не спрашивающим. logger.Info("Account created from login header", - "http.peer_addr", e.Request.RemoteAddr, - "account_id", record.Id, + "http.peer_addr", r.RemoteAddr, + "account_id", account.ID, "capability", "access", "transport", "http") } - e.Auth = record - - return e.Next() - }, + next.ServeHTTP(w, withAccount(r, account)) + }) } } -// underIdentifiedArea говорит, узнаётся ли пришедший на этом пути. +// RequireUser — слой предъявления адресов приложения. // -// Область — корень приложения из перечня адресного пространства плюс адрес -// выдачи файлового токена. Корень берётся из перечня, а не литералом: перечень -// объявлен единой точкой адресного пространства, и записанный здесь второй раз -// он разошёлся бы с ней молча. -func underIdentifiedArea(mounts []Mount, requestPath string) bool { - if requestPath == FileTokenPath { - return true - } +// Отказ наступает **до чтения тела**: запись, за которую не заплатит узнанный +// отправитель, не должна попасть даже в память, а позже пришлось бы убирать уже +// уложенный файл — чего сервис не умеет вовсе. +// +// Ветви «узнан, а учётной записи нет» здесь больше нет: узнавание заводит +// учётную запись само, и предъявителя без неё не бывает. +func RequireUser() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, ok := AccountOf(r); !ok { + fail(w, contract.ErrUnauthorized) + return + } - for _, mount := range mounts { - if mount.Path == AppRoot { - return mount.Covers(requestPath) - } + next.ServeHTTP(w, r) + }) } - - return false } // peerAddress достаёт адрес того, кто открыл соединение. diff --git a/internal/controller/http/journal.go b/internal/controller/http/journal.go new file mode 100644 index 0000000..e22aeca --- /dev/null +++ b/internal/controller/http/journal.go @@ -0,0 +1,175 @@ +package http + +import ( + "context" + "log/slog" + "net/http" + + "time" + + "git.vakhrushev.me/av/transcriber/internal/clock" +) + +// webappRoute — чем в журнале обозначается всякий путь, отданный приложению. +const webappRoute = "<приложение>" + +// journalNote — то, что обработчик оставляет слою журнала о своём запросе. +// Указателем в контексте: значение кладёт слой, а заполняет обработчик ниже. +type journalNote struct { + outcome string +} + +type journalKey struct{} + +// noteWebappOutcome оставляет исход раздачи слою журнала. +func noteWebappOutcome(r *http.Request, outcome string) { + if note, ok := r.Context().Value(journalKey{}).(*journalNote); ok { + note.outcome = outcome + } +} + +// Journal пишет строку о каждом входящем запросе. +// +// **Путь, которым распоряжается спрашивающий, в журнал не идёт** — вместо него +// маршрут из закрытого перечня и длина: по ним видно, что происходит, а +// дословная запись сделала бы журнал местом, куда аноним пишет свой текст +// произвольной длины. Корень приложения от этого правила не изъят: путь под ним +// выбирает тот же спрашивающий. +// +// Журнал у сервиса **один**: второй, куда чужая библиотека клала путь целиком +// вместе с адресом отправителя, ушёл вместе с ней. +func Journal(mounts []Mount, logger *slog.Logger) func(http.Handler) http.Handler { + if logger == nil { + logger = slog.Default() + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := clock.Start() + + note := &journalNote{} + ctx := context.WithValue(r.Context(), journalKey{}, note) + + recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(recorder, r.WithContext(ctx)) + + level := slog.LevelInfo + if IsObservationAddress(mounts, r.URL.Path) { + // Опрос здоровья и метрик идёт постоянно и полезного не несёт. + level = slog.LevelDebug + } + + attrs := []any{ + "http.method", r.Method, + "http.route", JournalRoute(r, mounts), + "http.status_code", recorder.status, + "duration_ms", time.Since(start).Milliseconds(), + // Длина идёт на всякую строку, а не только на раздачу + // приложения: обобщённое значение маршрута теперь достаётся и + // путям под корнем приложения, и без длины по строке не видно, + // спросили короткий адрес или мегабайт текста. + "http.path_length", len(r.URL.Path), + "transport", "http", + } + + if note.outcome != "" { + attrs = append(attrs, "webapp.outcome", note.outcome) + } + + logger.Log(ctx, level, "Incoming request", attrs...) + }) + } +} + +// JournalRoute готовит путь запроса к записи в журнал. +// +// **Дословно в журнал идёт только то, что принадлежит закрытому перечню**: +// точные адреса наблюдения и образцы адресов приложения. Всё прочее — и путь +// вне корней сервиса, и путь под корнем приложения, не совпавший ни с одним +// образцом, — обозначается одним значением, а длина уходит отдельным полем. +// +// Прежнее правило судило по принадлежности пути сервису и оставляло зазор +// шириной в корень приложения: `/app/<произвольный текст>` принадлежит сервису, +// отвечает отказом неузнанному и при этом уезжал в журнал дословно. Множеством +// значений под корнем распоряжается спрашивающий ровно так же, как и вне его, — +// значит и правило одно на обе половины. +// +// Идентификатор записи из строки при этом не пропадает: его пишет обработчик +// полем `record_id`, и пишет он тот, что прочитал, а не тот, что попросили. +// +// Имя файла в хранилище из журнала выводимо быть не должно, и сегодня оно туда +// не попадает по построению: адрес копии записи назван идентификатором самой +// записи, а имя файла на диске в путь не входит вовсе. +func JournalRoute(r *http.Request, mounts []Mount) string { + if exact, ok := ExactAddressOf(mounts, r.URL.Path); ok { + return exact + } + + if pattern, ok := appRoutePatternOf(r); ok { + return pattern + } + + return webappRoute +} + +// appRouteIndex — маршрутизатор, заведённый ради одного вопроса: какому образцу +// приложения отвечает этот запрос. +// +// Маршрутизатор, а не свой разбор пути: образец `{id}` разбирает стандартная +// библиотека, и второй разбор рядом с ней разошёлся бы с настоящей +// маршрутизацией молча. Образцы берутся тем же перечнем, которым вешаются +// обработчики. +var appRouteIndex = newAppRouteIndex() + +func newAppRouteIndex() *http.ServeMux { + mux := http.NewServeMux() + for _, pattern := range AppRoutePatterns { + mux.Handle(pattern, http.NotFoundHandler()) + } + return mux +} + +// knownAppRoutes — тот же перечень множеством: ответ маршрутизатора сверяется с +// ним. Перенаправление на очищенный путь маршрутизатор отдаёт образцом, +// собранным из самого пути, и без сверки такой ответ уехал бы в журнал +// дословно — то есть ровно тем, чего правило не допускает. +var knownAppRoutes = knownAppRouteSet() + +func knownAppRouteSet() map[string]struct{} { + out := make(map[string]struct{}, len(AppRoutePatterns)) + for _, pattern := range AppRoutePatterns { + out[pattern] = struct{}{} + } + return out +} + +// appRoutePatternOf называет образец адреса приложения. Второе значение ложно у +// всего, что ни одному образцу не отвечает. +func appRoutePatternOf(r *http.Request) (string, bool) { + _, pattern := appRouteIndex.Handler(r) + if _, ok := knownAppRoutes[pattern]; !ok { + return "", false + } + return pattern, true +} + +// statusRecorder запоминает код ответа: журнал пишется после обработчика, а +// готовый код читать больше неоткуда. +type statusRecorder struct { + http.ResponseWriter + status int + written bool +} + +func (w *statusRecorder) WriteHeader(status int) { + if !w.written { + w.status = status + w.written = true + } + w.ResponseWriter.WriteHeader(status) +} + +func (w *statusRecorder) Write(p []byte) (int, error) { + w.written = true + return w.ResponseWriter.Write(p) +} diff --git a/internal/controller/http/journal_test.go b/internal/controller/http/journal_test.go new file mode 100644 index 0000000..e807e59 --- /dev/null +++ b/internal/controller/http/journal_test.go @@ -0,0 +1,149 @@ +package http + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// journalMounts — перечень адресного пространства для проверок журнала. +// Обработчиков он здесь не вешает: журналу нужны только границы, а не то, что +// стоит за ними. +func journalMounts() []Mount { + return []Mount{ + {Path: AppRoot}, + {Path: HealthPath, Exact: true}, + {Path: MetricsPath, Exact: true}, + } +} + +// journalRouteOf — маршрут строки журнала для названного пути. +func journalRouteOf(path string) string { + return JournalRoute(httptest.NewRequest(http.MethodGet, path, http.NoBody), journalMounts()) +} + +// Путь, которым распоряжается спрашивающий, в журнал дословно не идёт — и +// принадлежность сервису тут ничего не меняет: путь под корнем приложения +// выбирает тот же аноним, что и путь вне корней. +func TestJournalRouteHidesRequestedPath(t *testing.T) { + cases := []string{ + "/", + "/records/abc123def456ghi", + "/" + strings.Repeat("a", 1024), + "/api/files/files/rec0000000000000/0f7b8dd3-d1cc-424c.mp3", + "/_/", + // Под корнем приложения — то же самое: образца с таким хвостом нет. + "/app", + "/app/", + "/app/" + strings.Repeat("b", 1024), + "/app/audiorecords/abc123def456ghi/file/" + strings.Repeat("c", 512), + "/app/audiorecords/abc/def/ghi", + } + + for _, path := range cases { + assert.Equal(t, webappRoute, journalRouteOf(path), path) + } +} + +// Дословно пишется закрытый перечень: точные адреса наблюдения и образцы +// адресов приложения. Идентификатор записи в образец не входит — он приходит +// строкой обработчика полем `record_id`. +func TestJournalRouteKeepsClosedList(t *testing.T) { + cases := map[string]string{ + "/app/audiorecords": AppRouteRecords, + "/app/audiorecords/abc123def456ghi": AppRouteRecord, + "/app/audiorecords/abc123def456ghi/text": AppRouteRecordText, + "/app/audiorecords/abc123def456ghi/file": AppRouteRecordFile, + "/app/me": AppRouteMe, + "/app/config": AppRouteConfig, + HealthPath: HealthPath, + MetricsPath: MetricsPath, + } + + for path, want := range cases { + assert.Equal(t, want, journalRouteOf(path), path) + } +} + +// Значение маршрута берётся из перечня образцов и ничего сверх него не +// возвращает: путь, приведённый маршрутизатором к другому виду, дословно уехать +// не может. +func TestJournalRouteAnswersOnlyFromClosedList(t *testing.T) { + allowed := map[string]bool{webappRoute: true, HealthPath: true, MetricsPath: true} + for _, pattern := range AppRoutePatterns { + allowed[pattern] = true + } + + cases := []string{ + "/app/me/", + "/app/audiorecords/../me", + "/app//me", + "/app/audiorecords/{id}", + "/app/me/" + strings.Repeat("d", 256), + } + + for _, path := range cases { + route := journalRouteOf(path) + assert.True(t, allowed[route], "маршрут %q не принадлежит закрытому перечню (путь %q)", route, path) + } +} + +// Имя, под которым копия легла в каталог данных, в журнал не идёт: строка +// журнала иначе стала бы бессрочным ключом к чужой записи. Сегодня оно не +// попадает туда по построению — адрес копии назван идентификатором самой записи, +// — и проверка сторожит именно это. +func TestJournalCarriesNoStoredFileName(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + created := httptest.NewRecorder() + env.serve(created, createMultipartRequest(t, "разговор.mp3", []byte("данные"))) + require.Equal(t, http.StatusCreated, created.Code) + + recordID := intakeItemOf(t, created).ID + require.Equal(t, http.StatusOK, + env.getOwn("/app/audiorecords/"+recordID+"/file?"+CopyParam+"="+CopyOriginal).Code) + + names := storedFileNames(t, env) + require.Len(t, names, 1) + + journal := env.journal.String() + assert.NotContains(t, journal, names[0], "имя файла в каталоге данных уехало в журнал") + assert.Contains(t, journal, recordID, "идентификатор записи остаётся: по нему прослеживается путь") +} + +// Путь, отданный приложению, журнал заменяет исходом и длиной: строка о нём не +// растёт вместе с длиной пути. +func TestJournalWebappOutcomeInsteadOfPath(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + long := "/" + strings.Repeat("щ", 500) + require.Equal(t, http.StatusOK, env.get(long).Code) + + journal := env.journal.String() + assert.NotContains(t, journal, strings.Repeat("щ", 500), "путь уехал в журнал дословно") + assert.Contains(t, journal, "webapp.outcome="+OutcomeMarkup) + assert.Contains(t, journal, "http.path_length=") +} + +// Неузнанный запрос под корнем приложения журнал тоже не пишет дословно, а +// строка о нём не растёт вместе с длиной пути. Прежде путь под корнем считался +// принадлежащим сервису и уезжал в строку целиком — аноним писал в журнал +// владельца свой текст произвольной длины. +func TestJournalHidesAnonymousPathUnderAppRoot(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + const filler = 4096 + long := "/app/" + strings.Repeat("щ", filler) + require.Equal(t, http.StatusUnauthorized, env.get(long).Code) + + journal := env.journal.String() + assert.NotContains(t, journal, strings.Repeat("щ", filler), "путь уехал в журнал дословно") + assert.Contains(t, journal, "http.route="+webappRoute) + assert.Contains(t, journal, "http.path_length=") + assert.Less(t, len(journal), filler, + "строка журнала растёт вместе с длиной запрошенного пути") +} diff --git a/internal/controller/http/list_test.go b/internal/controller/http/list_test.go index 282bd23..a5d213b 100644 --- a/internal/controller/http/list_test.go +++ b/internal/controller/http/list_test.go @@ -9,12 +9,9 @@ import ( "strings" "testing" - "github.com/pocketbase/pocketbase/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" - "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" "git.vakhrushev.me/av/transcriber/internal/entity" ) @@ -168,7 +165,7 @@ func TestList_MalformedCursorIsRejected(t *testing.T) { }, { name: "декодируется, но идентификатора нет", - cursor: base64.RawURLEncoding.EncodeToString([]byte("2026-08-15 10:00:00.000Z|")), + cursor: base64.RawURLEncoding.EncodeToString([]byte("2026-08-15T10:00:00Z|")), }, { name: "пара пуста целиком", @@ -202,25 +199,11 @@ func TestTopicsResolveToNames(t *testing.T) { // Тема заводится напрямую: словарь тем свой у каждого человека, и пишет его // задача языковой модели, которой ещё нет. - topics, err := env.app.FindCollectionByNameOrId(migrations.TopicsCollection) - require.NoError(t, err) - topic := core.NewRecord(topics) - topic.Set("owner", env.account.Id) - topic.Set("name", "семейный архив") - require.NoError(t, env.app.Save(topic)) + topicID := newTopic(t, env, env.account.ID, "семейный архив") + attachTopic(t, env, all.Items[0].ID, topicID) - repo := pbrepo.NewAudioRecordRepository(env.app) - record, err := repo.GetByID(all.Items[0].ID, env.account.Id) + record, err := env.handler.recordRepo.GetByID(all.Items[0].ID, env.account.ID) require.NoError(t, err) - record.TopicIDs = []string{topic.Id} - require.NoError(t, repo.Save(record, "")) - - // Правку тем конвейер не делает, поэтому кладём их тем же путём, каким это - // сделает задача языковой модели, — прямым сохранением записи коллекции. - raw, err := env.app.FindRecordById(migrations.RecordsCollection, record.Id) - require.NoError(t, err) - raw.Set("topics", []string{topic.Id}) - require.NoError(t, env.app.Save(raw)) page := pageOf(t, env, "") require.Len(t, page.Items, 1) @@ -247,14 +230,14 @@ func TestList_ThreeStatesEachRecordOnce(t *testing.T) { all := pageOf(t, env, "") require.Len(t, all.Items, 3) - repo := pbrepo.NewAudioRecordRepository(env.app) + repo := env.handler.recordRepo - halted, err := repo.GetByID(all.Items[0].ID, env.account.Id) + halted, err := repo.GetByID(all.Items[0].ID, env.account.ID) require.NoError(t, err) halted.Halt(entity.HaltReasonStuck, "застряла") require.NoError(t, repo.Save(halted, "")) - done, err := repo.GetByID(all.Items[1].ID, env.account.Id) + done, err := repo.GetByID(all.Items[1].ID, env.account.ID) require.NoError(t, err) done.MoveToState(entity.StateDone) require.NoError(t, repo.Save(done, "")) @@ -295,11 +278,11 @@ func TestList_ShowsOnlyOwnRecords(t *testing.T) { acceptRecords(t, env, 2) - _, stranger := newSecondAccount(t, env.app) + newSecondAccount(t, env) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/app/audiorecords", http.NoBody) - asUser(req, stranger) + asUser(req, "stranger") env.mux.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) @@ -319,12 +302,12 @@ func TestList_DoesNotReadTranscript(t *testing.T) { all := pageOf(t, env, "") require.Len(t, all.Items, 1) - repo := pbrepo.NewAudioRecordRepository(env.app) - record, err := repo.GetByID(all.Items[0].ID, env.account.Id) + repo := env.handler.recordRepo + record, err := repo.GetByID(all.Items[0].ID, env.account.ID) require.NoError(t, err) const marker = "СОДЕРЖИМОЕ-РАСШИФРОВКИ-МАРКЕР" - texts := pbrepo.NewTextRepository(env.app) + texts := env.handler.textRepo transcript, err := texts.Put(record.Id, entity.TextKindTranscript, marker) require.NoError(t, err) record.TranscriptTextID = &transcript.Id @@ -413,19 +396,10 @@ func TestForeignTopicDoesNotResolve(t *testing.T) { all := pageOf(t, env, "") require.Len(t, all.Items, 1) - stranger, _ := newSecondAccount(t, env.app) + stranger := newSecondAccount(t, env) - topics, err := env.app.FindCollectionByNameOrId(migrations.TopicsCollection) - require.NoError(t, err) - foreign := core.NewRecord(topics) - foreign.Set("owner", stranger.Id) - foreign.Set("name", "ЧУЖАЯ-ТЕМА-МАРКЕР") - require.NoError(t, env.app.Save(foreign)) - - raw, err := env.app.FindRecordById(migrations.RecordsCollection, all.Items[0].ID) - require.NoError(t, err) - raw.Set("topics", []string{foreign.Id}) - require.NoError(t, env.app.Save(raw)) + foreign := newTopic(t, env, stranger.ID, "ЧУЖАЯ-ТЕМА-МАРКЕР") + attachTopic(t, env, all.Items[0].ID, foreign) w := httptest.NewRecorder() env.serve(w, httptest.NewRequest("GET", "/app/audiorecords", http.NoBody)) diff --git a/internal/controller/http/mounts.go b/internal/controller/http/mounts.go new file mode 100644 index 0000000..cf66748 --- /dev/null +++ b/internal/controller/http/mounts.go @@ -0,0 +1,124 @@ +package http + +import ( + "net/http" + "strings" +) + +// Отдельные адреса наблюдения. Корней у сервиса остался один — корень +// приложения; `/api` и `/_` ушли вместе со встроенным хранилищем и его панелью, +// и адресов под этими именами не существует. +// +// Резервировать имя за отказом сервис не берётся: имя, за которым ничего не +// стоит, ничем не отличается от любого другого свободного, а второй перечень +// «когда-то занятых корней» разошёлся бы с первым молча. +const ( + HealthPath = "/health" + MetricsPath = "/metrics" +) + +// Mount — часть адресного пространства, принадлежащая сервису. +// +// Перечень этих частей — **единственное** описание того, что сервису +// принадлежит, и он не описывает регистрацию, а порождает её: корень, +// заведённый мимо перечня, не получит обработчика вовсе. Из него же выводятся +// правило неизвестного пути, уровень журнала и область действия узнавания. +type Mount struct { + // Path — корень либо точный адрес. + Path string + + // Exact — путь является точным адресом, а не корнем: `/health` накрывает + // только сам себя, а `/app` — всё, что под ним. + Exact bool + + // Bind вешает обработчики этой части. + Bind func(mux *http.ServeMux) +} + +// Covers говорит, принадлежит ли путь этой части адресного пространства. +// +// Условий два, и оба обязательны: точное совпадение либо префикс **вместе с +// косой чертой**. По одному префиксу корню `/app` достался бы посторонний +// `/apple`; по одному префиксу с косой чертой голый `/app` не достался бы никому +// и уехал бы разметкой приложения. +func (m Mount) Covers(requestPath string) bool { + if m.Exact { + return requestPath == m.Path + } + + return requestPath == m.Path || strings.HasPrefix(requestPath, m.Path+"/") +} + +// ServiceMounts перечисляет адресное пространство сервиса целиком. +// +// Обработчик приложения приходит уже одетым в свои слои — ограничитель частоты, +// узнавание, требование учётной записи: область их действия и есть корень +// приложения, и берётся она отсюда, а не перечисляется вторым списком. +func ServiceMounts(app http.Handler, metricsHandler http.Handler) []Mount { + return []Mount{ + {Path: AppRoot, Bind: bindApp(app)}, + {Path: HealthPath, Exact: true, Bind: bindHealth}, + {Path: MetricsPath, Exact: true, Bind: bindMetrics(metricsHandler)}, + } +} + +// RegisterServiceRoutes вешает всё, что сервис вешает сам. +func RegisterServiceRoutes(mux *http.ServeMux, mounts []Mount) { + for _, mount := range mounts { + if mount.Bind != nil { + mount.Bind(mux) + } + } +} + +// ExactAddressOf называет точный адрес сервиса, которому отвечает путь. Второе +// значение ложно у всего прочего — у пути под корнем и у пути вне корней. +// +// Точные адреса — закрытый перечень, и только они пишутся в журнал дословно: +// значением такого пути распоряжается не спрашивающий, а сам перечень. +func ExactAddressOf(mounts []Mount, requestPath string) (string, bool) { + for _, mount := range mounts { + if mount.Exact && mount.Covers(requestPath) { + return mount.Path, true + } + } + + return "", false +} + +// IsObservationAddress говорит, что путь — адрес наблюдения. +// +// Опрос здоровья и метрик идёт постоянно и полезного не несёт, поэтому уровень +// журнала у него свой. Перечень при этом тот же самый: второе перечисление этих +// адресов разошлось бы с первым молча. +func IsObservationAddress(mounts []Mount, requestPath string) bool { + _, ok := ExactAddressOf(mounts, requestPath) + return ok +} + +// bindApp вешает корень приложения. +// +// Образцов два, и оба обязательны: без точного `/app` маршрутизатор увёл бы +// голый корень перенаправлением на `/app/`, а норма требует от него отказа +// приложения, а не переезда. +func bindApp(app http.Handler) func(mux *http.ServeMux) { + return func(mux *http.ServeMux) { + mux.Handle(AppRoot+"/", app) + mux.Handle(AppRoot, app) + } +} + +func bindHealth(mux *http.ServeMux) { + mux.HandleFunc(HealthPath, func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{ + "status": "ok", + "message": "Transcriber service is running", + }) + }) +} + +func bindMetrics(handler http.Handler) func(mux *http.ServeMux) { + return func(mux *http.ServeMux) { + mux.Handle(MetricsPath, handler) + } +} diff --git a/internal/controller/http/ownership_test.go b/internal/controller/http/ownership_test.go index b272414..d9403a1 100644 --- a/internal/controller/http/ownership_test.go +++ b/internal/controller/http/ownership_test.go @@ -4,71 +4,71 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" - "github.com/pocketbase/pocketbase/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" + "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" ) -// Проверки разграничения записей по владельцу. Все идут через собранный роутер: -// сужение живёт в хранилище, но судится по тому, что видит отправитель. +// Проверки разграничения записей по владельцу. Все идут через собранную +// поверхность: сужение живёт в хранилище, но судится по тому, что видит +// отправитель. -// newSecondAccount заводит вторую учётную запись со своим логином. Постоянный -// адрес почты первой занят, и повторное сохранение отвергается — адрес здесь -// свой. -func newSecondAccount(t *testing.T, app core.App) (*core.Record, string) { +// unknownRecordID — идентификатор годного вида, которого нет ни у одной записи. +const unknownRecordID = "00000000000000000000000000" + +// newSecondAccount заводит вторую учётную запись со своим логином. +func newSecondAccount(t *testing.T, env *testEnv) *contract.UserAccount { t.Helper() - const login = "stranger" - - users, err := app.FindCollectionByNameOrId("users") + account, created, err := env.users.EnsureUser(contract.Identity{ + Login: "stranger", + Name: "Посторонний", + Email: "stranger@example.com", + }) require.NoError(t, err) + require.True(t, created) - record := core.NewRecord(users) - record.Set(migrations.ProviderLoginField, login) - record.Set("email", "stranger@example.com") - record.Set("verified", true) - record.SetRandomPassword() - require.NoError(t, app.Save(record)) - - return record, login + return account } -// serveAs шлёт запрос от имени названного логина, а не логина окружения. -func serveAs(env *testEnv, login string, w http.ResponseWriter, req *http.Request) { - env.mux.ServeHTTP(w, asUser(req, login)) -} - -// Чужая задача неотличима от несуществующей: тот же код и то же тело. Разница -// ответов обратила бы опрос в перебор — по ней считывается, какие задачи +// Чужая запись неотличима от несуществующей: тот же код и то же тело. Разница +// ответов обратила бы чтение в перебор — по ней считывается, какие записи // заведены. func TestGetTranscribeJobStatus_ForeignJobLooksMissing(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) job := jobWithFile(t, env) - _, stranger := newSecondAccount(t, env.app) + newSecondAccount(t, env) - foreign := httptest.NewRecorder() - serveAs(env, stranger, foreign, httptest.NewRequest("GET", "/app/audiorecords/"+job.Id, http.NoBody)) + foreign := env.getAs("stranger", "/app/audiorecords/"+job.Id) + unknown := env.getAs("stranger", "/app/audiorecords/"+unknownRecordID) - unknown := httptest.NewRecorder() - serveAs(env, stranger, unknown, httptest.NewRequest("GET", "/app/audiorecords/unknown0000000000", http.NoBody)) - - require.Equal(t, http.StatusNotFound, foreign.Code, "чужая задача не отдаётся") + require.Equal(t, http.StatusNotFound, foreign.Code, "чужая запись не отдаётся") assert.Equal(t, unknown.Code, foreign.Code, "код тот же, что у неизвестного идентификатора") assert.JSONEq(t, unknown.Body.String(), foreign.Body.String(), "и тело то же") - // Ни состояния, ни текста расшифровки в теле нет. assert.NotContains(t, foreign.Body.String(), entity.StateUploaded) - assert.NotContains(t, foreign.Body.String(), "transcription_text") } -// Владельцем принятой записи становится предъявитель сессии — и у задачи, и у -// её файла. +// Нечитаемый по виду идентификатор отвечает тем же, чем неизвестный: разбор идёт +// на границе, и до базы такой запрос не доходит вовсе. +func TestRecordCard_MalformedIDLooksLikeUnknown(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + malformed := env.getOwn("/app/audiorecords/не-идентификатор") + unknown := env.getOwn("/app/audiorecords/" + unknownRecordID) + + assert.Equal(t, http.StatusNotFound, malformed.Code) + assert.JSONEq(t, unknown.Body.String(), malformed.Body.String()) +} + +// Владельцем принятой записи становится узнанный предъявитель — и у записи, и у +// её копии. func TestCreateTranscribeJob_OwnerIsSession(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) @@ -78,24 +78,26 @@ func TestCreateTranscribeJob_OwnerIsSession(t *testing.T) { response := intakeItemOf(t, w) - record, err := env.app.FindRecordById(migrations.RecordsCollection, response.ID) - require.NoError(t, err) - assert.Equal(t, env.account.Id, record.GetString("owner"), "владелец задачи — предъявитель") + var owner string + require.NoError(t, env.db.Reader(). + QueryRow("SELECT owner_id FROM audio_records WHERE id = ?", response.ID).Scan(&owner)) + assert.Equal(t, env.account.ID, owner, "владелец записи — предъявитель") - fileRecord, err := env.app.FindRecordById("files", record.GetString("original_file")) - require.NoError(t, err) - assert.Equal(t, env.account.Id, fileRecord.GetString("owner"), "владелец файла — он же") + var fileOwner string + require.NoError(t, env.db.Reader(). + QueryRow("SELECT owner_id FROM files WHERE record_id = ?", response.ID).Scan(&fileOwner)) + assert.Equal(t, env.account.ID, fileOwner, "владелец копии — он же") } // Владельца не задают запросом: своё значение в форме на результат не влияет. func TestCreateTranscribeJob_OwnerFieldFromRequestIgnored(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) - _, stranger := newSecondAccount(t, env.app) + stranger := newSecondAccount(t, env) req := createMultipartRequest(t, "sample.mp3", []byte("запись")) query := req.URL.Query() - query.Set("owner", stranger) + query.Set("owner", stranger.ID) req.URL.RawQuery = query.Encode() w := httptest.NewRecorder() @@ -104,97 +106,198 @@ func TestCreateTranscribeJob_OwnerFieldFromRequestIgnored(t *testing.T) { response := intakeItemOf(t, w) - record, err := env.app.FindRecordById(migrations.RecordsCollection, response.ID) - require.NoError(t, err) - assert.Equal(t, env.account.Id, record.GetString("owner")) + var owner string + require.NoError(t, env.db.Reader(). + QueryRow("SELECT owner_id FROM audio_records WHERE id = ?", response.ID).Scan(&owner)) + assert.Equal(t, env.account.ID, owner) } -// Предъявитель, чья сессия не даёт учётной записи пользователя, получает отказ -// до чтения тела. Владелец панели — именно такой: узнан он узнан, а записи в -// коллекции пользователей у него нет, и владельцем записи он стать не может. +// TestFileDownload_NarrowedByOwner — **критерий приёмки**: чужой файл недостижим +// по прямой ссылке, и ответ на него тот же, что и на несуществующую запись. // -// Отказ **до** укладки обязателен: позже пришлось бы убирать уже сохранённый -// файл, а уборки файлов сервис не умеет вовсе. -func TestCreateTranscribeJob_SuperuserSessionRejected(t *testing.T) { - env := setupTestEnv(t, readableMetaViewer()) - - superusers, err := env.app.FindCollectionByNameOrId(core.CollectionNameSuperusers) - require.NoError(t, err) - - admin := core.NewRecord(superusers) - admin.Set("email", "owner@example.com") - admin.SetRandomPassword() - require.NoError(t, env.app.Save(admin)) - - token, err := admin.NewAuthToken() - require.NoError(t, err) - - // Владелец панели предъявляет **свой токен**, а не заголовок: заголовок ему - // никто не ставит, и узнавание по нему его учётной записи не касается. - req := createMultipartRequest(t, "sample.mp3", []byte("запись")) - req.Header.Set("Authorization", token) - - w := httptest.NewRecorder() - env.mux.ServeHTTP(w, req) - - assert.Equal(t, http.StatusForbidden, w.Code, "узнан, но не запись коллекции пользователей") - assert.Equal(t, 0, countJobs(t, env), "задачи не заведено") - assert.Equal(t, 0, countFiles(t, env), "и файла тоже") -} - -// Чужой файл не отдаётся по ссылке, а свой отдаётся. Проверяется именно переход -// по ссылке: токен файла хранилище выдаёт на предъявителя, а не на файл, и отказ -// наступает на скачивании, где правило просмотра судит владельца. Проверка, -// написанная на выдачу токена, зеленела бы, не касаясь пути, по которому аудио и -// уходит. +// Отказ наступает **на самом обращении за файлом**: другого места, где он мог бы +// наступить, у сервиса не осталось — значений на предъявителя он не выдаёт. func TestFileDownload_NarrowedByOwner(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) job := jobWithFile(t, env) - _, stranger := newSecondAccount(t, env.app) + newSecondAccount(t, env) - record, err := env.app.FindRecordById("files", *job.OriginalFileID) - require.NoError(t, err) - require.Equal(t, env.account.Id, record.GetString("owner")) - - link := "/api/files/files/" + record.Id + "/" + record.GetString("file") - - // Переход по ссылке идёт **без** заголовка: файл судится своим коротким - // токеном, а не узнаванием. Так же по ней пойдёт и браузер. - mine := httptest.NewRecorder() - env.mux.ServeHTTP(mine, - httptest.NewRequest("GET", link+"?token="+fileToken(t, env, env.login), http.NoBody)) - - foreign := httptest.NewRecorder() - env.mux.ServeHTTP(foreign, - httptest.NewRequest("GET", link+"?token="+fileToken(t, env, stranger), http.NoBody)) + link := "/app/audiorecords/" + job.Id + "/file?" + CopyParam + "=" + CopyOriginal + mine := env.getOwn(link) require.Equal(t, http.StatusOK, mine.Code, "свой файл отдаётся") assert.Equal(t, "запись", mine.Body.String(), "и отдаётся содержимым") - assert.NotEqual(t, http.StatusOK, foreign.Code, "чужой файл не отдаётся") + foreign := env.getAs("stranger", link) + unknown := env.getAs("stranger", + "/app/audiorecords/"+unknownRecordID+"/file?"+CopyParam+"="+CopyOriginal) + + assert.Equal(t, http.StatusNotFound, foreign.Code, "чужой файл не отдаётся") + assert.Equal(t, unknown.Code, foreign.Code, "код тот же, что у неизвестной записи") + assert.JSONEq(t, unknown.Body.String(), foreign.Body.String(), "и тело то же") assert.NotContains(t, foreign.Body.String(), "запись", "содержимого в отказе нет") } -// fileToken берёт у хранилища токен файла для названного логина. Токен выдаётся -// на предъявителя: о файле хранилище при выдаче не спрашивает. -// -// Адрес выдачи лежит в пространстве хранилища, а не приложения, и узнавание на -// нём работает поимённо — иначе весь путь «узнавание → токен файла → ссылка» -// обрывался бы на первом шаге, и файл записи стал бы недостижим для браузера. -func fileToken(t *testing.T, env *testEnv, login string) string { - t.Helper() +// Негодное значение копии у чужой записи неотличимо от неизвестной записи: +// владение судится **до** разбора значения копии. Разойдись эти ответы — по их +// разнице перебирался бы список заведённых записей одним негодным параметром. +func TestFileDownload_UnknownCopyOnForeignRecordLooksLikeUnknownRecord(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) - w := httptest.NewRecorder() - env.mux.ServeHTTP(w, asUser( - httptest.NewRequest("POST", "/api/files/token", http.NoBody), login)) - require.Equal(t, http.StatusOK, w.Code, "токен файла выдаётся всякому узнанному") + job := jobWithFile(t, env) + newSecondAccount(t, env) - var body struct { - Token string `json:"token"` - } - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) - require.NotEmpty(t, body.Token) + foreign := env.getAs("stranger", "/app/audiorecords/"+job.Id+"/file?"+CopyParam+"=whatever") + unknown := env.getAs("stranger", "/app/audiorecords/"+unknownRecordID+"/file?"+CopyParam+"=whatever") + missing := env.getAs("stranger", "/app/audiorecords/"+unknownRecordID+"/file") - return body.Token + assert.Equal(t, http.StatusNotFound, foreign.Code) + assert.Equal(t, unknown.Code, foreign.Code) + assert.JSONEq(t, unknown.Body.String(), foreign.Body.String()) + assert.JSONEq(t, missing.Body.String(), foreign.Body.String(), + "незаданная копия у чужой записи отвечает не как неизвестная запись") +} + +// Копия, которой у записи ещё нет, отвечает отказом состояния, а не «не +// найдено»: иначе человек увидел бы «не найдено» на своей записи, загруженной +// минуту назад. +func TestFileDownload_MissingCopyIsConflict(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + job := jobWithFile(t, env) + + w := env.getOwn("/app/audiorecords/" + job.Id + "/file?" + CopyParam + "=" + CopyNormalized) + + require.Equal(t, http.StatusConflict, w.Code) + + var body ErrorBody + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, CodeNotReady, body.Code) + + unknown := env.getOwn("/app/audiorecords/" + unknownRecordID + "/file?" + CopyParam + "=" + CopyOriginal) + assert.NotEqual(t, unknown.Code, w.Code, "отказ состояния слился с ответом на неизвестную запись") +} + +// Копия, которой сервис не знает, и незаданная копия у **своей** записи дают +// отказ по негодному вводу. +func TestFileDownload_UnknownCopyIsBadRequest(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + job := jobWithFile(t, env) + + for name, query := range map[string]string{ + "копия неизвестна": "?" + CopyParam + "=whatever", + "копия не названа": "", + } { + t.Run(name, func(t *testing.T) { + w := env.getOwn("/app/audiorecords/" + job.Id + "/file" + query) + + require.Equal(t, http.StatusBadRequest, w.Code) + + var body ErrorBody + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, CodeBadRequest, body.Code) + }) + } +} + +// Проигрыватель просит кусок записи: ответ несёт запрошенный кусок, а не файл +// целиком. +func TestFileDownload_RangeGivesRequestedSlice(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + const content = "0123456789abcdef" + + created := httptest.NewRecorder() + env.serve(created, createMultipartRequest(t, "sample.mp3", []byte(content))) + require.Equal(t, http.StatusCreated, created.Code) + + server := env.liveServer(t) + link := "/app/audiorecords/" + intakeItemOf(t, created).ID + "/file?" + CopyParam + "=" + CopyOriginal + + res := env.liveRequest(t, server, link, map[string]string{"Range": "bytes=4-9"}) + + require.Equal(t, http.StatusPartialContent, res.StatusCode) + assert.Equal(t, "456789", string(res.Body), "ответ несёт не запрошенный кусок") + assert.Equal(t, int64(len("456789")), res.ContentLength, "длина ответа — длина файла целиком") + assert.Equal(t, "bytes 4-9/16", res.Header.Get("Content-Range")) +} + +// TestFileDownload_BadRangeIsOurFailureForm — **критерий приёмки**: негодный +// диапазон приходит обычным отказом сервиса, а не `416` телом библиотеки. +// +// Проверка идёт настоящим HTTP-запросом через поднятую цепочку слоёв: вызовом +// отображателя ошибки это не проверяется — код `416` рождается не в нём. +func TestFileDownload_BadRangeIsOurFailureForm(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + created := httptest.NewRecorder() + env.serve(created, createMultipartRequest(t, "sample.mp3", []byte("0123456789"))) + require.Equal(t, http.StatusCreated, created.Code) + + server := env.liveServer(t) + link := "/app/audiorecords/" + intakeItemOf(t, created).ID + "/file?" + CopyParam + "=" + CopyOriginal + + cases := map[string]string{ + "неудовлетворимый диапазон": "bytes=99999999-", + "два диапазона в запросе": "bytes=0-1,4-5", + } + + for name, rangeHeader := range cases { + t.Run(name, func(t *testing.T) { + res := env.liveRequest(t, server, link, map[string]string{"Range": rangeHeader}) + + require.Equal(t, http.StatusBadRequest, res.StatusCode, "ответ не `400`") + assert.NotContains(t, res.Header.Get("Content-Type"), "multipart", + "сервис отдал ответ из нескольких частей") + + var body map[string]any + require.NoError(t, json.Unmarshal(res.Body, &body)) + assert.Contains(t, body, "error_code") + assert.Contains(t, body, "message") + }) + } +} + +// Имя файла на диске в ответ не идёт: имя, предлагаемое браузеру при сохранении, +// строится из имени, данного отправителем. +func TestFileDownload_CarriesNoStoredName(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + created := httptest.NewRecorder() + env.serve(created, createMultipartRequest(t, "разговор.mp3", []byte("запись"))) + require.Equal(t, http.StatusCreated, created.Code) + + recordID := intakeItemOf(t, created).ID + + w := env.getOwn("/app/audiorecords/" + recordID + "/file?" + CopyParam + "=" + CopyOriginal) + require.Equal(t, http.StatusOK, w.Code) + + names := storedFileNames(t, env) + require.Len(t, names, 1) + + headers := w.Result().Header + for _, value := range headers.Values("Content-Disposition") { + assert.NotContains(t, value, names[0], "имя файла на диске уехало в ответ") + } + assert.Contains(t, strings.Join(headers.Values("Content-Disposition"), " "), "%D1%80", + "имя, данное отправителем, в ответ не попало") +} + +// Перечня доступных копий карточка не объявляет: копий две, и каждая выводится +// из рубежа записи, который карточка несёт и так. +func TestRecordCard_HasNoCopyList(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + job := jobWithFile(t, env) + + w := env.getOwn("/app/audiorecords/" + job.Id) + require.Equal(t, http.StatusOK, w.Code) + + var raw map[string]json.RawMessage + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw)) + + assert.NotContains(t, raw, "available_copies") + assert.NotContains(t, raw, "copies") } diff --git a/internal/controller/http/rate_limit.go b/internal/controller/http/rate_limit.go index 8412463..2d44633 100644 --- a/internal/controller/http/rate_limit.go +++ b/internal/controller/http/rate_limit.go @@ -1,17 +1,17 @@ package http import ( - "fmt" + "net/http" + "net/netip" + "strings" + "sync" + "time" - "github.com/pocketbase/pocketbase/core" + "git.vakhrushev.me/av/transcriber/internal/clock" + "git.vakhrushev.me/av/transcriber/internal/contract" ) -// Своё правило ограничителя частоты под корнем приложения. -// -// Заводится потому, что правило хранилища настроено на **его** корень и наших -// адресов больше не покрывает: приложение уехало в своё пространство, и вместе с -// переездом ограничитель перестал бы существовать для него вовсе. Потеря тихая — -// заметить её нечем, пока кто-нибудь не начнёт опрашивать карточку в цикле. +// Бюджет ограничителя частоты под корнем приложения. // // Числа скромные намеренно: сервисом пользуются единицы человек, а экран // опрашивает карточку, пока запись идёт по конвейеру. Из них же выводится @@ -22,71 +22,202 @@ const ( appRateWindowSec = 60 ) -// ApplyAppRateLimit ставит правило ограничителя на корень приложения. -// -// Правило приводится к настройке при каждом подъёме, как и настройки провайдера: -// применённый шаг схемы не переписывается, а настройки хранилища живут в базе, и -// правило, положенное однажды, не пережило бы ни правки числа, ни чистого -// каталога данных. -func ApplyAppRateLimit(app core.App) error { - settings := app.Settings() +// staleBudgetAge — через сколько неиспользуемый счётчик выбрасывается. Карта +// счётчиков растёт с числом адресов, и без уборки она стала бы местом, куда +// спрашивающий кладёт по строке на каждый свой адрес. +const staleBudgetAge = 10 * time.Minute - rule := core.RateLimitRule{ - Label: AppRoot + "/", - MaxRequests: appRateMaxRequests, - Duration: appRateWindowSec, +// RateLimit — свой ограничитель частоты под корнем приложения. +// +// Бюджет считается по адресу спрашивающего скользящим окном: столько-то запросов +// за столько-то секунд. Отказ уходит **нашей** формой тела — слой отвечает +// доменной ошибкой, а не телом библиотеки, и второй формы отказа на адресах +// приложения не появляется. +// +// **Адрес спрашивающего берётся из заголовка пересылки — и только тогда, когда +// соединение пришло с адреса из объявленного перечня доверенных.** Во всяком +// другом случае адресом считается адрес пира, а пришедший заголовок на ключ +// бюджета не влияет ничем. +// +// **Цепочка пересылки читается справа налево до первого недоверенного адреса.** +// Значением слева распоряжается тот, кто шлёт запрос: прокси заголовок +// дописывает, а не заменяет, и левое значение цепочки он берёт из присланного. +// +// Обе половины правила закрывают свою поломку. Сервис стоит за обратным прокси, +// и пир у него один на всех: бюджет, посчитанный по пиру, становится общим на +// весь сервис, и один спрашивающий исчерпывает его остальным. Обратная ошибка — +// верить заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого +// он ограничивает: значением пересылаемого заголовка распоряжается тот, кто шлёт +// запрос, и меняет он его на каждом запросе. +// +// **Узнавание и ограничитель берут адрес разными способами, и это намеренно.** +// Узнаванию нужен адрес самого соединения: им оно решает, верить ли заголовку +// вообще. Ограничителю нужен адрес того, кого он ограничивает, а тот за прокси в +// адресе соединения не виден вовсе. +// +// Слой стоит **перед** узнаванием: узнавание читает базу, а на новом имени ещё и +// пишет в неё; выполненное раньше ограничителя, оно работало бы на запросах, +// которые тот уже отверг, и поток отвергнутых обращений заводил бы учётные +// записи, которые потом не убираются ничем. +func RateLimit(trusted []netip.Prefix) func(http.Handler) http.Handler { + limiter := newRateLimiter(appRateMaxRequests, appRateWindowSec*time.Second) + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !limiter.allow(clientAddress(r, trusted)) { + fail(w, contract.ErrTooManyRequests) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +// clientAddress — тот, кого ограничивают. +func clientAddress(r *http.Request, trusted []netip.Prefix) string { + peer, ok := peerAddress(r.RemoteAddr) + if !ok { + // Адрес пира не разобран — ключ берётся строкой как есть: общий бюджет + // лучше отсутствующего. + return r.RemoteAddr } - replaced := false - for i, existing := range settings.RateLimits.Rules { - if existing.Label == rule.Label { - settings.RateLimits.Rules[i] = rule - replaced = true - break + if !isTrusted(trusted, peer) { + return peer.String() + } + + if client, ok := forwardedClient(r, trusted); ok { + return client + } + + // Прокси адреса не назвал либо назвал одними доверенными: счётчик падает + // обратно на адрес пира — то есть на общий. Лучше общий, чем ключ, который + // выбирает сам спрашивающий. + return peer.String() +} + +// forwardedClient достаёт из цепочки пересылки того, кого ограничивают. +// +// **Цепочка читается справа налево, а доверенные адреса отбрасываются.** +// Значение слева пишет тот, кто шлёт запрос: прокси заголовок **дописывает**, а +// не заменяет, поэтому левым значением цепочки распоряжается аноним, и ключ +// бюджета, взятый оттуда, меняется у него на каждом запросе — бюджет обходится +// с первого. Справа же стоит адрес, который приписал ближайший к нам прокси, и +// каждый следующий шаг влево тем доверен, кто его приписал. Первый недоверенный +// справа и есть последний адрес, за который отвечает контур, а не спрашивающий. +// +// Заголовок читается **всеми** строками, а не одной: цепочка законно приходит +// несколькими заголовками, и разбор одной строки увидел бы только её часть — то +// есть снова кусок, которым распоряжается аноним. +func forwardedClient(r *http.Request, trusted []netip.Prefix) (string, bool) { + hops := forwardedChain(r) + + for i := len(hops) - 1; i >= 0; i-- { + addr, err := netip.ParseAddr(hops[i]) + if err != nil { + // Значение, которое не разбирается адресом, доверенным считать + // нечем: цепочка на нём кончается. + return "", false + } + + addr = addr.Unmap() + if isTrusted(trusted, addr) { + continue + } + + return addr.String(), true + } + + return "", false +} + +// forwardedChain — цепочка пересылки одним списком: все строки заголовка по +// порядку, каждая разобрана по запятым. +func forwardedChain(r *http.Request) []string { + var hops []string + for _, value := range r.Header.Values(ForwardedForHeader) { + for _, hop := range strings.Split(value, ",") { + hop = strings.TrimSpace(hop) + if hop != "" { + hops = append(hops, hop) + } } } - if !replaced { - settings.RateLimits.Rules = append(settings.RateLimits.Rules, rule) + return hops +} + +// rateLimiter — счётчики бюджета по ключу. +// +// Окно скользящее и хранится метками времени: счётчик с обнулением по границе +// окна пропустил бы двойной бюджет на стыке двух окон. +type rateLimiter struct { + mu sync.Mutex + max int + window time.Duration + budgets map[string]*budget + swept time.Time +} + +type budget struct { + hits []time.Time +} + +func newRateLimiter(maxRequests int, window time.Duration) *rateLimiter { + return &rateLimiter{ + max: maxRequests, + window: window, + budgets: map[string]*budget{}, + swept: clock.Now(), + } +} + +func (l *rateLimiter) allow(key string) bool { + now := clock.Now() + + l.mu.Lock() + defer l.mu.Unlock() + + l.sweep(now) + + b, ok := l.budgets[key] + if !ok { + b = &budget{} + l.budgets[key] = b } - settings.RateLimits.Enabled = true + b.hits = fresh(b.hits, now.Add(-l.window)) + if len(b.hits) >= l.max { + return false + } - if err := app.Save(settings); err != nil { - return fmt.Errorf("failed to apply app rate limit: %w", err) - } - return nil + b.hits = append(b.hits, now) + return true } -// ApplyTrustedProxyHeaders называет хранилищу заголовок, из которого брать -// адрес спрашивающего. -// -// Без этого ограничитель частоты ключует счётчик адресом **пира**, а пир с -// переездом входа на заголовок всегда один и тот же — обратный прокси. Бюджет -// в этом случае общий на весь сервис: восемь одновременно открытых карточек -// выбирают его целиком, и девятый человек получает отказ, не сделав ни одного -// запроса. Норма при этом требует обратного — бюджет считается по адресу -// спрашивающего. -// -// Доверие здесь той же природы, что и к `Remote-User`, и той же ширины: адрес -// берётся из пересылаемого заголовка, а верить пересылаемому можно ровно -// потому, что до нас дотянулся доверенный пир. Отсюда требование к контуру, -// записанное в модели угроз: прокси обязан `X-Forwarded-For` **перезаписывать**, -// а не дописывать к присланному, — иначе спрашивающий назначает себе ключ -// счётчика сам и обходит ограничитель, меняя значение. -// -// Барьером узнавания этот заголовок не служит и служить не может: кто пришёл, -// по-прежнему решает адрес самого соединения. -func ApplyTrustedProxyHeaders(app core.App) error { - settings := app.Settings() +// sweep выбрасывает счётчики, которыми давно не пользовались. Идёт не чаще раза +// в окно: обход карты на каждом запросе стоил бы больше самого ограничителя. +func (l *rateLimiter) sweep(now time.Time) { + if now.Sub(l.swept) < l.window { + return + } + l.swept = now - settings.TrustedProxy.Headers = []string{"X-Forwarded-For"} - // Пустой заголовок означает, что прокси адреса не назвал; счётчик тогда - // падает обратно на адрес пира — то есть на общий, как было. Лучше общий, - // чем один пустой ключ на всех. - settings.TrustedProxy.UseLeftmostIP = true + for key, b := range l.budgets { + b.hits = fresh(b.hits, now.Add(-staleBudgetAge)) + if len(b.hits) == 0 { + delete(l.budgets, key) + } + } +} - if err := app.Save(settings); err != nil { - return fmt.Errorf("failed to apply trusted proxy headers: %w", err) +// fresh отбрасывает метки старше границы. Метки лежат по возрастанию, поэтому +// достаточно найти первую свежую. +func fresh(hits []time.Time, edge time.Time) []time.Time { + for i, hit := range hits { + if hit.After(edge) { + return hits[i:] + } } return nil } diff --git a/internal/controller/http/server.go b/internal/controller/http/server.go new file mode 100644 index 0000000..9c603a2 --- /dev/null +++ b/internal/controller/http/server.go @@ -0,0 +1,54 @@ +package http + +import ( + "log/slog" + "net/http" + "net/netip" + + "git.vakhrushev.me/av/transcriber/internal/contract" +) + +// AppChain одевает адреса приложения в их слои. +// +// Порядок один и он несущий: ограничитель частоты → узнавание → требование +// учётной записи → обработчик. +// +// - ограничитель стоит первым, потому что узнавание читает базу, а на новом +// имени ещё и пишет в неё: поставленное раньше, оно работало бы на запросах, +// которые ограничитель уже отверг, и поток отвергнутых обращений заводил бы +// учётные записи, которые потом не убираются ничем; +// - требование учётной записи стоит перед чтением тела: запись, за которую не +// заплатит узнанный отправитель, не должна попасть даже в память, а позже +// пришлось бы убирать уже уложенный файл — чего сервис не умеет вовсе. +// +// Область действия всех трёх — корень приложения, и берётся она из перечня +// адресного пространства: цепочка вешается на корень целиком, вторым списком +// адресов её не описывают. +func AppChain( + routes http.Handler, + users contract.UserRepository, + trusted []netip.Prefix, + logger *slog.Logger, +) http.Handler { + handler := RequireUser()(routes) + handler = TrustedHeaderIdentity(users, trusted, logger)(handler) + handler = RateLimit(trusted)(handler) + return handler +} + +// BuildHandler собирает поверхность сервиса целиком. +// +// Раздача приложения вешается последней: она занимает корень, и всё, что не +// совпало ни с одним адресом сервиса, доходит до неё. +// +// Снаружи всех — журнал и восстановление после паники: первый обязан видеть код +// ответа, каким бы слоем тот ни родился, второй — не дать одному паникующему +// запросу уронить процесс. +func BuildHandler(mounts []Mount, webapp *WebappHandler, logger *slog.Logger) http.Handler { + mux := http.NewServeMux() + + RegisterServiceRoutes(mux, mounts) + webapp.Register(mux) + + return Journal(mounts, logger)(Recover(logger)(mux)) +} diff --git a/internal/controller/http/status_test.go b/internal/controller/http/status_test.go index 3b6eb14..1b81d97 100644 --- a/internal/controller/http/status_test.go +++ b/internal/controller/http/status_test.go @@ -8,12 +8,9 @@ import ( "net/http/httptest" "testing" - "github.com/pocketbase/pocketbase/apis" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" "git.vakhrushev.me/av/transcriber/internal/entity" ) @@ -47,7 +44,7 @@ func TestRecordCard_CarriesNoTextButListsViews(t *testing.T) { record := jobWithFile(t, env) - texts := pbrepo.NewTextRepository(env.app) + texts := env.handler.textRepo transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка") require.NoError(t, err) @@ -120,7 +117,7 @@ func TestRecordText_TranscriptView(t *testing.T) { record := jobWithFile(t, env) - texts := pbrepo.NewTextRepository(env.app) + texts := env.handler.textRepo transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка") require.NoError(t, err) literary, err := texts.Put(record.Id, entity.TextKindLiterary, "вычитанный текст") @@ -149,7 +146,7 @@ func TestRecordText_ReplicasView(t *testing.T) { record := jobWithFile(t, env) - structures := pbrepo.NewStructureRepository(env.app) + structures := env.handler.structureRepo structure, err := structures.Put(record.Id, 1, []entity.Replica{ {StartMs: 0, EndMs: 1500, Text: "первая реплика"}, {StartMs: 1500, EndMs: 3000, Text: "вторая реплика"}, @@ -179,7 +176,7 @@ func TestRecordText_NotReadyDiffersFromNotFound(t *testing.T) { record := jobWithFile(t, env) - texts := pbrepo.NewTextRepository(env.app) + texts := env.handler.textRepo transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка") require.NoError(t, err) record.TranscriptTextID = &transcript.Id @@ -204,6 +201,64 @@ func TestRecordText_NotReadyDiffersFromNotFound(t *testing.T) { "«ещё не готово» и «записи нет» ведут к разным действиям человека") } +// Каждая ветвь «ещё не готово» отвечает одним кодом и одним телом. +// +// Ветвей четыре, и достижимы все: текста нет вовсе, текст заведён пустым, +// структуры реплик нет, структура заведена без единой реплики. Шаг завершения +// пишет результат несколькими операциями, поэтому запись законно застаётся между +// ними. Ветвь, отвечающая иначе, отправила бы приложение за текстом, которого +// нет, либо показала бы «записи нет» на своей записи. +func TestRecordText_EveryNotReadyBranchIsOneAnswer(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) + + notReady := func(t *testing.T, record *entity.AudioRecord, view string) { + t.Helper() + + w := textOf(t, env, record.Id, view) + require.Equal(t, http.StatusConflict, w.Code, view) + + var body ErrorBody + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, CodeNotReady, body.Code, view) + assert.NotEmpty(t, body.Message, view) + } + + t.Run("текста нет вовсе", func(t *testing.T) { + record := jobWithFile(t, env) + notReady(t, record, entity.TextViewTranscript) + notReady(t, record, entity.TextViewLiterary) + notReady(t, record, entity.TextViewReplicas) + }) + + t.Run("текст заведён пустым", func(t *testing.T) { + record := jobWithFile(t, env) + + transcript, err := env.handler.textRepo.Put(record.Id, entity.TextKindTranscript, "") + require.NoError(t, err) + record.TranscriptTextID = &transcript.Id + + literary, err := env.handler.textRepo.Put(record.Id, entity.TextKindLiterary, "") + require.NoError(t, err) + record.LiteraryTextID = &literary.Id + + require.NoError(t, env.handler.recordRepo.Save(record, "")) + + notReady(t, record, entity.TextViewTranscript) + notReady(t, record, entity.TextViewLiterary) + }) + + t.Run("структура заведена без единой реплики", func(t *testing.T) { + record := jobWithFile(t, env) + + structure, err := env.handler.structureRepo.Put(record.Id, 1, []entity.Replica{}) + require.NoError(t, err) + record.StructureID = &structure.Id + require.NoError(t, env.handler.recordRepo.Save(record, "")) + + notReady(t, record, entity.TextViewReplicas) + }) +} + // Вид, которого сервис не знает, и незаданный вид дают отказ по негодному вводу: // умолчание сделало бы ответ функцией того, что успел записать конвейер. func TestRecordText_UnknownViewIsBadRequest(t *testing.T) { @@ -241,7 +296,7 @@ func TestRecordText_ReadFailureIsNotANotFound(t *testing.T) { record := jobWithFile(t, env) - texts := pbrepo.NewTextRepository(env.app) + texts := env.handler.textRepo transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка") require.NoError(t, err) @@ -251,20 +306,21 @@ func TestRecordText_ReadFailureIsNotANotFound(t *testing.T) { // Обработчик пересобирается с отказывающим хранилищем текстов: остальная // цепочка та же, что и в проде. journal := &journalBuffer{} + logger := slog.New(slog.NewTextHandler(journal, nil)) handler := NewAppHandler( env.handler.recordRepo, &failingTextRepo{}, - pbrepo.NewStructureRepository(env.app), + env.handler.structureRepo, + env.handler.fileRepo, env.handler.trsService, - slog.New(slog.NewTextHandler(journal, nil)), + logger, ) - r, err := apis.NewRouter(env.app) - require.NoError(t, err) - r.Bind(TrustedHeaderIdentity(env.app, ServiceMounts(handler, http.NotFoundHandler()), testTrustedNetworks(t), nil)) - handler.Register(r) - mux, err := r.BuildMux() - require.NoError(t, err) + mounts := ServiceMounts( + AppChain(handler.Routes(), env.users, testTrustedNetworks(t), logger), + http.NotFoundHandler(), + ) + mux := BuildHandler(mounts, NewWebappHandler(builtDist(), true, logger), logger) req := httptest.NewRequest("GET", "/app/audiorecords/"+record.Id+"/text?view="+entity.TextViewTranscript, http.NoBody) asUser(req, env.login) @@ -280,16 +336,25 @@ func TestRecordText_ReadFailureIsNotANotFound(t *testing.T) { // Прежние адреса приложения убраны целиком: контракт объявлен сломанным, и // адрес, отвечающий по-старому, означал бы два дома у одного вопроса. +// +// Отвечают они теперь тем же, чем отвечает всякий путь вне корней сервиса, — +// разметкой приложения: корень `/api` перестал существовать вместе со +// встроенным хранилищем, и резервировать имя за отказом сервис не берётся. func TestFormerAddressesAreGone(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) record := jobWithFile(t, env) + before := countJobs(t, env) status := httptest.NewRecorder() env.serve(status, httptest.NewRequest("GET", "/api/status/"+record.Id, http.NoBody)) - assert.Equal(t, http.StatusNotFound, status.Code) + assert.Equal(t, http.StatusOK, status.Code) + assert.Contains(t, status.Body.String(), "приложение", "прежний адрес отдал не разметку") + assert.NotContains(t, status.Body.String(), entity.StateUploaded, + "прежний адрес отдал данные записи") intake := httptest.NewRecorder() env.serve(intake, createMultipartRequestAt(t, "/api/audio", "запись.mp3", []byte("данные"))) - assert.Equal(t, http.StatusNotFound, intake.Code) + assert.Equal(t, http.StatusMethodNotAllowed, intake.Code) + assert.Equal(t, before, countJobs(t, env), "прежний адрес приёма завёл запись") } diff --git a/internal/controller/http/transcribe_test.go b/internal/controller/http/transcribe_test.go index 64cfb02..1075d96 100644 --- a/internal/controller/http/transcribe_test.go +++ b/internal/controller/http/transcribe_test.go @@ -6,6 +6,8 @@ import ( "encoding/json" "errors" "fmt" + "io" + "io/fs" "log/slog" "mime/multipart" "net/http" @@ -15,20 +17,19 @@ import ( "strings" "sync" "testing" + "testing/fstest" "time" - "github.com/pocketbase/pocketbase/apis" - "github.com/pocketbase/pocketbase/core" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "git.vakhrushev.me/av/transcriber/internal/adapter/recognizer" - pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" - "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" + sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite" "git.vakhrushev.me/av/transcriber/internal/clock" "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" + "git.vakhrushev.me/av/transcriber/internal/ident" "git.vakhrushev.me/av/transcriber/internal/service" ) @@ -66,19 +67,33 @@ func readableMetaViewer() *stubMetaViewer { return &stubMetaViewer{seconds: 42} } +const testMarkup = `приложение` + +// builtDist — собранное приложение, каким его видит раздача. +func builtDist() fs.FS { + return fstest.MapFS{ + "index.html": {Data: []byte(testMarkup)}, + "assets/index-abc123.js": {Data: []byte("console.log(1)")}, + "favicon.ico": {Data: []byte("значок")}, + } +} + // testEnv — собранное окружение одной проверки. Каталог данных свой у каждой: // рабочий каталог процесса проверки не трогают. type testEnv struct { mux http.Handler handler *AppHandler - app core.App + db *sqliterepo.DB + store *sqliterepo.Store + users *sqliterepo.UserRepository + files *sqliterepo.FileRepository journal *journalBuffer // login — логин у провайдера, которым доверенный источник называет // пришедшего. Адреса приложения закрыты узнаванием, и проверка, судящая их // по существу, обязана назваться ровно так же, как это делает прокси. login string // account — учётная запись, которой принадлежит этот логин. - account *core.Record + account *contract.UserAccount } // Доверенный источник проверок. @@ -93,10 +108,68 @@ const ( func testTrustedNetworks(t *testing.T) []netip.Prefix { t.Helper() - prefix, err := netip.ParsePrefix("10.0.0.0/8") + // Подсетей две: своя для запросов через `httptest.NewRecorder` и петлевая + // для тех проверок, которым нужен **настоящий** HTTP-запрос через поднятую + // цепочку слоёв — у них адрес пира ставит ядро, и он петлевой. + networks := make([]netip.Prefix, 0, 2) + for _, raw := range []string{"10.0.0.0/8", "127.0.0.0/8"} { + prefix, err := netip.ParsePrefix(raw) + require.NoError(t, err) + networks = append(networks, prefix) + } + + return networks +} + +// liveServer поднимает настоящий HTTP-сервер на этой поверхности. +// +// Нужен там, где проверка судит поведение, которого у вызова обработчика +// напрямую не бывает: разбор заголовка диапазона идёт по настоящему запросу, а +// адрес пира ставит ядро. +func (e *testEnv) liveServer(t *testing.T) *httptest.Server { + t.Helper() + + server := httptest.NewServer(e.mux) + t.Cleanup(server.Close) + + return server +} + +// liveResponse — готовый ответ настоящего запроса: тело прочитано и закрыто, и +// проверке остаётся судить значения. +type liveResponse struct { + StatusCode int + Header http.Header + ContentLength int64 + Body []byte +} + +// liveRequest шлёт настоящий запрос от имени учётной записи проверки. +func (e *testEnv) liveRequest( + t *testing.T, server *httptest.Server, path string, headers map[string]string, +) liveResponse { + t.Helper() + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+path, nil) + require.NoError(t, err) + req.Header.Set(LoginHeader, e.login) + for name, value := range headers { + req.Header.Set(name, value) + } + + res, err := server.Client().Do(req) + require.NoError(t, err) + defer func() { require.NoError(t, res.Body.Close()) }() + + body, err := io.ReadAll(res.Body) require.NoError(t, err) - return []netip.Prefix{prefix} + return liveResponse{ + StatusCode: res.StatusCode, + Header: res.Header, + ContentLength: res.ContentLength, + Body: body, + } } // asUser делает запрос запросом названного человека: ставит заголовок и адрес @@ -113,30 +186,23 @@ func (e *testEnv) serve(w http.ResponseWriter, req *http.Request) { e.mux.ServeHTTP(w, asUser(req, e.login)) } -// newTestAccount заводит учётную запись с известным логином. -// -// Запись создаётся прямым сохранением, а не запросом к API: заводить её -// запросом нельзя — создание закрыто шагом схемы. Прямое сохранение идёт мимо -// правил доступа так же, как идёт заведение записи первым обращением. -func newTestAccount(t *testing.T, app core.App) (*core.Record, string) { - t.Helper() +// get шлёт запрос **неузнанным**: заголовка на нём нет вовсе. +func (e *testEnv) get(path string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + e.mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + return w +} - const login = "person" +// getAs шлёт запрос от имени названного логина. +func (e *testEnv) getAs(login, path string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + e.mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, path, nil), login)) + return w +} - users, err := app.FindCollectionByNameOrId("users") - require.NoError(t, err) - - record := core.NewRecord(users) - record.Set(migrations.ProviderLoginField, login) - record.Set("email", "person@example.com") - record.Set("verified", true) - // Случайный пароль ставит и сам сервис, когда заводит запись первым - // обращением: запись auth-коллекции без пароля не сохраняется, а войти по - // нему всё равно нельзя — парольный вход выключен шагом схемы. - record.SetRandomPassword() - require.NoError(t, app.Save(record)) - - return record, login +// getOwn шлёт запрос от имени учётной записи проверки. +func (e *testEnv) getOwn(path string) *httptest.ResponseRecorder { + return e.getAs(e.login, path) } // journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на @@ -162,45 +228,72 @@ func (b *journalBuffer) String() string { return b.text.String() } -// newTestStorage поднимает хранилище на пустом каталоге и накатывает схему — -// ровно тем же путём, каким это делает сервис при старте. -func newTestStorage(t *testing.T) core.App { +// newTestStorage поднимает базу на пустом каталоге и накатывает схему — ровно +// тем же путём, каким это делает сервис при старте. +func newTestStorage(t *testing.T) (*sqliterepo.DB, *sqliterepo.Store, string) { t.Helper() - app, err := pbrepo.New(t.TempDir()) + dir := t.TempDir() + db, err := sqliterepo.Open(dir, sqliterepo.Settings{BusyTimeoutMs: 5000, ReadConnections: 4}) require.NoError(t, err) t.Cleanup(func() { - if err := app.ResetBootstrapState(); err != nil { - t.Logf("не удалось закрыть хранилище: %v", err) + if err := db.Close(); err != nil { + t.Logf("не удалось закрыть базу: %v", err) } }) - return app + require.NoError(t, sqliterepo.Migrate( + context.Background(), db, dir, slog.New(slog.DiscardHandler), + )) + + return db, sqliterepo.NewStore(dir), dir +} + +// envOptions — чем одна проверка отличается от другой. +type envOptions struct { + metaviewer contract.AudioMetaViewer + dist fs.FS + built bool } func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv { - app := newTestStorage(t) + return setupEnv(t, envOptions{metaviewer: metaviewer, dist: builtDist(), built: true}) +} - pbrepo.BindPanelRules(app) +// setupEnv собирает поверхность сервиса тем же способом, что и точка входа: +// адреса порождает перечень адресного пространства, слои одевает та же цепочка. +// Собери проверка поверхность иначе — и судила бы она не то. +func setupEnv(t *testing.T, opts envOptions) *testEnv { + t.Helper() + + db, store, _ := newTestStorage(t) + + recordRepo := sqliterepo.NewAudioRecordRepository(db) + textRepo := sqliterepo.NewTextRepository(db) + structureRepo := sqliterepo.NewStructureRepository(db) + fileRepo := sqliterepo.NewFileRepository(db, store) + users := sqliterepo.NewUserRepository(db) - recordRepo := pbrepo.NewAudioRecordRepository(app) - textRepo := pbrepo.NewTextRepository(app) repos := service.Repositories{ Records: recordRepo, - Files: pbrepo.NewFileRepository(app), + Files: fileRepo, Texts: textRepo, - Structures: pbrepo.NewStructureRepository(app), - Recognitions: pbrepo.NewRecognitionRepository(app), - Events: pbrepo.NewRecordEventRepository(app), + Structures: structureRepo, + Recognitions: sqliterepo.NewRecognitionRepository(db, store), + Events: sqliterepo.NewRecordEventRepository(db), } // Журнал уходит в буфер, а не в никуда: по нему судит проверка запрета на // имя отправителя. Вывод прогона от этого не меняется — ERROR-строки ветки - // отказа по-прежнему не попадают на экран и не размывают признак, по - // которому отличают новый красный шаг гейта от объявленного долга. + // отказа по-прежнему не попадают на экран. journal := &journalBuffer{} logger := slog.New(slog.NewTextHandler(journal, nil)) + metaviewer := opts.metaviewer + if metaviewer == nil { + metaviewer = readableMetaViewer() + } + trsService := service.NewTranscribeService( repos, metaviewer, @@ -210,29 +303,31 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv { logger, ) - handler := NewAppHandler(recordRepo, textRepo, pbrepo.NewStructureRepository(app), trsService, logger) + handler := NewAppHandler(recordRepo, textRepo, structureRepo, fileRepo, trsService, logger) - // Роутер собирается тем же способом, что и боевой: слой узнавания вешается - // корневым, маршруты вешает сам обработчик, и проверка судит ту же цепочку, - // что и прод. Собери роутер иначе — и проверка судила бы не то. - r, err := apis.NewRouter(app) + mounts := ServiceMounts( + AppChain(handler.Routes(), users, testTrustedNetworks(t), logger), + http.NotFoundHandler(), + ) + webapp := NewWebappHandler(opts.dist, opts.built, logger) + mux := BuildHandler(mounts, webapp, logger) + + account, _, err := users.EnsureUser(contract.Identity{ + Login: "person", + Name: "Человек", + Email: "person@example.com", + }) require.NoError(t, err) - mounts := ServiceMounts(handler, http.NotFoundHandler()) - r.Bind(TrustedHeaderIdentity(app, mounts, testTrustedNetworks(t), logger)) - handler.Register(r) - - mux, err := r.BuildMux() - require.NoError(t, err) - - account, login := newTestAccount(t, app) - return &testEnv{ mux: mux, handler: handler, - app: app, + db: db, + store: store, + users: users, + files: fileRepo, journal: journal, - login: login, + login: "person", account: account, } } @@ -285,65 +380,115 @@ func intakeItemOf(t *testing.T, w *httptest.ResponseRecorder) IntakeItem { return items[0] } -// storedFileNames отдаёт имена, под которыми файлы легли в хранилище. +// storedFileNames отдаёт имена, под которыми копии легли в каталог данных. func storedFileNames(t *testing.T, env *testEnv) []string { - records, err := env.app.FindAllRecords(migrations.FilesCollection) + t.Helper() + + rows, err := env.db.Reader().QueryContext(context.Background(), "SELECT file_name FROM files") require.NoError(t, err) + defer func() { require.NoError(t, rows.Close()) }() var names []string - for _, record := range records { - names = append(names, record.GetStringSlice("file")...) + for rows.Next() { + var name string + require.NoError(t, rows.Scan(&name)) + names = append(names, name) } + require.NoError(t, rows.Err()) return names } -// countFiles считает записи о файлах. +// countRows считает строки названной таблицы. +func countRows(t *testing.T, env *testEnv, table string) int { + t.Helper() + + var total int + require.NoError(t, env.db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM "+table).Scan(&total)) + return total +} + +// countFiles считает строки о копиях записей. func countFiles(t *testing.T, env *testEnv) int { - records, err := env.app.FindAllRecords(migrations.FilesCollection) - require.NoError(t, err) - return len(records) + return countRows(t, env, "files") } // countJobs считает заведённые аудиозаписи. func countJobs(t *testing.T, env *testEnv) int { - records, err := env.app.FindAllRecords(migrations.RecordsCollection) - require.NoError(t, err) - return len(records) + return countRows(t, env, "audio_records") } -// jobWithFile заводит задачу вместе с её записью: ссылка на файл обязательна -// схемой, потому что без неё задача не пройдёт ни одного шага. +// jobWithFile заводит запись вместе с её принятой копией: без ссылки на файл +// запись не прошла бы ни одного шага. func jobWithFile(t *testing.T, env *testEnv) *entity.AudioRecord { t.Helper() - repo := pbrepo.NewFileRepository(env.app) - work, err := repo.Stage(".mp3", strings.NewReader("запись")) + return jobWithFileOf(t, env, env.account.ID) +} + +// jobWithFileOf — то же, но от имени названного владельца. +func jobWithFileOf(t *testing.T, env *testEnv, ownerID string) *entity.AudioRecord { + t.Helper() + + recordID := ident.New() + + work, err := env.files.Stage(".mp3", strings.NewReader("запись")) require.NoError(t, err) defer func() { require.NoError(t, work.Close()) }() - // Владелец — учётная запись проверки: задача, пришедшая из веба, без - // владельца больше не заводится, и фикстура без него описывала бы состояние, - // которого в проде не бывает. - file, err := repo.Create("sample.mp3", work, contract.FileMeta{Format: "mp3"}, env.account.Id) + // Владелец — учётная запись проверки: ничьей записи в хранилище не бывает, и + // фикстура без владельца описывала бы состояние, которого в проде нет. + file, err := env.files.Create( + recordID, "sample.mp3", work, contract.FileMeta{Format: "mp3"}, ownerID, + ) require.NoError(t, err) record := &entity.AudioRecord{ + Id: recordID, State: entity.StateUploaded, StateEnteredAt: clock.Now(), - Source: entity.SourceApi, - OwnerID: env.account.Id, + OwnerID: ownerID, OriginalFileID: &file.Id, } require.NoError(t, env.handler.recordRepo.Create(record)) return record } -// storedContent читает содержимое файла из хранилища. -func storedContent(t *testing.T, env *testEnv, fileID string) []byte { - repo := pbrepo.NewFileRepository(env.app) - reader, err := repo.Open(fileID) +// newTopic заводит тему в словаре названного владельца. +// +// Запросом к базе, а не адресом сервиса: адреса, которым заводят тему, у сервиса +// нет — словарь пишет задача языковой модели, которой ещё нет. Проверка обязана +// пройти тот же путь разрешения тем, каким пойдёт она. +func newTopic(t *testing.T, env *testEnv, ownerID, name string) string { + t.Helper() + + id := ident.New() + now := clock.Now().UTC().Format("2006-01-02T15:04:05Z") + _, err := env.db.Writer().ExecContext(context.Background(), + "INSERT INTO topics (id, owner_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + id, ownerID, name, now, now, + ) require.NoError(t, err) - defer reader.Close() + + return id +} + +// attachTopic связывает тему с записью. +func attachTopic(t *testing.T, env *testEnv, recordID, topicID string) { + t.Helper() + + _, err := env.db.Writer().ExecContext(context.Background(), + "INSERT INTO record_topics (record_id, topic_id) VALUES (?, ?)", recordID, topicID, + ) + require.NoError(t, err) +} + +// storedContent читает содержимое копии из каталога данных. +func storedContent(t *testing.T, env *testEnv, fileID string) []byte { + t.Helper() + + reader, err := env.files.Open(fileID) + require.NoError(t, err) + defer func() { require.NoError(t, reader.Close()) }() var buf bytes.Buffer _, err = buf.ReadFrom(reader) @@ -387,7 +532,7 @@ func TestCreateTranscribeJob_Success(t *testing.T) { // отправитель получит идентификатор записи, которой не будет никогда. require.Equal(t, 1, countJobs(t, env)) - job, err := env.handler.recordRepo.GetByID(response.ID, env.account.Id) + job, err := env.handler.recordRepo.GetByID(response.ID, env.account.ID) require.NoError(t, err) assert.Equal(t, entity.StateUploaded, job.State) require.NotNil(t, job.OriginalFileID) @@ -529,9 +674,20 @@ func TestCreateTranscribeJob_SenderFileNameNotStored(t *testing.T) { require.Len(t, names, 1) assert.NotContains(t, names[0], "секретное-слово", - "имя, данное отправителем, в хранилище не попадает") + "имя, данное отправителем, в имя файла не попадает") assert.True(t, strings.HasSuffix(names[0], ".mp3"), "расширение при этом сохраняется: %q", names[0]) + + // И в **путь** к файлу оно не попадает тоже: подкаталог назван + // идентификатором записи, а не именем отправителя. + var recordID, storedRecord string + require.NoError(t, env.db.Reader(). + QueryRow("SELECT id FROM audio_records").Scan(&recordID)) + require.NoError(t, env.db.Reader(). + QueryRow("SELECT record_id FROM files").Scan(&storedRecord)) + assert.Equal(t, recordID, storedRecord, "подкаталог копии назван не записью") + assert.NotContains(t, storedRecord, "секретное-слово", + "имя, данное отправителем, попало в путь к файлу") } func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) { @@ -659,7 +815,7 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) { response := intakeItemOf(t, w) - job, err := env.handler.recordRepo.GetByID(response.ID, env.account.Id) + job, err := env.handler.recordRepo.GetByID(response.ID, env.account.ID) require.NoError(t, err) require.NotNil(t, job.OriginalFileID) @@ -821,7 +977,5 @@ func TestAcceptedRecordSurvivesSenderDisconnect(t *testing.T) { require.Equal(t, http.StatusCreated, w.Result().StatusCode, "тело ответа: %s", w.Body.String()) - jobs, err := env.app.FindAllRecords(migrations.RecordsCollection) - require.NoError(t, err) - assert.Len(t, jobs, 1, "задача заведена, несмотря на ушедшего отправителя") + assert.Equal(t, 1, countJobs(t, env), "задача заведена, несмотря на ушедшего отправителя") } diff --git a/internal/controller/http/webapp.go b/internal/controller/http/webapp.go index 1021db7..15ba45d 100644 --- a/internal/controller/http/webapp.go +++ b/internal/controller/http/webapp.go @@ -9,29 +9,6 @@ import ( "path" "strconv" "strings" - - "github.com/pocketbase/pocketbase/apis" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tools/router" -) - -// Корни адресного пространства и отдельные адреса наблюдения. -// -// `/api` и `/_` принадлежат хранилищу: первый — его наборам адресов, второй — -// панели владельца. Поменять их нельзя, это литералы библиотеки. -// -// Корня `/auth` здесь больше нет: собственного входа у сервиса не осталось, и -// адресов под этим корнем не существует. Прежние адреса входа поэтому отвечают -// тем же, чем отвечает всякий путь вне корней, — разметкой приложения. -// Резервировать имя за отказом сервис не берётся: имя, за которым ничего не -// стоит, ничем не отличается от любого другого свободного, а второй перечень -// «когда-то занятых корней» разошёлся бы с первым молча. -const ( - StorageRoot = "/api" - PanelRoot = "/_" - - HealthPath = "/health" - MetricsPath = "/metrics" ) // assetsDir — каталог, который наполняет сборщик приложения. @@ -45,104 +22,6 @@ const assetsDir = "assets" // Срок хранения ресурса сборщика — год. const assetMaxAgeSeconds = 31536000 -// Mount — часть адресного пространства, принадлежащая сервису. -// -// Перечень этих частей — **единственное** описание того, что сервису -// принадлежит, и он не описывает регистрацию, а порождает её: корень, -// заведённый мимо перечня, не получит обработчика вовсе. Прежде такой перечень -// был бы вторым описанием таблицы маршрутов, которую ведут три места, и корень, -// забытый в нём, молча отдавал бы разметку там, где программа ждёт отказ -// контракта. -type Mount struct { - // Path — корень либо точный адрес. - Path string - - // Exact — путь является точным адресом, а не корнем: `/health` накрывает - // только сам себя, а `/app` — всё, что под ним. - Exact bool - - // Bind вешает обработчики этой части. Пусто у того, что вешает библиотека. - Bind func(r *router.Router[*core.RequestEvent]) -} - -// Covers говорит, принадлежит ли путь этой части адресного пространства. -// -// Условий два, и оба обязательны: точное совпадение либо префикс **вместе с -// косой чертой**. По одному префиксу корню `/app` достался бы посторонний -// `/apple`; по одному префиксу с косой чертой голый `/api` не достался бы -// никому и уехал бы разметкой приложения. -func (m Mount) Covers(requestPath string) bool { - if m.Exact { - return requestPath == m.Path - } - - return requestPath == m.Path || strings.HasPrefix(requestPath, m.Path+"/") -} - -// ServiceMounts перечисляет адресное пространство сервиса целиком. -func ServiceMounts(appHandler *AppHandler, metricsHandler http.Handler) []Mount { - return []Mount{ - {Path: StorageRoot}, - {Path: PanelRoot}, - {Path: AppRoot, Bind: appHandler.Register}, - {Path: HealthPath, Exact: true, Bind: bindHealth}, - {Path: MetricsPath, Exact: true, Bind: bindMetrics(metricsHandler)}, - } -} - -// RegisterServiceRoutes вешает всё, что сервис вешает сам. -func RegisterServiceRoutes(r *router.Router[*core.RequestEvent], mounts []Mount) { - for _, mount := range mounts { - if mount.Bind != nil { - mount.Bind(r) - } - } -} - -// IsServiceAddress говорит, принадлежит ли путь сервису хоть какой-то частью. -func IsServiceAddress(mounts []Mount, requestPath string) bool { - for _, mount := range mounts { - if mount.Covers(requestPath) { - return true - } - } - - return false -} - -// IsObservationAddress говорит, что путь — адрес наблюдения. -// -// Опрос здоровья и метрик идёт постоянно и полезного не несёт, поэтому уровень -// журнала у него свой. Перечень при этом тот же самый: второе перечисление этих -// адресов разошлось бы с первым молча. -func IsObservationAddress(mounts []Mount, requestPath string) bool { - for _, mount := range mounts { - if mount.Exact && mount.Covers(requestPath) { - return true - } - } - - return false -} - -func bindHealth(r *router.Router[*core.RequestEvent]) { - r.GET(HealthPath, func(e *core.RequestEvent) error { - return e.JSON(http.StatusOK, map[string]string{ - "status": "ok", - "message": "Transcriber service is running", - }) - }) -} - -func bindMetrics(handler http.Handler) func(r *router.Router[*core.RequestEvent]) { - return func(r *router.Router[*core.RequestEvent]) { - r.GET(MetricsPath, func(e *core.RequestEvent) error { - handler.ServeHTTP(e.Response, e.Request) - return nil - }) - } -} - // notBuiltPage — что видит человек у бинарника без собранного приложения. // // Состояние возможно только у собранного мимо набора проверок: и набор @@ -164,25 +43,10 @@ const ( OutcomeFailure = "failure" ) -// journalOutcomeKey — под каким ключом раздача оставляет исход слою журнала. -const journalOutcomeKey = "transcriberWebappOutcome" - -// WebappOutcome отдаёт исход, оставленный раздачей, либо пустую строку, если -// запрос до неё не дошёл. -func WebappOutcome(e *core.RequestEvent) string { - outcome, ok := e.Get(journalOutcomeKey).(string) - if !ok { - return "" - } - - return outcome -} - // WebappHandler раздаёт собранное приложение и держит правило неизвестного пути. type WebappHandler struct { dist fs.FS built bool - mounts []Mount fingerprint string logger *slog.Logger } @@ -191,14 +55,14 @@ type WebappHandler struct { // // Файловая система приходит параметром, а не тянется пакетом: так тест // подставляет свою сборку, не собирая приложение. -func NewWebappHandler(dist fs.FS, built bool, mounts []Mount, logger *slog.Logger) *WebappHandler { +func NewWebappHandler(dist fs.FS, built bool, logger *slog.Logger) *WebappHandler { if logger == nil { logger = slog.Default() } if !built { logger.Warn("Webapp is not built, service will answer with a placeholder page") - return &WebappHandler{dist: dist, built: built, mounts: mounts, logger: logger} + return &WebappHandler{dist: dist, built: built, logger: logger} } // Отпечаток вшитой сборки — единственное, чем «не та сборка» отличается от @@ -211,17 +75,12 @@ func NewWebappHandler(dist fs.FS, built bool, mounts []Mount, logger *slog.Logge return &WebappHandler{ dist: dist, built: built, - mounts: mounts, fingerprint: fingerprint, logger: logger, } } // buildFingerprint — короткий отпечаток разметки вшитой сборки. -// -// Считается по самой разметке, а не по файлу, который положил бы сборщик: файл -// пришлось бы заводить настройкой сборки, а разметка меняется вместе с именами -// ресурсов, то есть при всякой пересборке с изменениями. func buildFingerprint(dist fs.FS) string { markup, err := fs.ReadFile(dist, "index.html") if err != nil { @@ -235,54 +94,41 @@ func buildFingerprint(dist fs.FS) string { // Register вешает раздачу на корень. // -// Маршрут стоит ровно на `/`, а не на `/{path...}`: сборка маршрутов сама -// вешает на `/` отказ «ничего не совпало», если такого маршрута ещё нет, и -// второй всепокрывающий образец рядом с ним спорил бы с ним за путь. -func (h *WebappHandler) Register(r *router.Router[*core.RequestEvent]) { - // Успешная раздача в журнал хранилища не пишется. - // - // Журнал хранилища — второй, помимо журнала контейнера, и в него библиотека - // кладёт путь запроса целиком вместе с адресом отправителя, храня строки - // пять суток. Путь здесь выбирает спрашивающий, и без этого отказа всякое - // открытие приложения оставляло бы там его текст и его адрес. Готовая - // раздача статики ставит тот же признак первой строкой; своя написана мимо - // неё, и признак перенесён руками. - // - // Отказы записываются по-прежнему: признак снимает только успех. - r.Any("/", h.Serve).Bind(apis.SkipSuccessActivityLog()) +// Путь, принадлежащий корню сервиса, до неё не доходит вовсе: его забирает +// маршрутизатор, у которого корень объявлен своим образцом. Отказ контракта +// поэтому остаётся отказом контракта, а не проваливается в разметку. +func (h *WebappHandler) Register(mux *http.ServeMux) { + mux.HandleFunc("/", h.Serve) } -// Serve отдаёт приложение либо отказ — по порядку, объявленному дизайном. -func (h *WebappHandler) Serve(e *core.RequestEvent) error { - requestPath := e.Request.URL.Path - - // Путь принадлежит сервису — значит под этим корнем такого адреса просто - // нет. Отказ уходит формой библиотеки, то есть тем же, чем этот корень - // отвечает и сегодня: своя форма здесь была бы второй. - if IsServiceAddress(h.mounts, requestPath) { - e.Set(journalOutcomeKey, OutcomeFailure) - return router.NewNotFoundError("", nil) - } - - if e.Request.Method != http.MethodGet && e.Request.Method != http.MethodHead { - e.Set(journalOutcomeKey, OutcomeFailure) - return router.NewApiError(http.StatusMethodNotAllowed, "", nil) +// Serve отдаёт приложение либо отказ. +func (h *WebappHandler) Serve(w http.ResponseWriter, r *http.Request) { + // Открывающими страницу считаются `GET` и `HEAD`, и только они. + if r.Method != http.MethodGet && r.Method != http.MethodHead { + noteWebappOutcome(r, OutcomeFailure) + http.Error(w, "", http.StatusMethodNotAllowed) + return } if !h.built { - e.Set(journalOutcomeKey, OutcomeFailure) - return e.HTML(http.StatusServiceUnavailable, notBuiltPage) + noteWebappOutcome(r, OutcomeFailure) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(notBuiltPage)) + return } - name := strings.TrimPrefix(path.Clean(requestPath), "/") + name := strings.TrimPrefix(path.Clean(r.URL.Path), "/") if name == "" || name == "." { - return h.serveIndex(e) + h.serveIndex(w, r) + return } if info, err := fs.Stat(h.dist, name); err == nil && !info.IsDir() { - e.Set(journalOutcomeKey, OutcomeAsset) - h.setCacheHeader(e, name) - return e.FileFS(h.dist, name) + noteWebappOutcome(r, OutcomeAsset) + h.setCacheHeader(w, name) + http.ServeFileFS(w, r, h.dist, name) + return } // Разметка прежней сборки называет ресурсы прежней сборки. Подменить их @@ -290,33 +136,29 @@ func (h *WebappHandler) Serve(e *core.RequestEvent) error { // типу содержимого, человек увидит пустой экран, а в кодах ответов сервиса // не останется ничего. if underAssets(name) { - e.Set(journalOutcomeKey, OutcomeFailure) - return router.NewNotFoundError("", nil) + noteWebappOutcome(r, OutcomeFailure) + http.NotFound(w, r) + return } - return h.serveIndex(e) + h.serveIndex(w, r) } -func (h *WebappHandler) serveIndex(e *core.RequestEvent) error { - e.Set(journalOutcomeKey, OutcomeMarkup) - h.setCacheHeader(e, "index.html") +func (h *WebappHandler) serveIndex(w http.ResponseWriter, r *http.Request) { + noteWebappOutcome(r, OutcomeMarkup) + h.setCacheHeader(w, "index.html") // Отпечаток идёт метке ответа: `no-cache` обещает дешёвую проверку — // «спроси заново и получи подтверждение», — а вшитый файл не несёт времени // правки вовсе, и без метки браузеру каждый раз отдаётся полное тело вместо - // подтверждения. Отпечаток уже посчитан при подъёме, второго счёта не надо. + // подтверждения. if h.fingerprint != "" { - e.Response.Header().Set("ETag", `"`+h.fingerprint+`"`) + w.Header().Set("ETag", `"`+h.fingerprint+`"`) } - return e.FileFS(h.dist, "index.html") + http.ServeFileFS(w, r, h.dist, "index.html") } -// setCacheHeader назначает срок хранения по каталогу, а не по виду файла. -// -// Вид файла признака не даёт: в сборке лежат и файлы с постоянными именами — -// иконка, манифест, — и такой файл, однажды отданный как неизменяемый, не -// отзывается со стороны сервиса ничем. Запросов он больше не увидит. // underAssets говорит, ведёт ли путь в каталог сборщика. // // Условий два, и оба обязательны — та же пара, что у принадлежности корню: сам @@ -327,14 +169,19 @@ func underAssets(name string) bool { return name == assetsDir || strings.HasPrefix(name, assetsDir+"/") } -func (h *WebappHandler) setCacheHeader(e *core.RequestEvent, name string) { +// setCacheHeader назначает срок хранения по каталогу, а не по виду файла. +// +// Вид файла признака не даёт: в сборке лежат и файлы с постоянными именами — +// иконка, манифест, — и такой файл, однажды отданный как неизменяемый, не +// отзывается со стороны сервиса ничем. +func (h *WebappHandler) setCacheHeader(w http.ResponseWriter, name string) { if underAssets(name) { - e.Response.Header().Set( + w.Header().Set( "Cache-Control", "public, max-age="+strconv.Itoa(assetMaxAgeSeconds)+", immutable", ) return } - e.Response.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Cache-Control", "no-cache") } diff --git a/internal/controller/http/webapp_test.go b/internal/controller/http/webapp_test.go index b87a053..948e8f7 100644 --- a/internal/controller/http/webapp_test.go +++ b/internal/controller/http/webapp_test.go @@ -2,88 +2,25 @@ package http import ( "io/fs" - "log/slog" "net/http" "net/http/httptest" "testing" "testing/fstest" - "time" - "github.com/pocketbase/pocketbase/apis" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "git.vakhrushev.me/av/transcriber/internal/adapter/recognizer" - pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" - "git.vakhrushev.me/av/transcriber/internal/entity" - "git.vakhrushev.me/av/transcriber/internal/service" ) -const testMarkup = `приложение` - -// builtDist — собранное приложение, каким его видит раздача. -func builtDist() fs.FS { - return fstest.MapFS{ - "index.html": {Data: []byte(testMarkup)}, - "assets/index-abc123.js": {Data: []byte("console.log(1)")}, - "favicon.ico": {Data: []byte("значок")}, - } -} - -// webappEnv — окружение проверок раздачи. Роутер собирается тем же способом, -// что и боевой: маршруты порождает перечень, а не перечисление в проверке. +// webappEnv — окружение проверок раздачи. Поверхность собирается тем же +// способом, что и боевая: маршруты порождает перечень адресного пространства, а +// не перечисление в проверке. type webappEnv struct { - mux http.Handler - journal *journalBuffer - login string + env *testEnv } func setupWebappEnv(t *testing.T, dist fs.FS, built bool) *webappEnv { t.Helper() - app := newTestStorage(t) - pbrepo.BindPanelRules(app) - - recordRepo := pbrepo.NewAudioRecordRepository(app) - textRepo := pbrepo.NewTextRepository(app) - structureRepo := pbrepo.NewStructureRepository(app) - - journal := &journalBuffer{} - logger := slog.New(slog.NewTextHandler(journal, nil)) - - trsService := service.NewTranscribeService( - service.Repositories{ - Records: recordRepo, - Files: pbrepo.NewFileRepository(app), - Texts: textRepo, - Structures: structureRepo, - Recognitions: pbrepo.NewRecognitionRepository(app), - Events: pbrepo.NewRecordEventRepository(app), - }, - readableMetaViewer(), - &stubConverter{}, - &recognizer.MemoryAudioRecognizer{}, - entity.StuckLimits{Own: time.Hour, Foreign: 24 * time.Hour}, - logger, - ) - - appHandler := NewAppHandler(recordRepo, textRepo, structureRepo, trsService, logger) - - mounts := ServiceMounts(appHandler, http.NotFoundHandler()) - - r, err := apis.NewRouter(app) - require.NoError(t, err) - - r.Bind(TrustedHeaderIdentity(app, mounts, testTrustedNetworks(t), logger)) - RegisterServiceRoutes(r, mounts) - NewWebappHandler(dist, built, mounts, logger).Register(r) - - mux, err := r.BuildMux() - require.NoError(t, err) - - _, login := newTestAccount(t, app) - - return &webappEnv{mux: mux, journal: journal, login: login} + return &webappEnv{env: setupEnv(t, envOptions{dist: dist, built: built})} } func (e *webappEnv) get(path string) *httptest.ResponseRecorder { @@ -93,15 +30,20 @@ func (e *webappEnv) get(path string) *httptest.ResponseRecorder { func (e *webappEnv) do(method, path string, identified bool) *httptest.ResponseRecorder { req := httptest.NewRequest(method, path, nil) if identified { - asUser(req, e.login) + asUser(req, e.env.login) } rec := httptest.NewRecorder() - e.mux.ServeHTTP(rec, req) + e.env.mux.ServeHTTP(rec, req) return rec } +// journal отдаёт перехваченный журнал окружения. +func (e *webappEnv) journal() string { + return e.env.journal.String() +} + // Обновление страницы посреди приложения открывает тот же экран: адреса у // приложения обычные, а не после решётки, и сервер обязан отдать разметку. func TestWebappServesMarkupOutsideServiceRoots(t *testing.T) { @@ -117,16 +59,33 @@ func TestWebappServesMarkupOutsideServiceRoots(t *testing.T) { // Путь внутри корня сервиса в приложение не проваливается никогда: иначе // программа, ошибшаяся адресом, приняла бы разметку с кодом 200 за ответ. +// +// Корень у сервиса остался один — корень приложения; `/api` и `/_` ушли вместе +// со встроенным хранилищем, и пути под ними стали обычными путями вне корней. func TestWebappNeverAnswersInsideServiceRoots(t *testing.T) { env := setupWebappEnv(t, builtDist(), true) - for _, path := range []string{"/api/nope", "/_/nope"} { + for _, path := range []string{"/app/nope", "/app"} { res := env.get(path) assert.NotContains(t, res.Body.String(), "приложение", path) } } +// Прежние корни хранилища и панели отвечают разметкой — тем же, чем отвечает +// всякий путь вне корней. Знак, записанный своим кодом, попадает в то же +// правило: правило одно, и особого случая у него нет. +func TestWebappServesFormerStorageAndPanelPaths(t *testing.T) { + env := setupWebappEnv(t, builtDist(), true) + + for _, path := range []string{"/api/nope", "/_/", "/%5f/"} { + res := env.get(path) + + assert.Equal(t, http.StatusOK, res.Code, path) + assert.Contains(t, res.Body.String(), "приложение", path) + } +} + // Под корнем приложения отказ уходит его собственной формой, и код отвечает // причине: без сессии — «предъяви себя», с сессией — «такого адреса нет». func TestWebappLeavesAppRootToItsOwnFailureForm(t *testing.T) { @@ -147,8 +106,14 @@ func TestWebappLeavesAppRootToItsOwnFailureForm(t *testing.T) { func TestWebappRootMatchNeedsExactOrSlash(t *testing.T) { env := setupWebappEnv(t, builtDist(), true) - bare := env.get(StorageRoot) - assert.NotContains(t, bare.Body.String(), "приложение", "голый корень разметкой не подменяется") + bare := env.do(http.MethodGet, AppRoot, true) + assert.Equal(t, http.StatusNotFound, bare.Code, "голый корень разметкой не подменяется") + assert.NotContains(t, bare.Body.String(), "приложение") + + bareAnonymous := env.get(AppRoot) + assert.Equal(t, http.StatusUnauthorized, bareAnonymous.Code, + "голый корень неузнанному отвечает как прочие адреса под ним") + assert.NotContains(t, bareAnonymous.Body.String(), "приложение") neighbour := env.get(AppRoot + "le") assert.Equal(t, http.StatusOK, neighbour.Code) @@ -245,5 +210,5 @@ func TestWebappNotBuilt(t *testing.T) { // Владелец сервиса узнаёт об этом журналом подъёма, а не от человека, // открывшего страницу. - assert.Contains(t, env.journal.String(), "Webapp is not built") + assert.Contains(t, env.journal(), "Webapp is not built") } diff --git a/internal/entity/audio_record.go b/internal/entity/audio_record.go index dbaac56..2ac8ddf 100644 --- a/internal/entity/audio_record.go +++ b/internal/entity/audio_record.go @@ -35,15 +35,6 @@ const ( HaltReasonStuck = "stuck" ) -const ( - SourceUnknown = "unknown" - SourceApi = "api" - // SourceTelegram — историческое значение. Вход Telegram убран, новых записей - // с этим источником не появляется, а константа остаётся: на неё ссылается - // применённый шаг схемы `202608140002`, а применённый шаг не переписывается. - SourceTelegram = "telegram" -) - // AudioRecord — аудиозапись, центральная сущность сервиса. // // Приложения к ней — файлы, тексты, структура реплик, темы, журнал событий и @@ -57,7 +48,6 @@ type AudioRecord struct { // хранилище не бывает. Назначается один раз, при приёме, и конвейером не // меняется. OwnerID string - Source string // Title и Brief читаются вместе со списком, сотней штук разом, и потому // лежат колонками записи, а не строками `texts`. @@ -142,7 +132,7 @@ const MaxOriginalFilenameLen = 255 // // Живёт в домене, а не в транспорте: имя доходит до колонки записи одним путём, // и правило чистки обязано быть одно. Управляющие знаки убираются потому, что -// иначе доезжают до экрана и до панели владельца; резка идёт **после** уборки, +// иначе доезжают до экрана; резка идёт **после** уборки, // иначе потолок съедали бы знаки, которых в сохранённом имени всё равно не будет. // // Режется по знакам, а не по байтам: имя русское чаще, чем латинское, и обрезка @@ -162,20 +152,6 @@ func SanitizeOriginalFilename(name string) string { return string(runes) } -// AllStates — закрытый перечень рубежей для схемы хранилища. -func AllStates() []string { - out := make([]string, 0, len(stages)) - for _, s := range stages { - out = append(out, s.Name) - } - return out -} - -// AllHaltReasons — закрытый перечень причин остановки для схемы хранилища. -func AllHaltReasons() []string { - return []string{HaltReasonStepFailed, HaltReasonAttempts, HaltReasonStuck} -} - // MoveToState двигает запись на новый рубеж и чистит служебные поля прошлого. // // Время входа в рубеж ставится заново: с этой минуты идёт отсчёт застревания. diff --git a/internal/entity/file.go b/internal/entity/file.go index de9086e..515b76d 100644 --- a/internal/entity/file.go +++ b/internal/entity/file.go @@ -4,21 +4,13 @@ import ( "time" ) -// Где лежит копия файла. Поле названо `location`, а не `storage`: последним -// словом зовут само хранилище, и третий смысл у одного слова развёл бы по -// разным вещам запись о файле и хранилище, в котором она лежит. -const ( - LocationLocal = "local" - LocationS3 = "s3" -) - // MaxRecordSize — потолок размера одного файла записи. Выведен из расчётного // потолка записи в шесть часов с запасом на видео, а не из замера. // -// Число нужно назвать **явно** в двух местах сразу: у поля файла в хранилище -// нулевой потолок значит не «без предела», а умолчание библиотеки в 5 МиБ, а у -// тела запроса приёма умолчание роутера отсекало бы запись раньше, чем она -// дойдёт до обработчика — без строки в журнале приёма. +// Число называется **явно** везде, где иначе действует умолчание: у тела запроса +// приёма умолчание отсекало бы запись раньше, чем она дойдёт до обработчика — и +// без строки в журнале приёма. Умолчания здесь не «без предела», а величины на +// два-три порядка меньше нужного. const MaxRecordSize int64 = 8 << 30 // 8 ГиБ // File — одна физическая копия записи. Их ровно две: принятая и приведённая к @@ -26,12 +18,13 @@ const MaxRecordSize int64 = 8 << 30 // 8 ГиБ // существует только потому, что провайдер читает аудио по адресу, и её ключ // живёт в строке попытки распознавания. type File struct { - Id string - Location string - // FileName — имя, под которым файл лежит: имя задаёт сервис. Своего суффикса - // хранилище к заданному имени не дописывает: суффикс появляется только у - // имён, которые оно строит само из имени отправителя, а это умолчание не - // применяется. + Id string + // RecordID — запись, которой копия принадлежит. Копии одной записи лежат её + // подкаталогом, названным этим идентификатором: так они лежат вместе, а + // запись убирается целиком одним движением. + RecordID string + // FileName — имя, под которым файл лежит: имя задаёт сервис. Имя, данное + // отправителем, в него не попадает — от него взято только расширение. FileName string Size int64 // Format — расширение без точки, приведённое к нижнему регистру. Наружу оно diff --git a/internal/entity/identity.go b/internal/entity/identity.go new file mode 100644 index 0000000..86d1e86 --- /dev/null +++ b/internal/entity/identity.go @@ -0,0 +1,102 @@ +package entity + +import ( + "net/mail" + "strings" + "unicode" + "unicode/utf8" +) + +// MaxProviderLoginLength — предел длины логина у провайдера. +// +// Значение приходит заголовком, то есть целиком задаётся тем, кто шлёт запрос, и +// без предела в колонку уехало бы столько, сколько влезет в заголовки. Число то +// же, что у имени: длиннее имени логин не бывает, а два разных предела на +// соседних колонках одной записи разошлись бы молча. +const MaxProviderLoginLength = 255 + +// MaxDisplayNameLength — предел длины имени, пригодного к показу. +const MaxDisplayNameLength = 255 + +// AcceptProviderLogin приводит пришедшее значение к годному логину либо +// отвергает его. +// +// Отвергается пустое, состоящее из одних пробельных знаков, длиннее предела и +// несущее управляющие знаки. Пустое значение — не крайний случай: обратный +// прокси штатно шлёт заголовок пустым там, где никого не назвал, и без этой +// проверки все неназванные собрались бы в одну учётную запись с общим архивом. +// +// Обрамляющие пробелы срезаются: заголовок с ведущим пробелом и без него +// называет одного человека, а две записи о нём разошлись бы молча. Сравнение при +// поиске остаётся точным, знак в знак: приведение регистра завело бы правило, +// которого у провайдера нет. +// +// Живёт в домене, а не в транспорте и не в хранилище: правило одно на все +// способы представиться, а уложенное куском в слой оно разошлось бы двумя +// копиями при первом же втором способе. +func AcceptProviderLogin(value string) (string, bool) { + login := strings.TrimSpace(value) + + // Предел считается в **знаках**, а не в байтах: колонка считает знаки, и два + // предела в разных единицах разошлись бы вдвое на любой кириллице. + if login == "" || utf8.RuneCountInString(login) > MaxProviderLoginLength { + return "", false + } + + for _, r := range login { + if unicode.IsControl(r) { + return "", false + } + } + + return login, true +} + +// AcceptDisplayName приводит имя к годному для колонки значению. +// +// Обрезается по пределу и чистится от управляющих знаков — тем же приёмом, каким +// приём записи чистит имя файла отправителя. Пустое значение законно: имени у +// человека может не быть вовсе, и негодное значение необязательного поля не +// вправе отменять заведение учётной записи. +func AcceptDisplayName(value string) string { + name := strings.TrimSpace(stripControls(value)) + + runes := []rune(name) + if len(runes) > MaxDisplayNameLength { + return string(runes[:MaxDisplayNameLength]) + } + + return name +} + +// AcceptEmail отдаёт адрес почты, если он вообще похож на адрес. +// +// Негодный отбрасывается здесь, а не отказом сохранения: иначе опечатка в +// заголовке кончалась бы либо отказом сервиса, либо — что хуже — ветвью «почта +// занята», и владелец искал бы общий ящик там, где сломан контур. +func AcceptEmail(value string) (string, bool) { + email := strings.TrimSpace(value) + if email == "" { + return "", false + } + + // Разбор адреса принимает и форму «Имя <адрес>»: такую строку заголовок + // приносить не должен, и адресом она не считается. + parsed, err := mail.ParseAddress(email) + if err != nil || parsed.Name != "" || parsed.Address != email { + return "", false + } + + return email, true +} + +// stripControls убирает управляющие знаки: они приезжают заголовком и в колонке +// им делать нечего. +func stripControls(value string) string { + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return -1 + } + return r + }, value) +} diff --git a/internal/entity/recognition.go b/internal/entity/recognition.go index 9f274d8..944bfd1 100644 --- a/internal/entity/recognition.go +++ b/internal/entity/recognition.go @@ -87,10 +87,11 @@ func (r *RecognitionResult) GetError() string { // Другой провайдер её не потребует, и смена провайдера не трогает доменную // сущность вовсе. // -// Сырой ответ хранится вложением, а не колонкой этой строки: шаг опроса читает -// её раз в несколько секунд, а хранилище читает запись целиком — ответ на -// многочасовую запись ехал бы в память при каждом опросе. Хранится он потому, -// что результат операции у провайдера не переспрашивается. +// Сохранённый ответ провайдера лежит **третьим файлом в подкаталоге записи**, а +// не колонкой этой строки: шаг опроса читает её раз в несколько секунд, а +// репозиторий читает строку целиком — ответ на многочасовую запись ехал бы в +// память при каждом опросе. Хранится он потому, что результат операции у +// провайдера не переспрашивается. type Recognition struct { Id string RecordID string @@ -115,6 +116,6 @@ type RecognitionOutcome struct { Replicas []Replica // PlainText — плоский текст расшифровки. PlainText string - // Raw — ответ провайдера целиком, как он пришёл, на хранение вложением. + // Raw — ответ провайдера целиком, как он пришёл, на хранение отдельным файлом. Raw []byte } diff --git a/internal/entity/record_event.go b/internal/entity/record_event.go index 9b4508a..d799d3b 100644 --- a/internal/entity/record_event.go +++ b/internal/entity/record_event.go @@ -17,16 +17,6 @@ const ( EventOutcomeResumed = "resumed" ) -// AllEventOrigins — закрытый перечень источников для схемы хранилища. -func AllEventOrigins() []string { - return []string{EventOriginPipeline, EventOriginHuman} -} - -// AllEventOutcomes — закрытый перечень исходов для схемы хранилища. -func AllEventOutcomes() []string { - return []string{EventOutcomeDone, EventOutcomeFailed, EventOutcomeHalted, EventOutcomeResumed} -} - // RecordEvent — строка журнала событий одной записи. // // Пишется на смену рубежа, на остановку и на снятие остановки — не на каждое diff --git a/internal/entity/retired_states.go b/internal/entity/retired_states.go deleted file mode 100644 index 5e6ef41..0000000 --- a/internal/entity/retired_states.go +++ /dev/null @@ -1,22 +0,0 @@ -package entity - -// Состояния прежней модели. **Частью модели они не являются** и в перечень -// рубежей не входят: цепочку рубежей объявляет `stage.go`, а закрытый перечень -// для схемы — `AllStates()`. -// -// Живут они здесь по одной причине: шаг схемы `202608110001_init.go` заводил -// прежнюю коллекцию задач этими значениями, а **применённый шаг схемы не -// переписывается** — хранилище считает применённое по имени файла, и правка -// сделала бы его другим шагом под прежним именем. Шаг ссылается на эти -// константы, значит они обязаны существовать, пока существует он. -// -// Коллекцию, которую он заводил, удаляет шаг `202608140002`. Ни один живой путь -// сервиса этих значений не читает и не пишет; `StateDone` в этом списке нет — -// то же слово осталось именем конечного рубежа новой модели. -const ( - StateCreated = "created" - StateConverted = "converted" - StateTranscribe = "transcribe" - StateFailed = "failed" - StateDead = "dead" -) diff --git a/internal/entity/text.go b/internal/entity/text.go index c85f858..78547ff 100644 --- a/internal/entity/text.go +++ b/internal/entity/text.go @@ -42,11 +42,6 @@ func IsKnownTextView(view string) bool { return false } -// AllTextKinds — закрытый перечень видов текста для схемы хранилища. -func AllTextKinds() []string { - return []string{TextKindTranscript, TextKindLiterary} -} - // Text — один вид текста одной записи. Пара «запись и вид» уникальна: повтор // прерванного шага иначе завёл бы второй комплект строк, и вопрос «какой текст // отдавать человеку» стал бы вопросом порядка записи, а не состояния. diff --git a/internal/ident/ident.go b/internal/ident/ident.go new file mode 100644 index 0000000..64e7afd --- /dev/null +++ b/internal/ident/ident.go @@ -0,0 +1,192 @@ +// Package ident — единая точка выдачи идентификаторов строк. +// +// Идентификатор — ULID в нижнем регистре: 48 бит времени в миллисекундах плюс +// 80 бит случайности, записанные алфавитом Crockford base32. Так требует +// конвенция проекта [docs/conventions/database.md]; прежде идентификаторы +// выдавало встроенное хранилище своим алфавитом, и точки выдачи у приложения не +// было вовсе. +// +// Почему ULID, а не UUID: ширина записи постоянная, а старшие разряды несут +// время — лента записей упорядочивается парой «время заведения и ключ», и ключ +// в этой паре не спорит с временем, а продолжает его. +// +// Регистр нижний, и это тоже правило конвенции: сравнение строк в SQLite +// побайтово, поэтому канонический вид обязан быть один. Пришедший снаружи +// идентификатор приводится к нему разбором на границе — Parse. +package ident + +import ( + "crypto/rand" + "strings" + "sync" + "time" + + "git.vakhrushev.me/av/transcriber/internal/clock" +) + +// alphabet — Crockford base32 в нижнем регистре. Из него исключены `i`, `l`, +// `o` и `u`: первые три неотличимы от цифр в наборах без засечек, последняя +// исключена, чтобы случайная строка не складывалась в бранное слово. +const alphabet = "0123456789abcdefghjkmnpqrstvwxyz" + +// Len — длина записи ULID: 10 знаков времени и 16 знаков случайности. +const Len = 26 + +// decode — обратная таблица алфавита. Заполняется один раз: разбор идёт на +// каждом запросе с идентификатором в пути, и собирать таблицу по месту значило +// бы платить за неё столько же раз. +var decode = func() [256]int8 { + var table [256]int8 + for i := range table { + table[i] = -1 + } + for i, r := range alphabet { + table[byte(r)] = int8(i) + // Заглавный знак принимается разбором наравне со строчным: алфавит + // Crockford к регистру нечувствителен, и человек, скопировавший + // идентификатор из чужого письма, не обязан знать про канонический вид. + table[byte(strings.ToUpper(string(r))[0])] = int8(i) + } + return table +}() + +// Состояние выдачи: последняя метка времени и последняя случайная часть. +// +// Нужно ради **монотонности внутри миллисекунды**. Колонка времени несёт +// секунды, и порядок записей, заведённых в одну секунду, задаёт ключ: случайная +// часть, выданная заново, поставила бы их в произвольном порядке — «новые +// сверху» стало бы «как повезёт», а страница ленты читалась бы через раз. +var ( + mu sync.Mutex + lastMs uint64 + lastRand [10]byte +) + +// New выдаёт новый идентификатор. +// +// Время берётся единой точкой чтения времени, а не `time.Now`: запрет держит +// линтер, и обойти его здесь значило бы завести вторые часы у ключей. +// +// Случайность берётся у `crypto/rand`. Он не отказывает: с Go 1.24 чтение из +// него не возвращает ошибки вовсе, а невозможность получить случайность — отказ +// такого рода, из которого не стартуют. +// +// В пределах одной миллисекунды случайная часть **растёт на единицу**, а не +// выдаётся заново: два идентификатора одной миллисекунды обязаны идти в порядке +// выдачи. Переполнение прибавляет миллисекунду — исход недостижимый на любой +// мыслимой нагрузке, но названный, потому что молчаливый откат назад испортил бы +// порядок сильнее любой случайности. +func New() string { + ms := uint64(clock.Now().UnixMilli()) + + mu.Lock() + switch { + case ms > lastMs: + lastMs = ms + // Ошибку `rand.Read` не проверяем сознательно: с Go 1.24 он её не + // возвращает, а `errcheck` довольствуется явным присваиванием в + // пустышку. + _, _ = rand.Read(lastRand[:]) + default: + // Часы могли и отступить назад: метка тогда остаётся прежней, а порядок + // держит растущая случайная часть. + if !increment(&lastRand) { + lastMs++ + _, _ = rand.Read(lastRand[:]) + } + } + ms, entropy := lastMs, lastRand + mu.Unlock() + + var raw [16]byte + raw[0] = byte(ms >> 40) + raw[1] = byte(ms >> 32) + raw[2] = byte(ms >> 24) + raw[3] = byte(ms >> 16) + raw[4] = byte(ms >> 8) + raw[5] = byte(ms) + copy(raw[6:], entropy[:]) + + return encode(raw) +} + +// increment прибавляет единицу к случайной части. Ложь означает переполнение — +// все восемьдесят разрядов были заняты. +func increment(value *[10]byte) bool { + for i := len(value) - 1; i >= 0; i-- { + value[i]++ + if value[i] != 0 { + return true + } + } + return false +} + +// encode переводит шестнадцать байт в двадцать шесть знаков алфавита. +// +// Разрядов у записи 130, а байт — 128, поэтому старший знак несёт только два +// младших бита первого байта; остальные знаки идут ровными пятёрками бит. +func encode(raw [16]byte) string { + var n [130]byte + for i := range 128 { + n[i+2] = raw[i/8] >> (7 - i%8) & 1 + } + + out := make([]byte, Len) + for i := range out { + var v byte + for j := range 5 { + v = v<<1 | n[i*5+j] + } + out[i] = alphabet[v] + } + + return string(out) +} + +// Parse разбирает идентификатор, пришедший снаружи: проверяет вид и приводит +// регистр. Второе значение ложно у всего, что видом не совпало. +// +// Разбор стоит на границе, а не в запросе к базе: строка приходит от +// спрашивающего, а сравнение в базе побайтово — идентификатор в верхнем +// регистре не совпал бы ни с одной строкой, и «моя запись пропала» читалось бы +// как отказ разграничения. +func Parse(value string) (string, bool) { + if len(value) != Len { + return "", false + } + + out := make([]byte, Len) + for i := range Len { + v := decode[value[i]] + if v < 0 { + return "", false + } + out[i] = alphabet[v] + } + + // Старший знак несёт всего два бита времени: запись, у которой он больше + // семёрки, описывает время за пределами разрядности и годным ULID не + // является. + if decode[out[0]] > 7 { + return "", false + } + + return string(out), true +} + +// Timestamp отдаёт время, зашитое в идентификатор. Нужен проверкам: по нему +// видно, что ключ и колонка времени идут в одну сторону. +func Timestamp(id string) (time.Time, bool) { + parsed, ok := Parse(id) + if !ok { + return time.Time{}, false + } + + var ms uint64 + for i := range 10 { + ms = ms<<5 | uint64(decode[parsed[i]]) + } + + return time.UnixMilli(int64(ms)).UTC(), true +} diff --git a/internal/ident/ident_test.go b/internal/ident/ident_test.go new file mode 100644 index 0000000..377eef1 --- /dev/null +++ b/internal/ident/ident_test.go @@ -0,0 +1,101 @@ +package ident + +import ( + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Идентификатор — ULID в нижнем регистре: постоянная ширина, алфавит Crockford, +// и разбор своего же значения его не меняет. +func TestNewIsCanonical(t *testing.T) { + id := New() + + assert.Len(t, id, Len) + assert.Equal(t, strings.ToLower(id), id, "идентификатор выдан не в нижнем регистре") + + parsed, ok := Parse(id) + require.True(t, ok, "свой же идентификатор не разобрался") + assert.Equal(t, id, parsed, "разбор изменил канонический вид") +} + +// Порядок выдачи и порядок сравнения строк совпадают — в том числе внутри одной +// миллисекунды: колонка времени несёт секунды, и «новые сверху» держит ключ. +func TestNewIsMonotonic(t *testing.T) { + previous := New() + for range 10000 { + next := New() + require.Less(t, previous, next, "идентификатор выдан не по возрастанию") + previous = next + } +} + +// Выдача идёт из нескольких потоков разом: два одинаковых ключа означали бы +// отказ вставки на живой базе. +func TestNewIsUniqueUnderConcurrency(t *testing.T) { + const goroutines = 8 + const each = 500 + + var ( + mu sync.Mutex + all = map[string]bool{} + ) + + var wg sync.WaitGroup + for range goroutines { + wg.Add(1) + go func() { + defer wg.Done() + own := make([]string, 0, each) + for range each { + own = append(own, New()) + } + mu.Lock() + defer mu.Unlock() + for _, id := range own { + all[id] = true + } + }() + } + wg.Wait() + + assert.Len(t, all, goroutines*each, "выданы одинаковые идентификаторы") +} + +// Разбор проверяет вид и приводит регистр: значение приходит от спрашивающего, а +// сравнение в базе побайтово. +func TestParse(t *testing.T) { + id := New() + + upper, ok := Parse(strings.ToUpper(id)) + require.True(t, ok, "верхний регистр не принят: алфавит к нему нечувствителен") + assert.Equal(t, id, upper, "разбор не привёл регистр к каноническому") + + for name, value := range map[string]string{ + "пусто": "", + "короче": id[:Len-1], + "длиннее": id + "0", + "знак вне алфавита": strings.Repeat("u", Len), + "время за пределом": "8" + id[1:], + "не латиница": strings.Repeat("я", Len), + } { + t.Run(name, func(t *testing.T) { + _, ok := Parse(value) + assert.False(t, ok, "негодное значение принято разбором") + }) + } +} + +// Время, зашитое в идентификатор, идёт в ту же сторону, что и колонка времени. +func TestTimestampGrowsWithIssue(t *testing.T) { + first, ok := Timestamp(New()) + require.True(t, ok) + + second, ok := Timestamp(New()) + require.True(t, ok) + + assert.False(t, second.Before(first), "время в ключе пошло назад") +} diff --git a/internal/service/ownership_test.go b/internal/service/ownership_test.go index d5026ca..93ecfa3 100644 --- a/internal/service/ownership_test.go +++ b/internal/service/ownership_test.go @@ -18,11 +18,11 @@ func TestWorkerTakesRecordsOfEveryOwner(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) first, err := env.service.CreateJobFromApi(t.Context(), - strings.NewReader("первая"), "one.mp3", newOwner(t, env.app)) + strings.NewReader("первая"), "one.mp3", newOwner(t, env)) require.NoError(t, err) second, err := env.service.CreateJobFromApi(t.Context(), - strings.NewReader("вторая"), "two.mp3", newOwner(t, env.app)) + strings.NewReader("вторая"), "two.mp3", newOwner(t, env)) require.NoError(t, err) // Третья — ещё одного владельца: воркер не сужается ни одним из них. @@ -52,7 +52,7 @@ func TestWorkerTakesRecordsOfEveryOwner(t *testing.T) { func TestAcquireReturnsIdentifierAndHolder(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) - owner := newOwner(t, env.app) + owner := newOwner(t, env) record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner) require.NoError(t, err) @@ -74,7 +74,7 @@ func TestAcquireReturnsIdentifierAndHolder(t *testing.T) { func TestPipelineStepKeepsOwner(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) - owner := newOwner(t, env.app) + owner := newOwner(t, env) record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner) require.NoError(t, err) @@ -102,8 +102,8 @@ func TestCreateJobFromApiRequiresOwner(t *testing.T) { func TestGetByIDHidesForeignRecords(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) - owner := newOwner(t, env.app) - stranger := newOwner(t, env.app) + owner := newOwner(t, env) + stranger := newOwner(t, env) record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner) require.NoError(t, err) diff --git a/internal/service/pipeline_test.go b/internal/service/pipeline_test.go index c33a866..5134b31 100644 --- a/internal/service/pipeline_test.go +++ b/internal/service/pipeline_test.go @@ -12,19 +12,21 @@ import ( "testing" "time" - "github.com/google/uuid" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tools/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "git.vakhrushev.me/av/transcriber/internal/adapter/recognizer" - pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" - "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" + sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite" + "git.vakhrushev.me/av/transcriber/internal/clock" "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" + "git.vakhrushev.me/av/transcriber/internal/ident" ) +// timeLayout — вид времени в колонках базы. Проверки, двигающие сроки, пишут его +// тем же видом, каким пишет хранилище: сравнение там побайтово. +const timeLayout = "2006-01-02T15:04:05Z" + // Проверки конвейера идут против настоящего хранилища: захват, число отказов и // остановка держатся на запросе, и подставной репозиторий проверял бы // собственную заглушку, а не то, что делает база. @@ -61,11 +63,13 @@ func (m *failingMetaViewer) GetInfo(context.Context, string) (*contract.AudioInf } type pipelineEnv struct { - app core.App + db *sqliterepo.DB + store *sqliterepo.Store + users *sqliterepo.UserRepository service *TranscribeService repos Repositories - recordRepo *pbrepo.AudioRecordRepository - fileRepo *pbrepo.FileRepository + recordRepo *sqliterepo.AudioRecordRepository + fileRepo *sqliterepo.FileRepository } // testLimits — пределы простоя проверок. Числа боевые; проверка застревания @@ -98,33 +102,34 @@ func newPipelineEnvWithLogger( ) *pipelineEnv { t.Helper() - app, err := pbrepo.New(t.TempDir()) + dir := t.TempDir() + db, err := sqliterepo.Open(dir, sqliterepo.Settings{BusyTimeoutMs: 5000, ReadConnections: 4}) require.NoError(t, err) t.Cleanup(func() { - if err := app.ResetBootstrapState(); err != nil { - t.Logf("не удалось закрыть хранилище: %v", err) + if err := db.Close(); err != nil { + t.Logf("не удалось закрыть базу: %v", err) } }) - // Правила панели вешаются и здесь: конфигурация под проверкой обязана - // совпадать с боевой, иначе утверждения говорят про прод то, чего в проде - // нет. - pbrepo.BindPanelRules(app) + require.NoError(t, sqliterepo.Migrate(t.Context(), db, dir, slog.New(slog.DiscardHandler))) - recordRepo := pbrepo.NewAudioRecordRepository(app) - fileRepo := pbrepo.NewFileRepository(app) + store := sqliterepo.NewStore(dir) + recordRepo := sqliterepo.NewAudioRecordRepository(db) + fileRepo := sqliterepo.NewFileRepository(db, store) repos := Repositories{ Records: recordRepo, Files: fileRepo, - Texts: pbrepo.NewTextRepository(app), - Structures: pbrepo.NewStructureRepository(app), - Recognitions: pbrepo.NewRecognitionRepository(app), - Events: pbrepo.NewRecordEventRepository(app), + Texts: sqliterepo.NewTextRepository(db), + Structures: sqliterepo.NewStructureRepository(db), + Recognitions: sqliterepo.NewRecognitionRepository(db, store), + Events: sqliterepo.NewRecordEventRepository(db), } svc := NewTranscribeService(repos, metaviewer, converter, rec, testLimits, logger) return &pipelineEnv{ - app: app, + db: db, + store: store, + users: sqliterepo.NewUserRepository(db), service: svc, repos: repos, recordRepo: recordRepo, @@ -132,13 +137,22 @@ func newPipelineEnvWithLogger( } } +// exec выполняет запрос к базе от имени проверки: фикстуры двигают колонки +// напрямую там, где домен такого перехода не делает. +func (e *pipelineEnv) exec(t *testing.T, query string, args ...any) { + t.Helper() + + _, err := e.db.Writer().ExecContext(context.Background(), query, args...) + require.NoError(t, err) +} + // newRecord заводит запись — так, как её заводит приём по HTTP: от имени // вошедшего, потому что ничьей записи в хранилище не бывает. func newRecord(t *testing.T, env *pipelineEnv) *entity.AudioRecord { t.Helper() record, err := env.service.CreateJobFromApi( - t.Context(), strings.NewReader("запись"), "voice.ogg", newOwner(t, env.app)) + t.Context(), strings.NewReader("запись"), "voice.ogg", newOwner(t, env)) require.NoError(t, err) return record } @@ -147,10 +161,7 @@ func newRecord(t *testing.T, env *pipelineEnv) *entity.AudioRecord { func clearDelay(t *testing.T, env *pipelineEnv, recordID string) { t.Helper() - record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID) - require.NoError(t, err) - record.Set("delay_time", "") - require.NoError(t, env.app.Save(record)) + env.exec(t, "UPDATE audio_records SET delay_time = NULL WHERE id = ?", recordID) } // enteredStateAt отодвигает время входа записи в рубеж: так это выглядит, когда @@ -158,13 +169,8 @@ func clearDelay(t *testing.T, env *pipelineEnv, recordID string) { func enteredStateAt(t *testing.T, env *pipelineEnv, recordID string, moment time.Time) { t.Helper() - record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID) - require.NoError(t, err) - record.Set("state_entered_at", types.DateTime{}.Add(0)) - stamp, err := types.ParseDateTime(moment) - require.NoError(t, err) - record.Set("state_entered_at", stamp) - require.NoError(t, env.app.Save(record)) + env.exec(t, "UPDATE audio_records SET state_entered_at = ? WHERE id = ?", + moment.UTC().Format(timeLayout), recordID) } // setAttempts ставит записи число отказов: так она выглядит, когда шаг отказал @@ -172,10 +178,7 @@ func enteredStateAt(t *testing.T, env *pipelineEnv, recordID string, moment time func setAttempts(t *testing.T, env *pipelineEnv, recordID string, attempts int) { t.Helper() - record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID) - require.NoError(t, err) - record.Set("attempts", attempts) - require.NoError(t, env.app.Save(record)) + env.exec(t, "UPDATE audio_records SET attempts = ? WHERE id = ?", attempts, recordID) } // drain крутит конвейер, пока он двигает записи. Паузы опроса снимаются: они @@ -253,10 +256,8 @@ func TestRecordHaltsAfterAttemptLimit(t *testing.T) { func expireAcquisition(t *testing.T, env *pipelineEnv, recordID string) { t.Helper() - record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID) - require.NoError(t, err) - record.Set("acquire_expires_at", types.NowDateTime().Add(-time.Hour)) - require.NoError(t, env.app.Save(record)) + env.exec(t, "UPDATE audio_records SET acquire_expires_at = ? WHERE id = ?", + clock.Now().Add(-time.Hour).Format(timeLayout), recordID) } // Критерий приёмки 1. Остановленная на шаге запись перезапускается снятием @@ -508,11 +509,10 @@ func TestEveryHaltReasonRecordsItsCause(t *testing.T) { events := recordEvents(t, env, record.Id) require.Len(t, events, 1, "остановка оставила строку журнала событий") - outcome := events[0].GetString("outcome") assert.Contains(t, - []string{entity.EventOutcomeHalted, entity.EventOutcomeFailed}, outcome, + []string{entity.EventOutcomeHalted, entity.EventOutcomeFailed}, events[0].Outcome, "строка журнала называет исход остановкой") - assert.NotEmpty(t, events[0].GetString("outcome_text"), "и несёт причину") + assert.NotEmpty(t, events[0].OutcomeText, "и несёт причину") }) } } @@ -536,9 +536,9 @@ func TestRepeatedStoreKeepsSingleText(t *testing.T) { require.NoError(t, err) assert.Equal(t, "второй разбор", stored.Contents) - count, err := env.app.CountRecords(migrations.TextsCollection) - require.NoError(t, err) - assert.Equal(t, int64(1), count, "второго комплекта строк не завелось") + var count int + require.NoError(t, env.db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM texts").Scan(&count)) + assert.Equal(t, 1, count, "второго комплекта строк не завелось") } // Пауза растёт с числом отказов и упирается в потолок. @@ -559,19 +559,12 @@ func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) { // Ссылка переставляется на запись о файле без содержимого: шаг отказывает на // получении рабочей копии — то есть отказом, а не приговором записи. - files, err := env.app.FindCollectionByNameOrId(migrations.FilesCollection) - require.NoError(t, err) - empty := core.NewRecord(files) - empty.Set("location", entity.LocationLocal) - empty.Set("size", 1) + emptyID := ident.New() // Владелец обязателен и у файла: схема ничьих не принимает. - empty.Set("owner", record.OwnerID) - require.NoError(t, env.app.Save(empty)) - - stored, err := env.app.FindRecordById(migrations.RecordsCollection, record.Id) - require.NoError(t, err) - stored.Set("original_file", empty.Id) - require.NoError(t, env.app.Save(stored)) + env.exec(t, `INSERT INTO files (id, owner_id, record_id, file_name, size_bytes, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + emptyID, record.OwnerID, record.Id, "missing.mp3", 1, clock.Now().Format(timeLayout)) + env.exec(t, "UPDATE audio_records SET original_file_id = ? WHERE id = ?", emptyID, record.Id) require.Error(t, env.service.RunStep(t.Context())) @@ -596,7 +589,7 @@ func TestWorkFileRemovedAfterIntakeFailure(t *testing.T) { env := newPipelineEnv(t, &failingMetaViewer{}, &failingConverter{}) - _, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3", newOwner(t, env.app)) + _, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3", newOwner(t, env)) require.Error(t, err, "отказ источника метаданных роняет приём") leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*")) @@ -612,7 +605,7 @@ func TestWorkFileRemovedAfterSuccessfulIntake(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) - _, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3", newOwner(t, env.app)) + _, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3", newOwner(t, env)) require.NoError(t, err) leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*")) @@ -666,7 +659,7 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) { content := strings.Repeat("запись ", 1000) - record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader(content), "sample.mp3", newOwner(t, env.app)) + record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader(content), "sample.mp3", newOwner(t, env)) require.NoError(t, err) require.NotNil(t, record.OriginalFileID) @@ -689,7 +682,7 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) { func TestLocalizeGivesReadableCopy(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) - record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("содержимое"), "sample.mp3", newOwner(t, env.app)) + record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("содержимое"), "sample.mp3", newOwner(t, env)) require.NoError(t, err) require.NotNil(t, record.OriginalFileID) @@ -707,24 +700,17 @@ func TestLocalizeGivesReadableCopy(t *testing.T) { // newOwner заводит учётную запись и отдаёт её идентификатор. // -// Владелец — связь с коллекцией пользователей, и хранилище проверяет, что такая -// запись есть: выдуманный идентификатор запись завести не даст. -func newOwner(t *testing.T, app core.App) string { +// Владелец — связь с таблицей учётных записей, и база проверяет, что такая +// строка есть: выдуманный идентификатор запись завести не даст. +func newOwner(t *testing.T, env *pipelineEnv) string { t.Helper() - users, err := app.FindCollectionByNameOrId(migrations.UsersCollection) + // Логин у провайдера — ключ учётной записи, и он уникален: две записи с + // одним ключом схема не примет. + account, _, err := env.users.EnsureUser(contract.Identity{Login: ident.New()}) require.NoError(t, err) - record := core.NewRecord(users) - // Логин у провайдера — ключ учётной записи, и он уникален: две записи с - // пустым ключом схема не примет. - record.Set(migrations.ProviderLoginField, uuid.NewString()) - record.Set("email", uuid.NewString()+"@example.test") - record.Set("verified", true) - record.Set("password", uuid.NewString()) - require.NoError(t, app.Save(record)) - - return record.Id + return account.ID } // Остановка приговором шага засчитывается **отказом**, а не успехом, и пишет в @@ -752,7 +738,7 @@ func TestHaltIsCountedAsFailureAndLoggedOnce(t *testing.T) { events := recordEvents(t, env, record.Id) require.Len(t, events, 1, "одна строка журнала, а не две") - assert.Equal(t, entity.EventOutcomeFailed, events[0].GetString("outcome"), + assert.Equal(t, entity.EventOutcomeFailed, events[0].Outcome, "исход назван приговором, а не сделанной работой") } @@ -780,18 +766,32 @@ func TestPostponeWritesNoEvent(t *testing.T) { "пять откладываний не оставили в журнале ни строки") } +// recordEventRow — строка журнала событий записи, какой её видит проверка. +type recordEventRow struct { + Origin string + Step string + Outcome string + OutcomeText string +} + // recordEvents читает журнал событий одной записи в порядке заведения. -func recordEvents(t *testing.T, env *pipelineEnv, recordID string) []*core.Record { +func recordEvents(t *testing.T, env *pipelineEnv, recordID string) []recordEventRow { t.Helper() - all, err := env.app.FindAllRecords(migrations.RecordEventsCollection) + rows, err := env.db.Reader().QueryContext(context.Background(), + "SELECT origin, step, outcome, outcome_text FROM record_events WHERE record_id = ? ORDER BY id", + recordID, + ) require.NoError(t, err) + defer func() { require.NoError(t, rows.Close()) }() - var own []*core.Record - for _, event := range all { - if event.GetString("record") == recordID { - own = append(own, event) - } + var own []recordEventRow + for rows.Next() { + var event recordEventRow + require.NoError(t, rows.Scan(&event.Origin, &event.Step, &event.Outcome, &event.OutcomeText)) + own = append(own, event) } + require.NoError(t, rows.Err()) + return own } diff --git a/internal/service/transcribe.go b/internal/service/transcribe.go index 350786d..96cde51 100644 --- a/internal/service/transcribe.go +++ b/internal/service/transcribe.go @@ -11,11 +11,10 @@ import ( "strings" "time" - "github.com/google/uuid" - "git.vakhrushev.me/av/transcriber/internal/clock" "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" + "git.vakhrushev.me/av/transcriber/internal/ident" "git.vakhrushev.me/av/transcriber/internal/metrics" ) @@ -155,9 +154,12 @@ func (s *TranscribeService) CreateJobFromApi(ctx context.Context, file io.Reader return nil, contract.ErrOwnerRequired } + // Идентификатор назначается здесь, до укладки файла: копии записи лежат её + // подкаталогом, названным этим идентификатором, и знать его надо раньше, чем + // класть первую копию. record := &entity.AudioRecord{ + Id: ident.New(), State: entity.StateUploaded, - Source: entity.SourceApi, OwnerID: ownerID, } @@ -176,10 +178,10 @@ func (s *TranscribeService) createRecord(ctx context.Context, r *entity.AudioRec ext = fmt.Sprintf(".%s", defaultAudioExt) } - // Собственное имя записи: идентификатор с расширением. Имя, данное - // отправителем, в хранилище не попадает — от него взято только расширение. - fileId := uuid.NewString() - storageFileName := fmt.Sprintf("%s%s", fileId, ext) + // Собственное имя копии: идентификатор с расширением. Имя, данное + // отправителем, в каталог данных не попадает — от него взято только + // расширение. + storageFileName := fmt.Sprintf("%s%s", ident.New(), ext) // Содержимое ложится в рабочую копию потоком: в память запись целиком не // читается, расчётный потолок — шесть часов. @@ -217,7 +219,7 @@ func (s *TranscribeService) createRecord(ctx context.Context, r *entity.AudioRec DurationMs: int64(info.Seconds) * 1000, } - fileRecord, err := s.repos.Files.Create(storageFileName, work, meta, r.OwnerID) + fileRecord, err := s.repos.Files.Create(r.Id, storageFileName, work, meta, r.OwnerID) if err != nil { s.logger.Error("Failed to create file record", "error", err, "file_ext", ext) return nil, err @@ -474,9 +476,9 @@ func (s *TranscribeService) normalize(ctx context.Context, r *entity.AudioRecord metrics.OutputFileSizeHistogram.WithLabelValues("ogg").Observe(float64(destSize)) - destFileName := fmt.Sprintf("%s%s", uuid.NewString(), ".ogg") + destFileName := fmt.Sprintf("%s%s", ident.New(), ".ogg") destMeta := contract.FileMeta{Format: "ogg", DurationMs: srcFile.DurationMs} - destFileRecord, err := s.repos.Files.Create(destFileName, dest, destMeta, r.OwnerID) + destFileRecord, err := s.repos.Files.Create(r.Id, destFileName, dest, destMeta, r.OwnerID) if err != nil { s.logger.Error("Failed to create normalized file record", "error", err, "record_id", r.Id) return outcomeDone, err @@ -780,8 +782,8 @@ func (s *TranscribeService) storeOutcome(r *entity.AudioRecord, outcome *entity. } // finish доводит запись до конечного рубежа. Наружу шаг не обращается: доставки -// ответа отправителю у сервиса нет, и свой исход отправитель узнаёт опросом -// готовности. +// ответа отправителю у сервиса нет, и свой исход владелец записи узнаёт её +// карточкой. func (s *TranscribeService) finish(ctx context.Context, r *entity.AudioRecord, holder string) (stepOutcome, error) { r.MoveToState(entity.StateDone) if err := s.repos.Records.Save(r, holder); err != nil { @@ -806,7 +808,7 @@ func (s *TranscribeService) failStep(r *entity.AudioRecord, holder, step string, // событий. // // Отправителю отсюда ничего не уходит: инвариант проекта «Принятая запись не -// теряется молча» держится теперь опросом готовности — остановка видна там +// теряется молча» держится теперь карточкой записи — остановка видна там // признаком — и журналом владельца, где у неё стоит причина. // // Счётчик растит **сама остановка**, а не воркер, и это не стилистика. diff --git a/openspec/changes/archive/2026-08-23-storage-without-pocketbase/.openspec.yaml b/openspec/changes/archive/2026-08-23-storage-without-pocketbase/.openspec.yaml new file mode 100644 index 0000000..6529e83 --- /dev/null +++ b/openspec/changes/archive/2026-08-23-storage-without-pocketbase/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-22 diff --git a/openspec/changes/archive/2026-08-23-storage-without-pocketbase/design.md b/openspec/changes/archive/2026-08-23-storage-without-pocketbase/design.md new file mode 100644 index 0000000..0828595 --- /dev/null +++ b/openspec/changes/archive/2026-08-23-storage-without-pocketbase/design.md @@ -0,0 +1,322 @@ +## Context + +Сегодня встроенное хранилище держит в этом коде шесть ролей сразу, и только две +из них про хранение. Что именно оно держит, чем за это плачено и чем платит +уход — измерено разведкой +[docs/research/storage-without-pocketbase.md](../../../docs/research/storage-without-pocketbase.md); +варианты («оставить как есть», «уйти целиком», «уйти в два шага», «локализовать +протечку») названы там же таблицей, а выбор и причины отказа от остальных +записаны решением +[ADR-2026-08-22-storage-without-pocketbase](../../../docs/adr/ADR-2026-08-22-storage-without-pocketbase.md). +Второй раз здесь они не переписываются. + +Обстоятельство, которое делает работу дешёвой именно сейчас: стадия проекта — +стройка, на сервере данных нет, сервис остановлен. Раскладка каталога данных +названа необратимой, и цена её смены сегодня нулевая. + +Ниже — только те решения, которых в ADR и разведке нет: они выбирают форму, а +не направление. + +## Goals / Non-Goals + +**Goals:** + +- Человек, открывший приложение, не замечает смены вовсе: адреса приложения, + формы запросов и ответов остаются прежними. +- Второго периметра на порту сервиса не остаётся: всё, что отвечает, написано + нами и судится нашими правилами. +- Файл записи достаётся только его владельцу, и право пройти по ссылке даёт + узнавание, а не значение, выданное на предъявителя. +- Захват записи остаётся неделимым, и результат по-прежнему пишет только + держатель захвата. +- Сервис поднимается на пустом каталоге данных сам, без ручного шага. + +**Non-Goals:** + +- Замены панели владельца это изменение не приносит: экраны правки записи и + прослушивания несут отдельные задачи. До них у владельца сервиса остаётся одно + действие — возврат остановленной записи в работу, — и делает его подкоманда + оснастки, а не экран. +- Переноса прежних данных нет: переносить нечего. +- Загрузка частями, узнавание записи по хеш-сумме, удаление записи и вторая + копия рядом становятся выполнимыми, но этим изменением не делаются. +- Состав полей карточки и списка не меняется ни одним полем. + +## Decisions + +### Файлы записей лежат каталогом на запись + +Каталог данных получает раздел записей, и каждой записи в нём принадлежит свой +подкаталог, названный её идентификатором. Внутри лежат копии — принятая и +приведённая, — под именами, которые задаёт сервис. + +Что человек увидит иначе: ничего. Что станет возможным — вторая копия рядом с +первой, дозапись частями и уборка записи целиком одним движением; сегодня всё +это упирается в чужую раскладку. + +Рассмотрено и отвергнуто: + +- **плоский каталог, имя файла — идентификатор с расширением.** Отвергнут: две + копии одной записи в плоском каталоге различаются только приставкой в имени, и + уборка записи превращается в перебор по маске; +- **раскладка по первым знакам идентификатора** (как у git). Отвергнута: она + лечит переполнение каталога тысячами имён, а расчётная нагрузка — единицы + записей в день; платить за это нечитаемыми путями сегодня не за что. + +### Файл отдаётся адресом приложения, а не адресом хранилища + +Файл записи уходит адресом под корнем приложения, и вид копии называет +спрашивающий — тем же способом, каким называется вид текста. Обработчик судит +владельца записи и отвечает на чужую и на несуществующую одинаково. + +Что человек увидит иначе: короткого токена файла больше нет, и порядок из трёх +шагов («узнавание → токен → ссылка») становится одним шагом. Отзыв доступа +доходит до файла сразу, а не через срок жизни токена. + +Рассмотрено и отвергнуто: + +- **своё значение на предъявителя, выдаваемое узнанному** — то, что было. + Отвергнуто: узнавание идёт на каждом запросе, второе значение ничего не + добавляет и создаёт окно, в котором отозванный доступ ещё работает; +- **ссылка со сроком и подписью** (как у объектных хранилищ). Отвергнута: та же + цена, что и у токена, плюс свой секрет подписи, которого у сервиса нет. + +### Шаги схемы двигает `goose`, а не свой раннер + +Шаги схемы накатывает `github.com/pressly/goose/v3` — **библиотекой, а не +командной строкой**: каталог шагов вшит в бинарник, провайдер заводится в точке +входа и получает пишущий пул базы, накат идёт до подъёма входов и до старта +воркеров, отказ шага роняет старт. Ни отдельного исполняемого файла, ни своего +шага выкладки не заводится. + +Инструмент выбран решением владельца 2026-08-22. Он уже был в этом проекте и +ушёл вместе с PocketBase: возврат не приносит нового знания, а снимает часть +работы. + +Из трёх норм, которые накат обязан выполнять, `goose` даёт две — и даёт по +факту, а не по обещанию: + +- **шаг и отметка о нём идут одной транзакцией.** Накат одного шага открывает + транзакцию на том же соединении и внутри неё выполняет и сам шаг, и вставку + версии в таблицу учёта (`provider_run.go`: `beginTx` вокруг `runMigration` и + `maybeInsertOrDelete`). Отменяет это только сам шаг — пометкой `-- +goose NO + TRANSACTION`, — и мы её не ставим; +- **порядок шагов детерминирован.** Версия шага читается числом из имени файла + до первого знака подчёркивания (`NumericComponent` в `migration.go`), а + собранные шаги сортируются по этому числу (`sort.Slice` в + `provider_collect.go`). Порядок обхода каталога на исход не влияет: его + результат пересортировывается. Две одинаковых версии дают отказ сбора с обоими + путями, а не молчаливый выбор одной. + +Третьей нормы — **исключающей блокировки наката** — `goose` под SQLite не даёт +вовсе. Пакет `lock` поставляет два запирателя, и оба для PostgreSQL: +`NewPostgresSessionLocker` и `NewPostgresTableLocker`; интерфейсы `SessionLocker` +и `Locker` объявлены вместе с отказом `ErrLockNotImplemented`, а провайдер, +которому запиратель не задан, накатывает без всякой блокировки. + +Блокировка остаётся нашей заботой и закрывается замком на файле в каталоге +данных: `syscall.Flock` с `LOCK_EX` берётся до наката и снимается после, а с +умершим процессом его снимает ядро — просроченного замка, который надо чистить +руками, не остаётся. Второй процесс, поднятый на том же каталоге, ждёт замка +либо отказывает. Норму держит требование capability `storage` «Сервис +поднимается на чистом каталоге данных», и написана она про поведение, а не про +инструмент. + +Что человек увидит иначе: ничего, пока всё цело. Отказ схемы становится отказом +старта, а не отказом каждого запроса потом. + +Рассмотрено и отвергнуто: + +- **свой раннер** — чтение каталога шагов, своя таблица учёта, своя сортировка. + Отвергнут: три нормы выше ему пришлось бы выполнять самому, две из них + `goose` уже выполняет, а третья остаётся нашей при любом выборе. Плата за + свой раннер — код, который надо писать, отлаживать и держать, — вносится ради + экономии одной зависимости, уже бывшей в проекте; +- **схема одним файлом, приводимым к желаемому виду при старте.** Отвергнута: + инвариант проекта требует версионированных шагов и запрещает переписывать + применённый. + +### Прежние шаги схемы заменяются одним шагом начальной схемы + +Применённые шаги PocketBase удаляются каталогом целиком, и на их месте встаёт +один шаг, заводящий схему сразу такой, какая нужна. Это **снятие инварианта +проекта «Миграция, уехавшая на сервер, не переписывается»** — разовое, решением +владельца от 2026-08-22. + +Причина: стадия проекта — стройка, на сервере данных нет, сервис остановлен, +выкладка пойдёт с чистого листа. Переписывать нечего: ни один из прежних шагов +не применён ни к одной живой базе, а новая база заводится другим инструментом и +другой таблицей учёта — отметки прежнего каталога ей не годятся вовсе. + +Граница: снятие разовое и кончается этим изменением. Шаг начальной схемы, уехав +на сервер, подпадает под инвариант как всякий прежний — менять его можно будет +только новым шагом. Шаг гейта `migrations` при этом обязан смотреть на новый +каталог: оставленный на старом, он прочитает удаление прежних файлов как +переписывание применённого и покраснеет. + +### База принимает одного писателя + +Соединение для записи одно, чтение идёт своим пулом; журнал упреждающей записи +включён, соблюдение внешних ключей включено, ожидание занятой базы задано числом. +Все три задаются строкой подключения обоих пулов, а не запросом после открытия: +две из трёх настроек в SQLite принадлежат соединению, а не базе, и пул заводит +новые соединения по мере надобности. Числа переезжают в дом числовых настроек +проекта. + +**Два числа заводятся ключами конфига сразу, этим же изменением:** + +- `[storage] busy_timeout_ms` — сколько ждать занятую базу, миллисекунды; +- `[storage] read_connections` — сколько соединений держит читающий пул. + +Оба крутят при одном и том же отказе — «база занята» под несколькими +воркерами, — и число воркеров у сервиса уже настраивается. Константами кода они +означали бы, что подбор ответа на этот отказ требует пересборки образа. + +Операция, которая читает и следом пишет, идёт целиком на пишущем соединении: +читающую транзакцию SQLite до пишущей не повышает и отказывает по занятости сразу, +не дожидаясь заданного числом срока. Так идут захват записи и накат шага схемы. + +Узнавание устроено иначе, и это решение владельца от 2026-08-23. Учётная запись +ищется читающим пулом, а пишущая транзакция открывается только когда поиск ничего +не нашёл; окно между двумя соединениями закрывает повторный поиск внутри +транзакции, а уникальность держит схема. Прежнее устройство брало писателя на +каждом запросе приложения — включая опрос карточки и запрос куска записи, — и +цена этого измерена: очередь к единственному пишущему соединению ожиданием +занятой базы не ограничена и отказом не кончается, а ждёт столько, сколько +занят писатель. + +Что человек увидит иначе: ничего, пока настройка верна. Ошибка здесь видна +отказами «база занята» под несколькими воркерами — тем самым, что чужая +библиотека держала за нас двумя пулами, — а ошибка во внешних ключах не видна +вовсе: ничья запись просто заводится. + +Рассмотрено и отвергнуто: **один пул на всё** — отвергнут разведкой, +измерившей, что драйвер пишет единственным соединением. + +### Пакет хранилища называется `internal/adapter/repo/sqlite` + +Пакет `internal/adapter/repo/pocketbase` уходит целиком, и на его месте встаёт +`internal/adapter/repo/sqlite`. Имя выбирается здесь, а не при написании кода: оно +разойдётся по импортам, по правилам `internal/archrules`, которые указывают в +пакет строкой, и по документам канона — переименование после этого стоит дороже +самого выбора. + +Названо по драйверу, а не по роли: соседи в `internal/adapter` названы тем же +способом — `converter`, `metaviewer`, `recognizer`, — и «repo/sqlite» читается как +«репозитории поверх SQLite» без знания кода. + +Рассмотрено и отвергнуто: **`internal/adapter/repo/db`** — не называет ничего, а +второй драйвер рядом с ним пришлось бы назвать по имени, и пара вышла бы +разнородной. + +### Захват остаётся одним запросом с возвратом + +Выбор пригодной записи и пометка её захваченной идут одним запросом, и он же +возвращает идентификатор записи и признак этого захвата. Признак уникален для +каждого захвата, и запись результата условна по нему. + +Решение подтверждается, а не принимается заново: замер, которым выбрана очередь +таблицей, снят на том же драйвере, который остаётся после ухода. Отменяется одно +слово — таблица перестаёт быть коллекцией. + +### Ограничитель частоты становится своим + +Бюджет считается по адресу спрашивающего под корнем приложения. Объявленная +приложению частота опроса карточки по-прежнему выводится из доли этого бюджета, +а не из своей константы. + +Что человек увидит иначе: включение нашего правила перестаёт вводить в действие +чужие правила на чужой поверхности — их больше нет. + +### Панель не заменяется ничем, а возврат в работу делает подкоманда оснастки + +Владелец сервиса теряет панель, и заменяющего её экрана это изменение не +приносит. Единственное действие, которое он делал панелью и которое нельзя +отложить до экранов, — возврат остановленной записи в работу — переезжает в +набор инструментов разработчика, `cmd/devtools`, отдельной подкомандой. Она +открывает базу того же каталога данных, зовёт домен и пишет событие журнала +записи с происхождением «человек». + +Причины две, и каждая своя. + +Первая: возврат в работу — не одно поле. Сбросить надо признак остановки, признак +захвата и срок его протухания, число отказов, паузу и время входа в рубеж; норму +держит capability `pipeline`. Рука, забывшая любое из них, оставляет запись либо +невидимой для захвата, либо останавливаемой снова первым же захватом — молча, без +единой строки. Подкоманда колонок не пишет: правило исполняет домен, а она +назначает порядок шагов. + +Вторая: `entity.EventOriginHuman` теряет иначе единственного писателя — его писала +панель. Происхождение события в журнале записи стало бы константой, и различие +«это сделал конвейер» против «это сделал человек» перестало бы значить что-либо. + +Что человек увидит иначе: вместо таблицы с полями у владельца одна команда с +одним предметом. Экраны приносят отдельные задачи, и подкоманда живёт до них. + +Рассмотрено и отвергнуто: **правка строки в базе руками** — то, чем возврат +описывался до чекпоинта. Отвергнута: перечисленные выше поля человек за +клавиатурой сбрасывает по памяти, а событие журнала записи не пишется вовсе. + +### Колонок `location` и `source` в новой схеме нет + +Обе колонки заводятся сегодня, пишутся одним значением и не читаются никем. + +`location` у сущности файла: туда пишется `local`, и второго значения (`s3`) не +пишет ни один шаг. Ветвления по нему в коде нет. + +`source` у аудиозаписи: всякий приём пишет туда `api`. Второе значение, +`telegram`, держалось не потребителем, а ссылкой из применённого шага схемы — +вход Telegram убран 2026-08-14, и константа осталась только потому, что +применённый шаг не переписывается. Шаги уходят, и держать её больше нечем. + +Ни одной колонки в новой схеме не заводится, и поля уходят из сущностей домена и +из их отображения в строки базы. Цена возврата названа прямо: поле, у которого +появится читатель — вторая копия в объектном хранилище либо второй вход, — +вернётся одним новым шагом схемы, и платится это тогда, а не сейчас. + +### Колонки записи пишутся и читаются по имени + +Отображение сущности в строку базы работает **именованными параметрами** запроса +и сканированием **по имени колонки**, а не позиционными списками. + +Причина в самой сущности: у аудиозаписи поля одного типа — необязательной +строки — идут длинным непрерывным рядом, и ссылки на файл, на структуру реплик, +на два вида текста и на попытку распознавания стоят в нём подряд. Позиционный +список даёт сдвиг на одно поле, который компилируется молча и кладёт +идентификатор файла в колонку текста. По имени такого сдвига не существует +вовсе: лишнее имя или недостающее — отказ запроса, а не тихая подмена значения. + +Инвариант проекта о колонках записи, правящихся в двух местах сразу, эта форма не +снимает: колонку по-прежнему можно забыть в отображении или в шаге схемы. Она +снимает **другую** поломку — ту, где колонка не забыта, а перепутана местом. +Сторожа инварианта в `internal/archrules` переписываются под эту форму: сегодня +они построены на динамической записи по имени колонки в API уходящей библиотеки. + +## Risks / Trade-offs + +- **Владелец остаётся без панели, а экранов ещё нет** → возврат остановленной + записи в работу делает подкоманда `cmd/devtools`, а прочая правка ждёт экранов; + сервис на стройке, живых записей нет, и цена ограничена этим окном. +- **Раскладка каталога данных меняется необратимо** → цена нулевая сегодня и + перестаёт быть нулевой после первой боевой записи; смена сделана до выкладки. +- **Отдача файла написана нами и может отдать чужое** → правило одно: обработчик + судит владельца записи, а чужая запись отвечает тем же, чем несуществующая; + проверка на это стоит критерием приёмки. +- **Единственный писатель настроен неверно** → отказы «база занята» под + несколькими воркерами; ожидание и размер читающего пула заданы ключами + `[storage] busy_timeout_ms` и `[storage] read_connections` и стоят в доме + числовых настроек проекта. +- **Шаг схемы не накатился, а сервис поднялся** → накат идёт до подъёма входов, + и его отказ роняет старт; критерий приёмки требует чистого журнала до строки о + готовности. +- **Два процесса накатывают схему на одном каталоге** → блокировки под SQLite + `goose` не даёт, и замок на файле берём сами; забытый замок виден только на + чистой базе, которую заводят один раз, поэтому проверка на два одновременных + наката стоит критерием приёмки. +- **Прежние адреса продолжают отвечать чем-то посторонним** → пути хранилища и + панели перестают быть корнями сервиса и подпадают под общее правило + неизвестного пути; критерий приёмки проверяет и подменённый знак в адресе + панели. +- **Норма отказа «узнан, но учётной записи нет» теряет свой единственный + случай** → ветвь снимается вместе с ним; вернуть её придётся задаче о личных + токенах, и там же она получит свой случай. diff --git a/openspec/changes/archive/2026-08-23-storage-without-pocketbase/proposal.md b/openspec/changes/archive/2026-08-23-storage-without-pocketbase/proposal.md new file mode 100644 index 0000000..c440d13 --- /dev/null +++ b/openspec/changes/archive/2026-08-23-storage-without-pocketbase/proposal.md @@ -0,0 +1,96 @@ +## Why + +Встроенное хранилище куплено ради трёх вещей — панели владельца, файлов рядом с +базой и входа через его провайдера, — и третья отпала 2026-08-22: пришедшего +называет обратный прокси, а учётную запись заводит сам сервис. Оставшееся +хранилище держит не только хранение: оно же маршрутизатор, цепочка слоёв, +ограничитель частоты, отдача файлов и второй периметр на том же порту. Второй +периметр опубликован в интернет вместе с панелью, его барьер обходится подменой +знака в адресе, а закрыть его целиком нельзя — за файлами записей туда ходит +браузер человека. + +Момент назначен обстоятельством: на сервере данных нет и сервис остановлен, +поэтому смена стоит только кода. Дешевле она не станет — код входов прирастает +чужими типами с каждой задачей. + +## What Changes + +- Сервис работает с базой напрямую и заводит свою схему своими шагами, двигая их + библиотекой `goose`. Раскладка каталога данных меняется — **BREAKING**, + необратимое. +- Файлы записей ложатся в свой каталог со своей раскладкой и отдаются своим + адресом сервиса. Право пройти по ссылке даёт узнавание и владение записью, а + не короткий токен, выданный хранилищем на предъявителя. +- Панель администратора исчезает и **не заменяется ничем** — **BREAKING**. + Остановленную запись возвращает в работу подкоманда набора инструментов + разработчика — через домен и с событием журнала записи, — а экраны владельца + приносят отдельные задачи. +- Второго адресного пространства у сервиса не остаётся: адреса хранилища и адрес + панели перестают существовать — **BREAKING**. Корень приложения остаётся тем + же, и контракт приложения не меняется ни одним полем. +- Собственного входа у хранилища больше нет — вместе с самим хранилищем, — и + требования, закрывавшие его наглухо, теряют предмет. Узнавание остаётся + прежним: заголовок доверенного источника на каждом запросе. +- Пароль владельца от панели пропадает — секрет, которого до перевода не было. +- Очередь остаётся своей таблицей, а захват — одним неделимым шагом с + возвратом идентификатора и признака захвата. +- Ограничитель частоты под корнем приложения становится своим и перестаёт зависеть + от настроек чужой поверхности. + +## Capabilities + +### New Capabilities + +Новых capability изменение не заводит: предмет тот же, меняется его норма. + +### Modified Capabilities + +- `storage`: раскладка каталога данных и шаги схемы становятся своими; файл + отдаётся адресом сервиса с проверкой владельца вместо ссылки хранилища с + коротким токеном; требования о панели владельца, о пароле от неё и о закрытии + собственной поверхности хранилища снимаются вместе с предметом; закрытость + содержимого переписывается со словаря коллекций и правил доступа на словарь + таблиц и адресов сервиса. +- `access`: требование, выключавшее собственные входы хранилища, снимается вместе + с ними; область узнавания перестаёт обходить чужую поверхность и перестаёт + включать выдачу токена файла; запрет на печать значения, дающего доступ, + теряет упоминание короткого токена. +- `archive`: адреса приложения перестают соседствовать с чужим пространством — + соседа больше нет; ограничитель частоты под корнем приложения становится + своим, и объявленная частота опроса по-прежнему выводится из его бюджета. +- `intake`: имя файла в хранилище задаёт сервис, и умолчания, которое строило имя + из имени отправителя, больше не существует; отказ узнанному без учётной записи + теряет свой единственный случай — владельца панели с собственным токеном. +- `webapp`: перечень корней сервиса, из которого выводится правило неизвестного + пути, теряет корень хранилища и корень панели; журнал у сервиса остаётся один. +- `pipeline`: возврат остановленной записи в работу перестаёт быть правкой в + панели и становится подкомандой оснастки, идущей через домен; перечень полей, + которые возврат обязан сбросить, называется целиком и в одном месте, и к нему + добавляется обязанность писать событие журнала записи; норма о том, что шаг + пишет только свои поля, остаётся в силе с прежней ценой. +- `recognition`: сохранённый ответ провайдера переезжает из вложения чужого + хранилища в отдельный файл каталога данных, а его закрытость переписывается с + пометки поля и правила просмотра коллекции на проверку владельца в обработчике + сервиса. Зачем ответ хранится целиком и где лежит попытка распознавания, смена + хранилища не трогает. + +## Impact + +- Публичный контракт HTTP: пространство адресов хранилища и адрес панели + исчезают, корень приложения остаётся. Формы запросов и ответов приложения не + меняются. +- Раскладка каталога данных: файл базы и каталог файлов записей — необратимое, + цена сегодня нулевая (стройка, на сервере пусто). +- Модель угроз: из периметра уходят панель, её пароль, дефект с подменой знака в + адресе и правило прокси на чужое пространство; приходит своя отдача файла и + свой ограничитель частоты. +- Инструмент владельца: панели нет; возврат остановленной записи в работу делает + подкоманда `cmd/devtools`, прочая правка ждёт экранов. +- Зависимости сборки: библиотека хранилища и всё, что достижимо только через + неё, уходят; драйвер базы без CGO остаётся; приходит `pressly/goose/v3` — + библиотекой, без командной строки. +- Настройки: ключ каталога данных остаётся, ключей панели не заводится, и + заводятся два ключа базы — ожидание занятой базы и число соединений чтения. +- Шаги схемы: применённые шаги прежнего каталога удаляются целиком и заменяются + одним шагом начальной схемы — разовое снятие инварианта «применённая миграция + не переписывается» решением владельца от 2026-08-22. diff --git a/openspec/changes/archive/2026-08-23-storage-without-pocketbase/review/triage-2026-08-23.md b/openspec/changes/archive/2026-08-23-storage-without-pocketbase/review/triage-2026-08-23.md new file mode 100644 index 0000000..7c9b25c --- /dev/null +++ b/openspec/changes/archive/2026-08-23-storage-without-pocketbase/review/triage-2026-08-23.md @@ -0,0 +1,93 @@ +# Отчёт триажа ревью кода — storage-without-pocketbase + +Дата: 2026-08-23 + +Режим: прогон по графу, база диффа HEAD, работа незакоммичена. Метка large — задана владельцем прогона, оси не мерились. Гейт зелёный целиком. Сигнал о метке от review-code: large подтверждена. Находок на входе 27 именованных плюс ~20 подпороговых; осталось 7 в основных секциях, 2 понижены в гипотезы, 8 в урожай, 2 в promote. + +План и исход по темам: requirements (дельта-спеки + openspec/specs, разбор, specs) — закрыта, 6 находок; autotests (CLAUDE.md «Гейт», autotests) — закрыта, 4 находки; conventions (docs/conventions/, разбор, code) — закрыта, 6 находок, потолок 4/4 сработал; architecture (docs/architecture.md + passport.md, доказательство, architecture) — закрыта, 3 находки, потолок 3 сработал; security (docs/security.md, доказательство, adversary) — закрыта, 5 находок, пути построены и прогнаны; operations (docs/architecture.md «Эксплуатация» + docs/database.md, доказательство, ops) — закрыта, 3 находки с замерами. Тем без отчёта нет. basics не запускался: своих тем проекта нет, темы ядра разобраны именными проходами. + +## 1. Блокирует мердж + +### Аноним пишет свой текст в журнал владельца со скоростью 121 МиБ/с, и ограничитель этому не мешает +Файл: internal/controller/http/journal.go:80-95 (JournalRoute), :55-75. Severity: critical. Confidence: high. +Оракул: TestAdversary_AnonymousWritesIntoJournalUnderAppRoot — строка журнала несёт путь дословно при ответе 401. TestAdversary_JournalLineGrowsWithRequestedPath: путь 1 044 480 знаков → прирост журнала 1 044 632 байта. TestAdversary_JournalThroughput: одно соединение, 1.003 с → 122 запроса, 121.5 МиБ журнала (121.2 МиБ/с). TestAdversary_RefusedRequestStillWritesJournal: 120 отказов ограничителя → 240 строк журнала, 1 019 617 байт: слой журнала стоит снаружи ограничителя. Конвенция docs/conventions/logging.md:113 запрещает это дословно. +Последствие: неузнанный снаружи наполняет журнал контейнера своим текстом с произвольной скоростью. Диск сервера общий с data/ — под ним и база, и записи живых людей; исчерпание места приходит на укладку записи, где причина отказа к тому же теряется. Собранные логи чужой текст уносят навсегда. +Предложение: JournalRoute возвращает webappRoute для всего, что накрыто корнем приложения, а не только для чужих путей; длину отдавать полем http.path_length. Точные адреса (/health, /metrics) пишутся дословно — они из закрытого перечня. +Найдено проходом: adversary (V2), code/конвенции (C3). Действие: инлайн. + +### Ограничитель частоты ключуется значением, которое пишет сам спрашивающий: бюджет обходится с первого запроса +Файл: internal/controller/http/rate_limit.go:86-101 (clientAddress). Severity: critical. Confidence: high. +Оракул: TestAdversary_RateLimitBypassedByForwardedFor — ограничитель пропустил 1200 запросов одного спрашивающего при бюджете 120. TestAdversary_RateLimiterBudgetsGrowth — 200 000 ключей в карте, прирост кучи 19 810 376 байт (99 байт на ключ). Код берёт strings.Cut(r.Header.Get(ForwardedForHeader), ",") — левое значение цепочки, а Caddy заголовок по умолчанию дописывает, а не заменяет. +Последствие: единственный бюджет на корне приложения не действует ни для одного противника, знающего про заголовок. Ключ карты выбирает он же, поэтому карта растёт линейно от числа выдуманных адресов. Складывается с предыдущей находкой: обход бюджета снимает потолок, который мог бы ограничить поток в журнал. +Предложение: сканировать X-Forwarded-For справа налево, отбрасывая доверенные адреса, и брать первый недоверенный; читать r.Header.Values, а не Get. Отдельно — предел числу ключей карты. +Найдено проходом: adversary (V1), specs (S5). Действие: инлайн. + +### Инвариант про колонки записи потерял предмет: он называет функции, которых в коде больше нет, а третье место чтения не механизировано ничем +Файл: CLAUDE.md:105, internal/adapter/repo/sqlite/record_mapping.go:111-173, internal/archrules/arch_test.go:333-361. Severity: major. Confidence: high. +Оракул: grep по applyOwnedByPipeline/applyToRecord/recordToAudioRecord — пусто, при том что инвариант называет ровно эти три имени. grep rowToAudioRecord в internal/archrules — пусто: правило сверяет writeOwnedByPipeline+writeRecord против readRecordColumns, а функция, заполняющая сущность, в предмет правила не входит. +Последствие: мест стало три (readRecordColumns — что спрошено, recordRow — куда лягут, rowToAudioRecord — что доедет до сущности), механизировано одно. Колонка, забытая в rowToAudioRecord, даёт зелёный гейт: значение не доезжает до сущности, а ближайший Save пишет нулевое поверх сохранённого — тихая порча данных, ровно та, которую инвариант объявляет закрытой. Сам инвариант стал неверифицируемым. +Предложение: обновить текст инварианта на действующие имена и действительное число мест; расширить правило internal/archrules на rowToAudioRecord. +Найдено проходом: architecture (R1). Действие: инлайн. + +## 2. Стоит исправить сейчас + +### Всякий запрос берёт пишущую транзакцию единственного пишущего соединения, а очередь к нему не ограничена ничем +Файл: internal/adapter/repo/sqlite/identity.go:40-58, db.go:56-64,114-118, internal/controller/http/identity.go:125. Severity: major. Confidence: high. +Оракул: падающий тест триажа — при BusyTimeoutMs 100 и занятом пишущем соединении EnsureUser ждал 606.494 мс и вернул nil: ожидание свободного соединения busy_timeout_ms не ограничено вовсе, границу задаёт только контекст, а контекст — context.Background(). EnsureUser зовётся слоем узнавания, одетым на весь корень приложения, значит BEGIN IMMEDIATE берётся на 100% узнанного потока. Требование storage/spec.md:26-32 составной операцией называет заведение учётной записи первым обращением, а не всякое. +Последствие: зависший (не отказавший) диск останавливает приём, опрос карточек и конвейер разом, без предела ожидания; сторож застревания стоит в той же очереди. Отказа не будет — будет тишина. +Предложение: искать учётную запись читающим пулом и уходить в пишущую транзакцию только когда не нашлось; ветвь повторного поиска после гонки уже написана (identity.go:74-80). +Найдено проходом: ops (O1), specs (S1), architecture (R3). Действие: развилка — вопрос владельцу, три варианта: (а) снять с горячего пути только узнавание; (б) то же плюс предел ожидания на обращениях, рождённых HTTP-запросом; (в) оставить и записать цену нормой в спеку storage. +Оговорка: довод «NoopJobError не считается, поэтому конвейер встал и очередь пуста неотличимы» опирается на то, что считать NoopJobError запрещено инвариантом. Законна только просьба про heartbeat воркера — она в урожае. + +### Диагностика укладки настроена ровно наоборот: причина отказа отброшена там, где нужна, и путь внутри каталога данных уехал в журнал там, где не должен +Файл: internal/adapter/repo/sqlite/store.go:64-112 (Put, Open, Remove), internal/service/transcribe.go:222-224. Severity: major. Confidence: high. +Оракул: падающий тест триажа. Отказ записи вернул: failed to store a copy of record 01M0NV5JFP5AYNVKR5FF8FW9HR / write /tmp/.../records/01M0NV5JFP5AYNVKR5FF8FW9HR/.partial-01m0nvcvk21w1qz0qhhy54bry4: file too large — полный путь внутри каталога данных в цепочке, при том что комментарий store.go:80-81 утверждает обратное. Отказ по правам вернул failed to create the directory of record …, и os.IsPermission(err) = false: причина отброшена целиком. Конвенция errors.md:26-34: обёртка %w — умолчание. +Последствие: у владельца единственная поверхность диагностики, и на ней ENOSPC, EACCES и EROFS неразличимы — сервис говорит одно и то же на три поломки, требующие трёх разных действий. Одновременно одна ветвь из семи делает обратное — кладёт полный путь в журнал. +Предложение: во всех семи местах обернуть причину %w, сохранив errors.Is до fs.ErrPermission и syscall.ENOSPC; путь снять — заворачивать не *os.PathError целиком, а его .Err. +Найдено проходом: code (C4 — причина), specs (S3 — путь). Чинить порознь нельзя: вторая правка отменит первую. Действие: инлайн. + +### Тип содержимого ответа выбирает отправитель: запись.html отдаётся text/html; charset=utf-8 с inline +Файл: internal/controller/http/file.go:16-33,119-126,171-180. Severity: minor. Confidence: high. +Оракул: TestAdversary_HostileExtension. запись.html → Content-Type text/html; charset=utf-8, Content-Disposition inline; запись.svg → image/svg+xml inline; запись.xhtml → application/xhtml+xml. Перечень contentTypes не знает mkv/mov/avi, которые сервис сам объявляет диалогу выбора файла, и откатывается на mime.TypeByExtension — в alpine нет /etc/mime.types, у разработчика есть: ответ становится функцией машины сборки. Инвариант CLAUDE.md: наружу расширение выходит только приведённым к перечню известных форматов; единая точка metrics.FormatLabel существует, транспорт ходит мимо неё. +Последствие: сегодня цена нулевая — файл видит только владелец. Появляется у первой задачи со вторым читателем, и появляется молча. Плюс уже действующая ошибка: mkv/mov/avi отдаются типом, зависящим от образа. +Предложение: тип содержимого выводить из закрытого перечня той же единой точки, что и метку метрики; всё, чего в перечне нет, — application/octet-stream с attachment; откат на mime.TypeByExtension убрать. +Найдено проходом: code (C1), architecture (R2), adversary (V4). Действие: инлайн. Вторая половина R2 — своя реализация диапазонов против http.ServeContent — в урожай. + +### Три решающих ветви отказа не проверены ничем, и одна из них — та, что держит процесс живым +Файл: internal/config/config.go:88-98, internal/controller/http/errors.go:184-190, internal/controller/http/app.go:459-462,490-493. Severity: major. Confidence: high. +Оракул: go tool cover -func = 0.0% на всех трёх местах, grep по тестам пуст. Все три ветви StorageConfig.Validate() не покрыты, config_test.go не упоминает StorageConfig вовсе; Recover не вызывается ни одним тестом; contract.ErrTextNotReady не проверяется во всём пакете, включая отображение в 409. +Последствие: журнал проекта знает три записи класса «проверка не могла упасть» и «тесты обработчика ни разу не были зелёными» (docs/review.md, 2026-08-10, 2026-08-11, 2026-08-15). Здесь тот же класс на новом коде. +Предложение: три теста — таблица на три ветви Validate, обработчик с паникой через полную цепочку слоёв, запрос текста у записи без готового текста с проверкой кода 409 и тела. +Найдено проходом: autotests (A1, A2, A3). Действие: инлайн. + +## 3. Гипотезы без доказательства +A4 — класс «репозиторий отказал во время запроса» не проверен нигде (minor). Оракула нет: инъекции отказов в HTTP-тестах не существует. Понижено до наблюдения. +V5 — владение судится у записи, а файл открывается по её ссылке без сверки files.record_id/files.owner_id (minor, свойство без пути). Находка о будущем: delete-record и long-audio-chunking будут править обе стороны. В урожай. +S2 — goose_db_version.tstamp несёт вид времени и умолчание вне объявленной нормы (minor). Исход — выбор нормы, не правка кода. В урожай развилкой. + +## 3б. Урожай — реальное, но не для этого мерджа +1. /%6detrics отдаёт метрики байт в байт (adversary V3, mounts.go:57-62). Проверено сырыми запросами. Понижено: docs/security.md:14 объявляет метрики открытыми без узнавания. Задача — судить по EscapedPath() либо вынести /metrics на отдельный слушатель. +2. Своя реализация диапазонов вместо http.ServeContent (R2): ~110 строк семантики HTTP, которую стандартная библиотека делает сама. +3. Остановка закрывает базу под живой горутиной (C2 + O3, main.go:249-263, db.go:157-172): по истечении ForceShutdownTimeout run() возвращается, отложенный db.Close() обнуляет пулы, брошенный воркер разыменует nil и роняет процесс паникой; замер — Close() вернулся за 2.8 мкс, пока другая горутина держала пишущую транзакцию, и та закоммитила после. Захват остаётся до 8 часов, следа нет. +4. Heartbeat воркера (законная половина O1): «конвейер встал» и «очередь пуста» неотличимы. Считать NoopJobError нельзя — инвариант. +5. Две строки ERROR на один транзиентный отказ шага (O2, воспроизведено дословным выводом). Вопрос записан в docs/review.md от 2026-08-10 и остаётся открытым. +6. sql.ErrNoRows не транслирован в доменную ошибку в трёх репозиториях (C6), при том что record_repo.go:139-141 в том же пакете правило исполняет. +7. cmd/devtools/resume.go не проверен ничем (S4): go test ./cmd/... даёт no test files, а тест воспроизводит тело подкоманды руками. Вынести тело из main-пакета в вызываемую функцию. +8. Развилка владельца по goose_db_version (S2): сузить требование и назвать таблицу учёта либо расширить сторож на всю применённую схему с поимённым исключением. +9. Запись решения ADR переписана на месте (S6): смена двух решений описана как уточнение формулировки. Это работа av-dev:doc-healthcheck. +10. Мелочь: имена копий не читаются без базы; defaultListLimit = 30 рядом с DefaultPageLimit = 30; RecordEventRepository.Append не заполняет event.CreatedAt; мёртвый довод prefix у selectList. + +Выброшено как вкусовщина: ident.Timestamp/store.HasTemporary как экспортированная поверхность ради тестов; nullString/bytesReader; имя параметра copy против view; busy_timeout_ms как ключ с единицей измерения в имени; C5 (устаревший абзац logging.md:217-223) — работа сверки документов. +Проверено против «Типовые ложноположительные» docs/review.md:120-169: совпадение одно — молчание воркера на NoopJobError, снято из O1. + +## 4. Promote candidates +- Правило сканера на rowToAudioRecord: перечень колонок чтения и перечень присвоений в сущность обязаны сверяться механически. +- Конвенция: значение, которым распоряжается спрашивающий, не идёт в журнал дословно ни под каким корнем. Сегодня logging.md:113 формулирует это только для запроса, отданного приложению, и находка V2 прошла в зазор. + +## 5. Границы покрытия +Запускались specs, autotests, code, architecture, adversary, ops — все на метке large, режим по графу. basics не запускался: своих тем проекта нет. Разметчик review-scope в обычном виде не отрабатывал — метка задана владельцем, размер и сложность не мерились, обоснования разметки у этого прогона не существует. Корректор метки: сигнал от review-code, занижения нет; второго голоса нет. +Сработавшие потолки: architecture — 3, за срезом четыре подпороговых наблюдения и четыре «дешевле переделать»; code — конвенций 4/4, за срезом мёртвый довод prefix и незаполненный event.CreatedAt; specs, ops, autotests, adversary своих потолков не сообщили — сказать, сколько осталось за их срезом, нельзя; потолок триажа — 7 мест на 27 находок, за срез уехали V3, вторая половина R2, C2+O3, heartbeat, O2, C6, S4, S2, S6, все названы поимённо в урожае. Молча не выброшено ничего. +Чего проходы не могли проверить: adversary не поднимал настоящую Authelia и настоящий обратный прокси — весь барьер входа держится им, браузера в прогоне нет; ops не имел реального профиля нагрузки; autotests судил покрытие, а не способность теста упасть — мутационная сверка в проекте запрещена. +Осталось на человеке (docs/review.md:289-335): поведение SpeechKit и Object Storage под нагрузкой; реальный профиль нагрузки; стойкость ffmpeg к вредоносному входу; поведение настоящей Authelia и правило обратного прокси — и это прямо задевает две находки: обход X-Forwarded-For вменяется прокси в чужом репозитории, а лечится здесь, а достижимость /%6detrics зависит от правила прокси, которого отсюда не видно; поведение браузера с куками. Перестали проверять сознательно: разбор вывода настоящего ffprobe; работа с настоящими внешними собеседниками. +Четыре строки, которых не принёс ни один проход: решения проекта не сверялись (docs/adr/ процессный, расхождение ловит doc-healthcheck); записанные наблюдения не использовались (docs/research/ не открывался, каждое число снято на этом прогоне); поимённая сверка с руководствами по стилю Go не задавалась; альтернативной реализации, с которой можно сдиффить решения, у конвейера нет — «не знаю, чего не знаю» на изменении, переносящем всё хранилище, не достаёт никто. +Поразрядная деградация одна и своя: инвариант про колонки записи существует, но потерял предмет, поэтому сослаться на него как на оракул было нельзя — вынесено отдельной находкой. diff --git a/openspec/changes/archive/2026-08-23-storage-without-pocketbase/specs/access/spec.md b/openspec/changes/archive/2026-08-23-storage-without-pocketbase/specs/access/spec.md new file mode 100644 index 0000000..185e63b --- /dev/null +++ b/openspec/changes/archive/2026-08-23-storage-without-pocketbase/specs/access/spec.md @@ -0,0 +1,389 @@ +## MODIFIED Requirements + +### Requirement: Значение, дающее доступ, не печатается + +Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение заголовка, +которым назван пришедший, ни адрес почты пользователя. + +Записанное значение MUST читаться как ключ к чужому доступу: заголовок целиком +задаёт тот, кто шлёт запрос, и строка журнала уезжает в собранные логи, откуда её +не убрать. Требование того же рода, что и запрет писать имя файла на диске: там +строка журнала собирала бы путь к чужой записи, здесь — имя, которым довольно +назваться, чтобы стать этим человеком. + +Короткий токен файла из перечня ушёл вместе с самим токеном: значений на +предъявителя сервис больше не выдаёт, и запрет остался бы правилом без предмета. + +Адрес почты приходит от провайдера и принадлежит человеку, а не сервису. Имя из +заголовка — тоже: это логин человека у провайдера. + +Идентификатор учётной записи в журнал писать можно и нужно: он выдан сервисом, +доступа сам по себе не даёт и без него путь запроса не прослеживается. + +#### Scenario: Значения заголовка нет в журнале + +- **WHEN** запрос с заголовком проходит через сервис +- **THEN** значение заголовка не встречается ни в одной журнальной записи + +#### Scenario: Адреса почты нет в журнале + +- **WHEN** приходит первое обращение и учётная запись заводится +- **THEN** адрес почты не встречается ни в одной журнальной записи + +### Requirement: У записи есть владелец, и чужую ей не отдают + +Сервис SHALL заводить у каждой принятой записи владельца — учётную запись, от +имени которой запись принята, — и MUST отдавать данные такой записи только её +владельцу. Запись без владельца MUST не заводиться ничем — ни приёмом, ни +конвейером, ни запросом к базе: колонка владельца пустого значения не принимает, +и норму эту держит capability `storage`. + +Владелец назначается один раз, при приёме, и MUST не меняться: совместного +доступа, ролей и передачи записи другому сервис не знает. + +Владелец MUST браться из узнанного предъявителя и ниоткуда больше. Владелец, +пришедший полем запроса, дал бы всякому узнанному право завести запись на чужое +имя. + +Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей — и +к её карточке, и к её тексту, и к её файлу. Отдельный отказ «доступ запрещён» +превращает чтение в перебор: по разнице ответов считывается, какие записи +заведены, а идентификатор записи и есть то, что разграничение прячет. Каким +именно ответом это выражено, нормирует capability `archive`: там живут адреса +чтения записи, и держатель нормы обязан быть один. + +Пустой владелец MUST не совпадать ни с одной записью. Правило записано со стороны +**спрашивающего** и остаётся в силе, хотя записей без владельца в базе не бывает: +спрашивающий с пустым владельцем — это вызов, у которого нет учётной записи, и +отвечать ему надо отказом, а не выборкой. Держится оно отдельно от схемы +намеренно: схема запрещает **заводить** ничью запись, а это правило запрещает +**спрашивать** ничьим именем, и одно другое не заменяет. + +#### Scenario: Своя запись доступна + +- **GIVEN** человек узнан и принял запись +- **WHEN** он спрашивает карточку этой записи +- **THEN** ответ несёт данные записи + +#### Scenario: Чужая запись неотличима от несуществующей + +- **GIVEN** запись принята одним узнанным +- **WHEN** её карточку спрашивает другой узнанный +- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом + +#### Scenario: Чужой файл неотличим от несуществующего + +- **GIVEN** запись принята одним узнанным +- **WHEN** её файл просит другой узнанный +- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом + +#### Scenario: Владельца не задают запросом + +- **WHEN** запрос на приём записи несёт своё значение владельца +- **THEN** владельцем принятой записи становится узнанный предъявитель + +#### Scenario: Ничью запись завести нечем + +- **WHEN** запись пытаются завести с пустым владельцем +- **THEN** база её не сохраняет + +#### Scenario: Пустой владелец не открывает ничего + +- **GIVEN** заведены две записи: своя и чужая +- **WHEN** карточку каждой спрашивают с пустым владельцем +- **THEN** ответ на обе тот же, что и на неизвестный идентификатор + +### Requirement: Пришедшего называет доверенный источник + +Сервис SHALL узнавать пришедшего по заголовку `Remote-User`, который ставит +обратный прокси, сходивший к провайдеру, и MUST не вести собственного входа: ни +адреса, уводящего к провайдеру, ни адреса возврата, ни куки, ни выхода у сервиса +не остаётся. + +Заголовку сервис MUST верить только тогда, когда запрос пришёл с адреса из +объявленного перечня доверенных, и адрес этот MUST браться у самого соединения, +а не из пересылаемого заголовка: значением пересылаемого распоряжается тот, кто +шлёт запрос, и барьер, подделываемый той же строкой, которой он обходится, не +барьер вовсе. + +Заголовок, пришедший с недоверенного адреса, MUST не узнавать никого. Отказа при +этом MUST не наступать в самом узнавании: проба здоровья, метрики и разметка +приложения открыты неузнанному, и отказ на них закрыл бы наблюдение за сервисом +всякому, кто пришлёт заголовок. Отказ приходит там, где приходил и раньше, — +требованием учётной записи на адресах приложения. + +**Узнавание идёт после ограничителя частоты, а не до него.** Оно читает базу, а +на новом имени ещё и пишет в неё; выполненное раньше ограничителя, оно работало +бы на запросах, которые тот уже отверг, и поток отвергнутых обращений заводил бы +учётные записи, которые потом не убираются ничем. + +**Узнавание действует на объявленной области, а не на всей поверхности сервиса.** +Область — корень приложения; она MUST выводиться из объявленного адресного +пространства сервиса, а не перечисляться вторым списком. Прежде область была +шире на один адрес — тот, которым хранилище выдавало короткий токен файла; ни +адреса, ни токена не осталось. Прежде область была и уже: собственную поверхность +хранилища требовалось из неё вычитать, потому что ключ учётной записи лежал в +коллекции обычной колонкой, а правило правки было библиотечным. Поверхности этой +нет, и вычитать больше нечего. + +Сужение области закрывает вещь, которая от смены хранилища не зависит: узнавание +MUST не срабатывать на пробе здоровья, на метриках и на ресурсах приложения. +Иначе запрос за каждой картинкой стоил бы обращения к базе, а первый такой запрос +с новым именем — записи в неё. + +**Значение заголовка принимается, а не берётся как есть.** Пустое значение и +значение из одних пробельных знаков MUST не узнавать никого и MUST не заводить +учётной записи: прокси штатно шлёт пустой заголовок там, где никого не назвал, и +без этой нормы все неназванные собрались бы в одну учётную запись с общим +архивом. Запрос, несущий **более одного** значения `Remote-User`, MUST не +узнавать никого: прокси, настроенный добавлять заголовок вместо замены, оставляет +рядом со своим значением присланное анонимом, и выбор «первое попавшееся» отдал +бы вход анониму. Значение сверх объявленного предела длины и значение с +управляющими знаками MUST не узнавать никого. Сравнение при поиске MUST быть +точным, знак в знак: приведение регистра склеило бы двух разных людей по правилу, +которого у провайдера нет. Обрамляющие пробелы при этом MUST срезаться до +сравнения: они не часть имени, и заголовок с ведущим пробелом называет того же +человека. Предел длины MUST считаться в **знаках** — той же единицей, что +считает колонка. + +Отказ базы при узнавании MUST кончаться отказом сервиса, а не молчаливым +проходом неузнанным: иначе человек увидит отказ входа там, где легла база. + +Исход узнавания MUST оставлять строку журнала — и когда заголовок пришёл с +недоверенного адреса, и когда заголовок пришёл **более чем одним значением**, и +когда учётная запись заведена. Уровень первых двух MUST быть виден при боевой +настройке журнала: обе строки означают поломку контура, а поломка, записанная +уровнем, который в бою выключен, не записана вовсе. Без неё владелец, у +которого никто не может войти, не отличит своей поломки (перечень доверенных +адресов) от поломки контура (прокси заголовка не ставит), а это разные поломки в +разных местах. Строка несёт адрес пира и идентификатор учётной записи и MUST не +нести значения заголовка. + +Имя, пригодное к показу, сервис SHALL брать из заголовка `Remote-Name`, адрес +почты — из `Remote-Email`. Имена всех трёх заголовков нормативны: смена имени +молча перестаёт узнавать всех, а проверка, которая сама ставит и сама читает своё +имя, этого не замечает. Контур уже пишет эти имена соседним сервисам. + +Узнавание MUST идти на каждом запросе, и значения, переживающего запрос, сервис +MUST не выдавать вовсе — ни куки, ни токена сессии, ни короткого токена файла. +Исключений у этого правила больше нет: файл записи отдаётся тому же узнаванию, +что и всё прочее, и отзыв доступа доходит до него сразу. + +Смысл именно таков: отзыв доступа судит провайдер на каждом обращении, а не +однажды выданный срок. + +Собственных токенов сервис не принимает: значения, предъявленного запросом и +дающего доступ помимо заголовка, у него не существует. Прежде такое значение +било заголовок — им пользовался владелец панели; панели нет, и правило приоритета +осталось бы правилом без предмета. + +Значение заголовка MUST не попадать ни в журнал, ни в ответ, ни в метку метрики. +Оно приходит строкой запроса и целиком задаётся тем, кто её шлёт, а с +недоверенного адреса — анонимом; сверх того имя принадлежит человеку наравне с +адресом его почты. + +#### Scenario: Заголовок с доверенного адреса узнаёт человека + +- **GIVEN** адрес источника стоит в перечне доверенных +- **WHEN** запрос к адресу приложения приходит с заголовком `Remote-User` +- **THEN** запрос идёт от имени учётной записи с этим значением + +#### Scenario: Заголовок с недоверенного адреса не узнаёт никого + +- **GIVEN** адреса источника в перечне доверенных нет +- **WHEN** запрос к адресу приложения приходит с тем же заголовком +- **THEN** ответ имеет код `401` +- **AND** учётной записи с этим значением не появляется + +#### Scenario: Предъявленного значения сервис не признаёт + +- **GIVEN** запрос несёт заголовок `Remote-User` и постороннее значение доступа + в заголовке или в параметре +- **WHEN** сервис решает, кто пришёл +- **THEN** пришедшим считается названный заголовком + +#### Scenario: Пустой заголовок не узнаёт никого + +- **GIVEN** адрес источника стоит в перечне доверенных +- **WHEN** запрос к адресу приложения приходит с пустым `Remote-User` +- **THEN** ответ имеет код `401` +- **AND** учётной записи не появляется + +#### Scenario: Два значения одного заголовка не узнают никого + +- **GIVEN** адрес источника стоит в перечне доверенных +- **WHEN** запрос к адресу приложения несёт два значения `Remote-User` +- **THEN** ответ имеет код `401` +- **AND** учётной записи не появляется + +#### Scenario: Значение сверх предела длины не узнаёт никого + +- **GIVEN** адрес источника стоит в перечне доверенных +- **WHEN** запрос несёт `Remote-User` длиннее объявленного предела +- **THEN** ответ имеет код `401` +- **AND** учётной записи не появляется + +#### Scenario: Область узнавания — корень приложения + +- **GIVEN** сервис поднялся +- **WHEN** смотрят, на каких адресах срабатывает узнавание +- **THEN** это адреса под корнем приложения, и второго списка адресов нет + +#### Scenario: Проба здоровья учётной записи не заводит + +- **GIVEN** учётной записи с этим значением ещё нет +- **WHEN** запрос с заголовком приходит на `GET /health` с доверенного адреса +- **THEN** учётной записи не появляется + +#### Scenario: Недоверенный источник виден в журнале + +- **WHEN** запрос с заголовком приходит с недоверенного адреса +- **THEN** журнал несёт строку об этом исходе с адресом пира +- **AND** значения заголовка в ней нет + +#### Scenario: Сервис не ставит браузеру куки + +- **GIVEN** адрес источника стоит в перечне доверенных +- **WHEN** запрос к адресу приложения проходит с заголовком +- **THEN** ответ не ставит браузеру ни куки сессии, ни иного значения доступа + +#### Scenario: Значения заголовка нет в журнале + +- **WHEN** запрос с заголовком `Remote-User` проходит через сервис +- **THEN** значение заголовка не встречается ни в одной журнальной записи + +### Requirement: Учётная запись заводится первым обращением + +Сервис SHALL заводить учётную запись при первом обращении с новым значением +`Remote-User` и MUST находить её по тому же значению при каждом следующем. +Значение MUST быть ключом учётной записи — уникальным и хранимым своей колонкой +таблицы пользователей. + +Имя и адрес почты MUST браться из заголовков того же запроса, и только при +заведении. Оба MUST **приниматься**, а не браться как есть: имя обрезается по +пределу колонки и чистится от управляющих знаков, негодный адрес почты +отбрасывается. Негодное значение необязательного поля MUST не отменять +заведения записи — иначе человек с длинным именем у провайдера не завёлся бы +никогда, получая отказ сервиса на каждом запросе. Найденную запись повторное +обращение MUST не переписывать: иначе всякий запрос был бы записью в базу, а +правка имени у провайдера меняла бы карточку человека молча, посреди его работы. + +Адрес почты MUST быть необязательным: провайдер не обязан его приносить, а ключом +он не служит. Ключом его брать нельзя вовсе — адрес меняется, и первое обращение +с чужим адресом досталось бы чужой записи. + +**Ключ учётной записи MUST не правиться ничем, кроме заведения самим сервисом.** +Переписанный ключ отдаёт архив следующему, кто придёт с этим именем, а вернуть +его будет нечем — владелец записи назначается один раз и не меняется. Держится +это тем, что адреса, которым учётная запись правится снаружи, у сервиса нет +вовсе: своих экранов профиля он не заводит, а поверхности хранилища, правившей +запись библиотечным правилом, не осталось. Правку остаётся сделать запросом к +базе, и это работа владельца сервиса, а не спрашивающего. + +Одновременные первые обращения одним значением MUST кончаться одной учётной +записью: уникальность держит схема, а не порядок обращений. + +**Два отказа уникальности различаются, и исход у них разный.** Отказ по ключевой +колонке — это гонка двух первых обращений одним именем, и он MUST кончаться +повторным поиском и продолжением работы. Отказ по любой другой колонке — адрес +почты, пришедший от провайдера, уже занят другой учётной записью — MUST кончаться +заведением записи **без почты**: она необязательна. Без этого разреза второй +человек с общим почтовым ящиком не завёлся бы никогда, потому что повторный поиск +по имени снова ничего не находит. + +Цена ключа называется целиком, обеими сторонами. Переименование пользователя у +провайдера заводит **новую** учётную запись, и записи прежней остаются у прежней; +слить их или убрать нечем — владелец записи не меняется, а учётная запись с +записями не удаляется по норме `storage`. **Логин же переиспользуем**: человек, +которому провайдер выдал логин ушедшего, при первом обращении попадает в +существующую запись и получает весь её архив. Не допускать переиспользования — +работа провайдера; сервису неизменяемого признака заголовок не приносит, и эта +цена принимается, а не обходится. + +#### Scenario: Первое обращение заводит запись + +- **GIVEN** учётной записи с этим значением ещё нет +- **WHEN** приходит запрос с заголовком `Remote-User` +- **THEN** учётная запись появляется +- **AND** запрос идёт от её имени + +#### Scenario: Повторное обращение попадает в ту же запись + +- **GIVEN** учётная запись заведена первым обращением +- **WHEN** приходит второй запрос с тем же значением заголовка +- **THEN** новой учётной записи не появляется +- **AND** запрос идёт от имени прежней + +#### Scenario: Разным значениям — разные записи + +- **WHEN** приходят запросы с двумя разными значениями заголовка +- **THEN** заводятся две учётные записи +- **AND** записи одного не видны другому + +#### Scenario: Имя не переписывается вторым обращением + +- **GIVEN** учётная запись заведена с одним значением `Remote-Name` +- **WHEN** приходит запрос с тем же `Remote-User` и другим `Remote-Name` +- **THEN** имя учётной записи остаётся прежним + +#### Scenario: Два одновременных первых обращения дают одну запись + +- **GIVEN** учётной записи с этим значением ещё нет +- **WHEN** два запроса с одним значением заголовка приходят одновременно +- **THEN** в таблице пользователей появляется ровно одна строка +- **AND** оба запроса идут от её имени + +#### Scenario: Занятая почта не мешает завести запись + +- **GIVEN** учётная запись с этим адресом почты уже заведена +- **WHEN** приходит первое обращение с другим `Remote-User` и тем же + `Remote-Email` +- **THEN** заводится своя учётная запись +- **AND** адреса почты у неё нет + +#### Scenario: Адреса правки учётной записи у сервиса нет + +- **GIVEN** человек узнан и его учётная запись заведена +- **WHEN** ищут адрес сервиса, которым он правит свою учётную запись +- **THEN** такого адреса нет + +#### Scenario: Негодное имя не отменяет заведения + +- **GIVEN** учётной записи с этим значением ещё нет +- **WHEN** приходит обращение с именем длиннее предела колонки +- **THEN** учётная запись заводится, а имя обрезано по пределу + +#### Scenario: Негодная почта отбрасывается, а не отменяет заведение + +- **GIVEN** учётной записи с этим значением ещё нет +- **WHEN** приходит обращение с адресом почты, не похожим на адрес +- **THEN** учётная запись заводится без почты + +#### Scenario: Отвергнутый ограничителем запрос учётной записи не заводит + +- **GIVEN** бюджет ограничителя частоты выбран +- **WHEN** приходит обращение с новым значением заголовка +- **THEN** ответ несёт отказ ограничителя +- **AND** учётной записи не появляется + +#### Scenario: Заведение учётной записи видно в журнале + +- **WHEN** приходит первое обращение с новым значением заголовка +- **THEN** журнал несёт строку о заведении с идентификатором записи +- **AND** значения заголовка в ней нет + +## REMOVED Requirements + +### Requirement: Иных способов открыть сессию нет + +**Reason**: Требование выключало собственные входы встроенного хранилища — +создание записи в коллекции пользователей, вход по паролю, вход по одноразовому +коду, обмен кода у внешнего провайдера, восстановление доступа — и закрывало +правилами доступа правку этой коллекции. Хранилище уходит из проекта целиком: +ни одного из этих адресов не существует, и выключать больше нечего. + +**Migration**: Единственный способ представиться остаётся прежним — заголовок +доверенного источника на каждом запросе, требование «Пришедшего называет +доверенный источник». Что учётную запись заводит только код сервиса и что её ключ +не правится снаружи, нормирует требование «Учётная запись заводится первым +обращением»: адреса правки у сервиса нет вовсе. diff --git a/openspec/changes/archive/2026-08-23-storage-without-pocketbase/specs/archive/spec.md b/openspec/changes/archive/2026-08-23-storage-without-pocketbase/specs/archive/spec.md new file mode 100644 index 0000000..4a955d9 --- /dev/null +++ b/openspec/changes/archive/2026-08-23-storage-without-pocketbase/specs/archive/spec.md @@ -0,0 +1,335 @@ +## ADDED Requirements + +### Requirement: Файл записи отдаётся адресом приложения + +Сервис SHALL отдавать файл записи адресом под корнем приложения — `GET +/app/audiorecords/{id}/file` — и MUST отдавать **копию, названную +спрашивающим**. Отдача «какой-нибудь» копии сделала бы ответ функцией того, что +успел записать конвейер, а не состояния записи. + +**Копию называет параметр запроса `copy`.** Имя параметра нормативно наравне со +значениями: разбирает его каждый экран, и выбранное кодом оно стало бы публичным +контрактом молча. + +**Перечень значений закрыт, и каждое называет ровно одну хранимую вещь:** + +- `original` — файл, принятый от отправителя; +- `normalized` — копия, приведённая к рабочему формату. + +Копию, которой у записи ещё нет, сервис MUST отдавать отказом состояния — тем же +кодом, каким отвечает ненаписанный текст: `409`. Пустой ответ читался бы как +пустой файл, а `404` слился бы с ответом на чужую и неизвестную запись, и человек +увидел бы «не найдено» на своей записи, загруженной минуту назад. + +Копия, которой сервис не знает, и незаданная копия MUST давать отказ по негодному +вводу — но только у **своей** записи. + +Порядок проверок MUST быть один: владение записью судится **до** разбора значения +копии. Неизвестное либо незаданное значение копии у чужой и у несуществующей +записи MUST давать тот же ответ, что и неизвестный идентификатор, — и кодом, и +телом. Неотличимость чужой записи от несуществующей главнее формы ответа на +негодный ввод: разбор параметра, выполненный раньше, отвечал бы одинаково на +чужую и на неизвестную только случайно, а стоило бы ответам разойтись — по этой +разнице перебирался бы список заведённых записей одним негодным параметром. + +Ответ MUST нести длину файла и тип содержимого и MUST допускать выдачу по частям: +запись расчётного потолка — шесть часов, и проигрыватель в браузере перематывает +её запросом диапазона, а не повторной загрузкой целиком. + +**Негодный диапазон MUST приводиться к обычному отказу сервиса** — телом той же +формы и кодом из закрытого перечня, — а не отвечать кодом `416` и телом +библиотеки. Негодных диапазонов два вида, и оба ведут себя одинаково: +неудовлетворимый (начало за концом файла) и множественный (в запросе назван +больше чем один диапазон). Второй сервис не отдаёт намеренно: ответ из нескольких +частей — это отдельный тип содержимого со своими границами, а просит его один +только самодельный запрос, потому что проигрыватель в браузере шлёт один +диапазон. + +Причина у требования общая с прочими отказами, рождающимися не в обработчике: +форма тела на адресах приложения одна, и код отказа принадлежит закрытому +перечню. Ответ `416` с телом библиотеки приходит без полей `error_code` и +`message`, и приложение разбирает его отдельной веткой — единственной такой на +все адреса. + +Файл чужой записи MUST быть недоступен наравне с её карточкой и отвечать тем же, +чем неизвестный идентификатор. Кто владелец файла и почему право пройти по адресу +даёт узнавание, а не выданное значение, нормирует capability `storage`. + +Имя файла на диске MUST в ответ не попадать: имя, предлагаемое браузеру при +сохранении, строится из имени, данного отправителем, и лежит оно колонкой записи. + +**Перечня доступных копий карточка записи не объявляет** — до задачи об экране +прослушивания его в ответе MUST не быть, и об отсутствующей копии спрашивающий +узнаёт отказом состояния на самом обращении за файлом. + +Довод, которым перечень доступных видов текста объявляется карточкой всегда, к +копиям файла не относится, и это разные случаи. Видов текста несколько, шаг +завершения пишет их несколькими операциями, поэтому состояние «сплошной текст +есть, реплик ещё нет» достижимо, а из состояния записи не выводится: приложение +обязано узнать перечень, иначе пойдёт за текстом, которого нет. Копий же две, и +каждая выводится из рубежа записи, который карточка несёт и так: принятая копия +есть у всякой заведённой записи, приведённая — у всякой, прошедшей приведение. +Второе поле повторяло бы рубеж и разошлось бы с ним молча. + +Перечень появится тогда, когда у него появится потребитель: экран прослушивания +приносит задача `play-recording-in-app`. Объявлять его раньше — закреплять +контракт, которого никто не разбирает. + +#### Scenario: Владелец забирает принятую копию + +- **GIVEN** запись принята +- **WHEN** владелец просит её файл копией `original` +- **THEN** ответ несёт содержимое принятого файла и его длину + +#### Scenario: Приведённой копии ещё нет + +- **GIVEN** запись принята и не дошла до приведения +- **WHEN** владелец просит её файл копией `normalized` +- **THEN** ответ имеет код `409` +- **AND** он отличается от ответа на неизвестный идентификатор + +#### Scenario: Копия неизвестна или не названа + +- **WHEN** владелец просит файл копией, которой сервис не знает, либо не + называет копии вовсе +- **THEN** ответ имеет код `400` + +#### Scenario: Чужой файл неотличим от неизвестной записи + +- **GIVEN** запись принята одним узнанным +- **WHEN** её файл просит другой узнанный +- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом + +#### Scenario: Негодная копия у чужой записи неотличима от неизвестной записи + +- **GIVEN** запись принята одним узнанным +- **WHEN** другой узнанный просит её файл копией, которой сервис не знает +- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом +- **AND** он не отличается от ответа на ту же просьбу к несуществующей записи + +#### Scenario: Перечня копий в карточке нет + +- **GIVEN** запись принята и приведена к рабочему формату +- **WHEN** владелец спрашивает её карточку +- **THEN** поля с перечнем доступных копий файла в ответе нет + +#### Scenario: Проигрыватель просит кусок записи + +- **GIVEN** запись принята +- **WHEN** владелец просит её файл с указанием диапазона +- **THEN** ответ несёт запрошенный кусок, а не файл целиком + +#### Scenario: Неудовлетворимый диапазон отвечает обычным отказом + +- **GIVEN** запись принята, и её файл короче запрошенного начала +- **WHEN** владелец шлёт настоящий HTTP-запрос за файлом с заголовком `Range: + bytes=99999999-` +- **THEN** ответ имеет код `400`, а не `416` +- **AND** тело несёт поля `error_code` и `message` + +#### Scenario: Двух диапазонов в одном запросе сервис не отдаёт + +- **GIVEN** запись принята +- **WHEN** владелец шлёт настоящий HTTP-запрос за файлом с заголовком, + называющим два диапазона +- **THEN** ответ имеет код `400`, а не `416` +- **AND** тело несёт поля `error_code` и `message` +- **AND** ответа из нескольких частей сервис не отдаёт + +## MODIFIED Requirements + +### Requirement: Адреса приложения живут своим пространством + +Сервис SHALL вешать собственные адреса приложения под корнем `/app/` и MUST не +заводить второго адресного пространства рядом. Пространство `/api/`, прежде +принадлежавшее встроенному хранилищу, и адрес панели `/_/` перестают +существовать: сервис их не занимает и отвечает на них тем же, чем отвечает +всякий неизвестный путь, — норму держит capability `webapp`. + +Корень приложения остаётся прежним, и это решение подтверждается, а не +принимается заново: формы запросов и ответов приложения смена хранилища не +трогает ни одним полем. + +Соседство, ради которого корень был выбран, кончилось вместе с соседом: чужого +обновления, вправе занять новое имя рядом с нашим, больше нет. + +Ограничитель частоты под корнем приложения MUST быть своим: он считает бюджет по +адресу спрашивающего и MUST не зависеть от настроек чужой поверхности. Прежде +включение нашего правила вводило в действие и чужие правила на чужих адресах; +платить за это больше нечем — чужих адресов нет. + +**Адрес спрашивающего ограничитель MUST брать из заголовка пересылки — и только +тогда, когда соединение пришло с адреса из объявленного перечня доверенных.** Во +всяком другом случае адресом MUST считаться адрес пира, а пришедший заголовок +MUST не влиять на ключ бюджета ничем. + +**Цепочку пересылки ограничитель MUST читать справа налево, отбрасывая адреса из +перечня доверенных, и брать первый недоверенный.** Читаются при этом **все** +строки заголовка, а не первая: цепочка законно приходит несколькими строками. +Значение, оставшееся слева, ключа бюджета MUST не задавать: прокси заголовок +дописывает, а не заменяет, поэтому слева стоит то, что прислал спрашивающий, — и +ключ, взятый оттуда, меняется у него на каждом запросе, то есть бюджет +обходится с первого. Цепочка, где недоверенного адреса не нашлось вовсе, MUST +падать обратно на адрес пира. + +Обе половины правила закрывают свою поломку. Сервис стоит за обратным прокси, и +пир у него один на всех: бюджет, посчитанный по пиру, становится общим на весь +сервис, и один спрашивающий исчерпывает его остальным. Обратная ошибка — верить +заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого он +ограничивает: значением пересылаемого заголовка распоряжается тот, кто шлёт +запрос, и меняет он его на каждом запросе. + +**Узнавание и ограничитель берут адрес разными способами, и это намеренно.** +Узнаванию нужен адрес самого соединения: им оно решает, верить ли заголовку +вообще, и взятый из пересылаемого заголовка он сделал бы барьер подделываемым той +же строкой, которой обходится, — норму держит capability `access`. Ограничителю +нужен адрес того, кого он ограничивает, а тот за прокси в адресе соединения не +виден вовсе. Вопросы разные — «кому верить» и «кого считать», — и один ответ на +оба ломает либо барьер, либо бюджет. + +#### Scenario: Адрес приложения отвечает под своим корнем + +- **GIVEN** человек узнан +- **WHEN** он спрашивает список своих записей под корнем приложения +- **THEN** ответ приходит от сервиса + +#### Scenario: Пространства хранилища не существует + +- **GIVEN** сервис поднялся +- **WHEN** запрос приходит на путь под прежним корнем хранилища +- **THEN** ответ тот же, что и на всякий неизвестный путь вне корней сервиса + +#### Scenario: Адреса панели не существует + +- **GIVEN** сервис поднялся +- **WHEN** запрос приходит на прежний адрес панели — и записанный знаком, и + записанный его кодом +- **THEN** ответ тот же, что и на всякий неизвестный путь вне корней сервиса + +#### Scenario: Ограничитель частоты покрывает адреса приложения + +- **GIVEN** сервис поднялся +- **WHEN** запросы с одного адреса идут чаще бюджета под корнем приложения +- **THEN** лишние получают отказ ограничителя + +#### Scenario: Два клиентских адреса через один прокси расходуют разные бюджеты + +- **GIVEN** запросы идут с доверенного адреса, и адрес пира у них один +- **WHEN** два разных клиентских адреса шлют запросы под корнем приложения +- **THEN** бюджет каждого считается отдельно +- **AND** исчерпание бюджета одним не отказывает другому + +#### Scenario: Заголовок с недоверенного адреса на ключ бюджета не влияет + +- **GIVEN** запросы приходят с адреса вне перечня доверенных +- **WHEN** они несут заголовок пересылки с разными значениями адреса +- **THEN** бюджет у них общий и считается по адресу пира + +#### Scenario: Значение, приписанное спрашивающим, ключа бюджета не задаёт + +- **GIVEN** запросы приходят с доверенного адреса +- **WHEN** они несут цепочку пересылки, где слева стоит меняющееся значение + спрашивающего, а справа — адрес, приписанный прокси +- **THEN** бюджет считается по правому значению +- **AND** запросы чаще бюджета получают отказ ограничителя + +#### Scenario: Цепочка читается всеми строками заголовка + +- **GIVEN** запросы приходят с доверенного адреса +- **WHEN** цепочка пересылки приходит несколькими строками заголовка +- **THEN** ключ бюджета берётся из последней строки, а не из первой + +### Requirement: Отказ называет причину, а не место + +Сервис SHALL отвечать на адресах приложения кодом, который отвечает **причине** +отказа, а не месту, где он случился. Перечень закрыт и назван поимённо: + +- пришедший не узнан — `401`, и он MUST наступать **до всякого чтения записи**, + одинаково для заведённой записи и для неизвестного идентификатора: иначе по + разнице кодов перебирается список заведённых записей; +- неизвестный идентификатор — `404`, и **тем же кодом с тем же телом** MUST + отвечать чужая и ничья запись; +- негодный ввод — `400`: нечитаемая запись, неизвестное значение параметра, + негодный размер страницы, негодный диапазон в запросе файла; +- запись сверх потолка размера — `413`, и тело MUST нести предел числом; +- состояние, в котором действие недоступно, — `409`: текста или копии файла + запрошенного вида у записи ещё нет; +- отказ базы и всякая неназванная причина — `500`. + +Ветвь «узнанный предъявитель без учётной записи пользователя» из перечня ушла +вместе со своим единственным случаем: им был владелец панели, предъявивший +собственный токен хранилища. Ни панели, ни токенов у сервиса не осталось, а +узнавание по заголовку учётную запись заводит само, и предъявителя без неё не +бывает. Ветвь, у которой нет достижимого случая, не проверяется ничем и остаётся +в коде мёртвой. + +Отображение доменной ошибки в код и сообщение MUST жить **одним местом** на все +адреса, и у него MUST быть определённая ветвь по умолчанию. + +Тело отказа MUST быть одной формы на всех адресах приложения и MUST нести **два** +поля: машиночитаемый код отказа из закрытого перечня и сообщение, пригодное +человеку, на русском языке. Одного сообщения мало: кода HTTP не хватает, чтобы +различить «файл негоден», «поля записи нет» и «неизвестное значение параметра» — +все три `400`, — а приложению надо решать, предлагать ли повтор и что показать +человеку. Разбор русской фразы был бы единственным оставшимся путём, и первая же +задача экрана переписала бы контракт, согласованный здесь один раз. + +Имена полей и перечень кодов нормативны — их разбирает каждый экран, и +выбранные кодом они стали бы контрактом молча: + +- поля тела: `error_code` и `message`; +- перечень `error_code`: `unauthorized`, `not_found`, `bad_request`, + `too_large`, `too_many_requests`, `not_ready`, `internal`. + +Часть отказов рождается **не в обработчике** — предел тела, ограничитель частоты, +неизвестный путь под корнем приложения, — и до отображения доменной ошибки не +доходит вовсе. Такие отказы MUST приводиться к той же форме: иначе форм на +адресах приложения две, а самый частый отказ у человека на мобильной сети — +«запись больше потолка» — приходит телом библиотеки, без кода и без предела +числом. + +Перечень закрыт и объявляется **одним местом**. Новая штатная ветвь отказа +заводится добавлением в него, а не строкой в обработчике: иначе ветвь по +умолчанию отдаст `internal` на обычный конфликт, и владелец сервиса увидит в +журнале аварию там, где её нет. + +Сырой текст ошибки MUST в тело не попадать — ни текст отказа драйвера, ни детали +устройства: имена внешних сервисов, пути на диске, имена файлов. Полная ошибка +остаётся в журнале владельца сервиса. + +#### Scenario: Сбой базы виден как сбой + +- **GIVEN** база отвечает отказом драйвера на чтение записи +- **WHEN** владелец спрашивает свою запись +- **THEN** ответ имеет код `500` +- **AND** тела записи в ответе нет + +#### Scenario: Негодная запись видна как негодная + +- **GIVEN** источник метаданных не может прочитать присланную запись +- **WHEN** отправитель шлёт её приёмом +- **THEN** ответ имеет код `400` и несёт сообщение, пригодное человеку +- **AND** причина отказа в тело ответа не попадает + +#### Scenario: Форма тела одна на всех ветвях отказа + +- **WHEN** сервис отказывает по ненайденной записи, по негодному вводу, по + недоступному состоянию и по сбою базы +- **THEN** тело каждого ответа несёт код отказа и сообщение одними и теми же + полями +- **AND** код отказа принадлежит закрытому перечню +- **AND** ни одно из них не содержит сырого текста ошибки + +#### Scenario: Неузнанному неизвестная запись неотличима от заведённой + +- **GIVEN** заведена запись +- **WHEN** её карточку спрашивают неузнанным, а затем спрашивают карточку по + неизвестному идентификатору +- **THEN** оба ответа имеют код `401` и одно тело + +#### Scenario: Запись сверх потолка размера + +- **GIVEN** отправитель узнан +- **WHEN** он шлёт запись длиннее потолка размера +- **THEN** ответ имеет код `413`, а тело несёт предел числом +- **AND** ни файла, ни аудиозаписи не заводится diff --git a/openspec/changes/archive/2026-08-23-storage-without-pocketbase/specs/intake/spec.md b/openspec/changes/archive/2026-08-23-storage-without-pocketbase/specs/intake/spec.md new file mode 100644 index 0000000..cb6ce62 --- /dev/null +++ b/openspec/changes/archive/2026-08-23-storage-without-pocketbase/specs/intake/spec.md @@ -0,0 +1,128 @@ +## MODIFIED Requirements + +### Requirement: Приём записи по HTTP + +Сервис SHALL принимать запись запросом `POST /app/audiorecords` с телом +`multipart/form-data` и полем `audio` **только от узнанного отправителя**. +Запрос от неузнанного MUST получать код `401`, и по нему MUST не заводиться ни файл, +ни аудиозапись. Принятая запись от узнанного отправителя MUST быть сохранена и +получить заведённую под неё аудиозапись на рубеже `uploaded`. + +Приём стоит тем же адресом, что и список записей, и отличается от него только +методом: он **заводит аудиозапись**, а не кладёт файл. + +Ответ MUST нести **список** заведённых записей и место под признак повторного +файла у каждой, даже когда файл в запросе один. Форма согласована один раз и +вперёд: приём, отдающий одну запись, пришлось бы переписывать вместе с приёмом +нескольких файлов и с распознаванием повтора по содержимому, а экран загрузки — +переделывать под вторую форму. Число файлов в запросе при этом остаётся прежним: +меняется форма ответа, не число файлов. + +Элемент списка MUST нести те же поля, что и карточка записи, плюс признак +повторного файла полем `duplicate`: две формы одной вещи разошлись бы молча. +Состав карточки нормирует capability `archive`. + +Прежние имена полей ответа — `job_id` и `status` — MUST не употребляться: +идентификатор записи зовётся `id`. + +Значение рубежа в ответе MUST принадлежать перечню рубежей конвейера и MUST не +перечисляться этой нормой порознь: рубеж объявлен одним дескриптором, и +перечисленный здесь второй раз он разошёлся бы с ним молча. Рубеж называет +достигнутое, а не предстоящее, и `created` в перечне отсутствует вовсе. + +Запись сверх потолка размера MUST отвергаться до заведения файла и аудиозаписи, +и код с телом такого отказа нормирует capability `archive` наравне с прочими +ветвями. + +Отказ неузнанному наступает **раньше** чтения тела: запись, за которую +не заплатит узнанный отправитель, не должна попасть даже в память. + +Приём не судит о годности записи сам: расширение он берёт из имени файла, а +пригодность содержимого узнаёт у источника метаданных. + +Куда именно ложится принятая запись, приёму не принадлежит: раскладку каталога +данных нормирует capability `storage`. + +Владельцем принятой записи приём SHALL назначать узнанного предъявителя. +Обязательность владельца при этом MUST держаться и схемой: колонка владельца +пустого значения не принимает вовсе, и норму эту держит capability `storage`. +Проверка в приёме от этого не лишняя — она отвечает отправителю понятным отказом +до того, как запись попадёт в память, а схема отвечала бы отказом сохранения +после укладки файла. + +Отдельной ветви «узнан, а учётной записи нет» у приёма больше нет: узнавание +заводит учётную запись само, а предъявителя с собственным токеном хранилища не +существует — токенов сервис не выдаёт и не принимает. Ветвь ушла вместе со своим +единственным случаем. + +Отказ **после** укладки записи потребовал бы убрать уже сохранённый файл, а +уборки файлов сервис не умеет вовсе: норма, обязывающая к недостижимому, не +пишется. + +#### Scenario: Запись принята + +- **GIVEN** источник метаданных читает запись и отдаёт её длительность +- **AND** отправитель узнан +- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio` +- **THEN** ответ имеет код `201`, а в теле лежит список из одного элемента +- **AND** элемент несёт непустой `id`, поле `state` со значением `uploaded` и + место под признак повторного файла +- **AND** содержимое записи целиком лежит в каталоге данных одним файлом +- **AND** владельцем заведённой аудиозаписи стоит узнанный предъявитель + +#### Scenario: Пришедший не узнан + +- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio` неузнанной +- **THEN** ответ имеет код `401` +- **AND** ни файла, ни аудиозаписи не заводится +- **AND** тело ответа не несёт данных записи + +#### Scenario: Поля с записью нет + +- **GIVEN** отправитель узнан +- **WHEN** программа шлёт `POST /app/audiorecords` без поля `audio` +- **THEN** ответ имеет код `400` и сообщение об отсутствии записи +- **AND** ни файла, ни аудиозаписи не заводится + +#### Scenario: Размеру записи приём не судья + +- **GIVEN** источник метаданных читает запись и отдаёт её длительность +- **AND** отправитель узнан +- **WHEN** программа шлёт запись нулевой длины +- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет + +### Requirement: Имя файла в хранилище + +Сервис SHALL сохранять принятую запись под собственным именем — идентификатором, +к которому приписано расширение из имени файла отправителя. Имя, данное +отправителем, MUST не попадать **ни в имя файла на диске, ни в путь к нему**: оно +приходит извне и содержимым своим приёму не подконтрольно. + +Норма сужена: имя отправителя доходит теперь до самой аудиозаписи собственной +колонкой — по нему человек узнаёт свою запись, — но не до имени файла и не до +журнала. Что с ним делает приём, нормирует требование «Имя файла отправителя +подписывает запись». + +Расширения в присланном имени нет — сервис MUST подставить `.audio`, чтобы у +файла на диске расширение было всегда. + +Требование пережило смену раскладки: имя задаёт сервис, а не умолчание чужой +библиотеки, строившее его из имени отправителя. Умолчания этого больше нет, и +правило перестало быть отменой чужого поведения — оно стало прямым описанием +своего. + +#### Scenario: Расширение взято из имени отправителя + +- **WHEN** программа шлёт запись с именем `test.mp3` +- **THEN** имя файла на диске оканчивается на `.mp3` + +#### Scenario: Имени без расширения назначено своё + +- **WHEN** программа шлёт запись с именем `test` без расширения +- **THEN** имя файла на диске оканчивается на `.audio` + +#### Scenario: Имя отправителя в имя файла не попало + +- **WHEN** программа шлёт запись с именем `секретное-слово.mp3` +- **THEN** имя файла на диске не содержит `секретное-слово` +- **AND** путь к этому файлу не содержит его тоже diff --git a/openspec/changes/archive/2026-08-23-storage-without-pocketbase/specs/pipeline/spec.md b/openspec/changes/archive/2026-08-23-storage-without-pocketbase/specs/pipeline/spec.md new file mode 100644 index 0000000..fcc4b75 --- /dev/null +++ b/openspec/changes/archive/2026-08-23-storage-without-pocketbase/specs/pipeline/spec.md @@ -0,0 +1,255 @@ +## MODIFIED Requirements + +### Requirement: Захват задачи неделим + +Захват записи воркером SHALL быть одним неделимым запросом к базе: выбор +подходящей записи и пометка её захваченной MUST происходить вместе, одним +оператором с возвратом. + +Захват MUST возвращать **идентификатор записи и признак этого захвата**, а не +перечень её колонок. Колонки записи шаг читает сам, обычным чтением. Иначе +всякая новая колонка аудиозаписи попадала бы под инвариант проекта о колонках +очереди, и забытая в захвате колонка приезжала бы нулевой, а первое же +сохранение писало бы этот ноль поверх сохранённого значения. + +**Признак захвата MUST быть значением, уникальным для каждого захвата**, а не +признаком занятости. Условие записи результата сверяет именно это значение: +захват, перевыданный другому — по протуханию срока или после того, как человек +снял признак остановки подкомандой оснастки, — обязан обращать запись первого в +отказ. +Условие, проверяющее лишь непустоту признака или срок, пропустило бы обоих, и +два шага записали бы в одну запись по очереди, испортив её результат. + +Одна и та же запись MUST доставаться ровно одному захватившему. Двум вызывающим, +пришедшим за работой одновременно, запись MUST достаться одному, а второй MUST +получить признак «работы сейчас нет». + +Срок протухания захвата MUST ехать с рубежом записи, а не с воркером: воркер не +привязан к шагу и не знает заранее, что вытянет. Срок MUST записываться числом +при самом захвате. + +Порядок выборки MUST быть определён однозначно: сравнения по неуникальному +значению для этого мало, и к нему MUST добавляться ключ записи. Иначе порядок +обработки невоспроизводим, а проверка, опирающаяся на «следующую» запись, зелена +через раз. + +Требование стоит на инварианте проекта «Принятая запись не теряется молча»: +захват, разделённый на два шага, отдаёт одну запись двум воркерам, и работа +одного из них теряется без следа. + +Признак «работы нет» этим требованием не переопределяется — его нормирует +требование «Пустой прогон воркера — не отказ». + +#### Scenario: За работой пришли трое разом + +- **GIVEN** к работе пригодна ровно одна запись +- **WHEN** три захвата идут одновременно +- **THEN** запись получает ровно один из них +- **AND** двое остальных получают признак «работы сейчас нет» + +#### Scenario: Захваченная запись не выдаётся второй раз + +- **GIVEN** запись захвачена и срок захвата не истёк +- **WHEN** приходит следующий захват +- **THEN** эта запись ему не выдаётся + +#### Scenario: Захват отдаёт идентификатор и свой признак + +- **GIVEN** к работе пригодна запись +- **WHEN** воркер её захватывает +- **THEN** захват возвращает идентификатор записи и признак этого захвата +- **AND** колонки записи шаг читает отдельным чтением + +#### Scenario: Признак перевыданного захвата отличается от прежнего + +- **GIVEN** запись захвачена, и признак первого захвата известен +- **WHEN** человек снимает признак остановки, и запись захватывает другой воркер +- **THEN** признак нового захвата отличается от признака первого + +### Requirement: Результат пишет только держатель захвата + +Шаг конвейера SHALL записывать свой результат только тогда, когда захват записи +всё ещё принадлежит ему. Запись MUST быть условна по **признаку этого захвата** — +значению, уникальному для каждого захвата, — а не по занятости записи вообще. +Шаг, чей захват за время работы достался другому, MUST завершиться без записи +результата. + +Требование закрывает то, чего неделимость захвата не закрывает: захват протухает +не только у мёртвого воркера, но и у живого — шаг, идущий дольше своего срока, +теряет запись, продолжая работать. Снять захват может и человек, вернувший +остановленную запись в работу. Без условия по уникальному признаку два воркера +пишут в одну запись по очереди, а счётчик отказов сбрасывает тот, кто уже не +владелец. + +Довод про два ответа отправителю из требования ушёл вместе с доставкой: обращений +наружу шаг не делает. Требование от этого не ослабло — порча записи двумя +пишущими остаётся его предметом целиком. + +Шаг MUST записывать только те поля, которыми распоряжается сам. Запись он держит +снимком с момента захвата и до записи — это часы, — и безусловная запись снимка +стёрла бы всё, что владелец правил за это время: молча, без строки в журнале и +без отказа тому, кто правил. Владелец записи, заголовок, краткое описание и темы +конвейер MUST не трогать. + +Возвращает остановленную запись в работу сегодня владелец сервиса подкомандой +оснастки — своего экрана для этого у сервиса нет. Норму это не меняет: она +написана про поля, а не про того, чьей рукой правка сделана, и переживёт +появление экранов. + +#### Scenario: Правка владельца пережила сохранение шага + +- **GIVEN** шаг держит захваченную запись +- **AND** владелец за это время изменил поле, которого шаг не касается +- **WHEN** шаг записывает свой результат +- **THEN** результат шага записан +- **AND** правка владельца на месте + +#### Scenario: Захват ушёл под работающим шагом + +- **GIVEN** шаг работает над захваченной записью +- **AND** за это время та же запись досталась другому захвату +- **WHEN** первый шаг доходит до записи результата +- **THEN** результат не записывается + +#### Scenario: Человек снял остановку под работающим шагом + +- **GIVEN** шаг работает над захваченной записью +- **AND** человек за это время снял с неё признак остановки, освободив захват +- **AND** запись досталась другому воркеру +- **WHEN** первый шаг доходит до записи результата +- **THEN** результат не записывается + +### Requirement: Остановка записи — признак, а не рубеж + +Сервис SHALL останавливать запись отдельным признаком с причиной и MUST не +стирать при этом достигнутый рубеж. Признак MUST нести время остановки, причину +и машинный текст отказа. + +Снятие признака SHALL возвращать запись в работу **с того рубежа, где она +стояла**, и MUST сбрасывать **всё, чем прошлый прогон её удерживал**: + +- признак остановки — время остановки, причину и машинный текст отказа; +- признак захвата и срок его протухания; +- число отказов; +- паузу перед повтором; +- время входа в рубеж. + +Перечень назван целиком и в одном месте, потому что забытое поле не даёт ни +отказа, ни строки в журнале. Оставленный признак захвата держит запись занятой до +протухания срока и отдаёт её потом чужому шагу — тому, чей результат условен по +прежнему значению. Оставленное время входа в рубеж останавливает запись снова +первым же захватом, если остановленной она простояла дольше предела, и перезапуск +не работает вовсе. Оставленные отказы и пауза откладывают первый же прогон на +накопленный срок. + +Возврат в работу MUST идти **через домен**: тот, кто его делает, называет запись, +а поля выше сбрасывает домен одним действием. Правка колонок мимо домена +повторяет перечень вторым местом, и второе место расходится с первым молча. + +Возврат в работу MUST писать событие журнала записи с происхождением «человек». +Иначе запись, побывавшая остановленной и вернувшаяся в работу, неотличима в +журнале от записи, которую конвейер вёл без остановок, а происхождение события +перестаёт различать что-либо: другого писателя, кроме конвейера, у журнала не +остаётся. + +Инструментом возврата сегодня служит подкоманда набора инструментов +разработчика: панели у сервиса нет, а экраны владельца приносят отдельные задачи. +Норма написана про поля и про домен, а не про инструмент, и появление экрана её +не трогает. + +Прежние состояния отказа и смерти MUST не заводиться заново: обе причины +восстанавливаются одинаково — снятием признака, — и различие между ними +перестаёт быть структурным, оставаясь причиной остановки. Состояние, называющее +отказ, стирает достигнутый рубеж, и продолжение с места остановки становится +невозможным. + +**Способ вывести запись из выборки MUST быть один — этот признак.** Второго +признака, исключающего запись из работы помимо рубежа и паузы, MUST не +заводиться: два способа расходятся, и молчаливо теряется тот, который забыли +проверить. Условие отбора MUST не выводить запись из выборки молча — запись, +переставшая браться в работу, обязана нести признак остановки с причиной. + +Остановку MUST ставить тот, кто запись захватил. Перевод принадлежит одному +месту: условие отбора, молча пропускающее запись мимо выборки, оставило бы её +без следа. + +Остановленная запись MUST не выдаваться захвату. + +#### Scenario: Остановленная запись продолжает с места остановки + +- **GIVEN** шаг остановил запись на рубеже приведения +- **WHEN** признак остановки снимают +- **THEN** следующим идёт отправка на распознавание, а не повторное приведение + +#### Scenario: Остановленная запись не выдаётся захвату + +- **GIVEN** у записи стоит признак остановки +- **WHEN** за её рубежом приходит захват +- **THEN** запись ему не выдаётся + +#### Scenario: Снятие признака сбрасывает всех сторожей + +- **GIVEN** запись остановлена с накопленными отказами и паузой +- **AND** остановленной она простояла дольше предела времени в рубеже +- **WHEN** признак остановки снимают +- **THEN** число отказов, пауза и время входа в рубеж сброшены +- **AND** ближайший захват выдаёт запись, а не останавливает её снова + +#### Scenario: Возврат в работу освобождает захват + +- **GIVEN** запись остановлена, и признак прошлого захвата на ней стоит +- **WHEN** признак остановки снимают +- **THEN** признака захвата и срока его протухания на записи нет +- **AND** ближайший захват выдаёт запись, не дожидаясь протухания прежнего срока + +#### Scenario: Возврат в работу виден в журнале записи + +- **GIVEN** запись остановлена +- **WHEN** её возвращают в работу +- **THEN** в журнале событий записи есть событие с происхождением «человек» + +### Requirement: Конвейер ответа отправителю не шлёт + +Шаг конвейера SHALL доводить запись до достигнутого рубежа и MUST не обращаться +к отправителю вовсе — ни с готовым текстом, ни с сообщением о неудаче. Исход +своей записи владелец узнаёт **карточкой записи**; адрес карточки и содержимое +ответа нормирует capability `archive`. + +Владелец сервиса узнаёт исход журналом и журналом событий записи. Панели, где он +видел бы то же строкой таблицы, у сервиса нет — она ушла вместе со встроенным +хранилищем, и второго канала наблюдения это не отняло: журнал событий пишется +по-прежнему, а читается запросом к базе, пока экрана нет. + +Держатель нормы сменился вместе с убранным опросом готовности: прежде исход +отдавал адрес опроса, нормированный capability `intake`, и адреса этого больше +нет. Обязанность при этом не изменилась — изменилось только то, каким адресом +она исполняется. + +Требование заведено взамен доставки в чат, убранной вместе с входом Telegram. +Без него молчание конвейера читалось бы как недоделка: прежде ответ уходил, и +всякий, кто помнит это, ищет в шаге отправку, а её отсутствие принимает за +потерянную ветку. + +Инвариант проекта «Принятая запись не теряется молча» держится теперь карточкой +записи — там остановка видна признаком и причиной — и журналом владельца, где у +неё стоит причина. Обязанность при этом сменила направление: прежде об отказе +сообщали, теперь отказ доступен спросившему. Отправитель, который не +спрашивает, об остановке не узнаёт. + +Записи, которой этот канал недоступен, не бывает: у каждой записи есть владелец, +и карточка отдаёт ему её исход. Держится это обязательностью владельца в схеме — +норму держит capability `storage`. + +#### Scenario: Готовый текст отправителю не уходит + +- **GIVEN** запись дошла до конечного рубежа +- **WHEN** шаг конвейера её завершает +- **THEN** ни одного обращения наружу с текстом расшифровки не уходит +- **AND** текст достаётся отдельным адресом текста записи + +#### Scenario: Остановка видна карточкой, а не сообщением + +- **GIVEN** запись остановлена по исчерпании отказов +- **WHEN** владелец записи спрашивает её карточку +- **THEN** ответ несёт достигнутый рубеж, признак остановки и её причину +- **AND** в журнале владельца сервиса есть запись об остановке с причиной diff --git a/openspec/changes/archive/2026-08-23-storage-without-pocketbase/specs/recognition/spec.md b/openspec/changes/archive/2026-08-23-storage-without-pocketbase/specs/recognition/spec.md new file mode 100644 index 0000000..7a78a36 --- /dev/null +++ b/openspec/changes/archive/2026-08-23-storage-without-pocketbase/specs/recognition/spec.md @@ -0,0 +1,56 @@ +## MODIFIED Requirements + +### Requirement: Сырой ответ провайдера сохраняется целиком + +Сервис SHALL сохранять ответ распознавателя целиком, в том виде, в каком он +пришёл, и MUST хранить его **отдельным файлом в каталоге данных**, а не колонкой +строки попытки. + +Хранится он потому, что **результат операции у провайдера не переспрашивается**: +связь реплики с говорящим сервис строить пока не умеет, и когда научится, архив +пересчитается из сохранённого без повторной оплаты. + +Файлом, а не колонкой, — потому что шаг опроса читает строку попытки часто, а +репозиторий читает строку целиком: ответ на многочасовую запись, положенный +колонкой, ехал бы в память при каждом опросе. Где именно этот файл лежит, +нормирует capability `storage`, требование «Файл записи живёт в хранилище»: +третьим файлом в подкаталоге записи, наравне с копиями аудио. Копией аудио он при +этом не считается — их у записи по-прежнему две. + +Чтение строки попытки шагом опроса MUST не тянуть за собой сохранённый ответ. + +Сохранённый ответ — это полный текст речи, и закрыт он MUST быть наравне с самой +записью. Закрытость MUST держаться **проверкой владельца в обработчике сервиса**: +адреса, которым сохранённый ответ читают снаружи, сервис MUST не заводить вовсе, а +всякий адрес, отдающий содержимое записи, MUST судить владельца связанной +аудиозаписи сам, при каждом обращении. Пометка поля защищённым и правило +просмотра коллекции, которыми закрытость держалась прежде, — механизмы +встроенного хранилища, и их не остаётся; норма от этого не ослабла, а перестала +зависеть от настройки, которую мы не писали. + +Путь к файлу сохранённого ответа MUST не попадать ни в журнал, ни в метку +метрики, ни в ответ отправителю. + +Норму держит capability `storage`, требование «Содержимое записи закрыто везде, +где лежит»; здесь она названа потому, что попытка распознавания — то место, куда +содержимое приезжает впервые. + +#### Scenario: Ответ сохранён и читается позже + +- **GIVEN** распознавание завершилось и ответ провайдера получен +- **WHEN** запись доходит до конечного рубежа +- **THEN** сохранённый ответ доступен по строке попытки целиком + +#### Scenario: Опрос не тянет сохранённый ответ + +- **GIVEN** у попытки распознавания есть сохранённый ответ +- **WHEN** шаг опроса читает строку попытки +- **THEN** сохранённый ответ в память при этом не читается + +#### Scenario: Адреса чтения сохранённого ответа у сервиса нет + +- **GIVEN** запись принята одним узнанным и прошла распознавание +- **WHEN** другой узнанный ищет адрес, которым читается сохранённый ответ этой + записи +- **THEN** такого адреса у сервиса нет +- **AND** содержимого он не получает diff --git a/openspec/changes/archive/2026-08-23-storage-without-pocketbase/specs/storage/spec.md b/openspec/changes/archive/2026-08-23-storage-without-pocketbase/specs/storage/spec.md new file mode 100644 index 0000000..14d8b44 --- /dev/null +++ b/openspec/changes/archive/2026-08-23-storage-without-pocketbase/specs/storage/spec.md @@ -0,0 +1,743 @@ +## ADDED Requirements + +### Requirement: База принимает одного писателя + +Сервис SHALL держать у базы **одно** соединение для записи, а чтение MUST вести +отдельно от него. Журнал упреждающей записи MUST быть включён, принудительное +соблюдение внешних ключей MUST быть включено, а ожидание занятой базы MUST +задаваться числом, а не оставаться умолчанием драйвера. + +Все три настройки MUST задаваться **строкой подключения обоих пулов** — и +пишущего, и читающего, — а не отдельным запросом после открытия. Соблюдение +внешних ключей в SQLite — настройка соединения, а не базы, и по умолчанию она +выключена: `PRAGMA foreign_keys` на свежем соединении отвечает `0`. Пул раздаёт +соединения и заводит новые по мере надобности, поэтому запрос, выполненный один +раз после открытия, настраивает одно соединение из многих, а остальные остаются с +умолчанием — молча. На включённых внешних ключах держатся требования «Учётная +запись с записями не удаляется», «Владелец, которого нет, не принимается» и «Файл +без владельца не сохраняется»: с выключенными все три зеленеют на том соединении, +где настройку успели поставить, и не работают на соседнем. + +Требование заводится потому, что эту настройку прежде держала за нас чужая +библиотека двумя пулами. Драйвер пишет единственным соединением: несколько +воркеров, пишущих разом мимо этого правила, получают отказ «база занята» — и +получают его на записи результата шага, то есть после оплаченной работы. + +**Всякая операция, которая читает и следом пишет, MUST идти целиком на пишущем +соединении** — и чтение, и запись, и объемлющая их транзакция. Транзакция, +начатая на читающем соединении и позже пытающаяся писать, получает отказ по +занятости **немедленно**: повысить начатую читающую транзакцию до пишущей SQLite +не даёт, и заданное числом ожидание такой отказ не лечит — ждать там нечего. Под +правило подпадают захват записи и накат шага схемы: каждый читает состояние, +которое сам же меняет. + +**Узнавание под правило не подпадает, и устроено оно двумя соединениями.** +Поиск учётной записи по логину у провайдера MUST идти читающим пулом, а пишущая +транзакция MUST открываться только тогда, когда запись не нашлась. Слой +узнавания одет на весь узнанный поток — опрос карточки и каждый запрос диапазона +при проигрывании, — а заводится учётная запись один раз за жизнь человека: +пишущая транзакция, взятая до поиска, ставила бы весь этот поток в очередь к +единственному пишущему соединению. Очередь эта ожиданием занятой базы не +ограничена и отказом не кончается — обращение просто ждёт, и сотни миллисекунд +ожидания видны только замером. + +Окно между двумя соединениями MUST закрываться **повторным поиском внутри +транзакции**: пока её ждали, запись успевает завести сосед, и найденную надо +взять, а не заводить вторую. Уникальность ключа при этом держит схема, а не +порядок обращений. + +Значения ожидания и числа соединений MUST жить там, где проект держит числовые +настройки, и MUST не повторяться второй константой рядом. + +#### Scenario: Несколько воркеров пишут разом + +- **GIVEN** число рабочих потоков конвейера больше одного +- **AND** все они дошли до записи своего результата одновременно +- **WHEN** результаты записываются +- **THEN** каждый записан, и ни один не отказал по занятости базы + +#### Scenario: Настройки базы применены при подъёме + +- **WHEN** сервис поднялся на чистом каталоге данных +- **THEN** у базы включён журнал упреждающей записи +- **AND** ожидание занятой базы равно объявленному числу + +#### Scenario: Внешние ключи включены на соединении читающего пула + +- **GIVEN** сервис поднялся на чистом каталоге данных +- **WHEN** соединение берут из читающего пула и спрашивают у него `PRAGMA + foreign_keys` +- **THEN** ответ — `1` + +#### Scenario: Узнавание известного не ждёт писателя + +- **GIVEN** учётная запись с этим логином уже заведена +- **AND** пишущее соединение занято открытой транзакцией +- **WHEN** приходит следующее обращение тем же логином +- **THEN** учётная запись узнана, и обращение не ждёт освобождения писателя +- **AND** второй учётной записи не заведено + +#### Scenario: Составная операция не отказывает по занятости + +- **GIVEN** число рабочих потоков конвейера больше одного +- **AND** каждый выполняет операцию, которая читает состояние записи и следом его + пишет +- **WHEN** операции идут одновременно +- **THEN** каждая завершена, и ни одна не отказала по занятости базы + +### Requirement: Время и идентификаторы приходят из одного места + +Хранилище SHALL держать **все** колонки времени одним представлением: `TEXT` в +RFC 3339, UTC, с суффиксом `Z` и секундной точностью — `2006-01-02T15:04:05Z`. +Второго вида времени в схеме MUST не заводиться, включая колонки, которые пишет +только сам сервис. + +Ширина такой записи постоянная, поэтому лексикографический порядок `TEXT` +совпадает с хронологией, и отбор по колонке времени работает без разбора +значения. Своего типа времени у SQLite нет: колонка хранит то, что в неё +положили, а отбор захвата сравнивает строки — колонка, заполненная то одним +видом, то другим, обращает условие срока протухания в постоянную истину или ложь +молча, и запись не выдаётся ни одному воркеру никогда. + +**Время ставит приложение, а не умолчание шага схемы**, и берёт оно его из единой +точки чтения времени, которую держит линтер проекта. Умолчаний вида +`CURRENT_TIMESTAMP` в схеме MUST не заводиться. Выбрано так по двум причинам: +умолчание схемы пишет свой вид времени, отличный от объявленного выше, и вставка, +забывшая проставить время, при умолчании проходит молча, а без него падает +громко. Прежнее расхождение — вид времени задавало встроенное хранилище своим +форматом с пробелом и долями секунды — уходит вместе с ним, и правило остаётся +одно. + +Идентификатор строки SHALL быть **ULID в нижнем регистре, колонкой `TEXT`**, и +ставить его MUST приложение единой точкой при заведении строки. Это то, что +конвенция проекта объявляет нормой; расхождение, при котором идентификаторы +выдавало встроенное хранилище собственным алфавитом, уходит вместе с ним. +Идентификатор, пришедший снаружи, MUST разбираться на границе — разбор проверяет +вид и приводит регистр, — а каким кодом отвечает негодный, нормирует capability +`archive`. + +#### Scenario: Вид времени один на все колонки + +- **GIVEN** сервис поднялся на чистом каталоге данных +- **WHEN** смотрят колонки времени в применённой схеме +- **THEN** все они объявлены одним типом и несут время одним видом +- **AND** умолчания времени ни у одной из них нет + +#### Scenario: Строка из приёма и строка из запроса к базе отбираются одинаково + +- **GIVEN** одна аудиозапись заведена приёмом, а вторая — запросом к базе руками +- **AND** обе стоят на одном рубеже и пригодны к захвату +- **WHEN** воркеры разбирают очередь +- **THEN** захвату выдаются обе +- **AND** ни одна не остаётся в очереди навсегда + + +### Requirement: Содержимое записи закрыто везде, где лежит + +Всякая таблица, куда переезжает содержимое аудиозаписи, SHALL быть закрыта +наравне с самой записью: сервис MUST не заводить ни одного адреса, которым её +строки перечисляются или читаются мимо проверки владельца связанной записи. + +Требование распространяется на все приложения записи — тексты, структуру реплик, +попытку распознавания с её сохранённым ответом, журнал событий и темы — и +заводится потому, что содержимое лежит не в одной строке, а в нескольких. +Правило одно на все: записанное у одного хранителя, у остальных оно читалось бы +как снятое. + +Сохранённый ответ провайдера — это полный текст речи, и он MUST быть закрыт +наравне с расшифровкой, а не считаться служебным вложением. Где именно он лежит, +нормирует требование «Файл записи живёт в хранилище»: третьим файлом в +подкаталоге записи. + +Ссылка или путь, по которому содержимое лежит на диске, MUST не попадать ни в +журнал, ни в метку метрики, ни в ответ отправителю — теми же словами, какими это +нормировано для файла записи. + +Требование заменяет прежнее «Содержимое записи закрыто во всех коллекциях, где +лежит»: правил доступа у коллекций и защищённых полей больше нет, а закрытость +держится тем, что адреса чтения содержимого пишет сервис и каждый из них судит +владельца. + +#### Scenario: Чужой сохранённый ответ не отдаётся + +- **GIVEN** запись принята одним узнанным и прошла распознавание +- **WHEN** другой узнанный просит сохранённый ответ провайдера по этой записи +- **THEN** содержимого он не получает + +#### Scenario: Неузнанному содержимое не отдаётся + +- **WHEN** содержимое записи запрашивают неузнанным +- **THEN** приходит отказ, а содержимого в ответе нет + +#### Scenario: Перечисления приложений записи не существует + +- **WHEN** ищут адрес, которым перечисляются строки текстов, реплик или попыток + распознавания +- **THEN** такого адреса у сервиса нет + +## MODIFIED Requirements + +### Requirement: Сервис поднимается на чистом каталоге данных + +Сервис SHALL приводить хранилище в рабочий вид сам: на пустом каталоге данных он +MUST завести свою схему и принимать записи своим входом — приёмом по HTTP — без +единого ручного шага до первого запуска. + +Прежние данные не переносятся. Каталог, оставшийся от прежней раскладки, MUST не +читаться и не считаться источником: сервис начинает с чистого листа, и это +решение задачи, а не следствие отказа. + +Схема MUST заводиться версионированными шагами, а применённый шаг MUST не +переписываться — только новым шагом. Иначе повторный запуск на уже заведённом +каталоге разошёлся бы с первым молча. Применённые шаги MUST учитываться самой +базой, а не порядком файлов на диске. + +Применение шага и запись отметки о нём MUST идти **одной транзакцией**. Процесс, +оборванный между ними, оставляет базу со шагом, который применён и не отмечен, а +следующий запуск применяет его второй раз — и второе применение отказывает на +заведённой таблице, роняя старт на шаге, который на самом деле цел. + +Накат MUST держаться **исключающей блокировкой базы** на всё своё время: второй +процесс, поднятый на том же каталоге данных, MUST ждать её освобождения либо +отказать, а не применять шаги параллельно. Каталог данных один, а запусков на нём +бывает два — старый экземпляр ещё не остановлен, новый уже поднят, — и два +наката, разошедшихся на одном шаге, оставляют схему в состоянии, которого не +описывает ни один шаг. + +Порядок шагов MUST быть детерминирован и выводиться из **версии самого шага**, а +не из порядка чтения каталога: порядка обхода файловая система не обещает, а +разошедшийся порядок шагов виден только на чистой базе, которую заводят один раз. +Две одинаковых версии MUST давать отказ, а не молчаливый выбор одного из шагов. + +**Схема MUST накатываться до подъёма входов и до старта воркеров**, а отказ шага +MUST ронять старт. Сервис, поднявшийся на неприведённой схеме, отвечает отказом +на каждый запрос и на каждый прогон воркера — вместо одной строки о причине их +становятся сотни, и первопричина в них теряется. + +Каталог данных у сервиса MUST быть один: база и файлы записей лежат под ним +вместе, и второго пути к ним не заводится. + +#### Scenario: Первый запуск на пустом каталоге + +- **GIVEN** каталог данных пуст +- **WHEN** сервис запускается +- **THEN** он заводит своё хранилище и продолжает работу +- **AND** принятая следом запись доходит до состояния `done` + +#### Scenario: Повторный запуск на заведённом каталоге + +- **GIVEN** сервис уже запускался на этом каталоге и завёл хранилище +- **WHEN** он запускается снова +- **THEN** он не заводит схему второй раз и не теряет прежних записей + +#### Scenario: Схема накатана до первой строки о готовности + +- **GIVEN** каталог данных пуст +- **WHEN** сервис запускается +- **THEN** до строки журнала о готовности схема приведена целиком +- **AND** ни одного отказа в журнале до неё нет + +#### Scenario: Старт, оборванный между шагом и отметкой о нём + +- **GIVEN** запуск оборван после применения шага схемы и до записи отметки о нём +- **WHEN** сервис запускается снова +- **THEN** исход тот же, что и у необорванного запуска, либо отказ, называющий + шаг +- **AND** шаг не применяется второй раз + +#### Scenario: Отказ шага схемы роняет старт + +- **GIVEN** шаг схемы не применяется +- **WHEN** сервис запускается +- **THEN** старт кончается отказом, называющим шаг +- **AND** ни один вход не поднят + +### Requirement: Файл записи живёт в хранилище + +Сервис SHALL держать файл записи в своём каталоге данных, и раскладку этого +каталога MUST задавать он сам. Файл MUST адресоваться записью, которой +принадлежит, а не путём на диске: шаг конвейера просит файл у записи и получает +его, ничего не зная о раскладке. + +Раскладка MUST держать копии одной записи вместе — под её идентификатором, — и +MUST давать убрать запись целиком одним движением, не перебирая имена по маске. +Плоского каталога, где копии различаются приставкой в имени, MUST не +заводиться. + +Содержимое записи MUST не читаться в память целиком ни при укладке, ни при +чтении: расчётный потолок записи — шесть часов, и такая запись в память не +помещается. + +**Укладка MUST быть атомарной:** содержимое пишется во временное имя **в том же +подкаталоге записи** и переименовывается в рабочее только после того, как поток +дочитан до конца без отказа. Временное имя берётся в том же каталоге потому, что +переименование в его пределах не копирует содержимое и не может оборваться на +середине. + +Порядок MUST быть один: строка о файле заводится **после** того, как содержимое +лежит целиком под рабочим именем. Обратный порядок оставляет в базе строку, +указывающую на файл, которого ещё нет или который короче принятого. + +Средство обнаружить усечение у сервиса одно, и оно снято намеренно: величины +записи со строкой файла не сверяются — так требует «Аудиозапись — центральная +сущность хранилища», — а другого не заведено. Усечённая запись поэтому уезжает в +конвейер, оплачивает распознавание и отдаёт расшифровку половины как готовый +результат. Атомарная укладка — единственное, что этого не допускает. + +Отсюда две нормы о неудачах: + +- содержимое легло, а сохранение самой аудиозаписи отказало — уложенный файл MUST + быть убран, и строки о нём MUST не остаться. Файл, переживший свою запись, — + штатное состояние только у приведённой копии, которую заводит шаг конвейера; у + принятой копии это мусор, на который не ссылается ничто и о котором узнать + неоткуда; +- отмена контекста посреди укладки MUST кончаться тем же исходом, что и отказ + источника: временного имени не остаётся, рабочего имени не появляется, строки о + файле нет. Записи, наполовину принятой, человек не видит. + +Сохранённый ответ провайдера распознавания MUST лежать **третьим файлом в том же +подкаталоге записи**, под именем, которое задаёт сервис. Колонкой строки попытки +он ехал бы в память при каждом опросе готовности — этого capability `recognition` +избегает намеренно; отдельной таблицей он завёл бы второй путь чтения содержимого +и остался бы в базе, которую сервис держит узкой. Третьим файлом он попадает под +ту же атомарную укладку и под ту же уборку записи одним движением, что и копии +аудио. + +Копией аудио сохранённый ответ при этом MUST не считаться: копий у записи +по-прежнему две — принятая и приведённая, — и перечень копий, которые сервис +отдаёт адресом приложения, этим не расширяется. Адреса, которым сохранённый ответ +читают снаружи, у сервиса нет вовсе. + +**Потолок размера записи MUST быть задан числом, выведенным из этого расчётного +потолка**, и задан он MUST быть везде, где иначе действует умолчание: и у тела +запроса приёма, и у всякого предела, который сервис ставит сам. Умолчания здесь +не «без предела», а величины на два-три порядка меньше нужного, и оставленные как +есть они отвергают штатную запись сервиса — приём отказывает, а уже принятая +запись исчерпывает попытки на шаге конвертации. + +Отказ по этому потолку MUST быть виден отправителю ответом, а не молчанием. + +Шаги, которым нужен файл именем на диске — конвертация и чтение метаданных +отдают его внешней программе, — MUST получать рабочую копию **одним общим +способом**, и у этого способа MUST быть единственный способ её убрать. Уборку +зовёт шаг, и звать её он MUST на любом исходе, включая отказ. Заводить копию по +месту шагам MUST не приходиться: иначе обязанность прибрать переписывается +столько раз, сколько шагов, а забытая копия — это шестичасовая запись, +оставшаяся во временном каталоге, и узнать о ней неоткуда. + +#### Scenario: Принятая запись легла в каталог данных + +- **WHEN** запись принята +- **THEN** её файл лежит в каталоге данных сервиса и связан со своей записью +- **AND** второго каталога записей рядом не появляется + +#### Scenario: Копии одной записи лежат вместе + +- **GIVEN** запись принята, приведена к рабочему формату и прошла распознавание +- **WHEN** смотрят, где лежат её файлы +- **THEN** принятая копия, приведённая копия и сохранённый ответ провайдера лежат + под идентификатором этой записи +- **AND** второго места, где лежит что-то из них, нет + +#### Scenario: Источник оборвался посреди потока + +- **GIVEN** отправитель шлёт запись и обрывает поток на середине +- **WHEN** укладка отказывает +- **THEN** строки о файле не заведено +- **AND** ни файла под рабочим именем, ни временного имени в подкаталоге записи + не остаётся + +#### Scenario: Запись длиннее умолчания принимается + +- **WHEN** сервису отдают запись длиннее всякого умолчания, действующего на пути + приёма +- **THEN** она ложится в каталог данных, а не отвергается + +#### Scenario: Шаг конвейера берёт файл по записи + +- **GIVEN** запись принята и её файл лежит в каталоге данных +- **WHEN** шаг конвейера берётся за эту запись +- **THEN** он получает файл по самой записи, а не по пути на диске + +#### Scenario: Рабочая копия убрана после отказа шага + +- **GIVEN** шагу выдана рабочая копия файла +- **WHEN** шаг завершается отказом +- **THEN** рабочей копии во временном каталоге не остаётся + +### Requirement: Файл отдаётся ссылкой + +Сервис SHALL отдавать файл записи **только её владельцу** и MUST судить владельца +сам, при каждом обращении. Отданный файл MUST совпадать с принятым по длине. + +Значения, дающего право пройти по ссылке, сервис MUST не выдавать: ни короткого +токена файла, ни подписанной ссылки со сроком. Право даёт узнавание пришедшего и +владение записью, и судится оно там же, где отдаётся файл. Отзыв доступа доходит +до файла сразу, а не через срок жизни выданного значения. + +Обращение к файлу чужой записи MUST быть неотличимо от обращения к +несуществующей — тем же кодом и тем же телом. Разница ответов превратила бы +чтение в перебор заведённых записей. + +Каким адресом файл уходит и как называется вид копии, нормирует capability +`archive`: там живут адреса приложения, и держатель нормы обязан быть один. + +Конвейер расшифровки этим не затронут: он читает файл из каталога данных, а не +по адресу приложения. + +**Путь, по которому файл лежит на диске, MUST не попадать ни в журнал, ни в +метку метрики, ни в ответ отправителю.** Имя, под которым файл лёг в каталог, из +журнала выводимо быть не должно: журнал уезжает в собранные логи, откуда строку +не убрать. + +Отсюда требование к отказам: сообщение об отказе чтения или укладки MUST не +называть ключ файла и путь к нему дословно, а отказ выгрузки во внешнее +хранилище MUST не называть полного адреса объекта. И то и другое кончается в +журнале и собирает ссылку не хуже успешного пути. + +Что именно журнал приёма пишет ради прослеживаемости, нормирует capability +`intake`. + +#### Scenario: Владелец забирает свой файл + +- **GIVEN** запись принята и её файл лежит в каталоге данных +- **WHEN** владелец записи просит её файл +- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого + +#### Scenario: Неузнанному файл не отдаётся + +- **GIVEN** запись принята и её файл лежит в каталоге данных +- **WHEN** файл просят неузнанным +- **THEN** приходит отказ, а содержимого записи в ответе нет + +#### Scenario: Значения на предъявителя сервис не выдаёт + +- **GIVEN** человек узнан +- **WHEN** ищут адрес, которым сервис выдаёт значение, открывающее файл +- **THEN** такого адреса у сервиса нет + +#### Scenario: Конвейер читает файл без узнавания + +- **GIVEN** запись принята и ждёт расшифровки +- **WHEN** шаг конвейера берётся за неё +- **THEN** файл читается из каталога данных и шаг проходит + +#### Scenario: Файл записи, которой нет + +- **WHEN** просят файл записи с неизвестным идентификатором +- **THEN** приходит отказ, а не пустой ответ + +#### Scenario: По журналу путь к файлу не собрать + +- **GIVEN** запись принята и прошла конвейер +- **WHEN** читают журнал сервиса целиком +- **THEN** имени, под которым файл лёг в каталог данных, в нём нет + +#### Scenario: Отказ чтения файла не называет его ключ + +- **GIVEN** файл записи не читается с диска +- **WHEN** шаг конвейера берётся за эту запись и отказывает +- **THEN** отказ называет запись её идентификатором и не несёт имени файла + +### Requirement: Владелец задачи лежит связью с учётной записью + +Хранилище SHALL держать владельца аудиозаписи отдельной колонкой — связью с +учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец +не назван, не достаётся никому по недосмотру схемы. + +Колонка MUST не допускать пустого значения, и MUST это держать сама схема: связь +объявлена внешним ключом на учётную запись и обязательна. Пока обязательность +жила в одном приёме, ничью запись заводили руками мимо него, она уходила в +конвейер, стоила денег на распознавание и не доставалась потом никому. + +Правку записи мимо адресов приложения сервис ничем не предоставляет: панели у +него нет. Обязательность от этого не отменяется — она перестала зависеть от того, +кто пишет, и стала свойством схемы. + +Владелец MUST не назначаться и не меняться конвейером. + +#### Scenario: Колонка появляется на пустой базе + +- **WHEN** сервис поднимается на чистом каталоге данных +- **THEN** у аудиозаписи есть колонка владельца +- **AND** умолчания у неё нет +- **AND** пустого значения она не принимает + +#### Scenario: Запись без владельца не сохраняется + +- **GIVEN** сервис поднят +- **WHEN** аудиозапись пытаются сохранить с пустым владельцем — приёмом, + конвейером или запросом к базе +- **THEN** база её не сохраняет + +#### Scenario: Владелец, которого нет, не принимается + +- **GIVEN** сервис поднят +- **WHEN** аудиозапись пытаются сохранить с владельцем, которому не отвечает ни + одна учётная запись +- **THEN** база её не сохраняет + +#### Scenario: Конвейер владельца не назначает + +- **GIVEN** запись с владельцем прошла шаг конвейера +- **WHEN** смотрят её владельца +- **THEN** он прежний + +### Requirement: Файл записи сужается владельцем наравне с задачей + +Хранилище SHALL держать владельца и у файла записи — той же связью с учётной +записью, — а отдача файла MUST пускать к нему только его владельца. + +Владелец файла MUST назначаться при приёме, из узнанного предъявителя, а колонка +файла MUST не допускать пустого значения наравне с колонкой записи. Разное +правило у записи и у её файла читалось бы как недосмотр. + +Файл, заведённый шагом конвейера, — приведённую копию заводит именно он — +MUST получать владельца своей записи. Иного источника владельца у файла нет, и +шаг, оставивший его пустым, упрётся в отказ сохранения: запись накопит отказы и +остановится признаком на первом же приведении. + +Ссылки на файлы у записи две — на принятую копию и на приведённую, — и обе живут +до конца, но владелец файла MUST по-прежнему лежать своей колонкой, а не +выводиться через запись: файл переживает свою запись, и заведённый шагом до +сохранения записи он остаётся с владельцем и без ссылки. + +Отказ наступает **на самом обращении за файлом**: другого места, где он мог бы +наступить, у сервиса не осталось — значений на предъявителя он не выдаёт. +Проверка, судящая владельца где-то ещё, зеленела бы, не касаясь пути, по +которому аудио и уходит. + +#### Scenario: Чужой файл не отдаётся + +- **GIVEN** запись принята одним узнанным +- **WHEN** другой узнанный просит файл этой записи +- **THEN** содержимого он не получает +- **AND** ответ тот же, что и на неизвестный идентификатор записи + +#### Scenario: Свой файл отдаётся + +- **GIVEN** человек принял запись +- **WHEN** он просит файл своей записи +- **THEN** содержимое отдаётся + +#### Scenario: Файл без владельца не сохраняется + +- **GIVEN** сервис поднят +- **WHEN** файл записи пытаются сохранить с пустым владельцем +- **THEN** база его не сохраняет + +#### Scenario: Приведённая копия получает владельца записи + +- **GIVEN** запись с владельцем дошла до приведения +- **WHEN** шаг заводит приведённую копию файла +- **THEN** владельцем копии стоит владелец записи +- **AND** шаг завершается без отказа + +### Requirement: Учётная запись с записями не удаляется + +Хранилище SHALL отвергать удаление учётной записи, у которой остались +аудиозаписи, файлы **либо темы словаря**, и MUST держать этот запрет самой +схемой — обязательной связью, которая не даёт убрать строку, пока на неё +ссылаются. + +Считаются **все** таблицы с колонкой владельца, и перечень их MUST жить одним +местом — шагом схемы, который эти связи объявляет. Таблица, пропущенная в счёте, +пропускает удаление вперёд и оставляет за собой строки, чей владелец больше не +существует. + +Запрет схемой, а не проверкой вызывающего, — потому что вызывающих у удаления +может стать больше одного, а проверка, записанная у одного, у остальных читалась +бы как снятая. Сборка, забывшая позвать проверку, теряет защиту молча — и теряла. + +Файл переживает свою запись: шаг конвейера заводит его до сохранения записи, и +потерянный захват оставляет файл с владельцем и без ссылки. Тема переживает её +так же: словарь принадлежит человеку, а не записи. + +Цена требования названа прямо: способа удалить записи в сервисе пока нет вовсе — +его приносит задача про удаление записи. До неё удаление учётной записи с +записями невозможно, и это осознанный тупик, а не недосмотр. Адреса, которым +учётную запись удаляют, у сервиса при этом нет: запрет закрывает удаление +запросом к базе. + +#### Scenario: Удаление учётной записи с записями отвергается + +- **GIVEN** у учётной записи есть аудиозаписи +- **WHEN** её строку удаляют +- **THEN** удаление не проходит +- **AND** записи и их владелец остаются прежними + +#### Scenario: Учётная запись с одними файлами тоже не удаляется + +- **GIVEN** у учётной записи остались файлы, но записей нет +- **WHEN** её строку удаляют +- **THEN** удаление не проходит, а владелец файлов остаётся прежним + +#### Scenario: Учётная запись с одними темами тоже не удаляется + +- **GIVEN** у учётной записи остались темы словаря, но ни записей, ни файлов нет +- **WHEN** её строку удаляют +- **THEN** удаление не проходит + +#### Scenario: Учётная запись без записей удаляется + +- **GIVEN** у учётной записи нет ни аудиозаписей, ни файлов, ни тем +- **WHEN** её строку удаляют +- **THEN** удаление проходит + +### Requirement: Аудиозапись — центральная сущность хранилища + +Хранилище SHALL держать аудиозапись отдельной таблицей, а всё, что к ней +приложено, — отдельными строками со ссылками на запись. Приложениями считаются +файлы, тексты, структура реплик, темы, журнал событий и попытка распознавания. + +Поля, которыми распоряжается очередь — признак захвата, срок его протухания, +пауза, число отказов, время входа в рубеж, — MUST не соседствовать с содержимым +записи в одной строке настолько, чтобы чтение очереди тянуло содержимое. + +Запись MUST нести заголовок и краткое описание своими колонками: они читаются +вместе со списком, сотней штук разом. Расшифровка и вычитанный текст MUST лежать +отдельными строками: они читаются по открытию одной записи. + +Тем же доводом запись MUST нести своими колонками **имя файла, данное +отправителем, длительность и размер**. Все три показываются в списке. Приём +узнаёт длительность и размер у источника метаданных и так, а имя файла приходит +вместе с записью. + +**Имена колонок и единицы измерения нормативны:** `original_filename`, +`duration_ms` (миллисекунды) и `size_bytes` (байты). Единица стоит в самом имени, +а не в комментарии: шаг схемы применённым не переписывается, а расхождение +«секунды против миллисекунд» между колонкой, ответом списка и объявленным +пределом не увидит ни компилятор, ни гейт — оба конца числа. Миллисекунды выбраны +потому, что этой единицей уже названы соседние колонки схемы. + +**Различать «неизвестно» и «ноль» эти колонки не обязаны, и это решение, а не +недосмотр.** Обе величины ставит приём, и ставит всегда — запись, метаданные +которой прочитать не удалось, отвергается отказом и не заводится вовсе. Ноль в +этих колонках означает ноль, и колонки MUST быть объявлены обязательными: пустое +значение, которое схема теперь допустить может, завело бы третий смысл, которого +никто не читает. Решение владельца 2026-08-15, и смена хранилища его не отменяет. + +Имя файла на записи и заголовок MUST лежать **разными колонками**. Заголовок +несёт название, которое дал человек либо посчитала языковая модель; имя файла — +то, по чему человек узнаёт свою запись, пока заголовка нет. Одной колонкой на оба +смысла посчитанное название затирало бы имя, и вернуть затёртое было бы неоткуда. + +Величины на записи и на её файле расходятся по смыслу, и **равенство между ними +не поддерживается никем — намеренно**. На записи лежит снимок **принятого**, +взятый приёмом один раз и больше не пересчитываемый; на файле — величины той +копии, которой файл является сейчас. Приведённая копия имеет свой размер, и +записи он не принадлежит. + +Отсюда норма, без которой два числа читались бы как копии одного: величины +записи MUST не сверяться со строкой файла и MUST не переписываться ничем после +приёма. Расхождение между ними — не поломка, а разные вопросы: «что человек +прислал» и «что лежит сейчас». + +#### Scenario: Список читается без содержимого + +- **GIVEN** у записи есть расшифровка +- **WHEN** читают запись ради её рубежа и заголовка +- **THEN** текст расшифровки при этом не читается + +#### Scenario: Длительность и размер читаются без строки файла + +- **GIVEN** запись принята +- **WHEN** читают её длительность и размер +- **THEN** строка файла при этом не читается + +#### Scenario: Пустая длительность в схему не ложится + +- **GIVEN** сервис поднят +- **WHEN** аудиозапись пытаются сохранить с пустой длительностью или пустым + размером +- **THEN** база её не сохраняет + +#### Scenario: Посчитанный заголовок не затирает имя файла + +- **GIVEN** запись принята с именем файла отправителя +- **WHEN** записи проставляют заголовок +- **THEN** имя файла остаётся прежним + +### Requirement: Словарь тем ведётся по владельцу + +Хранилище SHALL держать темы отдельной таблицей, и тема MUST быть уникальна в +паре «владелец и название»: словарь тем свой у каждого человека. У записи MUST +быть не больше пяти тем. + +Отдельной таблицей, а не набором строк в записи, — потому что перечень тем +человека нужен целиком перед каждым обращением к модели, а собрать его из +наборов строк можно только перебором всех его записей. + +Потолок в пять тем MUST держаться самой схемой: без него часовой разговор даёт +два десятка тем, и словарь распухает за неделю. То же число сервис объявляет +приложению — норму держит capability `archive`, — и второй константы рядом MUST +не заводиться. + +Название темы выведено из содержимого записи, а перечень тем человека — слепок +того, о чём он вообще говорит. В журнал сервиса темы MUST не попадать наравне с +текстом расшифровки. + +Ни один шаг этого изменения тем не пишет и не читает: место заведено вперёд, +чтобы задача, считающая темы языковой моделью, не платила вторым необратимым +шагом схемы. Цена решения названа прямо — имена таблицы и её колонок закрепляются +раньше, чем известен их потребитель. + +#### Scenario: Тема одного человека не мешает теме другого + +- **GIVEN** у двух владельцев заведена тема с одинаковым названием +- **WHEN** смотрят словарь тем +- **THEN** это две разные темы, каждая своего владельца + +#### Scenario: Шестая тема не заводится + +- **WHEN** записи назначают шестую тему +- **THEN** назначение не проходит + +## REMOVED Requirements + +### Requirement: Наружу хранилище отдаёт только то, что заказано + +**Reason**: Требование закрывало собственную поверхность встроенного хранилища — +перечисление записей коллекций, журналы запросов, резервные копии, настройки, +расписание, — которую тот публиковал тем же портом. Хранилище уходит из проекта +целиком, и поверхности этой не существует: закрывать больше нечего. + +**Migration**: Отвечает сервис теперь только своими адресами, а всё, что не +принадлежит корню приложения и не является отдельным адресом наблюдения, идёт +общим правилом неизвестного пути — норму держит capability `webapp`. Что +содержимое записи закрыто везде, где лежит, нормирует требование «Содержимое +записи закрыто везде, где лежит». + +### Requirement: Владелец видит записи в панели + +**Reason**: Панель администратора уходит вместе с хранилищем и **не заменяется +ничем** — решение владельца 2026-08-22. Требовать поведения от инструмента, +которого нет, нельзя. + +**Migration**: Возврат остановленной записи в работу делает подкоманда набора +инструментов разработчика — норму держит capability `pipeline`, требование +«Остановка записи — признак, а не рубеж»: там названы и поля, которые возврат +обязан сбросить, и обязанность писать событие журнала записи с происхождением +«человек». Прочая правка записи ждёт экранов владельца, а их приносят задачи +`audiorecord-actions` и `play-recording-in-app`. + +### Requirement: Пароль владельца от панели не лежит в конфигурации + +**Reason**: Пароля от панели больше нет — вместе с панелью. Секрет, появившийся +только ради перевода на встроенное хранилище, пропадает, и ключа под него в +конфигурации не заводится по той простой причине, что заводить нечего. + +**Migration**: Инвариант проекта «Секрет не покидает конфиг» остаётся в силе для +оставшихся секретов — ключа распознавания и пары ключей внешнего хранилища. +Приглашения завести владельца сервис больше не печатает. + +### Requirement: Содержимое записи закрыто во всех коллекциях, где лежит + +**Reason**: Требование написано словарём коллекций, правил доступа и защищённых +полей — механизмов встроенного хранилища. Механизмов этих не остаётся, а норма +остаётся: она переписана требованием «Содержимое записи закрыто везде, где +лежит». + +**Migration**: Читать норму по новому требованию. Закрытость держится тем, что +адреса чтения содержимого пишет сервис и каждый судит владельца связанной +записи, а не правилом просмотра коллекции и не пометкой поля. diff --git a/openspec/changes/archive/2026-08-23-storage-without-pocketbase/specs/webapp/spec.md b/openspec/changes/archive/2026-08-23-storage-without-pocketbase/specs/webapp/spec.md new file mode 100644 index 0000000..d12fbd8 --- /dev/null +++ b/openspec/changes/archive/2026-08-23-storage-without-pocketbase/specs/webapp/spec.md @@ -0,0 +1,178 @@ +## MODIFIED Requirements + +### Requirement: Неизвестный путь вне корней открывает приложение + +Сервис SHALL отдавать разметку приложения на всяком пути, который не принадлежит +ни одному корню сервиса и не совпадает с отдельным адресом наблюдения. Корень у +сервиса остался **один** — `/app` у приложения; отдельными адресами стоят +`/health` и `/metrics`. + +Корней `/api` и `/_` в перечне больше нет: встроенного хранилища с его +собственным пространством и панели администратора у сервиса не осталось, и +адресов под этими именами не существует. Прежние пути хранилища и панели поэтому +отвечают тем же, чем отвечает всякий путь вне корней, — разметкой приложения. +Резервировать имя за отказом сервис не берётся: имя, за которым ничего не стоит, +ничем не отличается от любого другого свободного имени, а второй перечень +«когда-то занятых корней» разошёлся бы с первым молча. + +Этим же снимается дефект подменённого знака: путь панели, записанный кодом знака, +раскодируется в тот же путь и попадает в то же правило — правило одно, и особого +случая у него нет. + +Корня `/auth` в перечне нет тоже: собственного входа у сервиса не осталось. + +Путь принадлежит корню, когда **совпадает с ним точно либо начинается им вместе с +косой чертой**. Оба условия обязательны: по одному лишь префиксу корню `/app` +достался бы посторонний `/apple`, а по одному лишь префиксу с косой чертой голый +`/app` не достался бы никому и уехал бы разметкой. + +Чем отвечает голый `/app`, названо прямо: он принадлежит корню приложения, +адресом приложения при этом не является и потому MUST отвечать как **неизвестный +путь под корнем приложения** — узнанному `404` телом отказа приложения, +неузнанному `401` тем же телом, каким отвечают прочие адреса под этим корнем. +Разметки в ответе нет ни в одном из двух случаев. Без этой строки «не разметка +приложения» читается как «что-нибудь ещё», и код ответа выбрала бы за нас первая +же сборка. + +Путь, принадлежащий корню, MUST не проваливаться в приложение никогда: отказ +контракта остаётся отказом контракта и уходит той формой, которой этот корень +отвечает и сегодня. Иначе программа, ошибшаяся адресом под корнем приложения, +получила бы разметку с кодом `200` вместо отказа с машиночитаемым кодом — и +приняла бы её за ответ. + +Путь **под каталогом ресурсов** — тем, который наполняет сборщик, — разметкой не +подменяется: не совпавший с файлом, он MUST отвечать `404`. Иначе разметка +прежней сборки, назвавшая ресурс, которого в новой сборке уже нет, получает на +него `200` и разметку вместо ресурса: браузер отвергнет её по типу содержимого, +человек увидит пустой экран, а в кодах ответов сервиса не останется ничего. + +Открывающими страницу считаются `GET` и `HEAD`, и только они; прочие методы MUST +отвечать `405`. + +#### Scenario: Обновление страницы посреди приложения открывает тот же экран + +- **GIVEN** приложение открыто на своём маршруте +- **WHEN** браузер спрашивает этот путь заново +- **THEN** ответ имеет код `200` +- **AND** тело ответа — разметка приложения + +#### Scenario: Голый корень разметкой не подменяется + +- **GIVEN** запрос идёт с заголовком, поставленным прокси +- **WHEN** запрос приходит на путь, совпадающий с корнем приложения точно и без + косой черты +- **THEN** ответ имеет код `404` +- **AND** тело ответа — отказ приложения с машиночитаемым кодом, а не разметка + +#### Scenario: Голый корень неузнанному отвечает как прочие адреса под корнем + +- **WHEN** запрос приходит без заголовка на путь, совпадающий с корнем приложения + точно и без косой черты +- **THEN** ответ имеет код `401` +- **AND** тело ответа — не разметка приложения + +#### Scenario: Посторонний путь, начинающийся именем корня, открывает приложение + +- **WHEN** запрос приходит на путь, который начинается именем корня, но не + отделён от него косой чертой +- **THEN** ответ имеет код `200` +- **AND** тело ответа — разметка приложения + +#### Scenario: Прежний адрес входа открывает приложение + +- **WHEN** запрос приходит на путь под прежним корнем входа +- **THEN** ответ имеет код `200` +- **AND** тело ответа — разметка приложения + +#### Scenario: Прежний путь хранилища открывает приложение + +- **WHEN** запрос приходит на путь под прежним корнем хранилища +- **THEN** ответ имеет код `200` +- **AND** тело ответа — разметка приложения + +#### Scenario: Прежний адрес панели открывает приложение + +- **WHEN** запрос приходит на прежний адрес панели — и записанный знаком, и + записанный кодом этого знака +- **THEN** оба ответа имеют код `200` +- **AND** тело каждого — разметка приложения + +#### Scenario: Неизвестный путь под корнем приложения отвечает отказом + +- **GIVEN** запрос идёт с заголовком, поставленным прокси +- **WHEN** он спрашивает неизвестный путь под корнем приложения +- **THEN** ответ имеет код `404` +- **AND** тело ответа — отказ приложения с машиночитаемым кодом, а не разметка + +#### Scenario: Неизвестный путь под корнем приложения неузнанному отвечает как все прочие его адреса + +- **WHEN** запрос приходит на неизвестный путь под корнем приложения без + заголовка +- **THEN** ответ имеет код `401` +- **AND** тело ответа — не разметка приложения + +#### Scenario: Несуществующий ресурс отвечает отсутствием, а не разметкой + +- **WHEN** браузер спрашивает под каталогом ресурсов файл, которого в сборке нет +- **THEN** ответ имеет код `404` +- **AND** тело ответа — не разметка приложения + +### Requirement: Путь, отданный приложению, в журнал не идёт + +Сервис SHALL записывать о запросе **маршрут из закрытого перечня** и длину пути, +а самого запрошенного пути MUST не записывать ни в один свой журнал. То же +относится к меткам метрик. Перечень маршрутов закрыт и назван: точные адреса +наблюдения и объявленные образцы адресов приложения; всё, что ни одному из них +не отвечает, MUST обозначаться одним общим значением. Для запроса, отданного +приложению, к строке добавляется **исход из закрытого перечня** — разметка, +ресурс, отказ. + +Правило MUST накрывать обе половины адресного пространства — и путь вне корней +сервиса, и путь под корнем приложения. Принадлежность пути сервису тут ничего не +меняет: `/app/<произвольный текст>` принадлежит сервису и отвечает отказом, но +множеством значений под корнем распоряжается спрашивающий ровно так же, как и +вне его. + +Причина в том, кто этот путь выбирает. До появления раздачи путь вне корней +ловил отказ маршрутизатора; теперь он успешный ответ, и множеством его значений +распоряжается спрашивающий — дословная запись сделала бы журнал местом, куда +аноним пишет свой текст произвольной длины. Правило того же рода у сервиса уже +есть: причина отказа, пришедшая от провайдера строкой запроса, приводится к +перечню известных. + +Идентификатор записи от этого не пропадает: его пишет обработчик своим полем, и +пишет он прочитанный идентификатор, а не тот, что стоял в запросе. + +Журнал у сервиса теперь **один**: второй, куда чужая библиотека клала путь целиком +вместе с адресом отправителя, ушёл вместе с ней. Правило от этого не ослабло, а +перестало зависеть от настройки чужого журнала, которую мы не писали. + +#### Scenario: Путь не доезжает до журнала + +- **WHEN** приходит запрос на путь вне корней сервиса +- **THEN** записи о нём не несут этого пути +- **AND** несут исход и длину пути + +#### Scenario: Длинный путь журнал не наполняет + +- **WHEN** приходит запрос на путь длиной в тысячу знаков +- **THEN** записи о нём не растут вместе с длиной пути + +#### Scenario: Путь под корнем приложения журнал не пишет + +- **GIVEN** пришедший не узнан +- **WHEN** он спрашивает под корнем приложения путь, не отвечающий ни одному + объявленному образцу адреса +- **THEN** записи о нём не несут этого пути +- **AND** не растут вместе с его длиной + +#### Scenario: Объявленный образец адреса приложения в журнале различим + +- **WHEN** приходит запрос на объявленный адрес приложения +- **THEN** запись о нём несёт образец этого адреса, а не запрошенный путь + +#### Scenario: Второго журнала у сервиса нет + +- **GIVEN** сервис поднялся +- **WHEN** приходит запрос на путь вне корней сервиса +- **THEN** запись о нём появляется только в журнале сервиса diff --git a/openspec/changes/archive/2026-08-23-storage-without-pocketbase/tasks.md b/openspec/changes/archive/2026-08-23-storage-without-pocketbase/tasks.md new file mode 100644 index 0000000..e569e6e --- /dev/null +++ b/openspec/changes/archive/2026-08-23-storage-without-pocketbase/tasks.md @@ -0,0 +1,230 @@ +## Критерии приёмки + +Скопированы из записи задачи `storage-without-pocketbase` дословно: запись +закрытие удалит, критерии обязаны её пережить. + +- Библиотеки нет в сборке. **Оракул:** `go list -deps ./... | grep -c pocketbase` + даёт `0`, а `go mod tidy` не возвращает её модулей в `go.mod`. +- Чужой файл недостижим по прямой ссылке. **Оракул:** тест контроллера — запрос + к файлу чужой записи и к несуществующей отвечает одинаково. +- Захват записи остаётся неделимым. **Оракул:** тест с параллельными воркерами + под `-race` — запись достаётся ровно одному, второй получает отказ по значению + признака захвата. +- Пространства хранилища не существует. **Оракул:** тест маршрутов — `/api/…`, + `/_/` и `/%5f/` отвечают тем же, чем всякий неизвестный путь. +- Схема накатывается на пустом каталоге до старта воркеров. **Оракул:** запуск на + чистом каталоге данных — ни одного отказа в журнале до первой строки о готовности. + +Ниже — рубрика ревью дизайна. Приёмка судится по одному списку, поэтому она +стоит здесь же, а не отдельным документом. + +- Ограничения схемы действуют при любом способе записи и на каждом соединении + пула. **Оракул:** соединение, взятое из читающего пула, отвечает `1` на `PRAGMA + foreign_keys`; вставка аудиозаписи с несуществующим владельцем отвергается базой + и на пишущем соединении, и на читающем. +- Захват неделим, а держатель узнаётся значением признака. **Оракул:** тест под + `-race` с несколькими воркерами — запись достаётся ровно одному; запись + результата с чужим значением признака не проходит и записи не меняет. +- Узел, читающий состояние, которое сам же меняет, называет неделимый шаг. + **Оракул:** захват записи, заведение учётной записи первым обращением и накат + шага схемы идут одним запросом либо одной транзакцией на пишущем соединении; + тест на два одновременных первых обращения даёт ровно одну строку пользователя. +- Границы транзакции названы, и отмена контекста оставляет запись либо прежней, + либо полной. **Оракул:** тест с отменой контекста посреди составной операции — + читающий следом видит либо всё прежнее, либо всё новое, и ни одной половины. +- Накат схемы идемпотентен и не оставляет полуприменённого состояния. + **Оракул:** второй запуск на заведённом каталоге не применяет ни одного шага; + запуск, оборванный между применением шага и отметкой о нём, при повторе даёт тот + же исход либо отказ с именем шага, но не удвоенное применение. +- Укладка файла атомарна, и неполная укладка не выдаёт себя за полную. + **Оракул:** источник, отдающий отказ на середине потока, не оставляет ни строки + о файле, ни файла под рабочим именем, ни временного имени в подкаталоге записи. +- Время, идентификаторы и их вид приходят из одного места. **Оракул:** все колонки + времени применённой схемы объявлены одним типом и без умолчания; строка, + заведённая приёмом, и строка, заведённая запросом к базе, попадают в отбор + захвата одинаково. +- Отдача файла одинаково отвечает на чужое и несуществующее, а размер ответа + ограничен запрошенным. **Оракул:** тест обработчика — чужая запись, + несуществующая запись и негодное значение параметра `copy` у чужой записи дают + один код и одно тело; запрос с диапазоном отдаёт длину запрошенного куска, а не + файла целиком; настоящий HTTP-запрос с `Range: bytes=99999999-` и запрос с двумя + диапазонами дают код `400` и тело с полями `error_code` и `message`, а не `416` + телом библиотеки. +- Отказы, рождающиеся не в обработчике, приходят той же формой, что и отказы + обработчика. **Оракул:** настоящий HTTP-запрос через поднятую цепочку слоёв — + предел тела, ограничитель частоты и неизвестный путь под корнем приложения дают + тело с полями `error_code` и `message` и код из закрытого перечня; вызовом + отображателя ошибки это не проверяется. +- Ключ ограничителя частоты называет того, кого надо ограничить. **Оракул:** два + клиентских адреса через один доверенный прокси расходуют разные бюджеты, а + заголовок пересылки, пришедший с недоверенного адреса, на ключ бюджета не + влияет. +- Горячие выборки опираются на индекс. **Оракул:** `EXPLAIN QUERY PLAN` отбора + захвата и `EXPLAIN QUERY PLAN` списка, сужаемого владельцем и страницей, не + показывают полного сканирования таблицы аудиозаписей. +- Подъём и остановка симметричны и громкие. **Оракул:** отказ шага подъёма даёт + ненулевой код выхода и ровно одну строку журнала о причине; мягкая остановка + закрывает то же, что открыл подъём, — оба пула базы, входы и воркеров, — и + повторная остановка не даёт паники. + +## 1. База и её схема + +- [x] 1.1 Завести подключение к базе на `modernc.org/sqlite`: одно соединение для + записи, отдельный пул для чтения, журнал упреждающей записи, ожидание + занятой базы числом из настроек +- [x] 1.2 Подключить `github.com/pressly/goose/v3` библиотекой: добавить модуль в + `go.mod`, завести каталог шагов, вшитый в бинарник, и поднимать провайдер в + точке входа. Командная строка `goose` не заводится: ни отдельного + исполняемого файла, ни своего шага выкладки +- [x] 1.3 Взять исключающую блокировку наката самим: `goose` под SQLite её не + поставляет — запиратели у него только для PostgreSQL. Замок на файле в + каталоге данных (`syscall.Flock`, `LOCK_EX`) берётся до наката и снимается + после; проверить тестом, что второй накат на том же каталоге ждёт либо + отказывает, а шаги параллельно не применяются +- [x] 1.4 Удалить каталог шагов PocketBase целиком — + `internal/adapter/repo/pocketbase/migrations` +- [x] 1.5 Завести один шаг начальной схемы в новом каталоге шагов: разовое снятие + инварианта «применённая миграция не переписывается» решением владельца + 2026-08-22 +- [x] 1.6 Перевести ключ `[docs] migrations` в `.av-dev.toml` на новый каталог + шагов. Без этого шаг гейта `migrations` покраснеет на удалении файлов + прежнего каталога — он читает их статусом `D` как переписанный применённый + шаг +- [x] 1.7 Написать шаги схемы под все таблицы — учётные записи, аудиозаписи, + файлы, тексты, структура реплик, попытки распознавания, журнал событий, + темы — с обязательными связями владельца и обязательными колонками + `original_filename`, `duration_ms`, `size_bytes` +- [x] 1.8 Завести тем же шагом два индекса аудиозаписей: под отбор захвата — по + рубежу, признаку остановки, паузе и сроку протухания захвата; под список — + по владельцу и колонке упорядочивания страницы вместе с ключом записи. + Индексы заводятся здесь, а не потом: применённый шаг схемы не + переписывается, и добавление индекса будет стоить отдельного шага, а замер + 2026-08-15 уже показывал полное сканирование на выборке, сужаемой владельцем +- [x] 1.9 Накатывать схему до подъёма входов и до старта воркеров, отказ шага + ронять стартом с именем шага +- [x] 1.10 Записать числа настроек базы в `docs/database.md`, «Настройки с + числовым значением» +- [x] 1.11 Завести в `config.example.toml` два ключа секции `[storage]` — + `busy_timeout_ms` (ожидание занятой базы, миллисекунды) и `read_connections` + (число соединений читающего пула) — с комментарием на каждый: зачем, + границы, единицы; проверить, что загрузчик их читает и старт на пустом + значении не молчит +- [x] 1.12 Снять `EXPLAIN QUERY PLAN` с отбора захвата и со списка, сужаемого + владельцем и страницей: полного сканирования таблицы аудиозаписей ни один + из планов не показывает + +## 2. Репозитории на своей базе + +- [x] 2.1 Переписать репозиторий аудиозаписи: чтение, сохранение своих полей, + условная запись результата по признаку захвата +- [x] 2.2 Переписать захват одним запросом с `RETURNING`, возвращающим + идентификатор записи и признак этого захвата +- [x] 2.3 Переписать репозитории приложений записи — тексты, структура реплик, + попытки распознавания, журнал событий, темы — с уникальностью по паре + «запись и вид» и «запись и версия разбора» +- [x] 2.4 Переписать репозиторий учётных записей: поиск по ключу, заведение при + первом обращении, разбор двух отказов уникальности +- [x] 2.5 Проверить тестом, что пустая замена не стирает сохранённый текст и + сохранённый ответ провайдера +- [x] 2.6 Отображать сущности в строки базы **по имени**: именованные параметры + запроса и сканирование по имени колонки, без позиционных списков. У + аудиозаписи ссылки на файлы, на структуру реплик, на два вида текста и на + попытку распознавания стоят подряд полями одного типа, и позиционный сдвиг + на одно поле скомпилировался бы молча, положив идентификатор файла в колонку + текста +- [x] 2.7 Убрать поля `location` у сущности файла и `source` у аудиозаписи — из + домена, из отображения в строки базы и из шага начальной схемы. Обе пишутся + сегодня одним значением и не читаются никем, а второе значение `source` + держалось ссылкой из применённого шага, который уходит. Вернуть поле, когда + у него появится читатель, будет стоить одного нового шага схемы + +## 3. Файлы записей своим каталогом + +- [x] 3.1 Завести раскладку: подкаталог на запись под её идентификатором, имя + файла задаёт сервис, копии `original` и `normalized` лежат вместе +- [x] 3.2 Переписать репозиторий файлов: укладка потоком без чтения в память, + чтение потоком, владелец колонкой, ссылки на две копии порознь +- [x] 3.3 Сохранить единый способ выдать рабочую копию шагу и единственный + способ её убрать +- [x] 3.4 Проверить тестом, что имени отправителя нет ни в имени файла, ни в + пути к нему, ни в журнале + +## 4. Маршруты и слои на `net/http` + +- [x] 4.1 Переписать подъём сервера и цепочку слоёв без чужого маршрутизатора, + сохранив порядок «ограничитель частоты → узнавание» +- [x] 4.2 Написать свой ограничитель частоты по адресу спрашивающего под корнем + приложения и вывести объявляемую частоту опроса из доли его бюджета +- [x] 4.3 Переписать узнавание по доверенному заголовку на своих типах, убрав + выдачу и приём всякого значения на предъявителя +- [x] 4.4 Переписать обработчики приёма, списка, карточки, текста, пределов и + «кто вошёл» на своих типах +- [x] 4.5 Завести обработчик отдачи файла `GET /app/audiorecords/{id}/file` с + проверкой владельца, параметром `copy` и закрытым перечнем его значений, + кодом `409` на отсутствующую копию и выдачей по диапазону. Негодный диапазон + — неудовлетворимый и множественный — приводить к обычному отказу `400` с + телом сервиса, а не отдавать `416` телом библиотеки +- [x] 4.6 Свести адресное пространство сервиса к одному корню `/app` плюс + `/health` и `/metrics`; проверить тестом ответы на `/api/…`, `/_/` и `/%5f/` +- [x] 4.7 Убрать ветвь отказа `403` и значение `forbidden` из перечня кодов + отказа вместе с её единственным случаем + +## 5. Уборка библиотеки + +- [x] 5.1 Удалить пакет адаптера хранилища вместе с панелью и правилами панели; + каталог его шагов схемы уходит пунктом 1.4 +- [x] 5.2 Убрать из настроек и из образца конфига всё, что относилось к панели и + к её владельцу +- [x] 5.3 Убрать библиотеку и достижимые только через неё модули из `go.mod`, + прогнать `go mod tidy` +- [x] 5.4 Проверить оракулом, что `go list -deps ./... | grep -c pocketbase` + даёт `0` +- [x] 5.5 Снять изъятие правил `internal/archrules`, разрешавшее транспорту + знать адаптер хранилища, и добавить правило на это направление +- [x] 5.6 Переписать правила `internal/archrules` о колонках записи + (`TestКолонкиЗаписиПишутсяИЧитаются`, + `TestКолонкиЗаписиЗаведеныШагомСхемы`) и о едином дескрипторе рубежа + (`TestОтборСпискаБерётРубежиУДескриптора`, + `TestУКаждогоРабочегоРубежаЕстьШаг`, `TestШагиОбъявленыРубежамиДескриптора`) + под новую форму хранилища: сегодня они построены на динамической записи по + имени колонки в API уходящей библиотеки и указывают в удаляемый пакет. + Форма выбрана — именованные параметры и сканирование по имени (пункт 2.6), — + и сторожа инварианта о колонках записи переписываются под неё: они сверяют + **имена** колонок в отображении, в чтении и в шаге начальной схемы, а не + порядок полей. Указывают правила в новый пакет `internal/adapter/repo/sqlite` + и в новый каталог шагов + +## 6. Оснастка владельца + +- [x] 6.1 Завести в `cmd/devtools` подкоманду возврата остановленной записи в + работу: принимает идентификатор записи, открывает базу каталога данных, + зовёт домен и пишет событие журнала записи с происхождением + `entity.EventOriginHuman`. Колонок подкоманда не пишет: перечень полей, + которые возврат обязан сбросить, исполняет домен — норму держит capability + `pipeline` +- [x] 6.2 Проверить тестом, что возврат в работу сбрасывает всё названное + требованием — признак остановки, признак захвата и срок его протухания, + число отказов, паузу и время входа в рубеж, — и что ближайший захват выдаёт + запись, не дожидаясь протухания прежнего срока + +## 7. Проверки и документы + +- [x] 7.1 Написать тест параллельного захвата под `-race`: запись достаётся + ровно одному, второй получает отказ по значению признака захвата +- [x] 7.2 Написать тест отдачи файла: чужая запись и несуществующая отвечают + одинаково +- [x] 7.3 Написать тест подъёма на чистом каталоге: до строки о готовности в + журнале нет ни одного отказа +- [x] 7.4 Прогнать `task gate` до зелёного +- [x] 7.5 Обновить документы канона — паспорт, архитектуру, модель угроз, + `docs/database.md` — под ушедшие панель, пространство хранилища и токен + файла; в перечне зависимостей архитектуры назвать `pressly/goose/v3` + пришедшим, а библиотеку хранилища — ушедшей +- [x] 7.6 Записать в `CLAUDE.md` решение владельца от 2026-08-22 о **разовом** + снятии инварианта «миграция, уехавшая на сервер, не переписывается»: причина + — стройка, на сервере данных нет, сервис остановлен; граница — снятие + кончается этим изменением, и шаг начальной схемы подпадает под инвариант как + всякий прежний. Пункт синка: документы канона правятся после задачи +- [x] 7.7 При архивации изменения поправить `Purpose` спеки `storage`: сегодня он + описывает отдачу файла ссылкой по токену, собственную поверхность хранилища + и панель владельца — то есть ушедшее diff --git a/openspec/specs/access/spec.md b/openspec/specs/access/spec.md index 8be916b..1cd051d 100644 --- a/openspec/specs/access/spec.md +++ b/openspec/specs/access/spec.md @@ -20,89 +20,20 @@ вход Telegram — связи чата с учётной записью сервис не вёл, — и 2026-08-14 вход убран вместе с этим исключением. ## Requirements -### Requirement: Иных способов открыть сессию нет - -Сервис SHALL оставить собственные адреса входа хранилища неработающими: ни один -из них MUST не давать доступа и MUST не менять учётной записи. Собственное -создание записи в коллекции пользователей, вход по паролю, вход по одноразовому -коду, обмен кода у внешнего провайдера и восстановление доступа MUST быть -выключены настройкой коллекции. - -**Закрывается не только вход, но и правка.** Перечисление, чтение, создание, -правка и удаление записи коллекции пользователей MUST быть закрыты правилами -доступа — то есть оставлены пустыми, что у хранилища означает «только владелец -панели». Умолчание библиотеки открывает всё это владельцу самой записи, и до сих -пор оно ничему не мешало ровно потому, что до поверхности хранилища браузер с -кукой не дотягивался. С узнаванием по заголовку эта защита перестаёт быть -защитой, а ключ учётной записи лежит в коллекции обычной колонкой: правка своей -записи и есть захват чужого имени. Наш код читает и заводит запись мимо правил, -панель работает суперпользователем, своих экранов профиля сервис не заводит — -закрытие не стоит ничего. - -Требование отдельно от «Пришедшего называет доверенный источник» намеренно: то -нормирует наш код, а это — **поверхность, которую приносит хранилище**. Умолчание -хранилища заводит коллекцию пользователей с открытым созданием записи и -включённым входом по паролю, и без этого требования узнавание по заголовку -обходится двумя запросами: завести себе запись, войти по паролю, предъявить -полученное. - -Отдельная цена у открытого создания записи — захват учётной записи: запись, -заведённая посторонним под чужим именем, досталась бы первому же настоящему -обращению с этим именем. - -Закрытие MUST не отменять заведения записи самим сервисом: учётную запись при -первом обращении заводит наш код, а не запрос снаружи, и правило коллекции ему -не судья. - -#### Scenario: Завести учётную запись самому нельзя - -- **WHEN** запрос снаружи создаёт запись в коллекции пользователей -- **THEN** ответ несёт отказ, а записи не появляется - -#### Scenario: Обращение с заголовком запись заводит - -- **GIVEN** учётной записи с этим значением ещё нет -- **WHEN** запрос с заголовком приходит с доверенного адреса -- **THEN** учётная запись появляется - -#### Scenario: Вход паролем недоступен - -- **WHEN** запрос идёт на вход по паролю к коллекции пользователей -- **THEN** ответ несёт отказ, а доступа не открывается - -#### Scenario: Обмен кода у провайдера недоступен - -- **WHEN** запрос идёт на обмен кода внешнего провайдера к коллекции - пользователей -- **THEN** ответ несёт отказ, а доступа не открывается - -#### Scenario: Восстановление доступа недоступно - -- **WHEN** запрос просит восстановление пароля или одноразовый код -- **THEN** ответ несёт отказ - -#### Scenario: Правка учётной записи снаружи закрыта - -- **GIVEN** человек узнан и его учётная запись заведена -- **WHEN** он правит свою запись в коллекции пользователей запросом к хранилищу -- **THEN** ответ несёт отказ, а запись остаётся прежней - -#### Scenario: Перечисление учётных записей закрыто - -- **GIVEN** человек узнан -- **WHEN** он перечисляет коллекцию пользователей запросом к хранилищу -- **THEN** ответ несёт отказ ### Requirement: Значение, дающее доступ, не печатается Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение заголовка, -которым назван пришедший, ни короткий токен файла, ни адрес почты пользователя. +которым назван пришедший, ни адрес почты пользователя. Записанное значение MUST читаться как ключ к чужому доступу: заголовок целиком задаёт тот, кто шлёт запрос, и строка журнала уезжает в собранные логи, откуда её -не убрать. Требование того же рода, что и запрет писать имя файла в хранилище: -там строка журнала собирала бы ссылку на чужую запись, здесь — имя, которым -довольно назваться, чтобы стать этим человеком. +не убрать. Требование того же рода, что и запрет писать имя файла на диске: там +строка журнала собирала бы путь к чужой записи, здесь — имя, которым довольно +назваться, чтобы стать этим человеком. + +Короткий токен файла из перечня ушёл вместе с самим токеном: значений на +предъявителя сервис больше не выдаёт, и запрет остался бы правилом без предмета. Адрес почты приходит от провайдера и принадлежит человеку, а не сервису. Имя из заголовка — тоже: это логин человека у провайдера. @@ -189,8 +120,8 @@ Сервис SHALL заводить у каждой принятой записи владельца — учётную запись, от имени которой запись принята, — и MUST отдавать данные такой записи только её владельцу. Запись без владельца MUST не заводиться ничем — ни приёмом, ни -конвейером, ни рукой в панели: колонка владельца пустого значения не принимает, и -норму эту держит capability `storage`. +конвейером, ни запросом к базе: колонка владельца пустого значения не принимает, +и норму эту держит capability `storage`. Владелец назначается один раз, при приёме, и MUST не меняться: совместного доступа, ролей и передачи записи другому сервис не знает. @@ -199,19 +130,19 @@ пришедший полем запроса, дал бы всякому узнанному право завести запись на чужое имя. -Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей. -Отдельный отказ «доступ запрещён» превращает чтение в перебор — по разнице -ответов считывается, какие записи заведены, а идентификатор записи и есть то, -что разграничение прячет. Каким именно ответом это выражено, нормирует -capability `archive`: там живут адреса чтения записи, и держатель нормы обязан -быть один. +Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей — и +к её карточке, и к её тексту, и к её файлу. Отдельный отказ «доступ запрещён» +превращает чтение в перебор: по разнице ответов считывается, какие записи +заведены, а идентификатор записи и есть то, что разграничение прячет. Каким +именно ответом это выражено, нормирует capability `archive`: там живут адреса +чтения записи, и держатель нормы обязан быть один. Пустой владелец MUST не совпадать ни с одной записью. Правило записано со стороны -**спрашивающего** и остаётся в силе, хотя записей без владельца в хранилище -больше нет: спрашивающий с пустым владельцем — это вызов, у которого нет учётной -записи, и отвечать ему надо отказом, а не выборкой. Держится оно отдельно от -схемы намеренно: схема запрещает **заводить** ничью запись, а это правило -запрещает **спрашивать** ничьим именем, и одно другое не заменяет. +**спрашивающего** и остаётся в силе, хотя записей без владельца в базе не бывает: +спрашивающий с пустым владельцем — это вызов, у которого нет учётной записи, и +отвечать ему надо отказом, а не выборкой. Держится оно отдельно от схемы +намеренно: схема запрещает **заводить** ничью запись, а это правило запрещает +**спрашивать** ничьим именем, и одно другое не заменяет. #### Scenario: Своя запись доступна @@ -225,6 +156,12 @@ capability `archive`: там живут адреса чтения записи, - **WHEN** её карточку спрашивает другой узнанный - **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом +#### Scenario: Чужой файл неотличим от несуществующего + +- **GIVEN** запись принята одним узнанным +- **WHEN** её файл просит другой узнанный +- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом + #### Scenario: Владельца не задают запросом - **WHEN** запрос на приём записи несёт своё значение владельца @@ -233,7 +170,7 @@ capability `archive`: там живут адреса чтения записи, #### Scenario: Ничью запись завести нечем - **WHEN** запись пытаются завести с пустым владельцем -- **THEN** хранилище её не сохраняет +- **THEN** база её не сохраняет #### Scenario: Пустой владелец не открывает ничего @@ -326,24 +263,24 @@ MUST отвечать отказом `401`, когда пришедший не всякому, кто пришлёт заголовок. Отказ приходит там, где приходил и раньше, — требованием учётной записи на адресах приложения. -**Узнавание идёт после ограничителя частоты, а не до него.** Оно читает -хранилище, а на новом имени ещё и пишет в него; выполненное раньше ограничителя, -оно работало бы на запросах, которые тот уже отверг, и поток отвергнутых -обращений заводил бы учётные записи, которые потом не убираются ничем. +**Узнавание идёт после ограничителя частоты, а не до него.** Оно читает базу, а +на новом имени ещё и пишет в неё; выполненное раньше ограничителя, оно работало +бы на запросах, которые тот уже отверг, и поток отвергнутых обращений заводил бы +учётные записи, которые потом не убираются ничем. **Узнавание действует на объявленной области, а не на всей поверхности сервиса.** -Область — корень приложения и адрес, которым хранилище выдаёт короткий токен -файла; она MUST выводиться из объявленного адресного пространства сервиса, а не -перечисляться вторым списком. Собственная поверхность хранилища под узнавание -MUST не подпадать: правка учётной записи, её чтение и перечисление коллекции -пользователей остаются недостижимыми для узнанного, потому что ключ учётной -записи лежит там обычной колонкой, а правило правки у коллекции — библиотечное и -разрешает править свою запись. Расширение области на всё дало бы узнанному -переписать себе ключ на чужое имя и забрать чужой архив. +Область — корень приложения; она MUST выводиться из объявленного адресного +пространства сервиса, а не перечисляться вторым списком. Прежде область была +шире на один адрес — тот, которым хранилище выдавало короткий токен файла; ни +адреса, ни токена не осталось. Прежде область была и уже: собственную поверхность +хранилища требовалось из неё вычитать, потому что ключ учётной записи лежал в +коллекции обычной колонкой, а правило правки было библиотечным. Поверхности этой +нет, и вычитать больше нечего. -Сужение области закрывает и вторую вещь: узнавание MUST не срабатывать на пробе -здоровья, на метриках и на ресурсах приложения. Иначе запрос за каждой картинкой -стоил бы обращения к базе, а первый такой запрос с новым именем — записи в неё. +Сужение области закрывает вещь, которая от смены хранилища не зависит: узнавание +MUST не срабатывать на пробе здоровья, на метриках и на ресурсах приложения. +Иначе запрос за каждой картинкой стоил бы обращения к базе, а первый такой запрос +с новым именем — записи в неё. **Значение заголовка принимается, а не берётся как есть.** Пустое значение и значение из одних пробельных знаков MUST не узнавать никого и MUST не заводить @@ -360,7 +297,7 @@ MUST не подпадать: правка учётной записи, её ч человека. Предел длины MUST считаться в **знаках** — той же единицей, что считает колонка. -Отказ хранилища при узнавании MUST кончаться отказом сервиса, а не молчаливым +Отказ базы при узнавании MUST кончаться отказом сервиса, а не молчаливым проходом неузнанным: иначе человек увидит отказ входа там, где легла база. Исход узнавания MUST оставлять строку журнала — и когда заголовок пришёл с @@ -379,18 +316,17 @@ MUST не подпадать: правка учётной записи, её ч имя, этого не замечает. Контур уже пишет эти имена соседним сервисам. Узнавание MUST идти на каждом запросе, и значения, переживающего запрос, сервис -MUST не выдавать — ни куки, ни токена сессии. Исключение одно и названо здесь же: -**короткий токен файла**, который хранилище выдаёт узнанному, чтобы тот прошёл по -ссылке на файл записи; его нормирует capability `storage`, а срок его жизни -назначается числом и живёт там, где проект держит числовые настройки. На этот -срок — и только на него — отзыв доступа до файловой ссылки не доходит. +MUST не выдавать вовсе — ни куки, ни токена сессии, ни короткого токена файла. +Исключений у этого правила больше нет: файл записи отдаётся тому же узнаванию, +что и всё прочее, и отзыв доступа доходит до него сразу. -В остальном смысл именно таков: отзыв доступа судит провайдер на каждом -обращении, а не однажды выданный срок. +Смысл именно таков: отзыв доступа судит провайдер на каждом обращении, а не +однажды выданный срок. -Собственный токен хранилища, предъявленный запросом, MUST побеждать заголовок: -владелец панели предъявляет свой, и подмена его учётной записью пользователя -отобрала бы у него панель посреди работы. +Собственных токенов сервис не принимает: значения, предъявленного запросом и +дающего доступ помимо заголовка, у него не существует. Прежде такое значение +било заголовок — им пользовался владелец панели; панели нет, и правило приоритета +осталось бы правилом без предмета. Значение заголовка MUST не попадать ни в журнал, ни в ответ, ни в метку метрики. Оно приходит строкой запроса и целиком задаётся тем, кто её шлёт, а с @@ -410,17 +346,10 @@ MUST не выдавать — ни куки, ни токена сессии. И - **THEN** ответ имеет код `401` - **AND** учётной записи с этим значением не появляется -#### Scenario: Предъявленный токен побеждает заголовок +#### Scenario: Предъявленного значения сервис не признаёт -- **GIVEN** запрос несёт и заголовок `Remote-User`, и годный собственный токен - хранилища -- **WHEN** сервис решает, кто пришёл -- **THEN** пришедшим считается предъявитель токена - -#### Scenario: Протухший токен узнаванию не мешает - -- **GIVEN** запрос несёт заголовок `Remote-User` и негодный либо протухший токен - хранилища +- **GIVEN** запрос несёт заголовок `Remote-User` и постороннее значение доступа + в заголовке или в параметре - **WHEN** сервис решает, кто пришёл - **THEN** пришедшим считается названный заголовком @@ -445,12 +374,11 @@ MUST не выдавать — ни куки, ни токена сессии. И - **THEN** ответ имеет код `401` - **AND** учётной записи не появляется -#### Scenario: Поверхность хранилища узнаванию не подпадает +#### Scenario: Область узнавания — корень приложения -- **GIVEN** запрос идёт с доверенного адреса с заголовком `Remote-User` -- **WHEN** он правит запись коллекции пользователей собственным адресом - хранилища -- **THEN** правка не проходит +- **GIVEN** сервис поднялся +- **WHEN** смотрят, на каких адресах срабатывает узнавание +- **THEN** это адреса под корнем приложения, и второго списка адресов нет #### Scenario: Проба здоровья учётной записи не заводит @@ -480,28 +408,28 @@ MUST не выдавать — ни куки, ни токена сессии. И Сервис SHALL заводить учётную запись при первом обращении с новым значением `Remote-User` и MUST находить её по тому же значению при каждом следующем. Значение MUST быть ключом учётной записи — уникальным и хранимым своей колонкой -коллекции пользователей. +таблицы пользователей. Имя и адрес почты MUST браться из заголовков того же запроса, и только при заведении. Оба MUST **приниматься**, а не браться как есть: имя обрезается по пределу колонки и чистится от управляющих знаков, негодный адрес почты отбрасывается. Негодное значение необязательного поля MUST не отменять заведения записи — иначе человек с длинным именем у провайдера не завёлся бы -никогда, получая отказ сервиса на каждом запросе. Найденную запись повторное обращение MUST не переписывать: иначе -всякий запрос был бы записью в базу, а правка имени у провайдера меняла бы -карточку человека молча, посреди его работы. +никогда, получая отказ сервиса на каждом запросе. Найденную запись повторное +обращение MUST не переписывать: иначе всякий запрос был бы записью в базу, а +правка имени у провайдера меняла бы карточку человека молча, посреди его работы. Адрес почты MUST быть необязательным: провайдер не обязан его приносить, а ключом он не служит. Ключом его брать нельзя вовсе — адрес меняется, и первое обращение с чужим адресом досталось бы чужой записи. **Ключ учётной записи MUST не правиться ничем, кроме заведения самим сервисом.** -Ни запросом снаружи, ни рукой в панели: переписанный ключ отдаёт архив -следующему, кто придёт с этим именем, а вернуть его будет нечем — владелец записи -назначается один раз и не меняется. Правило доступа коллекции пользователей MUST -закрывать правку записи снаружи наглухо, и MUST это держать схема, а не область -действия узнавания: защита, стоящая на том, что до поверхности хранилища никто не -дотянется, однажды уже оказалась случайной. +Переписанный ключ отдаёт архив следующему, кто придёт с этим именем, а вернуть +его будет нечем — владелец записи назначается один раз и не меняется. Держится +это тем, что адреса, которым учётная запись правится снаружи, у сервиса нет +вовсе: своих экранов профиля он не заводит, а поверхности хранилища, правившей +запись библиотечным правилом, не осталось. Правку остаётся сделать запросом к +базе, и это работа владельца сервиса, а не спрашивающего. Одновременные первые обращения одним значением MUST кончаться одной учётной записью: уникальность держит схема, а не порядок обращений. @@ -553,7 +481,7 @@ MUST не выдавать — ни куки, ни токена сессии. И - **GIVEN** учётной записи с этим значением ещё нет - **WHEN** два запроса с одним значением заголовка приходят одновременно -- **THEN** в коллекции пользователей появляется ровно одна запись +- **THEN** в таблице пользователей появляется ровно одна строка - **AND** оба запроса идут от её имени #### Scenario: Занятая почта не мешает завести запись @@ -564,11 +492,11 @@ MUST не выдавать — ни куки, ни токена сессии. И - **THEN** заводится своя учётная запись - **AND** адреса почты у неё нет -#### Scenario: Ключ учётной записи не правится и рукой в панели +#### Scenario: Адреса правки учётной записи у сервиса нет -- **GIVEN** учётная запись заведена -- **WHEN** её ключ меняют сохранением записи мимо адресов приложения -- **THEN** сохранение отвергается, а ключ остаётся прежним +- **GIVEN** человек узнан и его учётная запись заведена +- **WHEN** ищут адрес сервиса, которым он правит свою учётную запись +- **THEN** такого адреса нет #### Scenario: Негодное имя не отменяет заведения @@ -595,12 +523,6 @@ MUST не выдавать — ни куки, ни токена сессии. И - **THEN** журнал несёт строку о заведении с идентификатором записи - **AND** значения заголовка в ней нет -#### Scenario: Ключ учётной записи снаружи не правится - -- **GIVEN** человек узнан и его учётная запись заведена -- **WHEN** он правит ключ своей учётной записи запросом к хранилищу -- **THEN** правка не проходит, а ключ остаётся прежним - ### Requirement: Доверенный источник объявлен настройкой Сервис SHALL брать перечень доверенных адресов из конфига и MUST ронять старт, @@ -632,4 +554,3 @@ MUST не выдавать — ни куки, ни токена сессии. И - **WHEN** сервис поднимается с заполненным перечнем - **THEN** журнал подъёма называет доверенные адреса - diff --git a/openspec/specs/archive/spec.md b/openspec/specs/archive/spec.md index aabcd74..f4059f4 100644 --- a/openspec/specs/archive/spec.md +++ b/openspec/specs/archive/spec.md @@ -1,50 +1,119 @@ # archive Specification ## Purpose -TBD - created by archiving change app-json-contract. Update Purpose after archive. + +Что приложение спрашивает у сервиса и что получает в ответ: собственное адресное +пространство под корнем `/app/`, единая форма отказа, объявленные пределы, +страница своих записей, карточка записи, текст названного вида и файл записи +названной копии. + +Приём записи нормирует `intake`, узнавание пришедшего — `access`, раздачу самого +приложения — `webapp`, хранение записи и её файлов — `storage`. + +Сознательно не описаны: правка записи и её удаление — их приносят отдельные +задачи, и до них у приложения нет ни одного адреса, меняющего чужую строку. ## Requirements + ### Requirement: Адреса приложения живут своим пространством Сервис SHALL вешать собственные адреса приложения под корнем `/app/` и MUST не -занимать имён в пространстве `/api/`: последнее принадлежит хранилищу, оно -вешает туда собственные наборы адресов, и поменять этот префикс нельзя — он -литерал библиотеки, а не настройка. +заводить второго адресного пространства рядом. Пространство `/api/`, прежде +принадлежавшее встроенному хранилищу, и адрес панели `/_/` перестают +существовать: сервис их не занимает и отвечает на них тем же, чем отвечает +всякий неизвестный путь, — норму держит capability `webapp`. -Свободных имён в чужом пространстве сегодня хватает, но соседство остаётся: -обновление библиотеки вправе занять новое имя рядом с нашим, и разойдутся они -молча — тем же адресом начнёт отвечать не тот обработчик. +Корень приложения остаётся прежним, и это решение подтверждается, а не +принимается заново: формы запросов и ответов приложения смена хранилища не +трогает ни одним полем. -Цена переезда называется здесь же. Правило неизвестного пути, по которому -приложение отдаётся вместо отказа, MUST перечислять **все** корни сервиса, а не -один: путь внутри любого корня в приложение не проваливается никогда. Ограничитель -частоты хранилища настроен на чужой корень и наших адресов больше не покрывает, -поэтому сервис MUST заводить своё правило под корень приложения. +Соседство, ради которого корень был выбран, кончилось вместе с соседом: чужого +обновления, вправе занять новое имя рядом с нашим, больше нет. -Цена этого названа здесь же: ограничитель у хранилища один на всю его поверхность -и выключен умолчанием, поэтому включение нашего правила вводит в действие и его -собственные — на входе, на заведении записей и на его адресах. Принимается -сознательно: без включения наше правило не значит ничего. +Ограничитель частоты под корнем приложения MUST быть своим: он считает бюджет по +адресу спрашивающего и MUST не зависеть от настроек чужой поверхности. Прежде +включение нашего правила вводило в действие и чужие правила на чужих адресах; +платить за это больше нечем — чужих адресов нет. -Пространство `/api/settings` принадлежит хранилищу и остаётся ему: настройки -человека живут под корнем приложения. +**Адрес спрашивающего ограничитель MUST брать из заголовка пересылки — и только +тогда, когда соединение пришло с адреса из объявленного перечня доверенных.** Во +всяком другом случае адресом MUST считаться адрес пира, а пришедший заголовок +MUST не влиять на ключ бюджета ничем. + +**Цепочку пересылки ограничитель MUST читать справа налево, отбрасывая адреса из +перечня доверенных, и брать первый недоверенный.** Читаются при этом **все** +строки заголовка, а не первая: цепочка законно приходит несколькими строками. +Значение, оставшееся слева, ключа бюджета MUST не задавать: прокси заголовок +дописывает, а не заменяет, поэтому слева стоит то, что прислал спрашивающий, — и +ключ, взятый оттуда, меняется у него на каждом запросе, то есть бюджет +обходится с первого. Цепочка, где недоверенного адреса не нашлось вовсе, MUST +падать обратно на адрес пира. + +Обе половины правила закрывают свою поломку. Сервис стоит за обратным прокси, и +пир у него один на всех: бюджет, посчитанный по пиру, становится общим на весь +сервис, и один спрашивающий исчерпывает его остальным. Обратная ошибка — верить +заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого он +ограничивает: значением пересылаемого заголовка распоряжается тот, кто шлёт +запрос, и меняет он его на каждом запросе. + +**Узнавание и ограничитель берут адрес разными способами, и это намеренно.** +Узнаванию нужен адрес самого соединения: им оно решает, верить ли заголовку +вообще, и взятый из пересылаемого заголовка он сделал бы барьер подделываемым той +же строкой, которой обходится, — норму держит capability `access`. Ограничителю +нужен адрес того, кого он ограничивает, а тот за прокси в адресе соединения не +виден вовсе. Вопросы разные — «кому верить» и «кого считать», — и один ответ на +оба ломает либо барьер, либо бюджет. #### Scenario: Адрес приложения отвечает под своим корнем -- **GIVEN** человек вошёл и предъявил сессию +- **GIVEN** человек узнан - **WHEN** он спрашивает список своих записей под корнем приложения -- **THEN** ответ приходит от сервиса, а не от хранилища +- **THEN** ответ приходит от сервиса -#### Scenario: Прежние адреса приложения не отвечают +#### Scenario: Пространства хранилища не существует -- **GIVEN** заведена запись -- **WHEN** её спрашивают прежними адресами в чужом пространстве -- **THEN** ответ имеет код `404` +- **GIVEN** сервис поднялся +- **WHEN** запрос приходит на путь под прежним корнем хранилища +- **THEN** ответ тот же, что и на всякий неизвестный путь вне корней сервиса + +#### Scenario: Адреса панели не существует + +- **GIVEN** сервис поднялся +- **WHEN** запрос приходит на прежний адрес панели — и записанный знаком, и + записанный его кодом +- **THEN** ответ тот же, что и на всякий неизвестный путь вне корней сервиса #### Scenario: Ограничитель частоты покрывает адреса приложения -- **WHEN** сервис поднялся -- **THEN** настройки ограничителя несут правило, чей адрес начинается корнем - приложения +- **GIVEN** сервис поднялся +- **WHEN** запросы с одного адреса идут чаще бюджета под корнем приложения +- **THEN** лишние получают отказ ограничителя + +#### Scenario: Два клиентских адреса через один прокси расходуют разные бюджеты + +- **GIVEN** запросы идут с доверенного адреса, и адрес пира у них один +- **WHEN** два разных клиентских адреса шлют запросы под корнем приложения +- **THEN** бюджет каждого считается отдельно +- **AND** исчерпание бюджета одним не отказывает другому + +#### Scenario: Заголовок с недоверенного адреса на ключ бюджета не влияет + +- **GIVEN** запросы приходят с адреса вне перечня доверенных +- **WHEN** они несут заголовок пересылки с разными значениями адреса +- **THEN** бюджет у них общий и считается по адресу пира + +#### Scenario: Значение, приписанное спрашивающим, ключа бюджета не задаёт + +- **GIVEN** запросы приходят с доверенного адреса +- **WHEN** они несут цепочку пересылки, где слева стоит меняющееся значение + спрашивающего, а справа — адрес, приписанный прокси +- **THEN** бюджет считается по правому значению +- **AND** запросы чаще бюджета получают отказ ограничителя + +#### Scenario: Цепочка читается всеми строками заголовка + +- **GIVEN** запросы приходят с доверенного адреса +- **WHEN** цепочка пересылки приходит несколькими строками заголовка +- **THEN** ключ бюджета берётся из последней строки, а не из первой ### Requirement: Отказ называет причину, а не место @@ -54,21 +123,24 @@ TBD - created by archiving change app-json-contract. Update Purpose after archiv - пришедший не узнан — `401`, и он MUST наступать **до всякого чтения записи**, одинаково для заведённой записи и для неизвестного идентификатора: иначе по разнице кодов перебирается список заведённых записей; -- узнанный предъявитель без учётной записи пользователя — `403`; - неизвестный идентификатор — `404`, и **тем же кодом с тем же телом** MUST отвечать чужая и ничья запись; - негодный ввод — `400`: нечитаемая запись, неизвестное значение параметра, - негодный размер страницы; + негодный размер страницы, негодный диапазон в запросе файла; - запись сверх потолка размера — `413`, и тело MUST нести предел числом; -- состояние, в котором действие недоступно, — `409`: текста запрошенного вида у - записи ещё нет; -- отказ хранилища и всякая неназванная причина — `500`. +- состояние, в котором действие недоступно, — `409`: текста или копии файла + запрошенного вида у записи ещё нет; +- отказ базы и всякая неназванная причина — `500`. + +Ветвь «узнанный предъявитель без учётной записи пользователя» из перечня ушла +вместе со своим единственным случаем: им был владелец панели, предъявивший +собственный токен хранилища. Ни панели, ни токенов у сервиса не осталось, а +узнавание по заголовку учётную запись заводит само, и предъявителя без неё не +бывает. Ветвь, у которой нет достижимого случая, не проверяется ничем и остаётся +в коде мёртвой. Отображение доменной ошибки в код и сообщение MUST жить **одним местом** на все -адреса, и у него MUST быть определённая ветвь по умолчанию. Сегодня такого места -нет вовсе, и каждый обработчик решает сам: опрос отвечает «записи нет» на упавшую -базу, а приём — «внутренняя ошибка» на негодный файл. Человек читает первое как -«моя запись пропала», а второе не говорит ему ничего. +адреса, и у него MUST быть определённая ветвь по умолчанию. Тело отказа MUST быть одной формы на всех адресах приложения и MUST нести **два** поля: машиночитаемый код отказа из закрытого перечня и сообщение, пригодное @@ -82,8 +154,8 @@ TBD - created by archiving change app-json-contract. Update Purpose after archiv выбранные кодом они стали бы контрактом молча: - поля тела: `error_code` и `message`; -- перечень `error_code`: `unauthorized`, `forbidden`, `not_found`, - `bad_request`, `too_large`, `too_many_requests`, `not_ready`, `internal`. +- перечень `error_code`: `unauthorized`, `not_found`, `bad_request`, + `too_large`, `too_many_requests`, `not_ready`, `internal`. Часть отказов рождается **не в обработчике** — предел тела, ограничитель частоты, неизвестный путь под корнем приложения, — и до отображения доменной ошибки не @@ -97,13 +169,13 @@ TBD - created by archiving change app-json-contract. Update Purpose after archiv умолчанию отдаст `internal` на обычный конфликт, и владелец сервиса увидит в журнале аварию там, где её нет. -Сырой текст ошибки MUST в тело не попадать — ни `err.Error()`, ни детали -устройства: имена внешних сервисов, пути на диске, ключи файлов. Полная ошибка +Сырой текст ошибки MUST в тело не попадать — ни текст отказа драйвера, ни детали +устройства: имена внешних сервисов, пути на диске, имена файлов. Полная ошибка остаётся в журнале владельца сервиса. -#### Scenario: Сбой хранилища виден как сбой +#### Scenario: Сбой базы виден как сбой -- **GIVEN** хранилище отвечает отказом драйвера на чтение записи +- **GIVEN** база отвечает отказом драйвера на чтение записи - **WHEN** владелец спрашивает свою запись - **THEN** ответ имеет код `500` - **AND** тела записи в ответе нет @@ -115,16 +187,10 @@ TBD - created by archiving change app-json-contract. Update Purpose after archiv - **THEN** ответ имеет код `400` и несёт сообщение, пригодное человеку - **AND** причина отказа в тело ответа не попадает -#### Scenario: Отказ по пустому владельцу - -- **GIVEN** предъявитель узнан, но учётной записи пользователя у него нет -- **WHEN** он шлёт запись приёмом -- **THEN** ответ имеет код `403` - #### Scenario: Форма тела одна на всех ветвях отказа - **WHEN** сервис отказывает по ненайденной записи, по негодному вводу, по - отсутствию учётной записи и по сбою хранилища + недоступному состоянию и по сбою базы - **THEN** тело каждого ответа несёт код отказа и сообщение одними и теми же полями - **AND** код отказа принадлежит закрытому перечню @@ -468,3 +534,138 @@ MUST браться колонками самой записи, а не стро называет вида вовсе - **THEN** ответ имеет код `400` +### Requirement: Файл записи отдаётся адресом приложения + +Сервис SHALL отдавать файл записи адресом под корнем приложения — `GET +/app/audiorecords/{id}/file` — и MUST отдавать **копию, названную +спрашивающим**. Отдача «какой-нибудь» копии сделала бы ответ функцией того, что +успел записать конвейер, а не состояния записи. + +**Копию называет параметр запроса `copy`.** Имя параметра нормативно наравне со +значениями: разбирает его каждый экран, и выбранное кодом оно стало бы публичным +контрактом молча. + +**Перечень значений закрыт, и каждое называет ровно одну хранимую вещь:** + +- `original` — файл, принятый от отправителя; +- `normalized` — копия, приведённая к рабочему формату. + +Копию, которой у записи ещё нет, сервис MUST отдавать отказом состояния — тем же +кодом, каким отвечает ненаписанный текст: `409`. Пустой ответ читался бы как +пустой файл, а `404` слился бы с ответом на чужую и неизвестную запись, и человек +увидел бы «не найдено» на своей записи, загруженной минуту назад. + +Копия, которой сервис не знает, и незаданная копия MUST давать отказ по негодному +вводу — но только у **своей** записи. + +Порядок проверок MUST быть один: владение записью судится **до** разбора значения +копии. Неизвестное либо незаданное значение копии у чужой и у несуществующей +записи MUST давать тот же ответ, что и неизвестный идентификатор, — и кодом, и +телом. Неотличимость чужой записи от несуществующей главнее формы ответа на +негодный ввод: разбор параметра, выполненный раньше, отвечал бы одинаково на +чужую и на неизвестную только случайно, а стоило бы ответам разойтись — по этой +разнице перебирался бы список заведённых записей одним негодным параметром. + +Ответ MUST нести длину файла и тип содержимого и MUST допускать выдачу по частям: +запись расчётного потолка — шесть часов, и проигрыватель в браузере перематывает +её запросом диапазона, а не повторной загрузкой целиком. + +**Негодный диапазон MUST приводиться к обычному отказу сервиса** — телом той же +формы и кодом из закрытого перечня, — а не отвечать кодом `416` и телом +библиотеки. Негодных диапазонов два вида, и оба ведут себя одинаково: +неудовлетворимый (начало за концом файла) и множественный (в запросе назван +больше чем один диапазон). Второй сервис не отдаёт намеренно: ответ из нескольких +частей — это отдельный тип содержимого со своими границами, а просит его один +только самодельный запрос, потому что проигрыватель в браузере шлёт один +диапазон. + +Причина у требования общая с прочими отказами, рождающимися не в обработчике: +форма тела на адресах приложения одна, и код отказа принадлежит закрытому +перечню. Ответ `416` с телом библиотеки приходит без полей `error_code` и +`message`, и приложение разбирает его отдельной веткой — единственной такой на +все адреса. + +Файл чужой записи MUST быть недоступен наравне с её карточкой и отвечать тем же, +чем неизвестный идентификатор. Кто владелец файла и почему право пройти по адресу +даёт узнавание, а не выданное значение, нормирует capability `storage`. + +Имя файла на диске MUST в ответ не попадать: имя, предлагаемое браузеру при +сохранении, строится из имени, данного отправителем, и лежит оно колонкой записи. + +**Перечня доступных копий карточка записи не объявляет** — до задачи об экране +прослушивания его в ответе MUST не быть, и об отсутствующей копии спрашивающий +узнаёт отказом состояния на самом обращении за файлом. + +Довод, которым перечень доступных видов текста объявляется карточкой всегда, к +копиям файла не относится, и это разные случаи. Видов текста несколько, шаг +завершения пишет их несколькими операциями, поэтому состояние «сплошной текст +есть, реплик ещё нет» достижимо, а из состояния записи не выводится: приложение +обязано узнать перечень, иначе пойдёт за текстом, которого нет. Копий же две, и +каждая выводится из рубежа записи, который карточка несёт и так: принятая копия +есть у всякой заведённой записи, приведённая — у всякой, прошедшей приведение. +Второе поле повторяло бы рубеж и разошлось бы с ним молча. + +Перечень появится тогда, когда у него появится потребитель: экран прослушивания +приносит задача `play-recording-in-app`. Объявлять его раньше — закреплять +контракт, которого никто не разбирает. + +#### Scenario: Владелец забирает принятую копию + +- **GIVEN** запись принята +- **WHEN** владелец просит её файл копией `original` +- **THEN** ответ несёт содержимое принятого файла и его длину + +#### Scenario: Приведённой копии ещё нет + +- **GIVEN** запись принята и не дошла до приведения +- **WHEN** владелец просит её файл копией `normalized` +- **THEN** ответ имеет код `409` +- **AND** он отличается от ответа на неизвестный идентификатор + +#### Scenario: Копия неизвестна или не названа + +- **WHEN** владелец просит файл копией, которой сервис не знает, либо не + называет копии вовсе +- **THEN** ответ имеет код `400` + +#### Scenario: Чужой файл неотличим от неизвестной записи + +- **GIVEN** запись принята одним узнанным +- **WHEN** её файл просит другой узнанный +- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом + +#### Scenario: Негодная копия у чужой записи неотличима от неизвестной записи + +- **GIVEN** запись принята одним узнанным +- **WHEN** другой узнанный просит её файл копией, которой сервис не знает +- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом +- **AND** он не отличается от ответа на ту же просьбу к несуществующей записи + +#### Scenario: Перечня копий в карточке нет + +- **GIVEN** запись принята и приведена к рабочему формату +- **WHEN** владелец спрашивает её карточку +- **THEN** поля с перечнем доступных копий файла в ответе нет + +#### Scenario: Проигрыватель просит кусок записи + +- **GIVEN** запись принята +- **WHEN** владелец просит её файл с указанием диапазона +- **THEN** ответ несёт запрошенный кусок, а не файл целиком + +#### Scenario: Неудовлетворимый диапазон отвечает обычным отказом + +- **GIVEN** запись принята, и её файл короче запрошенного начала +- **WHEN** владелец шлёт настоящий HTTP-запрос за файлом с заголовком `Range: + bytes=99999999-` +- **THEN** ответ имеет код `400`, а не `416` +- **AND** тело несёт поля `error_code` и `message` + +#### Scenario: Двух диапазонов в одном запросе сервис не отдаёт + +- **GIVEN** запись принята +- **WHEN** владелец шлёт настоящий HTTP-запрос за файлом с заголовком, + называющим два диапазона +- **THEN** ответ имеет код `400`, а не `416` +- **AND** тело несёт поля `error_code` и `message` +- **AND** ответа из нескольких частей сервис не отдаёт diff --git a/openspec/specs/intake/spec.md b/openspec/specs/intake/spec.md index 218c8d5..e93d017 100644 --- a/openspec/specs/intake/spec.md +++ b/openspec/specs/intake/spec.md @@ -2,14 +2,16 @@ ## Purpose -Приём записи и опрос готовности задачи расшифровки: что считается принятой -записью, что уезжает в ответ и что происходит, когда запись не удалось -прочитать. Плюс наличие входов: с каким из них сервис вправе подняться. +Приём записи: что считается принятой записью, что уезжает в ответ и что +происходит, когда запись не удалось прочитать. Плюс наличие входов: с каким из +них сервис вправе подняться. Исход принятой записи её владелец узнаёт карточкой — +норму держит `archive`, и опрос готовности убран 2026-08-15. Вход у сервиса один — приём по HTTP, — и описан он тем, что нормируют проверки. Второй вход, Telegram, убран 2026-08-14 вместе со своими требованиями; его возвращение заводит их заново, вместе со связью чата и учётной записи. ## Requirements + ### Requirement: Приём записи по HTTP Сервис SHALL принимать запись запросом `POST /app/audiorecords` с телом @@ -19,9 +21,7 @@ получить заведённую под неё аудиозапись на рубеже `uploaded`. Приём стоит тем же адресом, что и список записей, и отличается от него только -методом: он **заводит аудиозапись**, а не кладёт файл. Прежнее имя называло -содержимое запроса, и по нему приём читался как отдельная от записи вещь — хотя -запись он и создаёт. +методом: он **заводит аудиозапись**, а не кладёт файл. Ответ MUST нести **список** заведённых записей и место под признак повторного файла у каждой, даже когда файл в запросе один. Форма согласована один раз и @@ -34,10 +34,8 @@ повторного файла полем `duplicate`: две формы одной вещи разошлись бы молча. Состав карточки нормирует capability `archive`. -Прежние имена полей ответа — `job_id` и `status` — MUST не употребляться: адрес -опроса убран целиком, и идентификатор записи зовётся `id`. Это объявленная ломка -публичного контракта: стадия проекта — стройка, на сервере данных нет, а внешней -программы на прежнем контракте не существует — своего токена у неё не было. +Прежние имена полей ответа — `job_id` и `status` — MUST не употребляться: +идентификатор записи зовётся `id`. Значение рубежа в ответе MUST принадлежать перечню рубежей конвейера и MUST не перечисляться этой нормой порознь: рубеж объявлен одним дескриптором, и @@ -46,9 +44,7 @@ Запись сверх потолка размера MUST отвергаться до заведения файла и аудиозаписи, и код с телом такого отказа нормирует capability `archive` наравне с прочими -ветвями. Потолок применяется уже сегодня, а ответ на его срабатывание — -самый частый отказ у человека на мобильной сети — прежде не был нормирован -ничем и уходил телом ограничителя тела, мимо единой формы. +ветвями. Отказ неузнанному наступает **раньше** чтения тела: запись, за которую не заплатит узнанный отправитель, не должна попасть даже в память. @@ -56,25 +52,20 @@ Приём не судит о годности записи сам: расширение он берёт из имени файла, а пригодность содержимого узнаёт у источника метаданных. -Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает -хранилище, и нормирует её capability `storage`. +Куда именно ложится принятая запись, приёму не принадлежит: раскладку каталога +данных нормирует capability `storage`. -Владельцем принятой записи приём SHALL назначать узнанного предъявителя. Обязательность -владельца при этом MUST держаться и схемой хранилища: колонка владельца пустого -значения не принимает вовсе, и норму эту держит capability `storage`. Проверка в -приёме от этого не лишняя — она отвечает отправителю понятным отказом до того, как -запись попадёт в память, а схема отвечала бы отказом сохранения после укладки -файла. +Владельцем принятой записи приём SHALL назначать узнанного предъявителя. +Обязательность владельца при этом MUST держаться и схемой: колонка владельца +пустого значения не принимает вовсе, и норму эту держит capability `storage`. +Проверка в приёме от этого не лишняя — она отвечает отправителю понятным отказом +до того, как запись попадёт в память, а схема отвечала бы отказом сохранения +после укладки файла. -Предъявитель, узнанный без учётной записи пользователя, MUST получать -отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ -неузнанному. Владелец панели, предъявивший собственный токен хранилища, — именно -такой случай: узнан он всё же узнан, а записи в коллекции пользователей у него -нет, и владельцем записи он стать не может. - -Код здесь другой, чем у запроса от неузнанного, и это не оплошность: `401` значит -«предъяви себя», а предъявитель себя предъявил. Утечки по разнице кодов нет — -оба ответа говорят о самом спрашивающем, а не о том, какие записи заведены. +Отдельной ветви «узнан, а учётной записи нет» у приёма больше нет: узнавание +заводит учётную запись само, а предъявителя с собственным токеном хранилища не +существует — токенов сервис не выдаёт и не принимает. Ветвь ушла вместе со своим +единственным случаем. Отказ **после** укладки записи потребовал бы убрать уже сохранённый файл, а уборки файлов сервис не умеет вовсе: норма, обязывающая к недостижимому, не @@ -88,16 +79,9 @@ - **THEN** ответ имеет код `201`, а в теле лежит список из одного элемента - **AND** элемент несёт непустой `id`, поле `state` со значением `uploaded` и место под признак повторного файла -- **AND** содержимое записи целиком лежит в хранилище одним файлом +- **AND** содержимое записи целиком лежит в каталоге данных одним файлом - **AND** владельцем заведённой аудиозаписи стоит узнанный предъявитель -#### Scenario: Узнанный без учётной записи пользователя - -- **GIVEN** предъявлен собственный токен владельца панели -- **WHEN** он шлёт `POST /app/audiorecords` с полем `audio` -- **THEN** ответ имеет код `403` -- **AND** ни файла, ни аудиозаписи не заводится - #### Scenario: Пришедший не узнан - **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio` неузнанной @@ -123,8 +107,8 @@ Сервис SHALL сохранять принятую запись под собственным именем — идентификатором, к которому приписано расширение из имени файла отправителя. Имя, данное -отправителем, MUST не попадать в **имя файла** в хранилище: оно приходит извне и -содержимым своим приёму не подконтрольно. +отправителем, MUST не попадать **ни в имя файла на диске, ни в путь к нему**: оно +приходит извне и содержимым своим приёму не подконтрольно. Норма сужена: имя отправителя доходит теперь до самой аудиозаписи собственной колонкой — по нему человек узнаёт свою запись, — но не до имени файла и не до @@ -132,27 +116,27 @@ подписывает запись». Расширения в присланном имени нет — сервис MUST подставить `.audio`, чтобы у -файла в хранилище расширение было всегда. +файла на диске расширение было всегда. -Требование переживает смену раскладки. Умолчание хранилища, строящее имя из -имени отправителя, MUST не применяться: имя отправителя в журнал не пишется по -инварианту приватности, а изъятие из него кончается расширением — хвостом после -последней точки. +Требование пережило смену раскладки: имя задаёт сервис, а не умолчание чужой +библиотеки, строившее его из имени отправителя. Умолчания этого больше нет, и +правило перестало быть отменой чужого поведения — оно стало прямым описанием +своего. #### Scenario: Расширение взято из имени отправителя - **WHEN** программа шлёт запись с именем `test.mp3` -- **THEN** имя файла в хранилище оканчивается на `.mp3` +- **THEN** имя файла на диске оканчивается на `.mp3` #### Scenario: Имени без расширения назначено своё - **WHEN** программа шлёт запись с именем `test` без расширения -- **THEN** имя файла в хранилище оканчивается на `.audio` +- **THEN** имя файла на диске оканчивается на `.audio` -#### Scenario: Имя отправителя в хранилище не попало +#### Scenario: Имя отправителя в имя файла не попало - **WHEN** программа шлёт запись с именем `секретное-слово.mp3` -- **THEN** имя файла в хранилище не содержит `секретное-слово` +- **THEN** имя файла на диске не содержит `секретное-слово` - **AND** путь к этому файлу не содержит его тоже ### Requirement: Отказ чтения метаданных @@ -334,4 +318,3 @@ MUST ограничивать его длину и MUST убирать из не - **WHEN** он шлёт запись, чьё имя длиннее предела и несёт управляющие знаки - **THEN** колонка имени файла несёт имя не длиннее предела - **AND** управляющих знаков в нём нет - diff --git a/openspec/specs/pipeline/spec.md b/openspec/specs/pipeline/spec.md index 6a2e58a..ea6b845 100644 --- a/openspec/specs/pipeline/spec.md +++ b/openspec/specs/pipeline/spec.md @@ -10,7 +10,7 @@ перехода, неделимость захвата и срок его протухания, условие записи результата держателем захвата, нарастающая пауза перед повтором, число воркеров настройкой, журнал событий записи и молчание конвейера наружу: обращений к отправителю он не -делает вовсе, и свой исход тот узнаёт опросом готовности. +делает вовсе, и свой исход тот узнаёт карточкой записи. Сознательно не описаны: освобождение ресурсов внешних клиентов и **какие отказы считаются приговором записи, а какие поводом к повтору**. Второе — не пробел @@ -20,6 +20,7 @@ потому что требование без проверки — предположение, а не норма. Первая задача, которая трогает любое из перечисленного, дописывает его сюда. ## Requirements + ### Requirement: Пустой прогон воркера — не отказ Воркер SHALL отличать «пригодной к работе записи сейчас нет» от отказа шага. На @@ -87,8 +88,9 @@ ### Requirement: Захват задачи неделим -Захват записи воркером SHALL быть одним неделимым шагом хранилища: выбор -подходящей записи и пометка её захваченной MUST происходить вместе. +Захват записи воркером SHALL быть одним неделимым запросом к базе: выбор +подходящей записи и пометка её захваченной MUST происходить вместе, одним +оператором с возвратом. Захват MUST возвращать **идентификатор записи и признак этого захвата**, а не перечень её колонок. Колонки записи шаг читает сам, обычным чтением. Иначе @@ -99,7 +101,8 @@ **Признак захвата MUST быть значением, уникальным для каждого захвата**, а не признаком занятости. Условие записи результата сверяет именно это значение: захват, перевыданный другому — по протуханию срока или после того, как человек -снял признак остановки в панели, — обязан обращать запись первого в отказ. +снял признак остановки подкомандой оснастки, — обязан обращать запись первого в +отказ. Условие, проверяющее лишь непустоту признака или срок, пропустило бы обоих, и два шага записали бы в одну запись по очереди, испортив её результат. @@ -170,15 +173,19 @@ Шаг MUST записывать только те поля, которыми распоряжается сам. Запись он держит снимком с момента захвата и до записи — это часы, — и безусловная запись снимка -стёрла бы всё, что владелец правил в панели за это время: молча, без строки в -журнале и без отказа в панели. Владелец увидел бы успешное сохранение и был бы -уверен, что правка на месте. Владелец записи, заголовок, краткое описание и темы +стёрла бы всё, что владелец правил за это время: молча, без строки в журнале и +без отказа тому, кто правил. Владелец записи, заголовок, краткое описание и темы конвейер MUST не трогать. +Возвращает остановленную запись в работу сегодня владелец сервиса подкомандой +оснастки — своего экрана для этого у сервиса нет. Норму это не меняет: она +написана про поля, а не про того, чьей рукой правка сделана, и переживёт +появление экранов. + #### Scenario: Правка владельца пережила сохранение шага - **GIVEN** шаг держит захваченную запись -- **AND** владелец за это время изменил в панели поле, которого шаг не касается +- **AND** владелец за это время изменил поле, которого шаг не касается - **WHEN** шаг записывает свой результат - **THEN** результат шага записан - **AND** правка владельца на месте @@ -346,10 +353,36 @@ MUST расти с числом её отказов до объявленног и машинный текст отказа. Снятие признака SHALL возвращать запись в работу **с того рубежа, где она -стояла**, и MUST сбрасывать число отказов, паузу **и время входа в рубеж**. -Время входа сбрасывается по той же причине, что и остальные сторожа: запись, -простоявшая остановленной дольше предела, иначе останавливалась бы снова первым -же захватом, и перезапуск не работал бы вовсе. +стояла**, и MUST сбрасывать **всё, чем прошлый прогон её удерживал**: + +- признак остановки — время остановки, причину и машинный текст отказа; +- признак захвата и срок его протухания; +- число отказов; +- паузу перед повтором; +- время входа в рубеж. + +Перечень назван целиком и в одном месте, потому что забытое поле не даёт ни +отказа, ни строки в журнале. Оставленный признак захвата держит запись занятой до +протухания срока и отдаёт её потом чужому шагу — тому, чей результат условен по +прежнему значению. Оставленное время входа в рубеж останавливает запись снова +первым же захватом, если остановленной она простояла дольше предела, и перезапуск +не работает вовсе. Оставленные отказы и пауза откладывают первый же прогон на +накопленный срок. + +Возврат в работу MUST идти **через домен**: тот, кто его делает, называет запись, +а поля выше сбрасывает домен одним действием. Правка колонок мимо домена +повторяет перечень вторым местом, и второе место расходится с первым молча. + +Возврат в работу MUST писать событие журнала записи с происхождением «человек». +Иначе запись, побывавшая остановленной и вернувшаяся в работу, неотличима в +журнале от записи, которую конвейер вёл без остановок, а происхождение события +перестаёт различать что-либо: другого писателя, кроме конвейера, у журнала не +остаётся. + +Инструментом возврата сегодня служит подкоманда набора инструментов +разработчика: панели у сервиса нет, а экраны владельца приносят отдельные задачи. +Норма написана про поля и про домен, а не про инструмент, и появление экрана её +не трогает. Прежние состояния отказа и смерти MUST не заводиться заново: обе причины восстанавливаются одинаково — снятием признака, — и различие между ними @@ -389,6 +422,19 @@ MUST расти с числом её отказов до объявленног - **THEN** число отказов, пауза и время входа в рубеж сброшены - **AND** ближайший захват выдаёт запись, а не останавливает её снова +#### Scenario: Возврат в работу освобождает захват + +- **GIVEN** запись остановлена, и признак прошлого захвата на ней стоит +- **WHEN** признак остановки снимают +- **THEN** признака захвата и срока его протухания на записи нет +- **AND** ближайший захват выдаёт запись, не дожидаясь протухания прежнего срока + +#### Scenario: Возврат в работу виден в журнале записи + +- **GIVEN** запись остановлена +- **WHEN** её возвращают в работу +- **THEN** в журнале событий записи есть событие с происхождением «человек» + ### Requirement: Время в рубеже ограничено У аудиозаписи SHALL быть время входа в рубеж, и оно MUST ставиться только при @@ -608,8 +654,13 @@ MUST не быть привязаны к отдельному шагу: кажд Шаг конвейера SHALL доводить запись до достигнутого рубежа и MUST не обращаться к отправителю вовсе — ни с готовым текстом, ни с сообщением о неудаче. Исход -своей записи владелец узнаёт **карточкой записи** и в панели владельца сервиса; -адрес карточки и содержимое ответа нормирует capability `archive`. +своей записи владелец узнаёт **карточкой записи**; адрес карточки и содержимое +ответа нормирует capability `archive`. + +Владелец сервиса узнаёт исход журналом и журналом событий записи. Панели, где он +видел бы то же строкой таблицы, у сервиса нет — она ушла вместе со встроенным +хранилищем, и второго канала наблюдения это не отняло: журнал событий пишется +по-прежнему, а читается запросом к базе, пока экрана нет. Держатель нормы сменился вместе с убранным опросом готовности: прежде исход отдавал адрес опроса, нормированный capability `intake`, и адреса этого больше @@ -628,8 +679,8 @@ MUST не быть привязаны к отдельному шагу: кажд спрашивает, об остановке не узнаёт. Записи, которой этот канал недоступен, не бывает: у каждой записи есть владелец, -и карточка отдаёт ему её исход. Держится это обязательностью владельца в схеме -хранилища — норму держит capability `storage`. +и карточка отдаёт ему её исход. Держится это обязательностью владельца в схеме — +норму держит capability `storage`. #### Scenario: Готовый текст отправителю не уходит @@ -644,4 +695,3 @@ MUST не быть привязаны к отдельному шагу: кажд - **WHEN** владелец записи спрашивает её карточку - **THEN** ответ несёт достигнутый рубеж, признак остановки и её причину - **AND** в журнале владельца сервиса есть запись об остановке с причиной - diff --git a/openspec/specs/recognition/spec.md b/openspec/specs/recognition/spec.md index 4e3c223..23e80de 100644 --- a/openspec/specs/recognition/spec.md +++ b/openspec/specs/recognition/spec.md @@ -1,8 +1,19 @@ # recognition Specification ## Purpose -TBD - created by archiving change record-centric-model. Update Purpose after archive. + +Что принадлежит внешнему распознавателю и как это лежит у нас: попытка +распознавания отдельной строкой, сырой ответ провайдера целиком, структура +реплик, построенная из сохранённого ответа, и граница, за которую разбор чужого +формата не выходит. + +Движение записи по рубежам нормирует `pipeline`, хранение текста и структуры — +`storage`, закрытость содержимого — `storage` же. + +Сознательно не описан формат ответа конкретного провайдера: он живёт в адаптере +и в записках разведки, а не в норме поведения. ## Requirements + ### Requirement: Попытка распознавания хранится отдельно от записи Сервис SHALL держать всё, что принадлежит внешнему распознавателю, отдельной @@ -39,24 +50,37 @@ TBD - created by archiving change record-centric-model. Update Purpose after arc ### Requirement: Сырой ответ провайдера сохраняется целиком Сервис SHALL сохранять ответ распознавателя целиком, в том виде, в каком он -пришёл, и MUST хранить его вложением, а не колонкой строки попытки. +пришёл, и MUST хранить его **отдельным файлом в каталоге данных**, а не колонкой +строки попытки. Хранится он потому, что **результат операции у провайдера не переспрашивается**: связь реплики с говорящим сервис строить пока не умеет, и когда научится, архив пересчитается из сохранённого без повторной оплаты. -Вложением, а не колонкой, — потому что шаг опроса читает строку попытки часто, а -хранилище читает запись целиком: ответ на многочасовую запись, положенный -колонкой, ехал бы в память при каждом опросе. +Файлом, а не колонкой, — потому что шаг опроса читает строку попытки часто, а +репозиторий читает строку целиком: ответ на многочасовую запись, положенный +колонкой, ехал бы в память при каждом опросе. Где именно этот файл лежит, +нормирует capability `storage`, требование «Файл записи живёт в хранилище»: +третьим файлом в подкаталоге записи, наравне с копиями аудио. Копией аудио он при +этом не считается — их у записи по-прежнему две. Чтение строки попытки шагом опроса MUST не тянуть за собой сохранённый ответ. Сохранённый ответ — это полный текст речи, и закрыт он MUST быть наравне с самой -записью: поле вложения помечено защищённым, правило просмотра пускает только -владельца связанной записи, ссылка не попадает ни в журнал, ни в метку метрики. -Норму держит capability `storage`, требование «Содержимое записи закрыто во всех -коллекциях, где лежит»; здесь она названа потому, что коллекция попыток — то -место, куда содержимое приезжает впервые. +записью. Закрытость MUST держаться **проверкой владельца в обработчике сервиса**: +адреса, которым сохранённый ответ читают снаружи, сервис MUST не заводить вовсе, а +всякий адрес, отдающий содержимое записи, MUST судить владельца связанной +аудиозаписи сам, при каждом обращении. Пометка поля защищённым и правило +просмотра коллекции, которыми закрытость держалась прежде, — механизмы +встроенного хранилища, и их не остаётся; норма от этого не ослабла, а перестала +зависеть от настройки, которую мы не писали. + +Путь к файлу сохранённого ответа MUST не попадать ни в журнал, ни в метку +метрики, ни в ответ отправителю. + +Норму держит capability `storage`, требование «Содержимое записи закрыто везде, +где лежит»; здесь она названа потому, что попытка распознавания — то место, куда +содержимое приезжает впервые. #### Scenario: Ответ сохранён и читается позже @@ -70,6 +94,14 @@ TBD - created by archiving change record-centric-model. Update Purpose after arc - **WHEN** шаг опроса читает строку попытки - **THEN** сохранённый ответ в память при этом не читается +#### Scenario: Адреса чтения сохранённого ответа у сервиса нет + +- **GIVEN** запись принята одним узнанным и прошла распознавание +- **WHEN** другой узнанный ищет адрес, которым читается сохранённый ответ этой + записи +- **THEN** такого адреса у сервиса нет +- **AND** содержимого он не получает + ### Requirement: Структура реплик строится из сохранённого ответа Сервис SHALL строить структуру реплик записи из сохранённого ответа провайдера и @@ -148,4 +180,3 @@ MUST не обращаться к провайдеру повторно ради - **WHEN** провайдер отвечает идентификатором операции - **THEN** идентификатор сохраняется в строке попытки - **AND** повторная отправка той же записи не заводится - diff --git a/openspec/specs/storage/spec.md b/openspec/specs/storage/spec.md index 8c07ac5..0e5d6cc 100644 --- a/openspec/specs/storage/spec.md +++ b/openspec/specs/storage/spec.md @@ -3,10 +3,11 @@ ## Purpose Где живут аудиозапись, её приложения и её файлы: раскладка каталога данных, -приведение схемы при подъёме, отдача файла ссылкой по токену, собственная -поверхность хранилища и панель владельца. +версионированный накат схемы при подъёме, правила чтения и записи базы, отдача +файла его владельцу с проверкой при каждом обращении. -Приём и опрос готовности нормирует `intake`, вход и сессию — `access`, попытку +Приём записи нормирует `intake`, узнавание пришедшего — `access`, адреса, по +которым приложение спрашивает запись и её файл, — `archive`, попытку распознавания у внешнего провайдера — `recognition`. Сознательно не описаны: перенос прежних данных — его нет ни по решению задачи @@ -14,6 +15,7 @@ удалены вместе с остановкой сервиса; удаление записей и файлов — сервис объявлен архивом 2026-08-11, а удаление приносит задача `delete-record`. ## Requirements + ### Requirement: Сервис поднимается на чистом каталоге данных Сервис SHALL приводить хранилище в рабочий вид сам: на пустом каталоге данных он @@ -26,7 +28,30 @@ MUST завести свою схему и принимать записи св Схема MUST заводиться версионированными шагами, а применённый шаг MUST не переписываться — только новым шагом. Иначе повторный запуск на уже заведённом -каталоге разошёлся бы с первым молча. +каталоге разошёлся бы с первым молча. Применённые шаги MUST учитываться самой +базой, а не порядком файлов на диске. + +Применение шага и запись отметки о нём MUST идти **одной транзакцией**. Процесс, +оборванный между ними, оставляет базу со шагом, который применён и не отмечен, а +следующий запуск применяет его второй раз — и второе применение отказывает на +заведённой таблице, роняя старт на шаге, который на самом деле цел. + +Накат MUST держаться **исключающей блокировкой базы** на всё своё время: второй +процесс, поднятый на том же каталоге данных, MUST ждать её освобождения либо +отказать, а не применять шаги параллельно. Каталог данных один, а запусков на нём +бывает два — старый экземпляр ещё не остановлен, новый уже поднят, — и два +наката, разошедшихся на одном шаге, оставляют схему в состоянии, которого не +описывает ни один шаг. + +Порядок шагов MUST быть детерминирован и выводиться из **версии самого шага**, а +не из порядка чтения каталога: порядка обхода файловая система не обещает, а +разошедшийся порядок шагов виден только на чистой базе, которую заводят один раз. +Две одинаковых версии MUST давать отказ, а не молчаливый выбор одного из шагов. + +**Схема MUST накатываться до подъёма входов и до старта воркеров**, а отказ шага +MUST ронять старт. Сервис, поднявшийся на неприведённой схеме, отвечает отказом +на каждый запрос и на каждый прогон воркера — вместо одной строки о причине их +становятся сотни, и первопричина в них теряется. Каталог данных у сервиса MUST быть один: база и файлы записей лежат под ним вместе, и второго пути к ним не заводится. @@ -42,29 +67,223 @@ MUST завести свою схему и принимать записи св - **GIVEN** сервис уже запускался на этом каталоге и завёл хранилище - **WHEN** он запускается снова -- **THEN** он не заводит схему второй раз и не теряет прежние записи +- **THEN** он не заводит схему второй раз и не теряет прежних записей + +#### Scenario: Схема накатана до первой строки о готовности + +- **GIVEN** каталог данных пуст +- **WHEN** сервис запускается +- **THEN** до строки журнала о готовности схема приведена целиком +- **AND** ни одного отказа в журнале до неё нет + +#### Scenario: Старт, оборванный между шагом и отметкой о нём + +- **GIVEN** запуск оборван после применения шага схемы и до записи отметки о нём +- **WHEN** сервис запускается снова +- **THEN** исход тот же, что и у необорванного запуска, либо отказ, называющий + шаг +- **AND** шаг не применяется второй раз + +#### Scenario: Отказ шага схемы роняет старт + +- **GIVEN** шаг схемы не применяется +- **WHEN** сервис запускается +- **THEN** старт кончается отказом, называющим шаг +- **AND** ни один вход не поднят + +### Requirement: База принимает одного писателя + +Сервис SHALL держать у базы **одно** соединение для записи, а чтение MUST вести +отдельно от него. Журнал упреждающей записи MUST быть включён, принудительное +соблюдение внешних ключей MUST быть включено, а ожидание занятой базы MUST +задаваться числом, а не оставаться умолчанием драйвера. + +Все три настройки MUST задаваться **строкой подключения обоих пулов** — и +пишущего, и читающего, — а не отдельным запросом после открытия. Соблюдение +внешних ключей в SQLite — настройка соединения, а не базы, и по умолчанию она +выключена: `PRAGMA foreign_keys` на свежем соединении отвечает `0`. Пул раздаёт +соединения и заводит новые по мере надобности, поэтому запрос, выполненный один +раз после открытия, настраивает одно соединение из многих, а остальные остаются с +умолчанием — молча. На включённых внешних ключах держатся требования «Учётная +запись с записями не удаляется», «Владелец, которого нет, не принимается» и «Файл +без владельца не сохраняется»: с выключенными все три зеленеют на том соединении, +где настройку успели поставить, и не работают на соседнем. + +Требование заводится потому, что эту настройку прежде держала за нас чужая +библиотека двумя пулами. Драйвер пишет единственным соединением: несколько +воркеров, пишущих разом мимо этого правила, получают отказ «база занята» — и +получают его на записи результата шага, то есть после оплаченной работы. + +**Всякая операция, которая читает и следом пишет, MUST идти целиком на пишущем +соединении** — и чтение, и запись, и объемлющая их транзакция. Транзакция, +начатая на читающем соединении и позже пытающаяся писать, получает отказ по +занятости **немедленно**: повысить начатую читающую транзакцию до пишущей SQLite +не даёт, и заданное числом ожидание такой отказ не лечит — ждать там нечего. Под +правило подпадают захват записи и накат шага схемы: каждый читает состояние, +которое сам же меняет. + +**Узнавание под правило не подпадает, и устроено оно двумя соединениями.** +Поиск учётной записи по логину у провайдера MUST идти читающим пулом, а пишущая +транзакция MUST открываться только тогда, когда запись не нашлась. Слой +узнавания одет на весь узнанный поток — опрос карточки и каждый запрос диапазона +при проигрывании, — а заводится учётная запись один раз за жизнь человека: +пишущая транзакция, взятая до поиска, ставила бы весь этот поток в очередь к +единственному пишущему соединению. Очередь эта ожиданием занятой базы не +ограничена и отказом не кончается — обращение просто ждёт, и сотни миллисекунд +ожидания видны только замером. + +Окно между двумя соединениями MUST закрываться **повторным поиском внутри +транзакции**: пока её ждали, запись успевает завести сосед, и найденную надо +взять, а не заводить вторую. Уникальность ключа при этом держит схема, а не +порядок обращений. + +Значения ожидания и числа соединений MUST жить там, где проект держит числовые +настройки, и MUST не повторяться второй константой рядом. + +#### Scenario: Несколько воркеров пишут разом + +- **GIVEN** число рабочих потоков конвейера больше одного +- **AND** все они дошли до записи своего результата одновременно +- **WHEN** результаты записываются +- **THEN** каждый записан, и ни один не отказал по занятости базы + +#### Scenario: Настройки базы применены при подъёме + +- **WHEN** сервис поднялся на чистом каталоге данных +- **THEN** у базы включён журнал упреждающей записи +- **AND** ожидание занятой базы равно объявленному числу + +#### Scenario: Внешние ключи включены на соединении читающего пула + +- **GIVEN** сервис поднялся на чистом каталоге данных +- **WHEN** соединение берут из читающего пула и спрашивают у него `PRAGMA + foreign_keys` +- **THEN** ответ — `1` + +#### Scenario: Узнавание известного не ждёт писателя + +- **GIVEN** учётная запись с этим логином уже заведена +- **AND** пишущее соединение занято открытой транзакцией +- **WHEN** приходит следующее обращение тем же логином +- **THEN** учётная запись узнана, и обращение не ждёт освобождения писателя +- **AND** второй учётной записи не заведено + +#### Scenario: Составная операция не отказывает по занятости + +- **GIVEN** число рабочих потоков конвейера больше одного +- **AND** каждый выполняет операцию, которая читает состояние записи и следом его + пишет +- **WHEN** операции идут одновременно +- **THEN** каждая завершена, и ни одна не отказала по занятости базы + +### Requirement: Время и идентификаторы приходят из одного места + +Хранилище SHALL держать **все** колонки времени одним представлением: `TEXT` в +RFC 3339, UTC, с суффиксом `Z` и секундной точностью — `2006-01-02T15:04:05Z`. +Второго вида времени в схеме MUST не заводиться, включая колонки, которые пишет +только сам сервис. + +Ширина такой записи постоянная, поэтому лексикографический порядок `TEXT` +совпадает с хронологией, и отбор по колонке времени работает без разбора +значения. Своего типа времени у SQLite нет: колонка хранит то, что в неё +положили, а отбор захвата сравнивает строки — колонка, заполненная то одним +видом, то другим, обращает условие срока протухания в постоянную истину или ложь +молча, и запись не выдаётся ни одному воркеру никогда. + +**Время ставит приложение, а не умолчание шага схемы**, и берёт оно его из единой +точки чтения времени, которую держит линтер проекта. Умолчаний вида +`CURRENT_TIMESTAMP` в схеме MUST не заводиться. Выбрано так по двум причинам: +умолчание схемы пишет свой вид времени, отличный от объявленного выше, и вставка, +забывшая проставить время, при умолчании проходит молча, а без него падает +громко. Прежнее расхождение — вид времени задавало встроенное хранилище своим +форматом с пробелом и долями секунды — уходит вместе с ним, и правило остаётся +одно. + +Идентификатор строки SHALL быть **ULID в нижнем регистре, колонкой `TEXT`**, и +ставить его MUST приложение единой точкой при заведении строки. Это то, что +конвенция проекта объявляет нормой; расхождение, при котором идентификаторы +выдавало встроенное хранилище собственным алфавитом, уходит вместе с ним. +Идентификатор, пришедший снаружи, MUST разбираться на границе — разбор проверяет +вид и приводит регистр, — а каким кодом отвечает негодный, нормирует capability +`archive`. + +#### Scenario: Вид времени один на все колонки + +- **GIVEN** сервис поднялся на чистом каталоге данных +- **WHEN** смотрят колонки времени в применённой схеме +- **THEN** все они объявлены одним типом и несут время одним видом +- **AND** умолчания времени ни у одной из них нет + +#### Scenario: Строка из приёма и строка из запроса к базе отбираются одинаково + +- **GIVEN** одна аудиозапись заведена приёмом, а вторая — запросом к базе руками +- **AND** обе стоят на одном рубеже и пригодны к захвату +- **WHEN** воркеры разбирают очередь +- **THEN** захвату выдаются обе +- **AND** ни одна не остаётся в очереди навсегда ### Requirement: Файл записи живёт в хранилище -Сервис SHALL держать файл записи в хранилище, а не отдельным каталогом рядом с -ним. Файл MUST попадать туда вместе с записью, которой принадлежит, и MUST -адресоваться этой записью, а не путём на диске. +Сервис SHALL держать файл записи в своём каталоге данных, и раскладку этого +каталога MUST задавать он сам. Файл MUST адресоваться записью, которой +принадлежит, а не путём на диске: шаг конвейера просит файл у записи и получает +его, ничего не зная о раскладке. -Раскладку файлов на диске выбирает хранилище. Собственного плоского каталога -записей у сервиса MUST не оставаться: файл, лежащий мимо хранилища, не попадёт -ни в панель владельца, ни в резервную копию, а ради этих двух вещей перевод и -делается. +Раскладка MUST держать копии одной записи вместе — под её идентификатором, — и +MUST давать убрать запись целиком одним движением, не перебирая имена по маске. +Плоского каталога, где копии различаются приставкой в имени, MUST не +заводиться. -Содержимое записи MUST не читаться в память целиком ни при укладке в хранилище, -ни при чтении из него: расчётный потолок записи — шесть часов, и такая запись в -память не помещается. +Содержимое записи MUST не читаться в память целиком ни при укладке, ни при +чтении: расчётный потолок записи — шесть часов, и такая запись в память не +помещается. + +**Укладка MUST быть атомарной:** содержимое пишется во временное имя **в том же +подкаталоге записи** и переименовывается в рабочее только после того, как поток +дочитан до конца без отказа. Временное имя берётся в том же каталоге потому, что +переименование в его пределах не копирует содержимое и не может оборваться на +середине. + +Порядок MUST быть один: строка о файле заводится **после** того, как содержимое +лежит целиком под рабочим именем. Обратный порядок оставляет в базе строку, +указывающую на файл, которого ещё нет или который короче принятого. + +Средство обнаружить усечение у сервиса одно, и оно снято намеренно: величины +записи со строкой файла не сверяются — так требует «Аудиозапись — центральная +сущность хранилища», — а другого не заведено. Усечённая запись поэтому уезжает в +конвейер, оплачивает распознавание и отдаёт расшифровку половины как готовый +результат. Атомарная укладка — единственное, что этого не допускает. + +Отсюда две нормы о неудачах: + +- содержимое легло, а сохранение самой аудиозаписи отказало — уложенный файл MUST + быть убран, и строки о нём MUST не остаться. Файл, переживший свою запись, — + штатное состояние только у приведённой копии, которую заводит шаг конвейера; у + принятой копии это мусор, на который не ссылается ничто и о котором узнать + неоткуда; +- отмена контекста посреди укладки MUST кончаться тем же исходом, что и отказ + источника: временного имени не остаётся, рабочего имени не появляется, строки о + файле нет. Записи, наполовину принятой, человек не видит. + +Сохранённый ответ провайдера распознавания MUST лежать **третьим файлом в том же +подкаталоге записи**, под именем, которое задаёт сервис. Колонкой строки попытки +он ехал бы в память при каждом опросе готовности — этого capability `recognition` +избегает намеренно; отдельной таблицей он завёл бы второй путь чтения содержимого +и остался бы в базе, которую сервис держит узкой. Третьим файлом он попадает под +ту же атомарную укладку и под ту же уборку записи одним движением, что и копии +аудио. + +Копией аудио сохранённый ответ при этом MUST не считаться: копий у записи +по-прежнему две — принятая и приведённая, — и перечень копий, которые сервис +отдаёт адресом приложения, этим не расширяется. Адреса, которым сохранённый ответ +читают снаружи, у сервиса нет вовсе. **Потолок размера записи MUST быть задан числом, выведенным из этого расчётного -потолка**, и задан он MUST быть везде, где иначе действует чужое умолчание: и у -поля файла в хранилище, и у тела запроса приёма. Умолчания здесь не «без -предела», а величины на два-три порядка меньше нужного, и оставленные как есть -они отвергают штатную запись сервиса — приём отказывает, а уже принятая запись -исчерпывает попытки на шаге конвертации. +потолка**, и задан он MUST быть везде, где иначе действует умолчание: и у тела +запроса приёма, и у всякого предела, который сервис ставит сам. Умолчания здесь +не «без предела», а величины на два-три порядка меньше нужного, и оставленные как +есть они отвергают штатную запись сервиса — приём отказывает, а уже принятая +запись исчерпывает попытки на шаге конвертации. Отказ по этому потолку MUST быть виден отправителю ответом, а не молчанием. @@ -76,20 +295,37 @@ MUST завести свою схему и принимать записи св столько раз, сколько шагов, а забытая копия — это шестичасовая запись, оставшаяся во временном каталоге, и узнать о ней неоткуда. -#### Scenario: Принятая запись легла в хранилище +#### Scenario: Принятая запись легла в каталог данных -- **WHEN** запись принята любым входом -- **THEN** её файл лежит в хранилище и связан со своей записью -- **AND** отдельного каталога записей рядом с хранилищем не появляется +- **WHEN** запись принята +- **THEN** её файл лежит в каталоге данных сервиса и связан со своей записью +- **AND** второго каталога записей рядом не появляется -#### Scenario: Запись длиннее чужого умолчания принимается +#### Scenario: Копии одной записи лежат вместе -- **WHEN** в хранилище кладут запись длиннее умолчания, действующего у поля файла -- **THEN** она ложится в хранилище, а не отвергается +- **GIVEN** запись принята, приведена к рабочему формату и прошла распознавание +- **WHEN** смотрят, где лежат её файлы +- **THEN** принятая копия, приведённая копия и сохранённый ответ провайдера лежат + под идентификатором этой записи +- **AND** второго места, где лежит что-то из них, нет + +#### Scenario: Источник оборвался посреди потока + +- **GIVEN** отправитель шлёт запись и обрывает поток на середине +- **WHEN** укладка отказывает +- **THEN** строки о файле не заведено +- **AND** ни файла под рабочим именем, ни временного имени в подкаталоге записи + не остаётся + +#### Scenario: Запись длиннее умолчания принимается + +- **WHEN** сервису отдают запись длиннее всякого умолчания, действующего на пути + приёма +- **THEN** она ложится в каталог данных, а не отвергается #### Scenario: Шаг конвейера берёт файл по записи -- **GIVEN** запись принята и её файл лежит в хранилище +- **GIVEN** запись принята и её файл лежит в каталоге данных - **WHEN** шаг конвейера берётся за эту запись - **THEN** он получает файл по самой записи, а не по пути на диске @@ -101,209 +337,92 @@ MUST завести свою схему и принимать записи св ### Requirement: Файл отдаётся ссылкой -Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой -записи, **и только узнанному отправителю**. Поле файла MUST быть помечено -защищённым: без этого ссылка открывает запись любому, кто её знает, и знание -ссылки становится правом. Отданный файл MUST совпадать с принятым по длине. +Сервис SHALL отдавать файл записи **только её владельцу** и MUST судить владельца +сам, при каждом обращении. Отданный файл MUST совпадать с принятым по длине. -Одной пометки мало: защищённый файл судится **коротким токеном файла**, который -узнанный отправитель берёт у хранилища, — и правилом просмотра коллекции. -Правило MUST пускать только владельца файла: незаданное означает «только владелец -панели», и тогда файла не получит и узнанный, а прежнее «всякий узнанный» -отдавало чужое аудио тому, кто знает идентификатор записи. +Значения, дающего право пройти по ссылке, сервис MUST не выдавать: ни короткого +токена файла, ни подписанной ссылки со сроком. Право даёт узнавание пришедшего и +владение записью, и судится оно там же, где отдаётся файл. Отзыв доступа доходит +до файла сразу, а не через срок жизни выданного значения. -Токен файла хранилище выдаёт **на предъявителя**, а не на файл, и о файле при -выдаче не спрашивает. Значит владельца судит переход по ссылке, а не выдача -токена: отказ наступает там, и требовать его от выдачи значит требовать -механизма, которого нет. +Обращение к файлу чужой записи MUST быть неотличимо от обращения к +несуществующей — тем же кодом и тем же телом. Разница ответов превратила бы +чтение в перебор заведённых записей. -Отсюда порядок для потребителя: узнавание → токен файла → ссылка с этим токеном. -Адрес выдачи токена лежит в пространстве хранилища, и узнавание по заголовку MUST -на нём работать — иначе файл записи недостижим для браузера вовсе. Одного -заголовка при этом мало: без токена ссылка файла не отдаёт, и это свойство -хранилища, а не недосмотр. +Каким адресом файл уходит и как называется вид копии, нормирует capability +`archive`: там живут адреса приложения, и держатель нормы обязан быть один. -Конвейер расшифровки этим не затронут: он читает файл из файловой системы -хранилища, а не по ссылке. +Конвейер расшифровки этим не затронут: он читает файл из каталога данных, а не +по адресу приложения. -Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом. +**Путь, по которому файл лежит на диске, MUST не попадать ни в журнал, ни в +метку метрики, ни в ответ отправителю.** Имя, под которым файл лёг в каталог, из +журнала выводимо быть не должно: журнал уезжает в собранные логи, откуда строку +не убрать. -**Ссылка сама по себе и есть право пройти по ней**, и потому она MUST не попадать -ни в журнал, ни в метку метрики, ни в ответ отправителю. Имя, под которым файл -лёг в хранилище, из журнала выводимо быть не должно: журнал уезжает в собранные -логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы -бессрочно. - -Защищённое поле сужает это право, но не отменяет запрета: право пройти теперь -требует ещё и узнавания, а строка журнала со ссылкой по-прежнему собирала бы -половину ключа. - -Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за -пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла -целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и -другое кончается в журнале и собирает ссылку не хуже успешного пути. +Отсюда требование к отказам: сообщение об отказе чтения или укладки MUST не +называть ключ файла и путь к нему дословно, а отказ выгрузки во внешнее +хранилище MUST не называть полного адреса объекта. И то и другое кончается в +журнале и собирает ссылку не хуже успешного пути. Что именно журнал приёма пишет ради прослеживаемости, нормирует capability `intake`. -#### Scenario: Файл забирают по ссылке +#### Scenario: Владелец забирает свой файл -- **GIVEN** запись принята и её файл лежит в хранилище -- **AND** забирающий узнан и взял токен файла -- **WHEN** ссылку на файл запрашивают с этим токеном +- **GIVEN** запись принята и её файл лежит в каталоге данных +- **WHEN** владелец записи просит её файл - **THEN** приходит тот же файл, и его длина совпадает с длиной принятого #### Scenario: Неузнанному файл не отдаётся -- **GIVEN** запись принята и её файл лежит в хранилище -- **WHEN** ссылку на файл запрашивают неузнанным +- **GIVEN** запись принята и её файл лежит в каталоге данных +- **WHEN** файл просят неузнанным - **THEN** приходит отказ, а содержимого записи в ответе нет -#### Scenario: Токен файла выдаётся узнанному по заголовку +#### Scenario: Значения на предъявителя сервис не выдаёт -- **GIVEN** запрос идёт с доверенного адреса с заголовком `Remote-User` -- **WHEN** он просит у хранилища токен файла -- **THEN** токен выдаётся +- **GIVEN** человек узнан +- **WHEN** ищут адрес, которым сервис выдаёт значение, открывающее файл +- **THEN** такого адреса у сервиса нет #### Scenario: Конвейер читает файл без узнавания - **GIVEN** запись принята и ждёт расшифровки - **WHEN** шаг конвейера берётся за неё -- **THEN** файл читается из файловой системы хранилища и шаг проходит +- **THEN** файл читается из каталога данных и шаг проходит -#### Scenario: Ссылка ведёт в никуда +#### Scenario: Файл записи, которой нет -- **WHEN** запрашивают ссылку на запись, которой нет +- **WHEN** просят файл записи с неизвестным идентификатором - **THEN** приходит отказ, а не пустой ответ -#### Scenario: По журналу ссылку не собрать +#### Scenario: По журналу путь к файлу не собрать - **GIVEN** запись принята и прошла конвейер - **WHEN** читают журнал сервиса целиком -- **THEN** имени, под которым файл лёг в хранилище, в нём нет +- **THEN** имени, под которым файл лёг в каталог данных, в нём нет #### Scenario: Отказ чтения файла не называет его ключ -- **GIVEN** файл записи не читается из хранилища +- **GIVEN** файл записи не читается с диска - **WHEN** шаг конвейера берётся за эту запись и отказывает - **THEN** отказ называет запись её идентификатором и не несёт имени файла -### Requirement: Наружу хранилище отдаёт только то, что заказано - -Сервис SHALL держать закрытыми собственные разделы хранилища, которые тот -публикует тем же портом. Запрос без прав владельца MUST получать отказ на -перечисление и чтение записей коллекций, на служебные разделы хранилища — -журналы запросов, резервные копии, настройки, расписание — и на правку чего бы -то ни было. - -Требование заводится потому, что порт опубликован в интернет, а вместе с -переводом наружу выходит поверхность, которой у сервиса не было. Что API сервиса -сегодня открыт всякому — известно и записано моделью угроз; новая поверхность под -это знание не подпадает и закрывается здесь. - -Правило доступа, оставленное пустым, значит «только владелец панели». Именно -пустым оно MUST и оставаться: непустое правило, поставленное будущей правкой -схемы, открыло бы перечисление всех записей анонимному запросу и не нарушило бы -при этом ни одного другого требования. - -#### Scenario: Аноним перечисляет записи - -- **WHEN** запрос без прав владельца просит список записей коллекции задач -- **THEN** приходит отказ - -#### Scenario: Аноним читает служебный раздел - -- **WHEN** запрос без прав владельца просит журнал запросов или список резервных - копий хранилища -- **THEN** приходит отказ - -### Requirement: Владелец видит записи в панели - -Сервис SHALL давать владельцу панель, где аудиозапись видна строкой, отбирается -по своему идентификатору и правится, а её файлы слушаются и скачиваются. - -Панель MUST отдаваться тем же сервисом по своему адресу и MUST не требовать -второго процесса. - -Панель — вход в запись наравне с конвейером, а не окно просмотра. Снятие -признака остановки в панели MUST возвращать запись в работу с сохранённого -рубежа и MUST очищать служебные поля прошлого захвата — признак захвата, срок -его протухания, паузу, число отказов — и MUST заново ставить время входа в -рубеж. Правка рубежа руками MUST делать то же самое. Иначе владелец, вернувший -запись в работу, получит запись, которая не выдаётся захвату до конца прежнего -срока, останавливается от первого же отказа или останавливается снова первым же -захватом по пределу времени, — и не узнает об этом. - -Запись, заведённая в панели руками, MUST не уносить сервис: поля, без которых -шаг конвейера не может работать, MUST быть обязательными в самой схеме, а -перечень рубежей — закрытым. - -Панель разграничению доступа сервиса не подчиняется: вошедший в неё видит все -записи, все файлы и всех пользователей разом. Закрывает её контур выкладки, а не -сервис — это записано моделью угроз проекта. - -#### Scenario: Принятая запись видна владельцу - -- **GIVEN** запись принята и заведена -- **WHEN** владелец отбирает записи по идентификатору принятой -- **THEN** он видит её строкой со своим рубежом -- **AND** её файл скачивается из той же строки - -#### Scenario: Остановленную запись вернули в работу правкой в панели - -- **GIVEN** запись остановлена признаком, с накопленными отказами и признаком - прежнего захвата -- **AND** остановленной она простояла дольше предела времени в рубеже -- **WHEN** владелец снимает признак остановки -- **THEN** признак захвата, срок его протухания, пауза и число отказов очищены -- **AND** время входа в рубеж поставлено заново -- **AND** ближайший захват выдаёт запись с сохранённого рубежа - -### Requirement: Пароль владельца от панели не лежит в конфигурации - -Сервис SHALL не заводить в конфигурации ключа под пароль владельца от панели. -Пароль MUST задаваться самим владельцем, а хранилище MUST держать только его -отпечаток. - -Требование стоит на инварианте проекта «Секрет не покидает конфиг» с другой -стороны: секрет, которого в конфигурации нет, не утекает вместе с ней и не -уезжает в выкладку третьим путём. Пароль от панели открывает все записи и все -файлы разом — это самое чувствительное, что есть у сервиса. - -Приглашение завести владельца сервис MUST печатать только пока владельца нет, и -оно MUST истекать по времени. Приглашение равносильно паролю от панели, а -печатается оно в журнал контейнера, откуда строку не убрать: бессрочное отдало бы -панель всякому читателю логов навсегда. - -Пока владелец пароля не задал, сервис MUST принимать записи: панель без владельца -приёму не мешает. - -#### Scenario: Владелец пароля ещё не задал - -- **GIVEN** каталог данных пуст и владелец панели не заведён -- **WHEN** сервис запускается -- **THEN** он принимает записи -- **AND** ни один ключ конфигурации не несёт пароля от панели - -#### Scenario: Владелец заведён, приглашение больше не печатается - -- **GIVEN** владелец панели заведён -- **WHEN** сервис запускается снова -- **THEN** приглашения завести владельца в журнале нет - ### Requirement: Владелец задачи лежит связью с учётной записью Хранилище SHALL держать владельца аудиозаписи отдельной колонкой — связью с учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец не назван, не достаётся никому по недосмотру схемы. -Колонка MUST не допускать пустого значения. Прежде допускала, и цену платили за -записи, принятые ботом: связи чата с учётной записью сервис не вёл. С убранным -входом заводить ничью запись стало некому, и обязательность переезжает из одного -лишь приёма в схему — туда, где её держит хранилище, а не договорённость. Разница -не косметическая: пока обязательность жила в приёме, ничью запись заводили руками -в панели, и она уходила в конвейер, стоила денег на распознавание и не доставалась -потом никому. +Колонка MUST не допускать пустого значения, и MUST это держать сама схема: связь +объявлена внешним ключом на учётную запись и обязательна. Пока обязательность +жила в одном приёме, ничью запись заводили руками мимо него, она уходила в +конвейер, стоила денег на распознавание и не доставалась потом никому. + +Правку записи мимо адресов приложения сервис ничем не предоставляет: панели у +него нет. Обязательность от этого не отменяется — она перестала зависеть от того, +кто пишет, и стала свойством схемы. Владелец MUST не назначаться и не меняться конвейером. @@ -318,8 +437,15 @@ MUST завести свою схему и принимать записи св - **GIVEN** сервис поднят - **WHEN** аудиозапись пытаются сохранить с пустым владельцем — приёмом, - конвейером или руками в панели -- **THEN** хранилище её не сохраняет + конвейером или запросом к базе +- **THEN** база её не сохраняет + +#### Scenario: Владелец, которого нет, не принимается + +- **GIVEN** сервис поднят +- **WHEN** аудиозапись пытаются сохранить с владельцем, которому не отвечает ни + одна учётная запись +- **THEN** база её не сохраняет #### Scenario: Конвейер владельца не назначает @@ -330,13 +456,11 @@ MUST завести свою схему и принимать записи св ### Requirement: Файл записи сужается владельцем наравне с задачей Хранилище SHALL держать владельца и у файла записи — той же связью с учётной -записью, — и правило просмотра файлов MUST пускать к файлу только его владельца. +записью, — а отдача файла MUST пускать к нему только его владельца. Владелец файла MUST назначаться при приёме, из узнанного предъявителя, а колонка -файла MUST не допускать пустого значения наравне с колонкой записи. Прежде пустое -значение оставалось у файлов, заведённых конвейером для записи без владельца; -таких записей больше не заводится, и разное правило у записи и у её файла -читалось бы как недосмотр. +файла MUST не допускать пустого значения наравне с колонкой записи. Разное +правило у записи и у её файла читалось бы как недосмотр. Файл, заведённый шагом конвейера, — приведённую копию заводит именно он — MUST получать владельца своей записи. Иного источника владельца у файла нет, и @@ -348,29 +472,29 @@ MUST получать владельца своей записи. Иного и выводиться через запись: файл переживает свою запись, и заведённый шагом до сохранения записи он остаётся с владельцем и без ссылки. -Отказ наступает **на переходе по ссылке**, а не на выдаче токена файла: токен -хранилище выдаёт на предъявителя, а не на файл, и о файле при выдаче не -спрашивает вовсе. Требовать отказа при выдаче значит требовать механизма, -которого нет, — а проверка, написанная под такое требование, зеленела бы, не -касаясь пути, по которому аудио и уходит. +Отказ наступает **на самом обращении за файлом**: другого места, где он мог бы +наступить, у сервиса не осталось — значений на предъявителя он не выдаёт. +Проверка, судящая владельца где-то ещё, зеленела бы, не касаясь пути, по +которому аудио и уходит. #### Scenario: Чужой файл не отдаётся - **GIVEN** запись принята одним узнанным -- **WHEN** другой узнанный идёт по ссылке на файл этой записи со своим токеном +- **WHEN** другой узнанный просит файл этой записи - **THEN** содержимого он не получает +- **AND** ответ тот же, что и на неизвестный идентификатор записи #### Scenario: Свой файл отдаётся - **GIVEN** человек принял запись -- **WHEN** он идёт по ссылке на файл своей записи со своим токеном +- **WHEN** он просит файл своей записи - **THEN** содержимое отдаётся #### Scenario: Файл без владельца не сохраняется - **GIVEN** сервис поднят - **WHEN** файл записи пытаются сохранить с пустым владельцем -- **THEN** хранилище его не сохраняет +- **THEN** база его не сохраняет #### Scenario: Приведённая копия получает владельца записи @@ -382,69 +506,63 @@ MUST получать владельца своей записи. Иного и ### Requirement: Учётная запись с записями не удаляется Хранилище SHALL отвергать удаление учётной записи, у которой остались -аудиозаписи **либо файлы**. Отказ MUST называть причину, и MUST доезжать до -спрашивающего: хранилище пропускает наружу только свою ошибку роутера, а всякую -другую подменяет сообщением про обязательную связь — подсказкой, по которой -владелец панели пойдёт удалять записи руками. +аудиозаписи, файлы **либо темы словаря**, и MUST держать этот запрет самой +схемой — обязательной связью, которая не даёт убрать строку, пока на неё +ссылаются. -Считаются **все** коллекции с колонкой владельца, и перечень их MUST жить одним -местом: коллекция, пропущенная в счёте, пропускает удаление вперёд, и наружу -приезжает не наш отказ с причиной, а подсказка библиотеки про обязательную связь -— та самая, по которой владелец панели пойдёт удалять записи руками. Сегодня их -три: аудиозаписи, файлы и словарь тем. +Считаются **все** таблицы с колонкой владельца, и перечень их MUST жить одним +местом — шагом схемы, который эти связи объявляет. Таблица, пропущенная в счёте, +пропускает удаление вперёд и оставляет за собой строки, чей владелец больше не +существует. + +Запрет схемой, а не проверкой вызывающего, — потому что вызывающих у удаления +может стать больше одного, а проверка, записанная у одного, у остальных читалась +бы как снятая. Сборка, забывшая позвать проверку, теряет защиту молча — и теряла. Файл переживает свою запись: шаг конвейера заводит его до сохранения записи, и потерянный захват оставляет файл с владельцем и без ссылки. Тема переживает её так же: словарь принадлежит человеку, а не записи. -Запрет MUST ставить сама сборка хранилища, а не вызывающий: сборка, забывшая его -позвать, теряет защиту молча — и теряла, пока запрет вешался отдельной строкой -запуска, а окружение проверок его не ставило вовсе. - -Удаление при этом не только панельное: умолчание библиотеки разрешает вошедшему -удалить **свою** учётную запись запросом, так что запрет закрывает и публичную -поверхность. - -Цена требования названа прямо: владелец панели упирается в отказ, а способа -удалить записи в сервисе пока нет вовсе — его приносит задача про удаление -записи. До неё удаление учётной записи с записями невозможно, и это осознанный -тупик, а не недосмотр. +Цена требования названа прямо: способа удалить записи в сервисе пока нет вовсе — +его приносит задача про удаление записи. До неё удаление учётной записи с +записями невозможно, и это осознанный тупик, а не недосмотр. Адреса, которым +учётную запись удаляют, у сервиса при этом нет: запрет закрывает удаление +запросом к базе. #### Scenario: Удаление учётной записи с записями отвергается - **GIVEN** у учётной записи есть аудиозаписи -- **WHEN** её удаляют -- **THEN** удаление не проходит, а отказ называет причину +- **WHEN** её строку удаляют +- **THEN** удаление не проходит - **AND** записи и их владелец остаются прежними #### Scenario: Учётная запись с одними файлами тоже не удаляется - **GIVEN** у учётной записи остались файлы, но записей нет -- **WHEN** её удаляют +- **WHEN** её строку удаляют - **THEN** удаление не проходит, а владелец файлов остаётся прежним #### Scenario: Учётная запись с одними темами тоже не удаляется - **GIVEN** у учётной записи остались темы словаря, но ни записей, ни файлов нет -- **WHEN** её удаляют -- **THEN** удаление не проходит, а отказ называет причину нашими словами +- **WHEN** её строку удаляют +- **THEN** удаление не проходит #### Scenario: Учётная запись без записей удаляется - **GIVEN** у учётной записи нет ни аудиозаписей, ни файлов, ни тем -- **WHEN** её удаляют +- **WHEN** её строку удаляют - **THEN** удаление проходит ### Requirement: Аудиозапись — центральная сущность хранилища -Хранилище SHALL держать аудиозапись отдельной сущностью, а всё, что к ней -приложено, — отдельными строками со ссылками с записи. Приложениями считаются +Хранилище SHALL держать аудиозапись отдельной таблицей, а всё, что к ней +приложено, — отдельными строками со ссылками на запись. Приложениями считаются файлы, тексты, структура реплик, темы, журнал событий и попытка распознавания. Поля, которыми распоряжается очередь — признак захвата, срок его протухания, пауза, число отказов, время входа в рубеж, — MUST не соседствовать с содержимым -записи в одной строке настолько, чтобы чтение очереди тянуло содержимое: сегодня -расшифровка лежит колонкой той же строки и читается при каждом захвате. +записи в одной строке настолько, чтобы чтение очереди тянуло содержимое. Запись MUST нести заголовок и краткое описание своими колонками: они читаются вместе со списком, сотней штук разом. Расшифровка и вычитанный текст MUST лежать @@ -463,12 +581,11 @@ MUST получать владельца своей записи. Иного и потому, что этой единицей уже названы соседние колонки схемы. **Различать «неизвестно» и «ноль» эти колонки не обязаны, и это решение, а не -недосмотр.** Числовая колонка хранилища пустого значения не держит вовсе: пустое -кладётся нулём, и норма, требующая отличимости, потребовала бы либо четвёртой -колонки-признака, либо текстового типа у чисел. Платить за это нечем: обе -величины ставит приём, и ставит всегда — запись, метаданные которой прочитать не -удалось, отвергается отказом и не заводится вовсе. Ноль в этих колонках означает -ноль. Решение владельца 2026-08-15. +недосмотр.** Обе величины ставит приём, и ставит всегда — запись, метаданные +которой прочитать не удалось, отвергается отказом и не заводится вовсе. Ноль в +этих колонках означает ноль, и колонки MUST быть объявлены обязательными: пустое +значение, которое схема теперь допустить может, завело бы третий смысл, которого +никто не читает. Решение владельца 2026-08-15, и смена хранилища его не отменяет. Имя файла на записи и заголовок MUST лежать **разными колонками**. Заголовок несёт название, которое дал человек либо посчитала языковая модель; имя файла — @@ -484,9 +601,7 @@ MUST получать владельца своей записи. Иного и Отсюда норма, без которой два числа читались бы как копии одного: величины записи MUST не сверяться со строкой файла и MUST не переписываться ничем после приёма. Расхождение между ними — не поломка, а разные вопросы: «что человек -прислал» и «что лежит сейчас». Уточнение длительности — перечитали метаданные, -сменили источник, нарезали длинную запись — меняет вторую величину и не трогает -первую. +прислал» и «что лежит сейчас». #### Scenario: Список читается без содержимого @@ -500,55 +615,61 @@ MUST получать владельца своей записи. Иного и - **WHEN** читают её длительность и размер - **THEN** строка файла при этом не читается +#### Scenario: Пустая длительность в схему не ложится + +- **GIVEN** сервис поднят +- **WHEN** аудиозапись пытаются сохранить с пустой длительностью или пустым + размером +- **THEN** база её не сохраняет + #### Scenario: Посчитанный заголовок не затирает имя файла - **GIVEN** запись принята с именем файла отправителя - **WHEN** записи проставляют заголовок - **THEN** имя файла остаётся прежним -### Requirement: Содержимое записи закрыто во всех коллекциях, где лежит +### Requirement: Содержимое записи закрыто везде, где лежит -Всякая коллекция, куда переезжает содержимое аудиозаписи, SHALL быть закрыта -наравне с самой записью: её правило просмотра MUST не открывать содержимое -никому, кроме владельца связанной записи, а поле, хранящее файл или вложение, -MUST быть помечено защищённым. +Всякая таблица, куда переезжает содержимое аудиозаписи, SHALL быть закрыта +наравне с самой записью: сервис MUST не заводить ни одного адреса, которым её +строки перечисляются или читаются мимо проверки владельца связанной записи. -Пока содержимое отдаётся собственным адресом сервиса, а не поверхностью -хранилища, правило просмотра MUST оставаться незаданным — то есть «только -владелец панели». Непустое правило открывает перечисление коллекции, и заводить -его раньше, чем появится потребитель, значит открывать поверхность впрок: -норму держит требование «Наружу хранилище отдаёт только то, что заказано». +Требование распространяется на все приложения записи — тексты, структуру реплик, +попытку распознавания с её сохранённым ответом, журнал событий и темы — и +заводится потому, что содержимое лежит не в одной строке, а в нескольких. +Правило одно на все: записанное у одного хранителя, у остальных оно читалось бы +как снятое. -Требование распространяется на все коллекции приложений — тексты, структуру -реплик, попытку распознавания с её сохранённым ответом, журнал событий и темы, — -и заводится потому, что содержимое **переезжает** из одной строки в шесть. Норма -о защищённом поле файла сегодня написана про файл записи, а сырой ответ -распознавателя — это полный текст речи в другой коллекции: реализация, следующая -только прежней норме, завела бы поле с умолчанием библиотеки, и ссылка на него -отдавала бы расшифровку любому, кто её знает, без сессии. +Сохранённый ответ провайдера — это полный текст речи, и он MUST быть закрыт +наравне с расшифровкой, а не считаться служебным вложением. Где именно он лежит, +нормирует требование «Файл записи живёт в хранилище»: третьим файлом в +подкаталоге записи. -Ссылка на такое вложение MUST не попадать ни в журнал, ни в метку метрики, ни в -ответ отправителю — теми же словами, какими это нормировано для файла записи. +Ссылка или путь, по которому содержимое лежит на диске, MUST не попадать ни в +журнал, ни в метку метрики, ни в ответ отправителю — теми же словами, какими это +нормировано для файла записи. -Умолчание библиотеки здесь не годится ни в одном месте: незаданное правило -просмотра значит «только владелец панели» и отнимает содержимое у самого -владельца записи, а незащищённое поле файла отдаёт его всем. +Требование заменяет прежнее «Содержимое записи закрыто во всех коллекциях, где +лежит»: правил доступа у коллекций и защищённых полей больше нет, а закрытость +держится тем, что адреса чтения содержимого пишет сервис и каждый из них судит +владельца. #### Scenario: Чужой сохранённый ответ не отдаётся -- **GIVEN** запись принята одним вошедшим и прошла распознавание -- **WHEN** другой вошедший идёт по ссылке на сохранённый ответ провайдера +- **GIVEN** запись принята одним узнанным и прошла распознавание +- **WHEN** другой узнанный просит сохранённый ответ провайдера по этой записи - **THEN** содержимого он не получает -#### Scenario: Без сессии содержимое не отдаётся +#### Scenario: Неузнанному содержимое не отдаётся -- **WHEN** ссылку на сохранённый ответ провайдера запрашивают без сессии +- **WHEN** содержимое записи запрашивают неузнанным - **THEN** приходит отказ, а содержимого в ответе нет -#### Scenario: Перечисление приложений закрыто +#### Scenario: Перечисления приложений записи не существует -- **WHEN** запрос без прав владельца просит список записей коллекции текстов -- **THEN** приходит отказ +- **WHEN** ищут адрес, которым перечисляются строки текстов, реплик или попыток + распознавания +- **THEN** такого адреса у сервиса нет ### Requirement: Ссылки на исходник и приведённую копию живут порознь @@ -603,25 +724,27 @@ MUST быть помечено защищённым. ### Requirement: Словарь тем ведётся по владельцу -Хранилище SHALL держать темы отдельной коллекцией, и тема MUST быть уникальна в +Хранилище SHALL держать темы отдельной таблицей, и тема MUST быть уникальна в паре «владелец и название»: словарь тем свой у каждого человека. У записи MUST быть не больше пяти тем. -Коллекцией, а не набором строк в записи, — потому что перечень тем человека -нужен целиком перед каждым обращением к модели, а собрать его из наборов строк -можно только перебором всех его записей. +Отдельной таблицей, а не набором строк в записи, — потому что перечень тем +человека нужен целиком перед каждым обращением к модели, а собрать его из +наборов строк можно только перебором всех его записей. -Потолок в пять тем MUST быть у самой записи: без него часовой разговор даёт два -десятка тем, и словарь распухает за неделю. +Потолок в пять тем MUST держаться самой схемой: без него часовой разговор даёт +два десятка тем, и словарь распухает за неделю. То же число сервис объявляет +приложению — норму держит capability `archive`, — и второй константы рядом MUST +не заводиться. Название темы выведено из содержимого записи, а перечень тем человека — слепок того, о чём он вообще говорит. В журнал сервиса темы MUST не попадать наравне с текстом расшифровки. -Ни один шаг этого изменения тем не пишет и не читает: место заводится вперёд, +Ни один шаг этого изменения тем не пишет и не читает: место заведено вперёд, чтобы задача, считающая темы языковой моделью, не платила вторым необратимым -шагом схемы. Цена решения названа прямо — имена коллекции и её колонок -закрепляются раньше, чем известен их потребитель. +шагом схемы. Цена решения названа прямо — имена таблицы и её колонок закрепляются +раньше, чем известен их потребитель. #### Scenario: Тема одного человека не мешает теме другого @@ -642,7 +765,7 @@ MUST быть помечено защищённым. Требование стоит на повторном опросе одной и той же операции распознавания. Повтор — обычное дело: держатель захвата умер, сохранение рубежа отказало, -человек снял признак остановки в панели. Провайдер при этом вправе ответить +человек снял признак остановки подкомандой оснастки. Провайдер при этом вправе ответить пустым потоком, отказом это не считается, и безусловная замена стирала бы расшифровку живого человека — без следа и без возврата, потому что сервис объявлен архивом и удаления по требованию не знает. @@ -661,4 +784,3 @@ MUST быть помечено защищённым. - **WHEN** ту же операцию опрашивают снова, и провайдер отвечает пустым - **THEN** сохранённая расшифровка остаётся прежней - **AND** шаг завершается без отказа - diff --git a/openspec/specs/webapp/spec.md b/openspec/specs/webapp/spec.md index 760d466..599bf18 100644 --- a/openspec/specs/webapp/spec.md +++ b/openspec/specs/webapp/spec.md @@ -9,6 +9,7 @@ хранения ответов, поведение при несобранном приложении и то, что уходит в журнал. Отпечаток в именах ресурсов — свойство сборки, и его дом — конвенция приложения. ## Requirements + ### Requirement: Приложение отдаётся самим бинарником Сервис SHALL отдавать разметку приложения и её ресурсы из самого бинарника. @@ -68,21 +69,36 @@ ### Requirement: Неизвестный путь вне корней открывает приложение Сервис SHALL отдавать разметку приложения на всяком пути, который не принадлежит -ни одному корню сервиса и не совпадает с отдельным адресом наблюдения. Корни -перечислены поимённо — `/api` у хранилища, `/app` у приложения, `/_` у панели, — -отдельными адресами стоят `/health` и `/metrics`. +ни одному корню сервиса и не совпадает с отдельным адресом наблюдения. Корень у +сервиса остался **один** — `/app` у приложения; отдельными адресами стоят +`/health` и `/metrics`. -Корня `/auth` в перечне больше нет: собственного входа у сервиса не осталось, и -адресов под этим корнем не существует. Прежние адреса входа поэтому отвечают тем -же, чем отвечает всякий путь вне корней, — разметкой приложения. Резервировать имя -за отказом сервис не берётся: имя, за которым ничего не стоит, ничем не отличается -от любого другого свободного имени, а второй перечень «когда-то занятых корней» -разошёлся бы с первым молча. +Корней `/api` и `/_` в перечне больше нет: встроенного хранилища с его +собственным пространством и панели администратора у сервиса не осталось, и +адресов под этими именами не существует. Прежние пути хранилища и панели поэтому +отвечают тем же, чем отвечает всякий путь вне корней, — разметкой приложения. +Резервировать имя за отказом сервис не берётся: имя, за которым ничего не стоит, +ничем не отличается от любого другого свободного имени, а второй перечень +«когда-то занятых корней» разошёлся бы с первым молча. + +Этим же снимается дефект подменённого знака: путь панели, записанный кодом знака, +раскодируется в тот же путь и попадает в то же правило — правило одно, и особого +случая у него нет. + +Корня `/auth` в перечне нет тоже: собственного входа у сервиса не осталось. Путь принадлежит корню, когда **совпадает с ним точно либо начинается им вместе с косой чертой**. Оба условия обязательны: по одному лишь префиксу корню `/app` достался бы посторонний `/apple`, а по одному лишь префиксу с косой чертой голый -`/api` не достался бы никому и уехал бы разметкой. +`/app` не достался бы никому и уехал бы разметкой. + +Чем отвечает голый `/app`, названо прямо: он принадлежит корню приложения, +адресом приложения при этом не является и потому MUST отвечать как **неизвестный +путь под корнем приложения** — узнанному `404` телом отказа приложения, +неузнанному `401` тем же телом, каким отвечают прочие адреса под этим корнем. +Разметки в ответе нет ни в одном из двух случаев. Без этой строки «не разметка +приложения» читается как «что-нибудь ещё», и код ответа выбрала бы за нас первая +же сборка. Путь, принадлежащий корню, MUST не проваливаться в приложение никогда: отказ контракта остаётся отказом контракта и уходит той формой, которой этот корень @@ -108,9 +124,18 @@ #### Scenario: Голый корень разметкой не подменяется -- **WHEN** запрос приходит на путь, совпадающий с корнем сервиса точно и без +- **GIVEN** запрос идёт с заголовком, поставленным прокси +- **WHEN** запрос приходит на путь, совпадающий с корнем приложения точно и без косой черты -- **THEN** тело ответа — не разметка приложения +- **THEN** ответ имеет код `404` +- **AND** тело ответа — отказ приложения с машиночитаемым кодом, а не разметка + +#### Scenario: Голый корень неузнанному отвечает как прочие адреса под корнем + +- **WHEN** запрос приходит без заголовка на путь, совпадающий с корнем приложения + точно и без косой черты +- **THEN** ответ имеет код `401` +- **AND** тело ответа — не разметка приложения #### Scenario: Посторонний путь, начинающийся именем корня, открывает приложение @@ -125,6 +150,19 @@ - **THEN** ответ имеет код `200` - **AND** тело ответа — разметка приложения +#### Scenario: Прежний путь хранилища открывает приложение + +- **WHEN** запрос приходит на путь под прежним корнем хранилища +- **THEN** ответ имеет код `200` +- **AND** тело ответа — разметка приложения + +#### Scenario: Прежний адрес панели открывает приложение + +- **WHEN** запрос приходит на прежний адрес панели — и записанный знаком, и + записанный кодом этого знака +- **THEN** оба ответа имеют код `200` +- **AND** тело каждого — разметка приложения + #### Scenario: Неизвестный путь под корнем приложения отвечает отказом - **GIVEN** запрос идёт с заголовком, поставленным прокси @@ -139,11 +177,6 @@ - **THEN** ответ имеет код `401` - **AND** тело ответа — не разметка приложения -#### Scenario: Неизвестный путь под корнем хранилища отвечает отказом - -- **WHEN** запрос приходит на неизвестный путь под корнем хранилища -- **THEN** тело ответа — не разметка приложения - #### Scenario: Несуществующий ресурс отвечает отсутствием, а не разметкой - **WHEN** браузер спрашивает под каталогом ресурсов файл, которого в сборке нет @@ -186,9 +219,19 @@ ### Requirement: Путь, отданный приложению, в журнал не идёт -Сервис SHALL записывать о таком запросе **исход из закрытого перечня** — -разметка, ресурс, отказ — и длину пути, а самого пути MUST не записывать ни в -один свой журнал. То же относится к меткам метрик. +Сервис SHALL записывать о запросе **маршрут из закрытого перечня** и длину пути, +а самого запрошенного пути MUST не записывать ни в один свой журнал. То же +относится к меткам метрик. Перечень маршрутов закрыт и назван: точные адреса +наблюдения и объявленные образцы адресов приложения; всё, что ни одному из них +не отвечает, MUST обозначаться одним общим значением. Для запроса, отданного +приложению, к строке добавляется **исход из закрытого перечня** — разметка, +ресурс, отказ. + +Правило MUST накрывать обе половины адресного пространства — и путь вне корней +сервиса, и путь под корнем приложения. Принадлежность пути сервису тут ничего не +меняет: `/app/<произвольный текст>` принадлежит сервису и отвечает отказом, но +множеством значений под корнем распоряжается спрашивающий ровно так же, как и +вне его. Причина в том, кто этот путь выбирает. До появления раздачи путь вне корней ловил отказ маршрутизатора; теперь он успешный ответ, и множеством его значений @@ -197,8 +240,12 @@ есть: причина отказа, пришедшая от провайдера строкой запроса, приводится к перечню известных. -Журналов при этом **два**: свой и журнал хранилища, куда библиотека кладёт путь -целиком вместе с адресом отправителя. Требование относится к обоим. +Идентификатор записи от этого не пропадает: его пишет обработчик своим полем, и +пишет он прочитанный идентификатор, а не тот, что стоял в запросе. + +Журнал у сервиса теперь **один**: второй, куда чужая библиотека клала путь целиком +вместе с адресом отправителя, ушёл вместе с ней. Правило от этого не ослабло, а +перестало зависеть от настройки чужого журнала, которую мы не писали. #### Scenario: Путь не доезжает до журнала @@ -211,6 +258,25 @@ - **WHEN** приходит запрос на путь длиной в тысячу знаков - **THEN** записи о нём не растут вместе с длиной пути +#### Scenario: Путь под корнем приложения журнал не пишет + +- **GIVEN** пришедший не узнан +- **WHEN** он спрашивает под корнем приложения путь, не отвечающий ни одному + объявленному образцу адреса +- **THEN** записи о нём не несут этого пути +- **AND** не растут вместе с его длиной + +#### Scenario: Объявленный образец адреса приложения в журнале различим + +- **WHEN** приходит запрос на объявленный адрес приложения +- **THEN** запись о нём несёт образец этого адреса, а не запрошенный путь + +#### Scenario: Второго журнала у сервиса нет + +- **GIVEN** сервис поднялся +- **WHEN** приходит запрос на путь вне корней сервиса +- **THEN** запись о нём появляется только в журнале сервиса + ### Requirement: Сервис объявляет, какая сборка приложения в нём вшита Сервис SHALL писать при подъёме отпечаток вшитой сборки. Он же MUST уходить @@ -272,4 +338,3 @@ - **WHEN** человек открывает приложение - **THEN** приложение показывает строку о неудаче - **AND** эта строка не та, которой оно сообщает о неузнавании -